Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a262217ab2 | ||
|
|
c5d06a2492 | ||
|
|
c93edde7d2 | ||
|
|
5103126001 | ||
|
|
b0c49c5141 | ||
|
|
2ed2cb3afd | ||
|
|
2bcda1f2ef | ||
|
|
ca20bbd298 | ||
|
|
c6da06cd4d | ||
|
|
50bf6c04c1 | ||
|
|
490417771c | ||
|
|
0cb491eaf9 | ||
|
|
ece6bcc125 | ||
|
|
4b79e559d2 | ||
|
|
322e510db2 | ||
|
|
4e279f127d | ||
|
|
5655a8136d | ||
|
|
d80534c51f | ||
|
|
0b97248fb8 | ||
|
|
f54f448ecb | ||
|
|
bd1f2b899d | ||
|
|
efd3c83193 | ||
|
|
85fa1e2359 | ||
|
|
cd6ba5884f | ||
|
|
bfed56ba13 | ||
|
|
d1018ba8d1 | ||
|
|
a42542c16e | ||
|
|
26a6ec954c | ||
|
|
f346282c3c | ||
|
|
f3e875eeea | ||
|
|
6ad8ffddf0 | ||
|
|
d37e2a3c80 | ||
|
|
18e1ac0e6c | ||
|
|
aa4f21a869 | ||
|
|
bbc052eebc | ||
|
|
c201b5816c | ||
|
|
043cabfd4e | ||
|
|
9b02ab5842 | ||
|
|
618a45d349 | ||
|
|
11dcef4e87 | ||
|
|
d87371aec4 | ||
|
|
cfb017f70b | ||
|
|
9ba95301db | ||
|
|
0518389f50 | ||
|
|
eb6e3e52df | ||
|
|
1e95a2fd95 | ||
|
|
83d24351cd | ||
|
|
470dc1c759 | ||
|
|
42b780ce6a | ||
|
|
5ab2b1ae31 | ||
|
|
055d73c829 | ||
|
|
469296790e | ||
|
|
c032dbea1a | ||
|
|
172566028b | ||
|
|
cc7de41bfd | ||
|
|
ba9c81a10a | ||
|
|
e158f2e4d7 | ||
|
|
62bd689031 | ||
|
|
cb6006f596 | ||
|
|
adad048873 |
@@ -4,10 +4,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [
|
"ignore": ["./src/routes.gen.ts", "./src/commands.gen.ts"]
|
||||||
"./src/routes.gen.ts",
|
|
||||||
"./src/commands.gen.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -1,86 +1,85 @@
|
|||||||
{
|
{
|
||||||
"name": "lume",
|
"name": "lume",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getalby/bitcoin-connect-react": "^3.6.2",
|
"@getalby/bitcoin-connect-react": "^3.6.2",
|
||||||
"@phosphor-icons/react": "^2.1.7",
|
"@phosphor-icons/react": "^2.1.7",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-progress": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@tanstack/query-persist-client-core": "^5.59.0",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/query-broadcast-client-experimental": "^5.59.20",
|
||||||
"@tanstack/react-router": "^1.63.5",
|
"@tanstack/query-persist-client-core": "^5.59.20",
|
||||||
"@tanstack/react-store": "^0.5.5",
|
"@tanstack/react-query": "^5.59.20",
|
||||||
"@tanstack/store": "^0.5.5",
|
"@tanstack/react-router": "^1.81.0",
|
||||||
"@tauri-apps/api": "^2.0.2",
|
"@tauri-apps/api": "^2.1.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
"@tauri-apps/plugin-fs": "^2.0.2",
|
||||||
"@tauri-apps/plugin-http": "^2.0.0",
|
"@tauri-apps/plugin-http": "^2.0.1",
|
||||||
"@tauri-apps/plugin-os": "^2.0.0",
|
"@tauri-apps/plugin-os": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
|
||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"@tauri-apps/plugin-upload": "^2.0.0",
|
"@tauri-apps/plugin-upload": "^2.1.0",
|
||||||
"@tauri-apps/plugin-window-state": "^2.0.0",
|
"@tauri-apps/plugin-window-state": "^2.0.0",
|
||||||
"bitcoin-units": "^1.0.0",
|
"bitcoin-units": "^1.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.1",
|
||||||
"i18next": "^23.15.2",
|
"i18next": "^23.16.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"light-bolt11-decoder": "^3.2.0",
|
"light-bolt11-decoder": "^3.2.0",
|
||||||
"minidenticons": "^4.2.1",
|
"minidenticons": "^4.2.1",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.8",
|
||||||
"nostr-tools": "^2.7.2",
|
"nostr-tools": "^2.10.1",
|
||||||
"react": "19.0.0-rc-d025ddd3-20240722",
|
"react": "19.0.0-rc-cae764ce-20241025",
|
||||||
"react-currency-input-field": "^3.8.0",
|
"react-currency-input-field": "^3.8.0",
|
||||||
"react-dom": "19.0.0-rc-d025ddd3-20240722",
|
"react-dom": "19.0.0-rc-cae764ce-20241025",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.2",
|
||||||
"react-i18next": "^15.0.2",
|
"react-i18next": "^15.1.1",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"rich-textarea": "^0.26.3",
|
"rich-textarea": "^0.26.4",
|
||||||
"use-debounce": "^10.0.3",
|
"use-debounce": "^10.0.4",
|
||||||
"virtua": "^0.34.2"
|
"virtua": "^0.34.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.3",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@evilmartians/harmony": "^1.2.0",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tanstack/router-devtools": "^1.81.0",
|
||||||
"@tanstack/router-devtools": "^1.63.5",
|
"@tanstack/router-plugin": "^1.79.0",
|
||||||
"@tanstack/router-plugin": "^1.63.5",
|
"@tauri-apps/cli": "^2.1.0",
|
||||||
"@tauri-apps/cli": "^2.0.2",
|
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"autoprefixer": "^10.4.20",
|
||||||
"autoprefixer": "^10.4.20",
|
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
||||||
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
"clsx": "^2.1.1",
|
||||||
"clsx": "^2.1.1",
|
"postcss": "^8.4.47",
|
||||||
"postcss": "^8.4.47",
|
"tailwind-gradient-mask-image": "^1.2.0",
|
||||||
"tailwind-gradient-mask-image": "^1.2.0",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
"tailwindcss": "^3.4.14",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss-content-visibility": "^1.0.0",
|
||||||
"tailwindcss-content-visibility": "^1.0.0",
|
"typescript": "^5.6.3",
|
||||||
"typescript": "^5.6.3",
|
"vite": "^5.4.10",
|
||||||
"vite": "^5.4.8",
|
"vite-tsconfig-paths": "5.0.0"
|
||||||
"vite-tsconfig-paths": "^5.0.1"
|
},
|
||||||
},
|
"overrides": {
|
||||||
"overrides": {
|
"@types/react": "npm:types-react@rc",
|
||||||
"@types/react": "npm:types-react@rc",
|
"@types/react-dom": "npm:types-react-dom@rc"
|
||||||
"@types/react-dom": "npm:types-react-dom@rc"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "Lume"
|
name = "Lume"
|
||||||
version = "4.0.0"
|
version = "24.11.0"
|
||||||
description = "nostr client"
|
description = "nostr client"
|
||||||
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
||||||
repository = "https://github.com/lumehq/lume"
|
repository = "https://github.com/lumehq/lume"
|
||||||
@@ -11,11 +11,10 @@ rust-version = "1.70"
|
|||||||
tauri-build = { version = "2.0.0", features = [] }
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0.0", features = [
|
tauri = { version = "2.0.0", features = [ "protocol-asset",
|
||||||
"unstable",
|
"unstable",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"macos-private-api",
|
"macos-private-api"
|
||||||
"protocol-asset",
|
|
||||||
] }
|
] }
|
||||||
tauri-plugin-window-state = "2.0.0"
|
tauri-plugin-window-state = "2.0.0"
|
||||||
tauri-plugin-clipboard-manager = "2.0.0"
|
tauri-plugin-clipboard-manager = "2.0.0"
|
||||||
@@ -28,14 +27,12 @@ tauri-plugin-process = "2.0.0"
|
|||||||
tauri-plugin-shell = "2.0.0"
|
tauri-plugin-shell = "2.0.0"
|
||||||
tauri-plugin-updater = "2.0.0"
|
tauri-plugin-updater = "2.0.0"
|
||||||
tauri-plugin-upload = "2.0.0"
|
tauri-plugin-upload = "2.0.0"
|
||||||
tauri-plugin-store = "2.0.0"
|
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
|
||||||
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum.git" }
|
|
||||||
tauri-plugin-prevent-default = "0.6"
|
tauri-plugin-prevent-default = "0.6"
|
||||||
|
tauri-plugin-theme = "2.1.2"
|
||||||
|
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum" }
|
||||||
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
|
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
|
||||||
|
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
|
|
||||||
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
|
|
||||||
specta = "^2.0.0-rc.20"
|
specta = "^2.0.0-rc.20"
|
||||||
specta-typescript = "0.0.7"
|
specta-typescript = "0.0.7"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -47,16 +44,17 @@ futures = "0.3.30"
|
|||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
regex = "1.10.4"
|
regex = "1.10.4"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
||||||
keyring-search = "1.2.0"
|
keyring-search = { git = "https://github.com/reyamir/keyring-search" }
|
||||||
tracing-subscriber = "0.3.18"
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
|
async-trait = "0.1.83"
|
||||||
|
webbrowser = "1.0.2"
|
||||||
|
|
||||||
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "webln", "all-nips"] }
|
||||||
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.25.0"
|
|
||||||
objc = "0.2.7"
|
|
||||||
rand = "0.8.5"
|
|
||||||
monitor = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
|
||||||
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||||
|
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "column",
|
|
||||||
"description": "Capability for the column",
|
|
||||||
"platforms": [
|
|
||||||
"linux",
|
|
||||||
"macOS",
|
|
||||||
"windows"
|
|
||||||
],
|
|
||||||
"windows": [
|
|
||||||
"column-*"
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
"core:resources:default",
|
|
||||||
"core:tray:default",
|
|
||||||
"os:allow-locale",
|
|
||||||
"os:allow-os-type",
|
|
||||||
"clipboard-manager:allow-write-text",
|
|
||||||
"dialog:allow-open",
|
|
||||||
"dialog:allow-ask",
|
|
||||||
"dialog:allow-message",
|
|
||||||
"fs:allow-read-file",
|
|
||||||
"core:menu:default",
|
|
||||||
"core:menu:allow-new",
|
|
||||||
"core:menu:allow-popup",
|
|
||||||
"http:default",
|
|
||||||
"shell:allow-open",
|
|
||||||
"store:allow-get",
|
|
||||||
"store:allow-set",
|
|
||||||
"store:allow-delete",
|
|
||||||
{
|
|
||||||
"identifier": "http:default",
|
|
||||||
"allow": [
|
|
||||||
{
|
|
||||||
"url": "http://**/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://**/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identifier": "fs:allow-read-text-file",
|
|
||||||
"allow": [
|
|
||||||
{
|
|
||||||
"path": "$RESOURCE/locales/*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "$RESOURCE/resources/*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,7 @@
|
|||||||
"windows"
|
"windows"
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main",
|
"*"
|
||||||
"panel",
|
|
||||||
"settings",
|
|
||||||
"search-*",
|
|
||||||
"zap-*",
|
|
||||||
"event-*",
|
|
||||||
"user-*",
|
|
||||||
"editor-*",
|
|
||||||
"popup-*"
|
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:path:default",
|
"core:path:default",
|
||||||
@@ -25,15 +17,6 @@
|
|||||||
"core:resources:default",
|
"core:resources:default",
|
||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"core:tray:default",
|
"core:tray:default",
|
||||||
"notification:allow-is-permission-granted",
|
|
||||||
"notification:allow-request-permission",
|
|
||||||
"notification:default",
|
|
||||||
"os:allow-locale",
|
|
||||||
"os:allow-platform",
|
|
||||||
"os:allow-os-type",
|
|
||||||
"updater:default",
|
|
||||||
"updater:allow-check",
|
|
||||||
"updater:allow-download-and-install",
|
|
||||||
"core:window:allow-create",
|
"core:window:allow-create",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-destroy",
|
"core:window:allow-destroy",
|
||||||
@@ -44,25 +27,33 @@
|
|||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"decorum:allow-show-snap-overlay",
|
|
||||||
"clipboard-manager:allow-write-text",
|
|
||||||
"clipboard-manager:allow-read-text",
|
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"core:webview:allow-create-webview",
|
"core:webview:allow-create-webview",
|
||||||
"core:webview:allow-set-webview-size",
|
"core:webview:allow-set-webview-size",
|
||||||
"core:webview:allow-set-webview-position",
|
"core:webview:allow-set-webview-position",
|
||||||
"core:webview:allow-webview-close",
|
"core:webview:allow-webview-close",
|
||||||
"dialog:allow-open",
|
"core:menu:allow-new",
|
||||||
"dialog:allow-ask",
|
"core:menu:allow-popup",
|
||||||
"dialog:allow-message",
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
"notification:default",
|
||||||
|
"os:allow-locale",
|
||||||
|
"os:allow-platform",
|
||||||
|
"os:allow-os-type",
|
||||||
|
"updater:default",
|
||||||
|
"updater:allow-check",
|
||||||
|
"updater:allow-download-and-install",
|
||||||
|
"decorum:allow-show-snap-overlay",
|
||||||
|
"clipboard-manager:allow-write-text",
|
||||||
|
"clipboard-manager:allow-read-text",
|
||||||
|
"dialog:default",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"process:allow-exit",
|
"process:allow-exit",
|
||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
"core:menu:allow-new",
|
|
||||||
"core:menu:allow-popup",
|
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"store:default",
|
"store:default",
|
||||||
"prevent-default:default",
|
"prevent-default:default",
|
||||||
|
"theme:default",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"column":{"identifier":"column","description":"Capability for the column","local":true,"windows":["column-*"],"permissions":["core:resources:default","core:tray:default","os:allow-locale","os:allow-os-type","clipboard-manager:allow-write-text","dialog:allow-open","dialog:allow-ask","dialog:allow-message","fs:allow-read-file","core:menu:default","core:menu:allow-new","core:menu:allow-popup","http:default","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]},"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*","popup-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:default","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
|
{"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","core:menu:allow-new","core:menu:allow-popup","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:default","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"Capability": {
|
"Capability": {
|
||||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```",
|
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"identifier",
|
"identifier",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
|
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/PermissionEntry"
|
"$ref": "#/definitions/PermissionEntry"
|
||||||
@@ -5444,11 +5444,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-clear"
|
"const": "store:allow-clear"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Enables the create_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-create-store"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Enables the delete command without any pre-configured scope.",
|
"description": "Enables the delete command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5464,6 +5459,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-get"
|
"const": "store:allow-get"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_store command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:allow-get-store"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the has command without any pre-configured scope.",
|
"description": "Enables the has command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5484,6 +5484,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-load"
|
"const": "store:allow-load"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reload command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:allow-reload"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the reset command without any pre-configured scope.",
|
"description": "Enables the reset command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5509,11 +5514,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-clear"
|
"const": "store:deny-clear"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Denies the create_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-create-store"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Denies the delete command without any pre-configured scope.",
|
"description": "Denies the delete command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5529,6 +5529,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-get"
|
"const": "store:deny-get"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_store command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:deny-get-store"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the has command without any pre-configured scope.",
|
"description": "Denies the has command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5549,6 +5554,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-load"
|
"const": "store:deny-load"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reload command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:deny-reload"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the reset command without any pre-configured scope.",
|
"description": "Denies the reset command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5569,6 +5579,31 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-values"
|
"const": "store:deny-values"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Allow all",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:allow-get-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the set_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:allow-set-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:deny-get-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the set_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:deny-set-theme"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n",
|
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"Capability": {
|
"Capability": {
|
||||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```",
|
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"identifier",
|
"identifier",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
|
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/PermissionEntry"
|
"$ref": "#/definitions/PermissionEntry"
|
||||||
@@ -5444,11 +5444,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-clear"
|
"const": "store:allow-clear"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Enables the create_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:allow-create-store"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Enables the delete command without any pre-configured scope.",
|
"description": "Enables the delete command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5464,6 +5459,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-get"
|
"const": "store:allow-get"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_store command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:allow-get-store"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the has command without any pre-configured scope.",
|
"description": "Enables the has command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5484,6 +5484,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:allow-load"
|
"const": "store:allow-load"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reload command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:allow-reload"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Enables the reset command without any pre-configured scope.",
|
"description": "Enables the reset command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5509,11 +5514,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-clear"
|
"const": "store:deny-clear"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Denies the create_store command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "store:deny-create-store"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Denies the delete command without any pre-configured scope.",
|
"description": "Denies the delete command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5529,6 +5529,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-get"
|
"const": "store:deny-get"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_store command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:deny-get-store"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the has command without any pre-configured scope.",
|
"description": "Denies the has command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5549,6 +5554,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-load"
|
"const": "store:deny-load"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reload command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "store:deny-reload"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the reset command without any pre-configured scope.",
|
"description": "Denies the reset command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -5569,6 +5579,31 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "store:deny-values"
|
"const": "store:deny-values"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Allow all",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the get_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:allow-get-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the set_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:allow-set-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the get_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:deny-get-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the set_theme command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "theme:deny-set-theme"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n",
|
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 620 B |
@@ -1,74 +1,37 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"default": true,
|
"default": true,
|
||||||
"official": true,
|
|
||||||
"label": "onboarding",
|
"label": "onboarding",
|
||||||
"name": "Onboarding",
|
"name": "Onboarding",
|
||||||
"description": "Tips for Mastering Lume.",
|
"description": "Tips for Mastering Lume.",
|
||||||
"url": "/columns/onboarding",
|
"url": "/columns/onboarding"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": true,
|
"default": true,
|
||||||
"official": true,
|
"label": "launchpad",
|
||||||
"label": "Launchpad",
|
|
||||||
"name": "Launchpad",
|
"name": "Launchpad",
|
||||||
"description": "Expand your experiences.",
|
"description": "Expand your experiences.",
|
||||||
"url": "/columns/launchpad",
|
"url": "/columns/launchpad"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "newsfeed",
|
|
||||||
"name": "Newsfeed",
|
|
||||||
"description": "All notes from you're following.",
|
|
||||||
"url": "/columns/newsfeed",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "notification",
|
|
||||||
"name": "Notification",
|
|
||||||
"description": "All things around you.",
|
|
||||||
"url": "/columns/notification",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "search",
|
"label": "search",
|
||||||
"name": "Search",
|
"name": "Search",
|
||||||
"description": "Find anything.",
|
"description": "Find anything.",
|
||||||
"url": "/columns/search",
|
"url": "/columns/search"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "stories",
|
|
||||||
"name": "Stories",
|
|
||||||
"description": "Keep up to date with your follows.",
|
|
||||||
"url": "/columns/stories",
|
|
||||||
"picture": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": false,
|
|
||||||
"official": true,
|
|
||||||
"label": "global_feeds",
|
"label": "global_feeds",
|
||||||
"name": "Global Feeds",
|
"name": "Global Feeds",
|
||||||
"description": "All global notes from all connected relays.",
|
"description": "All global notes from all connected relays.",
|
||||||
"url": "/columns/global",
|
"url": "/columns/global"
|
||||||
"picture": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": false,
|
"default": false,
|
||||||
"official": true,
|
|
||||||
"label": "trending",
|
"label": "trending",
|
||||||
"name": "Trending",
|
"name": "Trending",
|
||||||
"description": "Discover all trending notes.",
|
"description": "Discover all trending notes.",
|
||||||
"url": "/columns/trending",
|
"url": "/columns/trending"
|
||||||
"picture": ""
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
wss://relay.damus.io,
|
wss://relay.damus.io,
|
||||||
wss://relay.nostr.net,
|
wss://relay.primal.net,
|
||||||
wss://nostr.fmt.wiz.biz,
|
wss://nostr.fmt.wiz.biz,
|
||||||
wss://offchain.pub,
|
wss://directory.yabu.me,
|
||||||
|
wss://purplepag.es,
|
||||||
|
|||||||
@@ -1,126 +1,92 @@
|
|||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use keyring_search::{Limit, List, Search};
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{
|
use std::{str::FromStr, time::Duration};
|
||||||
collections::HashSet,
|
use tauri::{Emitter, State};
|
||||||
fs::{self, File},
|
|
||||||
str::FromStr,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
use tauri::{Emitter, Manager, State};
|
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{common::get_all_accounts, Nostr, Settings};
|
||||||
common::{get_latest_event, init_nip65},
|
|
||||||
Nostr, NOTIFICATION_SUB_ID,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
struct Account {
|
struct Account {
|
||||||
password: String,
|
secret_key: String,
|
||||||
nostr_connect: Option<String>,
|
nostr_connect: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AuthHandler;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AuthUrlHandler for AuthHandler {
|
||||||
|
async fn on_auth_url(&self, auth_url: Url) -> Result<()> {
|
||||||
|
webbrowser::open(auth_url.as_str())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_accounts() -> Vec<String> {
|
pub fn get_accounts() -> Vec<String> {
|
||||||
let search = Search::new().expect("Unexpected.");
|
get_all_accounts()
|
||||||
let results = search.by_service("Lume Secret Storage");
|
|
||||||
let list = List::list_credentials(&results, Limit::All);
|
|
||||||
let accounts: HashSet<String> = list
|
|
||||||
.split_whitespace()
|
|
||||||
.filter(|v| v.starts_with("npub1"))
|
|
||||||
.map(String::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
accounts.into_iter().collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn create_account(
|
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
name: String,
|
let client = &state.client;
|
||||||
about: String,
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
picture: String,
|
let npub = public_key.to_bech32().map_err(|e| e.to_string())?;
|
||||||
password: String,
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Set empty password
|
||||||
|
keyring.set_password("").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get user's profile
|
||||||
|
let _ = client
|
||||||
|
.fetch_metadata(public_key, Some(Duration::from_secs(4)))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(npub)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn import_account(
|
||||||
|
key: String,
|
||||||
|
password: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keys = Keys::generate();
|
|
||||||
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
|
|
||||||
let secret_key = keys.secret_key();
|
|
||||||
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
// Save account
|
// Create secret key
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let secret_key = if let Some(pw) = password {
|
||||||
let account = Account {
|
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
||||||
password: enc_bech32,
|
enc.to_secret_key(pw).map_err(|err| err.to_string())?
|
||||||
nostr_connect: None,
|
} else {
|
||||||
};
|
SecretKey::from_str(&key).map_err(|err| err.to_string())?
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
|
||||||
let _ = keyring.set_password(&j);
|
|
||||||
|
|
||||||
let signer = NostrSigner::Keys(keys);
|
|
||||||
|
|
||||||
// Update signer
|
|
||||||
client.set_signer(Some(signer)).await;
|
|
||||||
|
|
||||||
let mut metadata = Metadata::new()
|
|
||||||
.display_name(name.clone())
|
|
||||||
.name(name.to_lowercase())
|
|
||||||
.about(about);
|
|
||||||
|
|
||||||
if let Ok(url) = Url::parse(&picture) {
|
|
||||||
metadata = metadata.picture(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
match client.set_metadata(&metadata).await {
|
|
||||||
Ok(_) => Ok(npub),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn import_account(key: String, password: String) -> Result<String, String> {
|
|
||||||
let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
|
|
||||||
true => {
|
|
||||||
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
|
||||||
let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
|
|
||||||
let keys = Keys::new(secret_key);
|
|
||||||
let npub = keys.public_key().to_bech32().unwrap();
|
|
||||||
|
|
||||||
(npub, enc_bech32)
|
|
||||||
}
|
|
||||||
false => {
|
|
||||||
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
|
||||||
let keys = Keys::new(secret_key.clone());
|
|
||||||
let npub = keys.public_key().to_bech32().unwrap();
|
|
||||||
|
|
||||||
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
(npub, enc_bech32)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let hex = secret_key.to_secret_hex();
|
||||||
|
let keys = Keys::new(secret_key);
|
||||||
|
|
||||||
|
let public_key = keys.public_key();
|
||||||
|
let npub = public_key.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let account = Account {
|
let account = Account {
|
||||||
password: enc_bech32,
|
secret_key: hex,
|
||||||
nostr_connect: None,
|
nostr_connect: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save secret key to keyring
|
||||||
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Update signer
|
||||||
|
client.set_signer(keys).await;
|
||||||
|
|
||||||
Ok(npub)
|
Ok(npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,46 +94,50 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match NostrConnectURI::parse(uri.clone()) {
|
// Local user
|
||||||
Ok(bunker_uri) => {
|
let app_keys = Keys::generate();
|
||||||
// Local user
|
let app_secret = app_keys.secret_key().to_secret_hex();
|
||||||
let app_keys = Keys::generate();
|
|
||||||
let app_secret = app_keys.secret_key().to_secret_hex();
|
|
||||||
|
|
||||||
// Get remote user
|
// Get remote user
|
||||||
let remote_user = bunker_uri.signer_public_key().unwrap();
|
let remote_user = bunker_uri.remote_signer_public_key().unwrap();
|
||||||
let remote_npub = remote_user.to_bech32().unwrap();
|
let remote_npub = remote_user.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None) {
|
// Init nostr connect
|
||||||
Ok(signer) => {
|
let mut nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
|
||||||
let mut url = Url::parse(&uri).unwrap();
|
.map_err(|err| err.to_string())?;
|
||||||
let query: Vec<(String, String)> = url
|
|
||||||
.query_pairs()
|
|
||||||
.filter(|(name, _)| name != "secret")
|
|
||||||
.map(|(name, value)| (name.into_owned(), value.into_owned()))
|
|
||||||
.collect();
|
|
||||||
url.query_pairs_mut().clear().extend_pairs(&query);
|
|
||||||
|
|
||||||
let key = format!("{}_nostrconnect", remote_npub);
|
// Handle auth url
|
||||||
let keyring = Entry::new("Lume Secret Storage", &key).unwrap();
|
nostr_connect.auth_url_handler(AuthHandler);
|
||||||
let account = Account {
|
|
||||||
password: app_secret,
|
|
||||||
nostr_connect: Some(url.to_string()),
|
|
||||||
};
|
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
|
||||||
let _ = keyring.set_password(&j);
|
|
||||||
|
|
||||||
// Update signer
|
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
|
||||||
let _ = client.set_signer(Some(signer.into())).await;
|
|
||||||
|
|
||||||
Ok(remote_npub)
|
let reuse_bunker = nostr_connect
|
||||||
}
|
.bunker_uri()
|
||||||
Err(err) => Err(err.to_string()),
|
.await
|
||||||
}
|
.map_err(|err| err.to_string())?;
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
let mut reuse_uri = reuse_bunker.to_string();
|
||||||
|
|
||||||
|
if let Some(secret) = reuse_bunker.secret() {
|
||||||
|
let replace = format!("&secret={}", secret);
|
||||||
|
reuse_uri = reuse_uri.replace(replace.as_str(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let account = Account {
|
||||||
|
secret_key: app_secret,
|
||||||
|
nostr_connect: Some(reuse_uri),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save secret key to keyring
|
||||||
|
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
|
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Update signer
|
||||||
|
let _ = client.set_signer(nostr_connect).await;
|
||||||
|
|
||||||
|
Ok(remote_npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -181,9 +151,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
|||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
|
||||||
let account = Account {
|
let account = Account {
|
||||||
password: enc_bech32,
|
secret_key: enc_bech32,
|
||||||
nostr_connect: None,
|
nostr_connect: None,
|
||||||
};
|
};
|
||||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||||
@@ -195,16 +165,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_private_key(id: String) -> Result<String, String> {
|
pub fn get_private_key(id: String) -> Result<String, String> {
|
||||||
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
let password = keyring.get_password().map_err(|e| e.to_string())?;
|
let password = keyring.get_password().map_err(|e| e.to_string())?;
|
||||||
|
let account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(password)
|
Ok(account.secret_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn delete_account(id: String) -> Result<(), String> {
|
pub fn delete_account(id: String) -> Result<(), String> {
|
||||||
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
let _ = keyring.delete_credential();
|
let _ = keyring.delete_credential();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -212,226 +183,91 @@ pub fn delete_account(id: String) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
|
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
let config_dir = handle
|
let client = &state.client;
|
||||||
.path()
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
.app_config_dir()
|
|
||||||
.expect("Error: app config directory not found.");
|
|
||||||
|
|
||||||
fs::metadata(config_dir.join(id)).is_ok()
|
match client.signer().await {
|
||||||
|
Ok(signer) => {
|
||||||
|
let signer_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
|
||||||
|
let is_match = signer_key == public_key;
|
||||||
|
|
||||||
|
Ok(is_match)
|
||||||
|
}
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool {
|
pub async fn set_signer(
|
||||||
let config_dir = handle
|
id: String,
|
||||||
.path()
|
|
||||||
.app_config_dir()
|
|
||||||
.expect("Error: app config directory not found.");
|
|
||||||
|
|
||||||
File::create(config_dir.join(id)).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn login(
|
|
||||||
account: String,
|
|
||||||
password: String,
|
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
handle: tauri::AppHandle,
|
handle: tauri::AppHandle,
|
||||||
) -> Result<String, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?;
|
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let account = match keyring.get_password() {
|
let account = match keyring.get_password() {
|
||||||
Ok(pw) => {
|
Ok(pw) => {
|
||||||
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
|
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if account.secret_key.is_empty() {
|
||||||
|
return Err("Watch Only account".into());
|
||||||
|
};
|
||||||
|
|
||||||
account
|
account
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.to_string()),
|
Err(e) => return Err(e.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let public_key = match account.nostr_connect {
|
match account.nostr_connect {
|
||||||
None => {
|
None => {
|
||||||
let ncryptsec =
|
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||||
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
|
|
||||||
let secret_key = ncryptsec
|
|
||||||
.to_secret_key(password)
|
|
||||||
.map_err(|_| "Wrong password.")?;
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let public_key = keys.public_key().to_bech32().unwrap();
|
|
||||||
let signer = NostrSigner::Keys(keys);
|
|
||||||
|
|
||||||
// Update signer
|
// Update signer
|
||||||
client.set_signer(Some(signer)).await;
|
client.set_signer(keys).await;
|
||||||
|
// Emit to front-end
|
||||||
|
handle.emit("signer-updated", ()).unwrap();
|
||||||
|
|
||||||
public_key
|
Ok(())
|
||||||
}
|
}
|
||||||
Some(bunker) => {
|
Some(bunker) => {
|
||||||
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
||||||
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
|
let app_keys = Keys::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||||
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) {
|
match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
|
||||||
Ok(signer) => {
|
Ok(mut signer) => {
|
||||||
|
// Handle auth url
|
||||||
|
signer.auth_url_handler(AuthHandler);
|
||||||
// Update signer
|
// Update signer
|
||||||
client.set_signer(Some(signer.into())).await;
|
client.set_signer(signer).await;
|
||||||
public_key
|
// Emit to front-end
|
||||||
|
handle.emit("signer-updated", ()).unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
// NIP-65: Connect to user's relay list
|
|
||||||
init_nip65(client, &public_key).await;
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
// NIP-03: Get user's contact list
|
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
|
||||||
let contact_list = {
|
let settings = state.settings.read().await.clone();
|
||||||
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
|
Ok(settings)
|
||||||
state.contact_list.lock().await.clone_from(&contacts);
|
}
|
||||||
contacts
|
|
||||||
} else {
|
#[tauri::command]
|
||||||
Vec::new()
|
#[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;
|
||||||
let public_key_clone = public_key.clone();
|
// Update state
|
||||||
|
settings.clone_from(&parsed);
|
||||||
// Run seperate thread for sync
|
|
||||||
tauri::async_runtime::spawn(async move {
|
Ok(())
|
||||||
let state = handle.state::<Nostr>();
|
|
||||||
let client = &state.client;
|
|
||||||
let author = PublicKey::from_str(&public_key).unwrap();
|
|
||||||
|
|
||||||
// Subscribe for new notification
|
|
||||||
if let Ok(e) = client
|
|
||||||
.subscribe_with_id(
|
|
||||||
SubscriptionId::new(NOTIFICATION_SUB_ID),
|
|
||||||
vec![Filter::new().pubkey(author).since(Timestamp::now())],
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Subscribed: {}", e.success.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get events from contact list
|
|
||||||
if !contact_list.is_empty() {
|
|
||||||
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
|
|
||||||
|
|
||||||
// Syncing all metadata events from contact list
|
|
||||||
if let Ok(report) = client
|
|
||||||
.reconcile(
|
|
||||||
Filter::new()
|
|
||||||
.authors(authors.clone())
|
|
||||||
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::EventDeletion])
|
|
||||||
.limit(authors.len() * 20),
|
|
||||||
NegentropyOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Received: {}", report.received.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncing all events from contact list
|
|
||||||
if let Ok(report) = client
|
|
||||||
.reconcile(
|
|
||||||
Filter::new()
|
|
||||||
.authors(authors.clone())
|
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
|
||||||
.limit(authors.len() * 50),
|
|
||||||
NegentropyOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Received: {}", report.received.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the trusted public key list from contact list
|
|
||||||
// TODO: create a cached file
|
|
||||||
let mut trusted_list: HashSet<PublicKey> = HashSet::new();
|
|
||||||
|
|
||||||
for author in authors.into_iter() {
|
|
||||||
trusted_list.insert(author);
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(author)
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if let Ok(events) = client.database().query(vec![filter]).await {
|
|
||||||
if let Some(event) = get_latest_event(&events) {
|
|
||||||
for tag in event.tags.iter() {
|
|
||||||
if let Some(TagStandard::PublicKey {
|
|
||||||
public_key,
|
|
||||||
uppercase: false,
|
|
||||||
..
|
|
||||||
}) = tag.to_owned().to_standardized()
|
|
||||||
{
|
|
||||||
trusted_list.insert(public_key);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update app's state
|
|
||||||
state.trusted_list.lock().await.clone_from(&trusted_list);
|
|
||||||
|
|
||||||
// Syncing all user's events
|
|
||||||
if let Ok(report) = client
|
|
||||||
.reconcile(Filter::new().author(author), NegentropyOptions::default())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Received: {}", report.received.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncing all tagged events for current user
|
|
||||||
if let Ok(report) = client
|
|
||||||
.reconcile(
|
|
||||||
Filter::new().pubkey(author).kinds(vec![
|
|
||||||
Kind::TextNote,
|
|
||||||
Kind::Repost,
|
|
||||||
Kind::Reaction,
|
|
||||||
Kind::ZapReceipt,
|
|
||||||
]),
|
|
||||||
NegentropyOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Received: {}", report.received.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncing all events for trusted list
|
|
||||||
let trusted: Vec<PublicKey> = trusted_list.into_iter().collect();
|
|
||||||
if let Ok(report) = client
|
|
||||||
.reconcile(
|
|
||||||
Filter::new()
|
|
||||||
.authors(trusted)
|
|
||||||
.kinds(vec![
|
|
||||||
Kind::Metadata,
|
|
||||||
Kind::TextNote,
|
|
||||||
Kind::Repost,
|
|
||||||
Kind::EventDeletion,
|
|
||||||
])
|
|
||||||
.limit(30000),
|
|
||||||
NegentropyOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Received: {}", report.received.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a little longer
|
|
||||||
// TODO: remove?
|
|
||||||
sleep(Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle
|
|
||||||
.emit("neg_synchronized", ())
|
|
||||||
.expect("Something wrong!");
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(public_key_clone)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use futures::future::join_all;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{str::FromStr, time::Duration};
|
use std::{str::FromStr, time::Duration};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::common::{create_tags, get_latest_event, parse_event, process_event, Meta};
|
use crate::common::{create_tags, parse_event, process_event, Meta};
|
||||||
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
|
use crate::{Nostr, DEFAULT_DIFFICULTY, FETCH_LIMIT};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Type)]
|
#[derive(Debug, Clone, Serialize, Type)]
|
||||||
@@ -14,185 +13,84 @@ pub struct RichEvent {
|
|||||||
pub parsed: Option<Meta>,
|
pub parsed: Option<Meta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn get_event_meta(content: String) -> Result<Meta, ()> {
|
|
||||||
let meta = parse_event(&content).await;
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||||
let filter = Filter::new().id(event_id);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter.clone()]).await {
|
let events = client
|
||||||
Ok(events) => {
|
.database()
|
||||||
if let Some(event) = get_latest_event(&events) {
|
.event_by_id(&event_id)
|
||||||
let raw = event.as_json();
|
.await
|
||||||
let parsed = if event.kind == Kind::TextNote {
|
.map_err(|err| err.to_string())?;
|
||||||
Some(parse_event(&event.content).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RichEvent { raw, parsed })
|
if let Some(event) = events {
|
||||||
} else {
|
let raw = event.as_json();
|
||||||
println!("Not found, getting event from relays...");
|
let parsed = if event.kind == Kind::TextNote {
|
||||||
match client
|
Some(parse_event(&event.content).await)
|
||||||
.stream_events_of(vec![filter], Some(Duration::from_secs(10)))
|
} else {
|
||||||
.await
|
None
|
||||||
{
|
};
|
||||||
Ok(mut rx) => {
|
|
||||||
let mut raw: String = String::new();
|
|
||||||
let mut parsed: Option<Meta> = None;
|
|
||||||
|
|
||||||
while let Some(event) = rx.next().await {
|
Ok(RichEvent { raw, parsed })
|
||||||
raw = event.as_json();
|
} else {
|
||||||
parsed = if event.kind == Kind::TextNote {
|
let filter = Filter::new().id(event_id).limit(1);
|
||||||
Some(parse_event(&event.content).await)
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
} else {
|
let mut rx = client
|
||||||
None
|
.stream_events(vec![filter], Some(Duration::from_secs(5)))
|
||||||
};
|
.await
|
||||||
}
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(RichEvent { raw, parsed })
|
while let Some(event) = rx.next().await {
|
||||||
}
|
events.insert(event);
|
||||||
Err(err) => Err(err.to_string()),
|
}
|
||||||
}
|
|
||||||
}
|
if let Some(event) = events.first() {
|
||||||
|
let raw = event.as_json();
|
||||||
|
let parsed = if event.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&event.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RichEvent { raw, parsed })
|
||||||
|
} else {
|
||||||
|
Err("Event not found.".into())
|
||||||
}
|
}
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_event_from(
|
pub async fn get_meta_from_event(content: String) -> Result<Meta, ()> {
|
||||||
id: String,
|
Ok(parse_event(&content).await)
|
||||||
relay_hint: String,
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
) -> Result<RichEvent, String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let settings = state.settings.lock().await;
|
|
||||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
|
||||||
let filter = Filter::new().id(event_id);
|
|
||||||
|
|
||||||
if !settings.use_relay_hint {
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let raw = event.as_json();
|
|
||||||
let parsed = if event.kind == Kind::TextNote {
|
|
||||||
Some(parse_event(&event.content).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RichEvent { raw, parsed })
|
|
||||||
} else {
|
|
||||||
Err("Cannot found this event with current relay list".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add relay hint to relay pool
|
|
||||||
if let Err(e) = client.add_relay(&relay_hint).await {
|
|
||||||
return Err(e.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = client.connect_relay(&relay_hint).await {
|
|
||||||
return Err(e.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![Filter::new().id(event_id)],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let raw = event.as_json();
|
|
||||||
let parsed = if event.kind == Kind::TextNote {
|
|
||||||
Some(parse_event(&event.content).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RichEvent { raw, parsed })
|
|
||||||
} else {
|
|
||||||
Err("Cannot found this event with current relay list".into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||||
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
let futures = events.iter().map(|ev| async move {
|
|
||||||
let raw = ev.as_json();
|
|
||||||
let parsed = if ev.kind == Kind::TextNote {
|
|
||||||
Some(parse_event(&ev.content).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
RichEvent { raw, parsed }
|
|
||||||
});
|
|
||||||
let rich_events = join_all(futures).await;
|
|
||||||
|
|
||||||
Ok(rich_events)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn subscribe_to(id: String, state: State<'_, Nostr>) -> Result<(), String> {
|
|
||||||
let client = &state.client;
|
|
||||||
|
|
||||||
let subscription_id = SubscriptionId::new(&id);
|
|
||||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kinds(vec![Kind::TextNote])
|
.kind(Kind::Comment)
|
||||||
.event(event_id)
|
.custom_tag(SingleLetterTag::uppercase(Alphabet::E), [event_id]);
|
||||||
.since(Timestamp::now());
|
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(e.to_string()),
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, true).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -210,13 +108,20 @@ pub async fn get_all_events_by_author(
|
|||||||
.author(author)
|
.author(author)
|
||||||
.limit(limit as usize);
|
.limit(limit as usize);
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.get_events_of(vec![filter], EventSource::Database)
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
|
||||||
Err(err) => Err(err.to_string()),
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -229,28 +134,35 @@ pub async fn get_all_events_by_authors(
|
|||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
let as_of = match until {
|
let as_of = match until {
|
||||||
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
None => Timestamp::now(),
|
None => Timestamp::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let authors: Vec<PublicKey> = public_keys
|
let authors: Vec<PublicKey> = public_keys
|
||||||
.iter()
|
.iter()
|
||||||
.map(|pk| PublicKey::from_str(pk).map_err(|err| err.to_string()))
|
.filter_map(|pk| PublicKey::from_str(pk).ok())
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect();
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
|
.authors(authors)
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
.limit(FETCH_LIMIT)
|
.limit(FETCH_LIMIT)
|
||||||
.until(as_of)
|
.until(as_of);
|
||||||
.authors(authors);
|
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.get_events_of(vec![filter], EventSource::Database)
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
|
||||||
Err(err) => Err(err.to_string()),
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -273,15 +185,224 @@ pub async fn get_all_events_by_hashtags(
|
|||||||
.until(as_of)
|
.until(as_of)
|
||||||
.hashtags(hashtags);
|
.hashtags(hashtags);
|
||||||
|
|
||||||
match client
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
let mut rx = client
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
|
||||||
Err(err) => Err(err.to_string()),
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_events_from(
|
||||||
|
url: String,
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let _ = client.add_read_relay(&url).await;
|
||||||
|
let _ = client.connect_relay(&url).await;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events_from(vec![url], vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_events_by_kind(
|
||||||
|
kind: u16,
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(kind))
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_providers(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(31990))
|
||||||
|
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), vec!["5300"]);
|
||||||
|
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn request_events_from_provider(
|
||||||
|
provider: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||||
|
let public_key = signer
|
||||||
|
.get_public_key()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
// Get current user's relay list
|
||||||
|
let relay_list = client
|
||||||
|
.database()
|
||||||
|
.relay_list(public_key)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let relay_list: Vec<String> = relay_list.iter().map(|item| item.0.to_string()).collect();
|
||||||
|
|
||||||
|
// Create job request
|
||||||
|
let builder = EventBuilder::job_request(
|
||||||
|
Kind::JobRequest(5300),
|
||||||
|
vec![
|
||||||
|
Tag::public_key(provider),
|
||||||
|
Tag::custom(TagKind::Relays, relay_list),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
match client.send_event_builder(builder).await {
|
||||||
|
Ok(output) => {
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::JobResult(6300))
|
||||||
|
.author(provider)
|
||||||
|
.pubkey(public_key)
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||||
|
|
||||||
|
let _ = client.subscribe(vec![filter], Some(opts)).await;
|
||||||
|
|
||||||
|
Ok(output.val.to_hex())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_events_by_request(
|
||||||
|
id: String,
|
||||||
|
provider: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|err| err.to_string())?;
|
||||||
|
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::JobResult(6300))
|
||||||
|
.author(provider)
|
||||||
|
.pubkey(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
if let Some(event) = events.first() {
|
||||||
|
let parsed: Vec<Vec<String>> =
|
||||||
|
serde_json::from_str(&event.content).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let vec: Vec<Tag> = parsed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| Tag::parse(&item).ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let tags = Tags::new(vec);
|
||||||
|
let ids: Vec<EventId> = tags.event_ids().copied().collect();
|
||||||
|
|
||||||
|
let filter = Filter::new().ids(ids);
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
} else {
|
||||||
|
Err("Job result not found.".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +425,7 @@ pub async fn get_local_events(
|
|||||||
.until(as_of);
|
.until(as_of);
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
match client.database().query(vec![filter]).await {
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
Ok(events) => Ok(process_event(client, events, false).await),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,14 +448,8 @@ pub async fn get_global_events(
|
|||||||
.limit(FETCH_LIMIT)
|
.limit(FETCH_LIMIT)
|
||||||
.until(as_of);
|
.until(as_of);
|
||||||
|
|
||||||
match client
|
match client.database().query(vec![filter]).await {
|
||||||
.get_events_of(
|
Ok(events) => Ok(process_event(client, events, false).await),
|
||||||
vec![filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +464,7 @@ pub async fn publish(
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
// Create tags from content
|
// Create event tags from content
|
||||||
let mut tags = create_tags(&content);
|
let mut tags = create_tags(&content);
|
||||||
|
|
||||||
// Add client tag
|
// Add client tag
|
||||||
@@ -369,85 +484,81 @@ pub async fn publish(
|
|||||||
let builder =
|
let builder =
|
||||||
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
|
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
|
||||||
|
|
||||||
// Publish
|
// Sign event
|
||||||
match client.send_event_builder(builder).await {
|
let event = client
|
||||||
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
|
.sign_event_builder(builder)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
// Save to local database
|
||||||
|
match client.send_event(event).await {
|
||||||
|
Ok(output) => Ok(output.to_hex()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn reply(
|
pub async fn reply(content: String, to: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
content: String,
|
|
||||||
to: String,
|
|
||||||
root: Option<String>,
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let database = client.database();
|
|
||||||
|
|
||||||
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
|
// Create event tags from content
|
||||||
let mut tags = create_tags(&content);
|
let mut tags = create_tags(&content);
|
||||||
|
|
||||||
match database.query(vec![Filter::new().id(reply_id)]).await {
|
// Add client tag
|
||||||
Ok(events) => {
|
// TODO: allow user config this setting
|
||||||
if let Some(event) = events.first() {
|
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
|
||||||
let relay_hint = if let Some(relays) = database
|
|
||||||
.event_seen_on_relays(&event.id)
|
// Get reply event
|
||||||
.await
|
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
|
||||||
.map_err(|err| err.to_string())?
|
|
||||||
{
|
let reply_to = match client.database().event_by_id(&reply_id).await {
|
||||||
relays.into_iter().next().map(UncheckedUrl::new)
|
Ok(event) => {
|
||||||
} else {
|
if let Some(event) = event {
|
||||||
None
|
event
|
||||||
};
|
|
||||||
let t = TagStandard::Event {
|
|
||||||
event_id: event.id,
|
|
||||||
relay_url: relay_hint,
|
|
||||||
marker: Some(Marker::Reply),
|
|
||||||
public_key: Some(event.pubkey),
|
|
||||||
};
|
|
||||||
let tag = Tag::from(t);
|
|
||||||
tags.push(tag)
|
|
||||||
} else {
|
} else {
|
||||||
return Err("Reply event is not found.".into());
|
return Err("Event not found in database, cannot reply.".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err.to_string()),
|
Err(e) => return Err(e.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(id) = root {
|
// Find root event from reply
|
||||||
let root_id = match EventId::from_hex(id) {
|
let root_tag = reply_to
|
||||||
Ok(val) => val,
|
.tags
|
||||||
Err(_) => return Err("Event is not valid.".into()),
|
.find(TagKind::SingleLetter(SingleLetterTag::uppercase(
|
||||||
};
|
Alphabet::E,
|
||||||
|
)));
|
||||||
|
|
||||||
if let Ok(events) = database.query(vec![Filter::new().id(root_id)]).await {
|
// Get root event if exist
|
||||||
if let Some(event) = events.first() {
|
let root = match root_tag {
|
||||||
let relay_hint = if let Some(relays) = database
|
Some(tag) => match tag.content() {
|
||||||
.event_seen_on_relays(&event.id)
|
Some(content) => {
|
||||||
|
let id = EventId::parse(content).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.database()
|
||||||
|
.event_by_id(&id)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?
|
.map_err(|err| err.to_string())?
|
||||||
{
|
|
||||||
relays.into_iter().next().map(UncheckedUrl::new)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let t = TagStandard::Event {
|
|
||||||
event_id: event.id,
|
|
||||||
relay_url: relay_hint,
|
|
||||||
marker: Some(Marker::Root),
|
|
||||||
public_key: Some(event.pubkey),
|
|
||||||
};
|
|
||||||
let tag = Tag::from(t);
|
|
||||||
tags.push(tag)
|
|
||||||
}
|
}
|
||||||
}
|
None => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match client.publish_text_note(content, tags).await {
|
let builder = EventBuilder::comment(content, &reply_to, root.as_ref(), None)
|
||||||
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
|
.add_tags(tags)
|
||||||
|
.pow(DEFAULT_DIFFICULTY);
|
||||||
|
|
||||||
|
// Sign event
|
||||||
|
let event = client
|
||||||
|
.sign_event_builder(builder)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
match client.send_event(event).await {
|
||||||
|
Ok(output) => Ok(output.to_hex()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,23 +570,69 @@ pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, Stri
|
|||||||
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
|
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match client.repost(&event, None).await {
|
match client.repost(&event, None).await {
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
Ok(output) => Ok(output.to_hex()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn delete(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||||
|
let public_key = signer
|
||||||
|
.get_public_key()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match client.delete_event(event_id).await {
|
let filter = Filter::new()
|
||||||
Ok(event_id) => Ok(event_id.to_string()),
|
.event(event_id)
|
||||||
|
.kind(Kind::Repost)
|
||||||
|
.author(public_key);
|
||||||
|
|
||||||
|
match client.database().query(vec![filter]).await {
|
||||||
|
Ok(events) => Ok(!events.is_empty()),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
match client.delete_event(event_id).await {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
||||||
|
let public_key = signer
|
||||||
|
.get_public_key()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.event(event_id)
|
||||||
|
.kind(Kind::EventDeletion);
|
||||||
|
|
||||||
|
match client.database().query(vec![filter]).await {
|
||||||
|
Ok(events) => Ok(!events.is_empty()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
@@ -512,13 +669,11 @@ pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<Str
|
|||||||
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
|
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.get_events_of(
|
.database()
|
||||||
vec![Filter::new()
|
.query(vec![Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::RelayList)
|
.kind(Kind::RelayList)
|
||||||
.limit(1)],
|
.limit(1)])
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(events) => match events.first() {
|
Ok(events) => match events.first() {
|
||||||
@@ -552,38 +707,7 @@ pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEv
|
|||||||
let filter = Filter::new().search(query);
|
let filter = Filter::new().search(query);
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
match client.database().query(vec![filter]).await {
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
Ok(events) => Ok(process_event(client, events, false).await),
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let signer = client.signer().await.map_err(|err| err.to_string())?;
|
|
||||||
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
|
|
||||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.event(event_id)
|
|
||||||
.kind(Kind::EventDeletion);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
|
||||||
Ok(events) => Ok(!events.is_empty()),
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
|
||||||
let builder = EventBuilder::delete(vec![event_id]);
|
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,9 @@ use nostr_sdk::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{str::FromStr, time::Duration};
|
use std::{str::FromStr, time::Duration};
|
||||||
use tauri::{Emitter, Manager, State};
|
use tauri::State;
|
||||||
use tauri_specta::Event;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
|
||||||
common::{get_latest_event, process_event},
|
|
||||||
NewSettings, Nostr, RichEvent, Settings,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[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 {
|
||||||
@@ -33,54 +17,27 @@ pub struct Mention {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_profile(id: Option<String>, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
let public_key: PublicKey = match id {
|
|
||||||
Some(user_id) => PublicKey::parse(&user_id).map_err(|e| e.to_string())?,
|
|
||||||
None => {
|
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
|
||||||
signer.public_key().await.map_err(|e| e.to_string())?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
match client.database().query(vec![filter.clone()]).await {
|
let events = client
|
||||||
Ok(events) => {
|
.database()
|
||||||
if let Some(event) = get_latest_event(&events) {
|
.query(vec![filter])
|
||||||
if let Ok(metadata) = Metadata::from_json(&event.content) {
|
.await
|
||||||
Ok(metadata.as_json())
|
.map_err(|e| e.to_string())?;
|
||||||
} else {
|
|
||||||
Err("Parse metadata failed".into())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("Not found, getting event from relays...");
|
|
||||||
match client
|
|
||||||
.stream_events_of(vec![filter], Some(Duration::from_secs(10)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(mut rx) => {
|
|
||||||
let mut metadata: String = Metadata::new().as_json();
|
|
||||||
|
|
||||||
while let Some(event) = rx.next().await {
|
match events.first() {
|
||||||
println!("Event: {}", event.as_json());
|
Some(event) => match Metadata::from_json(&event.content) {
|
||||||
if let Ok(m) = Metadata::from_json(&event.content) {
|
Ok(metadata) => Ok(metadata.as_json()),
|
||||||
metadata = m.as_json();
|
Err(e) => Err(e.to_string()),
|
||||||
break;
|
},
|
||||||
}
|
None => Err("Metadata not found".into()),
|
||||||
}
|
|
||||||
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,42 +64,26 @@ pub async fn set_contact_list(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let contact_list = state.contact_list.lock().await.clone();
|
let client = &state.client;
|
||||||
let vec: Vec<String> = contact_list
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
.into_iter()
|
|
||||||
.map(|f| f.public_key.to_hex())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(vec)
|
let contact_list = client
|
||||||
|
.database()
|
||||||
|
.contacts_public_keys(public_key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let pubkeys: Vec<String> = contact_list.into_iter().map(|pk| pk.to_hex()).collect();
|
||||||
|
|
||||||
|
Ok(pubkeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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()),
|
||||||
@@ -152,13 +93,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn check_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
let contact_list = &state.contact_list.lock().await;
|
let client = &state.client;
|
||||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match contact_list.iter().position(|x| x.public_key == public_key) {
|
match client.database().contacts_public_keys(public_key).await {
|
||||||
Some(_) => Ok(true),
|
Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
|
||||||
None => Ok(false),
|
Err(_) => Ok(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +147,17 @@ pub async fn set_group(
|
|||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
users: Vec<String>,
|
users: Vec<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
handle: tauri::AppHandle,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let public_keys: Vec<PublicKey> = users
|
let public_keys: Vec<PublicKey> = users
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| PublicKey::from_str(u).unwrap())
|
.filter_map(|u| {
|
||||||
|
if let Ok(pk) = PublicKey::from_str(u) {
|
||||||
|
Some(pk)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let label = title.to_lowercase().replace(" ", "-");
|
let label = title.to_lowercase().replace(" ", "-");
|
||||||
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
let mut tags: Vec<Tag> = vec![Tag::title(title)];
|
||||||
@@ -227,26 +173,15 @@ pub async fn set_group(
|
|||||||
|
|
||||||
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
|
let builder = EventBuilder::follow_set(label, public_keys.clone()).add_tags(tags);
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
// Sign event
|
||||||
Ok(report) => {
|
let event = client
|
||||||
tauri::async_runtime::spawn(async move {
|
.sign_event_builder(builder)
|
||||||
let state = handle.state::<Nostr>();
|
.await
|
||||||
let client = &state.client;
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
match client.send_event(event).await {
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
Ok(output) => Ok(output.to_hex()),
|
||||||
.authors(public_keys)
|
Err(err) => Err(err.to_string()),
|
||||||
.limit(500);
|
|
||||||
|
|
||||||
if let Ok(report) = client.reconcile(filter, NegentropyOptions::default()).await {
|
|
||||||
println!("Received: {}", report.received.len());
|
|
||||||
handle.emit("synchronized", ()).unwrap();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(report.id().to_hex())
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,29 +190,83 @@ pub async fn set_group(
|
|||||||
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
let filter = Filter::new().kind(Kind::FollowSet).id(event_id);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
match client.database().event_by_id(&event_id).await {
|
||||||
Ok(events) => match get_latest_event(&events) {
|
Ok(event) => {
|
||||||
Some(ev) => Ok(ev.as_json()),
|
if let Some(ev) = event {
|
||||||
None => Err("Not found.".to_string()),
|
Ok(ev.as_json())
|
||||||
},
|
} else {
|
||||||
|
Err("Event not found".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_all_groups(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
pub async fn get_all_newsfeeds(
|
||||||
|
id: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
let filter = Filter::new().kind(Kind::FollowSet).author(public_key);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
let groups = Filter::new().kind(Kind::FollowSet).author(public_key);
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
let contacts = Filter::new()
|
||||||
Err(e) => Err(e.to_string()),
|
.kind(Kind::ContactList)
|
||||||
|
.author(public_key)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let mut remote_events = Events::new(&[groups.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![groups], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
remote_events.insert(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contact_events = client
|
||||||
|
.fetch_events(vec![contacts], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let events = remote_events.merge(contact_events);
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_local_newsfeeds(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::FollowSet)
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -288,7 +277,6 @@ pub async fn set_interest(
|
|||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
hashtags: Vec<String>,
|
hashtags: Vec<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
handle: tauri::AppHandle,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let label = title.to_lowercase().replace(" ", "-");
|
let label = title.to_lowercase().replace(" ", "-");
|
||||||
@@ -305,26 +293,15 @@ pub async fn set_interest(
|
|||||||
|
|
||||||
let builder = EventBuilder::interest_set(label, hashtags.clone()).add_tags(tags);
|
let builder = EventBuilder::interest_set(label, hashtags.clone()).add_tags(tags);
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
// Sign event
|
||||||
Ok(report) => {
|
let event = client
|
||||||
tauri::async_runtime::spawn(async move {
|
.sign_event_builder(builder)
|
||||||
let state = handle.state::<Nostr>();
|
.await
|
||||||
let client = &state.client;
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
let filter = Filter::new()
|
match client.send_event(event).await {
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
Ok(output) => Ok(output.to_hex()),
|
||||||
.hashtags(hashtags)
|
Err(err) => Err(err.to_string()),
|
||||||
.limit(500);
|
|
||||||
|
|
||||||
if let Ok(report) = client.reconcile(filter, NegentropyOptions::default()).await {
|
|
||||||
println!("Received: {}", report.received.len());
|
|
||||||
handle.emit("synchronized", ()).unwrap();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(report.id().to_hex())
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,38 +310,109 @@ pub async fn set_interest(
|
|||||||
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
let filter = Filter::new()
|
|
||||||
.kinds(vec![Kind::Interests, Kind::InterestSet])
|
|
||||||
.id(event_id);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
match client.database().event_by_id(&event_id).await {
|
||||||
Ok(events) => match get_latest_event(&events) {
|
Ok(event) => {
|
||||||
Some(ev) => Ok(ev.as_json()),
|
if let Some(ev) = event {
|
||||||
None => Err("Not found.".to_string()),
|
Ok(ev.as_json())
|
||||||
},
|
} else {
|
||||||
|
Err("Event not found".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
pub async fn get_all_interests(
|
||||||
|
id: String,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kinds(vec![Kind::InterestSet, Kind::Interests])
|
.kinds(vec![Kind::InterestSet, Kind::Interests])
|
||||||
.author(public_key);
|
.author(public_key);
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
Ok(events) => Ok(process_event(client, events).await),
|
|
||||||
Err(e) => Err(e.to_string()),
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_all_local_interests(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let as_of = match until {
|
||||||
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
|
None => Timestamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds(vec![Kind::Interests, Kind::InterestSet])
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let alt_events = process_event(client, events, false).await;
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_relay_list(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.author(public_key)
|
||||||
|
.kind(Kind::RelayList)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
|
|
||||||
|
let mut rx = client
|
||||||
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(event) = events.first() {
|
||||||
|
Ok(event.as_json())
|
||||||
|
} else {
|
||||||
|
Err("Relay list not found".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let filter = Filter::new().kind(Kind::Metadata);
|
let filter = Filter::new().kind(Kind::Metadata);
|
||||||
|
|
||||||
@@ -399,7 +447,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
|||||||
|
|
||||||
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
|
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
|
||||||
let nwc = NWC::new(nwc_uri);
|
let nwc = NWC::new(nwc_uri);
|
||||||
let keyring = Entry::new("Lume Secret", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
let keyring =
|
||||||
|
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
keyring.set_password(uri).map_err(|e| e.to_string())?;
|
keyring.set_password(uri).map_err(|e| e.to_string())?;
|
||||||
client.set_zapper(nwc).await;
|
client.set_zapper(nwc).await;
|
||||||
|
|
||||||
@@ -411,37 +461,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> {
|
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keyring =
|
|
||||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match keyring.get_password() {
|
if client.zapper().await.is_err() {
|
||||||
Ok(val) => {
|
let keyring =
|
||||||
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
let nwc = NWC::new(uri);
|
|
||||||
|
|
||||||
// Get current balance
|
match keyring.get_password() {
|
||||||
let balance = nwc.get_balance().await;
|
Ok(val) => {
|
||||||
|
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
||||||
|
let nwc = NWC::new(uri);
|
||||||
|
|
||||||
// Update zapper
|
client.set_zapper(nwc).await;
|
||||||
client.set_zapper(nwc).await;
|
|
||||||
|
|
||||||
match balance {
|
|
||||||
Ok(val) => Ok(val.to_string()),
|
|
||||||
Err(_) => Err("Get balance failed.".into()),
|
|
||||||
}
|
}
|
||||||
|
Err(_) => return Err("Wallet not found.".into()),
|
||||||
}
|
}
|
||||||
Err(_) => Err("NWC not found.".into()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let keyring =
|
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match keyring.delete_credential() {
|
match keyring.delete_credential() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -455,52 +500,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn zap_profile(
|
pub async fn zap_profile(
|
||||||
id: &str,
|
id: String,
|
||||||
amount: &str,
|
amount: String,
|
||||||
message: &str,
|
message: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
|
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let details = ZapDetails::new(ZapType::Private).message(message);
|
|
||||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||||
|
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||||
|
|
||||||
if client.zap(public_key, num, Some(details)).await.is_ok() {
|
match client.zap(public_key, num, details).await {
|
||||||
Ok(true)
|
Ok(()) => Ok(()),
|
||||||
} else {
|
Err(e) => Err(e.to_string()),
|
||||||
Err("Zap profile failed".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn zap_event(
|
pub async fn zap_event(
|
||||||
id: &str,
|
id: String,
|
||||||
amount: &str,
|
amount: String,
|
||||||
message: &str,
|
message: Option<String>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let event_id = match Nip19::from_bech32(id) {
|
|
||||||
Ok(val) => match val {
|
|
||||||
Nip19::EventId(id) => id,
|
|
||||||
Nip19::Event(event) => event.event_id,
|
|
||||||
_ => return Err("Event ID is invalid.".into()),
|
|
||||||
},
|
|
||||||
Err(_) => match EventId::from_hex(id) {
|
|
||||||
Ok(val) => val,
|
|
||||||
Err(_) => return Err("Event ID is invalid.".into()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let details = ZapDetails::new(ZapType::Private).message(message);
|
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
|
||||||
|
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||||
|
|
||||||
if client.zap(event_id, num, Some(details)).await.is_ok() {
|
match client.zap(event_id, num, details).await {
|
||||||
Ok(true)
|
Ok(()) => Ok(()),
|
||||||
} else {
|
Err(e) => Err(e.to_string()),
|
||||||
Err("Zap event failed".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,10 +551,7 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if let Ok(contact_list_events) = client
|
if let Ok(contact_list_events) = client
|
||||||
.get_events_of(
|
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
|
||||||
vec![contact_list_filter],
|
|
||||||
EventSource::both(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
for event in contact_list_events.into_iter() {
|
for event in contact_list_events.into_iter() {
|
||||||
@@ -550,62 +580,34 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match client.signer().await {
|
let filter = Filter::new()
|
||||||
Ok(signer) => {
|
.pubkey(public_key)
|
||||||
let public_key = signer.public_key().await.unwrap();
|
.kinds(vec![
|
||||||
let filter = Filter::new()
|
Kind::TextNote,
|
||||||
.pubkey(public_key)
|
Kind::Repost,
|
||||||
.kinds(vec![
|
Kind::Reaction,
|
||||||
Kind::TextNote,
|
Kind::ZapReceipt,
|
||||||
Kind::Repost,
|
])
|
||||||
Kind::Reaction,
|
.limit(500);
|
||||||
Kind::ZapReceipt,
|
|
||||||
])
|
|
||||||
.limit(200);
|
|
||||||
|
|
||||||
match client.database().query(vec![filter]).await {
|
let mut events = Events::new(&[filter.clone()]);
|
||||||
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
|
|
||||||
Err(err) => Err(err.to_string()),
|
let mut rx = client
|
||||||
}
|
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||||
}
|
.await
|
||||||
Err(err) => Err(err.to_string()),
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
events.insert(event);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
|
||||||
#[specta::specta]
|
|
||||||
pub async fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
|
|
||||||
Ok(state.settings.lock().await.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
Ok(alt_events)
|
||||||
#[specta::specta]
|
|
||||||
pub async fn set_user_settings(
|
|
||||||
settings: String,
|
|
||||||
state: State<'_, Nostr>,
|
|
||||||
handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let client = &state.client;
|
|
||||||
let tags = vec![Tag::identifier("lume_user_setting")];
|
|
||||||
let builder = EventBuilder::new(Kind::ApplicationSpecificData, &settings, tags);
|
|
||||||
|
|
||||||
match client.send_event_builder(builder).await {
|
|
||||||
Ok(_) => {
|
|
||||||
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
state.settings.lock().await.clone_from(&parsed);
|
|
||||||
|
|
||||||
// Emit new changes to frontend
|
|
||||||
NewSettings(parsed).emit(&handle).unwrap();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => Err(err.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -619,12 +621,3 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
|
|||||||
Err(e) => Err(e.to_string()),
|
Err(e) => Err(e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
|
||||||
let trusted_list = &state.trusted_list.lock().await;
|
|
||||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
Ok(trusted_list.contains(&public_key))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod account;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
|
pub mod sync;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
use crate::Nostr;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::{
|
use std::str::FromStr;
|
||||||
fs::OpenOptions,
|
use tauri::State;
|
||||||
io::{self, BufRead, Write},
|
|
||||||
time::Duration,
|
use crate::{Nostr, FETCH_LIMIT};
|
||||||
};
|
|
||||||
use tauri::{path::BaseDirectory, Manager, State};
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
#[derive(Serialize, Type)]
|
||||||
pub struct Relays {
|
pub struct Relays {
|
||||||
@@ -19,149 +16,67 @@ pub struct Relays {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
|
pub async fn get_all_relays(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let relays = client.pool().all_relays().await;
|
||||||
|
let v: Vec<String> = relays.iter().map(|item| item.0.to_string()).collect();
|
||||||
|
|
||||||
let connected_relays = client
|
Ok(v)
|
||||||
.relays()
|
|
||||||
.await
|
|
||||||
.into_keys()
|
|
||||||
.map(|url| url.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let signer = client.signer().await.map_err(|e| e.to_string())?;
|
|
||||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
|
||||||
.author(public_key)
|
|
||||||
.kind(Kind::RelayList)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
match client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::Relays {
|
|
||||||
timeout: Some(Duration::from_secs(5)),
|
|
||||||
specific_relays: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(events) => {
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let read = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if let Some(RelayMetadata::Read) = meta {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let write = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if let Some(RelayMetadata::Write) = meta {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let both = nip65_list
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(url, meta)| {
|
|
||||||
if meta.is_none() {
|
|
||||||
Some(url.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Relays {
|
|
||||||
connected: connected_relays,
|
|
||||||
read: Some(read),
|
|
||||||
write: Some(write),
|
|
||||||
both: Some(both),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(Relays {
|
|
||||||
connected: connected_relays,
|
|
||||||
read: None,
|
|
||||||
write: None,
|
|
||||||
both: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn get_all_relay_lists(
|
||||||
|
until: Option<String>,
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
|
|
||||||
if status {
|
let as_of = match until {
|
||||||
println!("Connecting to relay: {}", relay);
|
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||||
client
|
None => Timestamp::now(),
|
||||||
.connect_relay(relay)
|
};
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
let filter = Filter::new()
|
||||||
}
|
.kind(Kind::RelayList)
|
||||||
|
.limit(FETCH_LIMIT)
|
||||||
|
.until(as_of);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
|
||||||
|
|
||||||
|
Ok(alt_events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn is_relay_connected(relay: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||||
|
let client = &state.client;
|
||||||
|
let status = client.add_relay(&relay).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
pub async fn connect_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
client
|
let _ = client.add_relay(&relay).await;
|
||||||
.remove_relay(relay)
|
let _ = client.connect_relay(&relay).await;
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
Ok(())
|
||||||
client
|
|
||||||
.disconnect_relay(relay)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||||
let relays_path = app
|
let client = &state.client;
|
||||||
.path()
|
let _ = client.force_remove_relay(relay).await;
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let file = std::fs::File::open(relays_path).map_err(|e| e.to_string())?;
|
Ok(())
|
||||||
let reader = io::BufReader::new(file);
|
|
||||||
|
|
||||||
reader
|
|
||||||
.lines()
|
|
||||||
.collect::<Result<Vec<String>, io::Error>>()
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
let relays_path = app
|
|
||||||
.path()
|
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.open(relays_path)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
file.write_all(relays.as_bytes()).map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn sync_all(
|
||||||
|
state: State<'_, Nostr>,
|
||||||
|
reader: tauri::ipc::Channel<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
// Create a filter for get all public keys
|
||||||
|
let filter = Filter::new().kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::ContactList,
|
||||||
|
Kind::MuteList,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.database()
|
||||||
|
.query(vec![filter])
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let public_keys: Vec<PublicKey> = events
|
||||||
|
.iter()
|
||||||
|
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (tx, mut rx) = SyncProgress::channel();
|
||||||
|
let opts = SyncOptions::default().progress(tx);
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
while rx.changed().await.is_ok() {
|
||||||
|
let progress = *rx.borrow_and_update();
|
||||||
|
|
||||||
|
if progress.total > 0 {
|
||||||
|
reader.send(progress.percentage() * 100.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for chunk in public_keys.chunks(200) {
|
||||||
|
let authors = chunk.to_owned();
|
||||||
|
let filter = Filter::new().authors(authors).kinds(vec![
|
||||||
|
Kind::Metadata,
|
||||||
|
Kind::ContactList,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::Interests,
|
||||||
|
Kind::InterestSet,
|
||||||
|
Kind::EventDeletion,
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Comment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.sync(filter, &opts)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::utils::config::WindowEffectsConfig;
|
use std::path::PathBuf;
|
||||||
use tauri::window::Effect;
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::TitleBarStyle;
|
use tauri::TitleBarStyle;
|
||||||
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl};
|
use tauri::{
|
||||||
use tauri::{WebviewBuilder, WebviewWindowBuilder};
|
utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
|
||||||
|
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
use tauri_plugin_decorum::WebviewWindowExt;
|
use tauri_plugin_decorum::WebviewWindowExt;
|
||||||
|
|
||||||
|
use crate::common::get_last_segment;
|
||||||
|
use crate::Nostr;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Type)]
|
||||||
pub struct Window {
|
pub struct NewWindow {
|
||||||
label: String,
|
label: String,
|
||||||
title: String,
|
title: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -22,6 +26,7 @@ pub struct Window {
|
|||||||
maximizable: bool,
|
maximizable: bool,
|
||||||
minimizable: bool,
|
minimizable: bool,
|
||||||
hidden_title: bool,
|
hidden_title: bool,
|
||||||
|
closable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Type)]
|
||||||
@@ -34,85 +39,150 @@ pub struct Column {
|
|||||||
height: f32,
|
height: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> {
|
pub async fn create_column(
|
||||||
match app_handle.get_window("main") {
|
column: Column,
|
||||||
Some(main_window) => match app_handle.get_webview(&column.label) {
|
app_handle: tauri::AppHandle,
|
||||||
Some(_) => Ok(column.label),
|
main_window: Window,
|
||||||
None => {
|
) -> Result<String, String> {
|
||||||
let path = PathBuf::from(column.url);
|
match app_handle.get_webview(&column.label) {
|
||||||
let webview_url = WebviewUrl::App(path);
|
Some(webview) => {
|
||||||
|
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
|
||||||
let builder = WebviewBuilder::new(column.label, webview_url)
|
return Err(e.to_string());
|
||||||
.incognito(true)
|
|
||||||
.transparent(true);
|
|
||||||
|
|
||||||
if let Ok(webview) = main_window.add_child(
|
|
||||||
builder,
|
|
||||||
LogicalPosition::new(column.x, column.y),
|
|
||||||
LogicalSize::new(column.width, column.height),
|
|
||||||
) {
|
|
||||||
Ok(webview.label().into())
|
|
||||||
} else {
|
|
||||||
Err("Create webview failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
None => Err("Main window not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
|
||||||
#[specta::specta]
|
return Err(e.to_string());
|
||||||
pub fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
}
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.close().is_ok()),
|
|
||||||
None => Err("Not found.".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
Ok(column.label)
|
||||||
#[specta::specta]
|
}
|
||||||
pub fn reposition_column(
|
None => {
|
||||||
label: String,
|
let path = PathBuf::from(column.url);
|
||||||
x: f32,
|
let webview_url = WebviewUrl::App(path);
|
||||||
y: f32,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.set_position(LogicalPosition::new(x, y)).is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
let builder = WebviewBuilder::new(column.label, webview_url)
|
||||||
#[specta::specta]
|
.incognito(true)
|
||||||
pub fn resize_column(
|
.transparent(true)
|
||||||
label: String,
|
.on_page_load(|webview, payload| match payload.event() {
|
||||||
width: f32,
|
PageLoadEvent::Started => {
|
||||||
height: f32,
|
if let Ok(id) = get_last_segment(payload.url()) {
|
||||||
app_handle: tauri::AppHandle,
|
if let Ok(public_key) = PublicKey::parse(&id) {
|
||||||
) -> Result<bool, String> {
|
let is_newsfeed = payload.url().to_string().contains("newsfeed");
|
||||||
match app_handle.get_webview(&label) {
|
|
||||||
Some(webview) => Ok(webview.set_size(LogicalSize::new(width, height)).is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
if is_newsfeed {
|
||||||
#[specta::specta]
|
tauri::async_runtime::spawn(async move {
|
||||||
pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
let state = webview.state::<Nostr>();
|
||||||
match app_handle.get_webview(&label) {
|
let client = &state.client;
|
||||||
Some(webview) => Ok(webview.eval("window.location.reload()").is_ok()),
|
|
||||||
None => Err("Not found".into()),
|
if let Ok(contact_list) =
|
||||||
|
client.database().contacts_public_keys(public_key).await
|
||||||
|
{
|
||||||
|
let subscription_id =
|
||||||
|
SubscriptionId::new(webview.label());
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(contact_list)
|
||||||
|
.kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::EventDeletion,
|
||||||
|
])
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_with_id(
|
||||||
|
subscription_id,
|
||||||
|
vec![filter],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Subscription error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if let Ok(event_id) = EventId::parse(&id) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let state = webview.state::<Nostr>();
|
||||||
|
let client = &state.client;
|
||||||
|
|
||||||
|
let subscription_id = SubscriptionId::new(webview.label());
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.custom_tag(
|
||||||
|
SingleLetterTag::uppercase(Alphabet::E),
|
||||||
|
[event_id],
|
||||||
|
)
|
||||||
|
.kind(Kind::Comment)
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Subscription error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PageLoadEvent::Finished => {
|
||||||
|
println!("{} finished loading", payload.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match main_window.add_child(
|
||||||
|
builder,
|
||||||
|
LogicalPosition::new(column.x, column.y),
|
||||||
|
LogicalSize::new(column.width, column.height),
|
||||||
|
) {
|
||||||
|
Ok(webview) => Ok(webview.label().into()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
#[cfg(target_os = "macos")]
|
pub async fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||||
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
|
match app_handle.get_webview(&label) {
|
||||||
|
Some(webview) => Ok(webview.close().is_ok()),
|
||||||
|
None => Err(format!("Cannot close, column not found: {}", label)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn close_all_columns(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let mut webviews = app_handle.webviews();
|
||||||
|
webviews.remove("main");
|
||||||
|
|
||||||
|
for webview in webviews.values() {
|
||||||
|
webview.close().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
match app_handle.get_webview(&label) {
|
||||||
|
Some(webview) => {
|
||||||
|
webview.eval("location.reload(true)").unwrap();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err("Cannot reload, column not found.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn open_window(window: NewWindow, app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||||
if let Some(current_window) = app_handle.get_window(&window.label) {
|
if let Some(current_window) = app_handle.get_window(&window.label) {
|
||||||
if current_window.is_visible().unwrap_or_default() {
|
if current_window.is_visible().unwrap_or_default() {
|
||||||
let _ = current_window.set_focus();
|
let _ = current_window.set_focus();
|
||||||
@@ -120,7 +190,10 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
let _ = current_window.show();
|
let _ = current_window.show();
|
||||||
let _ = current_window.set_focus();
|
let _ = current_window.set_focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok(current_window.label().to_string())
|
||||||
} else {
|
} else {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
let new_window = WebviewWindowBuilder::new(
|
let new_window = WebviewWindowBuilder::new(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&window.label,
|
&window.label,
|
||||||
@@ -134,6 +207,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.minimizable(window.minimizable)
|
.minimizable(window.minimizable)
|
||||||
.maximizable(window.maximizable)
|
.maximizable(window.maximizable)
|
||||||
.transparent(true)
|
.transparent(true)
|
||||||
|
.closable(window.closable)
|
||||||
.effects(WindowEffectsConfig {
|
.effects(WindowEffectsConfig {
|
||||||
state: None,
|
state: None,
|
||||||
effects: vec![Effect::UnderWindowBackground],
|
effects: vec![Effect::UnderWindowBackground],
|
||||||
@@ -143,26 +217,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Restore native border
|
#[cfg(target_os = "windows")]
|
||||||
new_window.add_border(None);
|
let new_window = WebviewWindowBuilder::new(
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
#[specta::specta]
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
|
|
||||||
if let Some(window) = app_handle.get_window(&window.label) {
|
|
||||||
if window.is_visible().unwrap_or_default() {
|
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let window = WebviewWindowBuilder::new(
|
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&window.label,
|
&window.label,
|
||||||
WebviewUrl::App(PathBuf::from(window.url)),
|
WebviewUrl::App(PathBuf::from(window.url)),
|
||||||
@@ -174,65 +230,18 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
|||||||
.maximizable(window.maximizable)
|
.maximizable(window.maximizable)
|
||||||
.transparent(true)
|
.transparent(true)
|
||||||
.decorations(false)
|
.decorations(false)
|
||||||
.effects(WindowEffectsConfig {
|
.closable(window.closable)
|
||||||
state: None,
|
|
||||||
effects: vec![Effect::Mica],
|
|
||||||
radius: None,
|
|
||||||
color: None,
|
|
||||||
})
|
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Set decoration
|
|
||||||
window.create_overlay_titlebar().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn reopen_lume(app: tauri::AppHandle) {
|
|
||||||
if let Some(window) = app.get_window("main") {
|
|
||||||
if window.is_visible().unwrap_or_default() {
|
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let window =
|
|
||||||
WebviewWindowBuilder::from_config(&app, app.config().app.windows.first().unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Set decoration
|
// Set decoration
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
window.create_overlay_titlebar().unwrap();
|
new_window.create_overlay_titlebar().unwrap();
|
||||||
|
|
||||||
// Restore native border
|
// Restore native border
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
window.add_border(None);
|
new_window.add_border(None);
|
||||||
|
|
||||||
// Set a custom inset to the traffic lights
|
Ok(new_window.label().to_string())
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let win = window.clone();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
window.on_window_event(move |event| {
|
|
||||||
if let tauri::WindowEvent::ThemeChanged(_) = event {
|
|
||||||
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn quit() {
|
|
||||||
std::process::exit(0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
|
use keyring_search::{Limit, List, Search};
|
||||||
use linkify::LinkFinder;
|
use linkify::LinkFinder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use reqwest::Client as ReqClient;
|
use reqwest::Client as ReqClient;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use std::collections::HashSet;
|
use std::{collections::HashSet, str::FromStr};
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::RichEvent;
|
use crate::RichEvent;
|
||||||
|
|
||||||
@@ -20,6 +19,8 @@ pub struct Meta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||||
|
// const VIDEOS: [&str; 6] = ["mp4", "avi", "mov", "mkv", "wmv", "webm"];
|
||||||
|
|
||||||
const NOSTR_EVENTS: [&str; 10] = [
|
const NOSTR_EVENTS: [&str; 10] = [
|
||||||
"@nevent1",
|
"@nevent1",
|
||||||
"@note1",
|
"@note1",
|
||||||
@@ -32,20 +33,19 @@ const NOSTR_EVENTS: [&str; 10] = [
|
|||||||
"Nostr:note1",
|
"Nostr:note1",
|
||||||
"Nostr:nevent1",
|
"Nostr:nevent1",
|
||||||
];
|
];
|
||||||
const NOSTR_MENTIONS: [&str; 10] = [
|
|
||||||
|
const NOSTR_MENTIONS: [&str; 8] = [
|
||||||
"@npub1",
|
"@npub1",
|
||||||
"nostr:npub1",
|
"nostr:npub1",
|
||||||
"nostr:nprofile1",
|
"nostr:nprofile1",
|
||||||
"nostr:naddr1",
|
|
||||||
"npub1",
|
"npub1",
|
||||||
"nprofile1",
|
"nprofile1",
|
||||||
"naddr1",
|
|
||||||
"Nostr:npub1",
|
"Nostr:npub1",
|
||||||
"Nostr:nprofile1",
|
"Nostr:nprofile1",
|
||||||
"Nostr:naddr1",
|
"Nostr:naddr1",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
|
pub fn get_latest_event(events: &Events) -> Option<&Event> {
|
||||||
events.iter().next()
|
events.iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,17 +56,17 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
|||||||
// Get words
|
// Get words
|
||||||
let words: Vec<_> = content.split_whitespace().collect();
|
let words: Vec<_> = content.split_whitespace().collect();
|
||||||
|
|
||||||
// Get mentions
|
|
||||||
let mentions = words
|
|
||||||
.iter()
|
|
||||||
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
|
||||||
.map(|&s| s.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
// Get hashtags
|
// Get hashtags
|
||||||
let hashtags = words
|
let hashtags = words
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&word| word.starts_with('#'))
|
.filter(|&&word| word.starts_with('#'))
|
||||||
|
.map(|&s| s.to_string().replace("#", "").to_lowercase())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Get mentions
|
||||||
|
let mentions = words
|
||||||
|
.iter()
|
||||||
|
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
|
||||||
.map(|&s| s.to_string())
|
.map(|&s| s.to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -92,8 +92,11 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
|||||||
}
|
}
|
||||||
if entity.starts_with("note") {
|
if entity.starts_with("note") {
|
||||||
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
if let Ok(event_id) = EventId::from_bech32(&entity) {
|
||||||
let hex = event_id.to_hex();
|
let tag = Tag::from_standardized(TagStandard::Quote {
|
||||||
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
|
event_id,
|
||||||
|
relay_url: None,
|
||||||
|
public_key: None,
|
||||||
|
});
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
@@ -101,14 +104,12 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
|||||||
}
|
}
|
||||||
if entity.starts_with("nevent") {
|
if entity.starts_with("nevent") {
|
||||||
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
if let Ok(event) = Nip19Event::from_bech32(&entity) {
|
||||||
let hex = event.event_id.to_hex();
|
let relay_url = event.relays.first().and_then(|i| Url::parse(i).ok());
|
||||||
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
|
let tag = Tag::from_standardized(TagStandard::Quote {
|
||||||
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
|
event_id: event.event_id,
|
||||||
|
relay_url,
|
||||||
if let Some(author) = event.author {
|
public_key: event.author,
|
||||||
let tag = Tag::public_key(author);
|
});
|
||||||
tags.push(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
} else {
|
} else {
|
||||||
@@ -130,30 +131,55 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
|||||||
tags
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent> {
|
pub fn get_all_accounts() -> Vec<String> {
|
||||||
// Remove event thread if event is TextNote
|
let search = Search::new().expect("Unexpected.");
|
||||||
let events: Vec<Event> = events
|
let results = search.by_service("Lume Safe Storage");
|
||||||
.into_iter()
|
let list = List::list_credentials(&results, Limit::All);
|
||||||
.filter_map(|ev| {
|
let accounts: HashSet<String> = list
|
||||||
if ev.kind == Kind::TextNote {
|
.split_whitespace()
|
||||||
let tags = ev
|
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
|
||||||
.tags
|
.map(String::from)
|
||||||
.iter()
|
|
||||||
.filter(|t| t.is_reply() || t.is_root())
|
|
||||||
.filter_map(|t| t.content())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if tags.is_empty() {
|
|
||||||
Some(ev)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some(ev)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
accounts.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_last_segment(url: &Url) -> Result<String, String> {
|
||||||
|
url.path_segments()
|
||||||
|
.ok_or("No segments".to_string())?
|
||||||
|
.last()
|
||||||
|
.ok_or("No items".into())
|
||||||
|
.map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_event(client: &Client, events: Events, is_reply: bool) -> Vec<RichEvent> {
|
||||||
|
// Remove event thread if event is TextNote
|
||||||
|
let events: Vec<Event> = if !is_reply {
|
||||||
|
events
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|ev| {
|
||||||
|
if ev.kind == Kind::TextNote {
|
||||||
|
let tags = ev
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.is_reply() || t.is_root())
|
||||||
|
.filter_map(|t| t.content())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if tags.is_empty() {
|
||||||
|
Some(ev)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(ev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
events.into_iter().collect()
|
||||||
|
};
|
||||||
|
|
||||||
// Get deletion request by event's authors
|
// Get deletion request by event's authors
|
||||||
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
||||||
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
||||||
@@ -164,7 +190,14 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
|
|||||||
if !requests.is_empty() {
|
if !requests.is_empty() {
|
||||||
let ids: Vec<&str> = requests
|
let ids: Vec<&str> = requests
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|ev| ev.get_tags_content(TagKind::e()))
|
.flat_map(|event| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.kind() == TagKind::e())
|
||||||
|
.filter_map(|t| t.content())
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Remove event if event is deleted by author
|
// Remove event if event is deleted by author
|
||||||
@@ -196,41 +229,6 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
|
|||||||
join_all(futures).await
|
join_all(futures).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn init_nip65(client: &Client, public_key: &str) {
|
|
||||||
let author = PublicKey::from_str(public_key).unwrap();
|
|
||||||
let filter = Filter::new().author(author).kind(Kind::RelayList).limit(1);
|
|
||||||
|
|
||||||
// client.add_relay("ws://127.0.0.1:1984").await.unwrap();
|
|
||||||
// client.connect_relay("ws://127.0.0.1:1984").await.unwrap();
|
|
||||||
|
|
||||||
if let Ok(events) = client
|
|
||||||
.get_events_of(
|
|
||||||
vec![filter],
|
|
||||||
EventSource::relays(Some(Duration::from_secs(5))),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Some(event) = events.first() {
|
|
||||||
let relay_list = nip65::extract_relay_list(event);
|
|
||||||
for (url, metadata) in relay_list {
|
|
||||||
let opts = match metadata {
|
|
||||||
Some(RelayMetadata::Read) => RelayOptions::new().read(true).write(false),
|
|
||||||
Some(_) => RelayOptions::new().write(true).read(false),
|
|
||||||
None => RelayOptions::default(),
|
|
||||||
};
|
|
||||||
if let Err(e) = client.pool().add_relay(&url.to_string(), opts).await {
|
|
||||||
eprintln!("Failed to add relay {}: {:?}", url, e);
|
|
||||||
}
|
|
||||||
if let Err(e) = client.connect_relay(url.to_string()).await {
|
|
||||||
eprintln!("Failed to connect to relay {}: {:?}", url, e);
|
|
||||||
} else {
|
|
||||||
println!("Connecting to relay: {} - {:?}", url, metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn parse_event(content: &str) -> Meta {
|
pub async fn parse_event(content: &str) -> Meta {
|
||||||
let mut finder = LinkFinder::new();
|
let mut finder = LinkFinder::new();
|
||||||
finder.url_must_have_scheme(false);
|
finder.url_must_have_scheme(false);
|
||||||
|
|||||||
@@ -5,181 +5,162 @@
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||||
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
|
use commands::{account::*, event::*, metadata::*, relay::*, sync::*, window::*};
|
||||||
use common::parse_event;
|
use common::{get_all_accounts, parse_event};
|
||||||
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
|
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use specta_typescript::Typescript;
|
use specta_typescript::Typescript;
|
||||||
use std::{
|
use std::{collections::HashSet, fs, str::FromStr, time::Duration};
|
||||||
collections::HashSet,
|
use tauri::{
|
||||||
fs,
|
menu::{Menu, MenuItem},
|
||||||
io::{self, BufRead},
|
Emitter, EventTarget, Listener, Manager, WebviewWindowBuilder,
|
||||||
str::FromStr,
|
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
use tauri::{path::BaseDirectory, Emitter, EventTarget, 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, collect_events, Builder, Event as TauriEvent};
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::sync::Mutex;
|
use tauri_specta::{collect_commands, Builder};
|
||||||
|
use tokio::{sync::RwLock, time::sleep};
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
|
||||||
pub struct Nostr {
|
pub struct Nostr {
|
||||||
client: Client,
|
client: Client,
|
||||||
settings: Mutex<Settings>,
|
queue: RwLock<HashSet<PublicKey>>,
|
||||||
contact_list: Mutex<Vec<Contact>>,
|
settings: RwLock<Settings>,
|
||||||
trusted_list: Mutex<HashSet<PublicKey>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Payload {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type)]
|
pub const DEFAULT_DIFFICULTY: u8 = 0;
|
||||||
enum SubKind {
|
pub const FETCH_LIMIT: usize = 50;
|
||||||
Subscribe,
|
pub const QUEUE_DELAY: u64 = 150;
|
||||||
Unsubscribe,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type, TauriEvent)]
|
|
||||||
struct Subscription {
|
|
||||||
label: String,
|
|
||||||
kind: SubKind,
|
|
||||||
event_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
|
|
||||||
struct NewSettings(Settings);
|
|
||||||
|
|
||||||
pub const DEFAULT_DIFFICULTY: u8 = 21;
|
|
||||||
pub const FETCH_LIMIT: usize = 100;
|
|
||||||
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
|
|
||||||
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
||||||
|
// Will be removed when almost relays support negentropy
|
||||||
|
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||||
|
"wss://relay.damus.io",
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://nostr.fmt.wiz.biz",
|
||||||
|
"wss://directory.yabu.me",
|
||||||
|
"wss://purplepag.es",
|
||||||
|
];
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
#[cfg(debug_assertions)]
|
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let builder = Builder::<tauri::Wry>::new()
|
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||||
// Then register them (separated by a comma)
|
sync_all,
|
||||||
.commands(collect_commands![
|
get_all_relays,
|
||||||
get_relays,
|
get_all_relay_lists,
|
||||||
connect_relay,
|
is_relay_connected,
|
||||||
remove_relay,
|
connect_relay,
|
||||||
get_bootstrap_relays,
|
remove_relay,
|
||||||
save_bootstrap_relays,
|
get_accounts,
|
||||||
get_accounts,
|
watch_account,
|
||||||
create_account,
|
import_account,
|
||||||
import_account,
|
connect_account,
|
||||||
connect_account,
|
get_private_key,
|
||||||
get_private_key,
|
delete_account,
|
||||||
delete_account,
|
reset_password,
|
||||||
reset_password,
|
has_signer,
|
||||||
is_account_sync,
|
set_signer,
|
||||||
create_sync_file,
|
get_profile,
|
||||||
login,
|
set_profile,
|
||||||
get_profile,
|
get_contact_list,
|
||||||
set_profile,
|
set_contact_list,
|
||||||
get_contact_list,
|
is_contact,
|
||||||
set_contact_list,
|
toggle_contact,
|
||||||
check_contact,
|
get_all_profiles,
|
||||||
toggle_contact,
|
set_group,
|
||||||
get_mention_list,
|
get_group,
|
||||||
set_group,
|
get_all_newsfeeds,
|
||||||
get_group,
|
get_all_local_newsfeeds,
|
||||||
get_all_groups,
|
set_interest,
|
||||||
set_interest,
|
get_interest,
|
||||||
get_interest,
|
get_all_interests,
|
||||||
get_all_interests,
|
get_all_local_interests,
|
||||||
set_wallet,
|
get_relay_list,
|
||||||
load_wallet,
|
set_wallet,
|
||||||
remove_wallet,
|
load_wallet,
|
||||||
zap_profile,
|
remove_wallet,
|
||||||
zap_event,
|
zap_profile,
|
||||||
copy_friend,
|
zap_event,
|
||||||
get_notifications,
|
copy_friend,
|
||||||
get_user_settings,
|
get_notifications,
|
||||||
set_user_settings,
|
verify_nip05,
|
||||||
verify_nip05,
|
get_meta_from_event,
|
||||||
is_trusted_user,
|
get_event,
|
||||||
get_event_meta,
|
get_replies,
|
||||||
get_event,
|
get_all_events_by_author,
|
||||||
get_event_from,
|
get_all_events_by_authors,
|
||||||
get_replies,
|
get_all_events_by_hashtags,
|
||||||
subscribe_to,
|
get_all_events_from,
|
||||||
get_all_events_by_author,
|
get_all_events_by_kind,
|
||||||
get_all_events_by_authors,
|
get_all_providers,
|
||||||
get_all_events_by_hashtags,
|
request_events_from_provider,
|
||||||
get_local_events,
|
get_all_events_by_request,
|
||||||
get_global_events,
|
get_local_events,
|
||||||
is_deleted_event,
|
get_global_events,
|
||||||
request_delete,
|
search,
|
||||||
search,
|
publish,
|
||||||
publish,
|
reply,
|
||||||
reply,
|
repost,
|
||||||
repost,
|
is_reposted,
|
||||||
event_to_bech32,
|
request_delete,
|
||||||
user_to_bech32,
|
is_deleted_event,
|
||||||
create_column,
|
event_to_bech32,
|
||||||
close_column,
|
user_to_bech32,
|
||||||
reposition_column,
|
create_column,
|
||||||
resize_column,
|
reload_column,
|
||||||
reload_column,
|
close_column,
|
||||||
open_window,
|
close_all_columns,
|
||||||
reopen_lume,
|
open_window,
|
||||||
quit
|
get_app_settings,
|
||||||
])
|
set_app_settings,
|
||||||
.events(collect_events![Subscription, NewSettings]);
|
]);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
builder
|
builder
|
||||||
.export(Typescript::default(), "../src/commands.gen.ts")
|
.export(Typescript::default(), "../src/commands.gen.ts")
|
||||||
.expect("Failed to export typescript bindings");
|
.expect("Failed to export typescript bindings");
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let tauri_builder = tauri::Builder::default().plugin(tauri_nspanel::init());
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let tauri_builder = tauri::Builder::default();
|
let tauri_builder = tauri::Builder::default();
|
||||||
|
let mut ctx = tauri::generate_context!();
|
||||||
|
|
||||||
tauri_builder
|
tauri_builder
|
||||||
.invoke_handler(builder.invoke_handler())
|
.invoke_handler(builder.invoke_handler())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
builder.mount_events(app);
|
|
||||||
|
|
||||||
let handle = app.handle();
|
let handle = app.handle();
|
||||||
let handle_clone = handle.clone();
|
let handle_clone = handle.clone();
|
||||||
let handle_clone_child = handle_clone.clone();
|
let handle_clone_child = handle_clone.clone();
|
||||||
|
let handle_clone_event = handle_clone_child.clone();
|
||||||
let main_window = app.get_webview_window("main").unwrap();
|
let main_window = app.get_webview_window("main").unwrap();
|
||||||
|
|
||||||
let config_dir = handle
|
let config_dir = handle
|
||||||
@@ -187,13 +168,7 @@ fn main() {
|
|||||||
.app_config_dir()
|
.app_config_dir()
|
||||||
.expect("Error: app config directory not found.");
|
.expect("Error: app config directory not found.");
|
||||||
|
|
||||||
let data_dir = handle
|
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.expect("Error: app data directory not found.");
|
|
||||||
|
|
||||||
let _ = fs::create_dir_all(&config_dir);
|
let _ = fs::create_dir_all(&config_dir);
|
||||||
let _ = fs::create_dir_all(&data_dir);
|
|
||||||
|
|
||||||
// Set custom decoration for Windows
|
// Set custom decoration for Windows
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
@@ -207,29 +182,63 @@ fn main() {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
// Setup tray menu item
|
||||||
let win = main_window.clone();
|
let open_i = MenuItem::with_id(app, "open", "Open Lume", true, None::<&str>)?;
|
||||||
|
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
// Create tray menu
|
||||||
|
let menu = Menu::with_items(app, &[&open_i, &quit_i])?;
|
||||||
|
// Get main tray
|
||||||
|
let tray = app.tray_by_id("main").unwrap();
|
||||||
|
// Set menu
|
||||||
|
tray.set_menu(Some(menu)).unwrap();
|
||||||
|
// Listen to tray events
|
||||||
|
tray.on_menu_event(|handle, event| match event.id().as_ref() {
|
||||||
|
"open" => {
|
||||||
|
if let Some(window) = handle.get_window("main") {
|
||||||
|
if window.is_visible().unwrap_or_default() {
|
||||||
|
let _ = window.set_focus();
|
||||||
|
} else {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let window = WebviewWindowBuilder::from_config(
|
||||||
|
handle,
|
||||||
|
handle.config().app.windows.first().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
// Set decoration
|
||||||
main_window.on_window_event(move |event| {
|
#[cfg(target_os = "windows")]
|
||||||
if let tauri::WindowEvent::ThemeChanged(_) = event {
|
window.create_overlay_titlebar().unwrap();
|
||||||
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
|
||||||
|
// Restore native border
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
window.add_border(None);
|
||||||
|
|
||||||
|
// Set a custom inset to the traffic lights
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
"quit" => {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = tauri::async_runtime::block_on(async move {
|
let client = tauri::async_runtime::block_on(async move {
|
||||||
// Setup database
|
// Setup database
|
||||||
let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
|
let database = NostrLMDB::open(config_dir.join("nostr"))
|
||||||
.expect("Error: cannot create database.");
|
.expect("Error: cannot create database.");
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
let opts = Options::new()
|
let opts = Options::new()
|
||||||
.gossip(true)
|
.gossip(true)
|
||||||
.max_avg_latency(Duration::from_millis(500))
|
.max_avg_latency(Duration::from_secs(2))
|
||||||
.automatic_authentication(false)
|
.timeout(Duration::from_secs(10));
|
||||||
.connection_timeout(Some(Duration::from_secs(20)))
|
|
||||||
.send_timeout(Some(Duration::from_secs(10)))
|
|
||||||
.timeout(Duration::from_secs(20));
|
|
||||||
|
|
||||||
// Setup nostr client
|
// Setup nostr client
|
||||||
let client = ClientBuilder::default()
|
let client = ClientBuilder::default()
|
||||||
@@ -237,7 +246,7 @@ fn main() {
|
|||||||
.opts(opts)
|
.opts(opts)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Get bootstrap relays
|
/* Get bootstrap relays
|
||||||
if let Ok(path) = handle
|
if let Ok(path) = handle
|
||||||
.path()
|
.path()
|
||||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
||||||
@@ -264,106 +273,223 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
|
for relay in BOOTSTRAP_RELAYS {
|
||||||
println!("Add discovery relay failed: {}", e)
|
let _ = client.add_relay(relay).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = client.add_discovery_relay("wss://directory.yabu.me/").await {
|
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
|
||||||
println!("Add discovery relay failed: {}", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
client.connect_with_timeout(Duration::from_secs(20)).await;
|
client.connect().await;
|
||||||
|
|
||||||
client
|
client
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load app settings
|
||||||
|
let store = app.store(".data")?;
|
||||||
|
|
||||||
|
// Parse app settings if exist
|
||||||
|
let settings = if let Some(data) = store.get("tanstack-query-[\"settings\"]") {
|
||||||
|
if let Some(str) = data.as_str() {
|
||||||
|
let v: Value = serde_json::from_str(str).unwrap();
|
||||||
|
let data = v["state"]["data"].clone();
|
||||||
|
let parse: Settings = serde_json::from_value(data).unwrap();
|
||||||
|
|
||||||
|
RwLock::new(parse)
|
||||||
|
} else {
|
||||||
|
RwLock::new(Settings::default())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RwLock::new(Settings::default())
|
||||||
|
};
|
||||||
|
|
||||||
// Create global state
|
// Create global state
|
||||||
app.manage(Nostr {
|
app.manage(Nostr {
|
||||||
client,
|
client,
|
||||||
settings: Mutex::new(Settings::default()),
|
settings,
|
||||||
contact_list: Mutex::new(Vec::new()),
|
queue: RwLock::new(HashSet::new()),
|
||||||
trusted_list: Mutex::new(HashSet::new()),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Subscription::listen_any(app, move |event| {
|
// Listen for request metadata
|
||||||
let handle = handle_clone_child.to_owned();
|
app.listen_any("request_metadata", move |event| {
|
||||||
let payload = event.payload;
|
let payload = event.payload();
|
||||||
|
let parsed_payload: Payload = serde_json::from_str(payload).expect("Parse failed");
|
||||||
|
let handle = handle_clone_child.clone();
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
match payload.kind {
|
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
|
||||||
SubKind::Subscribe => {
|
let mut write_queue = state.queue.write().await;
|
||||||
let subscription_id = SubscriptionId::new(payload.label);
|
write_queue.insert(public_key);
|
||||||
|
};
|
||||||
|
|
||||||
match payload.event_id {
|
// Wait for [QUEUE_DELAY]
|
||||||
Some(id) => {
|
sleep(Duration::from_millis(QUEUE_DELAY)).await;
|
||||||
let event_id = EventId::from_str(&id).unwrap();
|
|
||||||
let filter =
|
|
||||||
Filter::new().event(event_id).since(Timestamp::now());
|
|
||||||
|
|
||||||
if let Err(e) = client
|
let read_queue = state.queue.read().await;
|
||||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
println!("Subscription error: {}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let contact_list = client
|
|
||||||
.get_contact_list(Some(Duration::from_secs(5)))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if !contact_list.is_empty() {
|
if !read_queue.is_empty() {
|
||||||
let authors: Vec<PublicKey> =
|
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
|
||||||
contact_list.iter().map(|f| f.public_key).collect();
|
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
.authors(authors)
|
||||||
.authors(authors)
|
.kind(Kind::Metadata)
|
||||||
.since(Timestamp::now());
|
.limit(200);
|
||||||
|
|
||||||
if let Err(e) = client
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||||
.await
|
|
||||||
{
|
// Drop queue, you don't need it at this time anymore
|
||||||
println!("Subscription error: {}", e)
|
drop(read_queue);
|
||||||
}
|
// Clear queue
|
||||||
}
|
let mut write_queue = state.queue.write().await;
|
||||||
}
|
write_queue.clear();
|
||||||
};
|
|
||||||
}
|
if let Err(e) = client.subscribe(vec![filter], Some(opts)).await {
|
||||||
SubKind::Unsubscribe => {
|
println!("Subscribe error: {}", e);
|
||||||
let subscription_id = SubscriptionId::new(payload.label);
|
|
||||||
client.unsubscribe(subscription_id).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run local relay thread
|
// Run a thread for negentropy
|
||||||
//tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// let database = NostrLMDB::open(data_dir.join("local-relay"))
|
let state = handle_clone_event.state::<Nostr>();
|
||||||
// .expect("Error: cannot create database.");
|
let client = &state.client;
|
||||||
// let builder = RelayBuilder::default().database(database).port(1984);
|
|
||||||
//
|
|
||||||
// if let Ok(relay) = LocalRelay::run(builder).await {
|
|
||||||
// println!("Running local relay: {}", relay.url())
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// loop {
|
|
||||||
// tokio::time::sleep(Duration::from_secs(60)).await;
|
|
||||||
// }
|
|
||||||
//});
|
|
||||||
|
|
||||||
// Run notification thread
|
// Use default sync options
|
||||||
|
let opts = SyncOptions::default();
|
||||||
|
|
||||||
|
// Set interval
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
|
||||||
|
// Skip the first tick
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
let accounts = get_all_accounts();
|
||||||
|
let public_keys: Vec<PublicKey> = accounts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !public_keys.is_empty() {
|
||||||
|
// Create filter for notification
|
||||||
|
//
|
||||||
|
let filter = Filter::new().pubkeys(public_keys.clone()).kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Reaction,
|
||||||
|
Kind::ZapReceipt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync notification
|
||||||
|
//
|
||||||
|
if let Ok(output) = client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter for contact list
|
||||||
|
//
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(public_keys)
|
||||||
|
.kinds(vec![Kind::ContactList, Kind::FollowSet]);
|
||||||
|
|
||||||
|
// Sync events for contact list
|
||||||
|
//
|
||||||
|
if let Ok(events) = client.database().query(vec![filter]).await {
|
||||||
|
// Get unique public keys
|
||||||
|
let public_keys: HashSet<PublicKey> = events
|
||||||
|
.iter()
|
||||||
|
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert to vector
|
||||||
|
let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||||
|
|
||||||
|
for chunk in public_keys.chunks(1000) {
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authors = chunk.to_owned();
|
||||||
|
|
||||||
|
// Create filter for metadata
|
||||||
|
//
|
||||||
|
let filter = Filter::new().authors(authors.clone()).kinds(vec![
|
||||||
|
Kind::Metadata,
|
||||||
|
Kind::FollowSet,
|
||||||
|
Kind::Interests,
|
||||||
|
Kind::InterestSet,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sync metadata
|
||||||
|
//
|
||||||
|
if let Ok(output) =
|
||||||
|
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filter for text note
|
||||||
|
//
|
||||||
|
let filter = Filter::new()
|
||||||
|
.authors(authors)
|
||||||
|
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
// Sync text note
|
||||||
|
//
|
||||||
|
if let Ok(output) =
|
||||||
|
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
|
||||||
|
{
|
||||||
|
println!("Received: {}", output.received.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run a thread for handle notification
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let state = handle_clone.state::<Nostr>();
|
let state = handle_clone.state::<Nostr>();
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
let accounts = get_all_accounts();
|
||||||
|
|
||||||
|
if !accounts.is_empty() {
|
||||||
|
let subscription_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||||
|
|
||||||
|
let public_keys: Vec<PublicKey> = accounts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|acc| PublicKey::from_str(acc).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.pubkeys(public_keys)
|
||||||
|
.kinds(vec![
|
||||||
|
Kind::TextNote,
|
||||||
|
Kind::Repost,
|
||||||
|
Kind::Reaction,
|
||||||
|
Kind::ZapReceipt,
|
||||||
|
Kind::Comment,
|
||||||
|
])
|
||||||
|
.since(Timestamp::now());
|
||||||
|
|
||||||
|
// Subscribe for new notification
|
||||||
|
if let Err(e) = client
|
||||||
|
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Subscribe error: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let allow_notification = match handle_clone.notification().request_permission() {
|
let allow_notification = match handle_clone.notification().request_permission() {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -377,57 +503,17 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||||
let mut notifications = client.pool().notifications();
|
|
||||||
|
|
||||||
while let Ok(notification) = notifications.recv().await {
|
let _ = client
|
||||||
match notification {
|
.handle_notifications(|notification| async {
|
||||||
RelayPoolNotification::Message { relay_url, message } => {
|
#[allow(clippy::collapsible_match)]
|
||||||
if let RelayMessage::Auth { challenge } = message {
|
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||||
match client.auth(challenge, relay_url.clone()).await {
|
if let RelayMessage::Event {
|
||||||
Ok(..) => {
|
|
||||||
if let Ok(relay) = client.relay(&relay_url).await {
|
|
||||||
let msg =
|
|
||||||
format!("Authenticated to {} relay.", relay_url);
|
|
||||||
let opts = RelaySendOptions::new()
|
|
||||||
.skip_send_confirmation(true);
|
|
||||||
|
|
||||||
if let Err(e) = relay.resubscribe(opts).await {
|
|
||||||
println!("Error: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if allow_notification {
|
|
||||||
if let Err(e) = &handle_clone
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.body(&msg)
|
|
||||||
.title("Lume")
|
|
||||||
.show()
|
|
||||||
{
|
|
||||||
println!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if allow_notification {
|
|
||||||
if let Err(e) = &handle_clone
|
|
||||||
.notification()
|
|
||||||
.builder()
|
|
||||||
.body(e.to_string())
|
|
||||||
.title("Lume")
|
|
||||||
.show()
|
|
||||||
{
|
|
||||||
println!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let RelayMessage::Event {
|
|
||||||
subscription_id,
|
|
||||||
event,
|
event,
|
||||||
|
subscription_id,
|
||||||
|
..
|
||||||
} = message
|
} = message
|
||||||
{
|
{
|
||||||
// Handle events from notification subscription
|
|
||||||
if subscription_id == notification_id {
|
if subscription_id == notification_id {
|
||||||
// Send native notification
|
// Send native notification
|
||||||
if allow_notification {
|
if allow_notification {
|
||||||
@@ -438,79 +524,46 @@ fn main() {
|
|||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
DatabaseProfile::new(event.pubkey, Metadata::new())
|
DatabaseProfile::new(event.pubkey, Metadata::new())
|
||||||
});
|
});
|
||||||
let metadata = author.metadata();
|
|
||||||
|
|
||||||
send_event_notification(&event, metadata, &handle_clone);
|
send_event_notification(
|
||||||
|
&event,
|
||||||
|
author.metadata(),
|
||||||
|
&handle_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
} else if event.kind == Kind::Metadata {
|
||||||
|
if let Err(e) = handle_clone.emit("metadata", event.as_json()) {
|
||||||
|
println!("Emit error: {}", e)
|
||||||
|
}
|
||||||
|
} else if event.kind == Kind::Comment {
|
||||||
|
let payload = RichEvent {
|
||||||
|
raw: event.as_json(),
|
||||||
|
parsed: if event.kind == Kind::TextNote {
|
||||||
|
Some(parse_event(&event.content).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let label = subscription_id.to_string();
|
if let Err(e) = handle_clone.emit_to(
|
||||||
let raw = event.as_json();
|
EventTarget::labeled(subscription_id.to_string()),
|
||||||
let parsed = if event.kind == Kind::TextNote {
|
"comment",
|
||||||
Some(parse_event(&event.content).await)
|
payload,
|
||||||
} else {
|
) {
|
||||||
None
|
println!("Emit error: {}", e)
|
||||||
};
|
|
||||||
|
|
||||||
handle_clone
|
|
||||||
.emit_to(
|
|
||||||
EventTarget::labeled(label),
|
|
||||||
"event",
|
|
||||||
RichEvent { raw, parsed },
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
RelayPoolNotification::Shutdown => break,
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.on_window_event(|window, event| {
|
|
||||||
if let tauri::WindowEvent::Focused(focused) = event {
|
|
||||||
if !focused {
|
|
||||||
let handle = window.app_handle().to_owned();
|
|
||||||
let config_dir = handle.path().app_config_dir().unwrap();
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let state = handle.state::<Nostr>();
|
|
||||||
let client = &state.client;
|
|
||||||
|
|
||||||
if let Ok(signer) = client.signer().await {
|
|
||||||
let public_key = signer.public_key().await.unwrap();
|
|
||||||
let bech32 = public_key.to_bech32().unwrap();
|
|
||||||
|
|
||||||
if fs::metadata(config_dir.join(bech32)).is_ok() {
|
|
||||||
if let Ok(contact_list) =
|
|
||||||
client.get_contact_list(Some(Duration::from_secs(5))).await
|
|
||||||
{
|
|
||||||
let authors: Vec<PublicKey> =
|
|
||||||
contact_list.iter().map(|f| f.public_key).collect();
|
|
||||||
|
|
||||||
if client
|
|
||||||
.reconcile(
|
|
||||||
Filter::new()
|
|
||||||
.authors(authors)
|
|
||||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
|
||||||
.limit(1000),
|
|
||||||
NegentropyOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
handle.emit("synchronized", ()).unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
Ok(false)
|
||||||
}
|
})
|
||||||
}
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(prevent_default())
|
.plugin(prevent_default())
|
||||||
|
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
|
||||||
.plugin(tauri_plugin_decorum::init())
|
.plugin(tauri_plugin_decorum::init())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
@@ -524,8 +577,13 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_upload::init())
|
.plugin(tauri_plugin_upload::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.run(tauri::generate_context!())
|
.build(ctx)
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application")
|
||||||
|
.run(|_app_handle, event| {
|
||||||
|
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||||
|
api.prevent_exit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -548,7 +606,7 @@ fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppH
|
|||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Mentioned you in a thread.")
|
.body("You're mentioned in a thread.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
@@ -559,7 +617,7 @@ fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppH
|
|||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Reposted your note.")
|
.body("Your note has been reposted.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
@@ -570,7 +628,7 @@ fn send_event_notification(event: &Event, author: Metadata, handle: &tauri::AppH
|
|||||||
if let Err(e) = handle
|
if let Err(e) = handle
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
.body("Zapped you.")
|
.body("You've received zap.")
|
||||||
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
.title(author.display_name.unwrap_or_else(|| "Lume".to_string()))
|
||||||
.show()
|
.show()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "Lume",
|
"productName": "Lume",
|
||||||
"version": "4.3.0",
|
"version": "24.11.8",
|
||||||
"identifier": "nu.lume.Lume",
|
"identifier": "nu.lume.Lume",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
"$RESOURCE/*"
|
"$RESOURCE/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"trayIcon": {
|
||||||
|
"id": "main",
|
||||||
|
"iconAsTemplate": true,
|
||||||
|
"menuOnLeftClick": true,
|
||||||
|
"tooltip": "Lume",
|
||||||
|
"iconPath": "./icons/tray.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -39,10 +46,7 @@
|
|||||||
"targets": "all",
|
"targets": "all",
|
||||||
"active": true,
|
"active": true,
|
||||||
"category": "SocialNetworking",
|
"category": "SocialNetworking",
|
||||||
"resources": [
|
"resources": ["resources/*", "locales/*"],
|
||||||
"resources/*",
|
|
||||||
"locales/*"
|
|
||||||
],
|
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -13,9 +13,7 @@
|
|||||||
"hiddenTitle": true,
|
"hiddenTitle": true,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"windowEffects": {
|
"windowEffects": {
|
||||||
"effects": [
|
"effects": ["underWindowBackground"]
|
||||||
"underWindowBackground"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -10,12 +10,7 @@
|
|||||||
"minWidth": 480,
|
"minWidth": 480,
|
||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
"decorations": false,
|
"decorations": false
|
||||||
"windowEffects": {
|
|
||||||
"effects": [
|
|
||||||
"mica"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,17 @@
|
|||||||
|
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
import { type } from "@tauri-apps/plugin-os";
|
||||||
|
import { Store } from "@tauri-apps/plugin-store";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { newQueryStorage } from "./commons";
|
import { newQueryStorage } from "./commons";
|
||||||
import type { LumeEvent } from "./system";
|
import type { LumeEvent } from "./system";
|
||||||
|
|
||||||
|
import { routeTree } from "./routes.gen"; // auto generated file
|
||||||
import "./app.css"; // global styles
|
import "./app.css"; // global styles
|
||||||
|
|
||||||
import { createStore } from "@tauri-apps/plugin-store";
|
|
||||||
import { routeTree } from "./routes.gen"; // auto generated file
|
|
||||||
|
|
||||||
const platform = type();
|
|
||||||
// @ts-expect-error, required: https://github.com/tauri-apps/plugins-workspace/pull/1860
|
|
||||||
const store = await createStore(".cache", { autoSave: 250 });
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
gcTime: 1000 * 30,
|
|
||||||
persister: experimental_createPersister({
|
|
||||||
storage: newQueryStorage(store),
|
|
||||||
maxAge: 1000 * 60 * 60 * 12,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
routeTree,
|
|
||||||
context: { queryClient, platform },
|
|
||||||
Wrap: ({ children }) => {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register things for typesafety
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router;
|
||||||
@@ -47,18 +21,43 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
const platform = type();
|
||||||
return <RouterProvider router={router} />;
|
// @ts-ignore, won't fix
|
||||||
}
|
const store = await Store.load(".data", { autoSave: 300 });
|
||||||
|
const storage = newQueryStorage(store);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 20, // 20 seconds
|
||||||
|
persister: experimental_createPersister({
|
||||||
|
storage: storage,
|
||||||
|
maxAge: 1000 * 60 * 60 * 6, // 6 hours
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: idk
|
// Make sure all webviews use same query client
|
||||||
const rootElement = document.getElementById("root")!;
|
broadcastQueryClient({
|
||||||
|
queryClient,
|
||||||
|
broadcastChannel: "lume",
|
||||||
|
});
|
||||||
|
|
||||||
if (!rootElement.innerHTML) {
|
const router = createRouter({
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
routeTree,
|
||||||
root.render(
|
context: { store, queryClient, platform },
|
||||||
<StrictMode>
|
Wrap: ({ children }) => {
|
||||||
<App />
|
return (
|
||||||
</StrictMode>,
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
const root = ReactDOM.createRoot(rootElement as unknown as HTMLElement);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,15 +5,39 @@
|
|||||||
|
|
||||||
|
|
||||||
export const commands = {
|
export const commands = {
|
||||||
async getRelays() : Promise<Result<Relays, string>> {
|
async syncAll(reader: TAURI_CHANNEL<number>) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
|
return { status: "ok", data: await TAURI_INVOKE("sync_all", { reader }) };
|
||||||
} 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
async getAllRelays() : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_relays") };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllRelayLists(until: string | null) : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_relay_lists", { until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isRelayConnected(relay: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_relay_connected", { relay }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async connectRelay(relay: string) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
|
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -21,7 +45,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async removeRelay(relay: string) : Promise<Result<boolean, string>> {
|
async removeRelay(relay: string) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
|
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -29,34 +53,18 @@ async removeRelay(relay: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getBootstrapRelays() : Promise<Result<string[], string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getAccounts() : Promise<string[]> {
|
async getAccounts() : Promise<string[]> {
|
||||||
return await TAURI_INVOKE("get_accounts");
|
return await TAURI_INVOKE("get_accounts");
|
||||||
},
|
},
|
||||||
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> {
|
async watchAccount(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("watch_account", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async importAccount(key: string, password: string) : Promise<Result<string, string>> {
|
async importAccount(key: string, password: string | null) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,21 +104,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isAccountSync(id: string) : Promise<boolean> {
|
async hasSigner(id: string) : Promise<Result<boolean, string>> {
|
||||||
return await TAURI_INVOKE("is_account_sync", { id });
|
|
||||||
},
|
|
||||||
async createSyncFile(id: string) : Promise<boolean> {
|
|
||||||
return await TAURI_INVOKE("create_sync_file", { id });
|
|
||||||
},
|
|
||||||
async login(account: string, password: string) : Promise<Result<string, string>> {
|
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
|
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
|
||||||
} catch (e) {
|
} 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getProfile(id: string | null) : Promise<Result<string, string>> {
|
async setSigner(id: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("set_signer", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getProfile(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -118,17 +128,17 @@ async getProfile(id: string | null) : 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getContactList() : Promise<Result<string[], string>> {
|
async getContactList(id: string) : Promise<Result<string[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
|
return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -142,9 +152,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async checkContact(id: string) : Promise<Result<boolean, string>> {
|
async isContact(id: string) : Promise<Result<boolean, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -158,9 +168,9 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getMentionList() : Promise<Result<Mention[], string>> {
|
async getAllProfiles() : Promise<Result<Mention[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -182,9 +192,17 @@ async getGroup(id: string) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getAllGroups() : Promise<Result<RichEvent[], string>> {
|
async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_all_groups") };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_newsfeeds", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllLocalNewsfeeds(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_local_newsfeeds", { until }) };
|
||||||
} 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 };
|
||||||
@@ -206,9 +224,25 @@ async getInterest(id: string) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getAllInterests() : Promise<Result<RichEvent[], string>> {
|
async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_all_interests") };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_interests", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllLocalInterests(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_local_interests", { until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getRelayList(id: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_relay_list", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -222,7 +256,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadWallet() : Promise<Result<string, string>> {
|
async loadWallet() : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
|
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -238,7 +272,7 @@ async removeWallet() : Promise<Result<null, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
|
async zapProfile(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
|
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -246,7 +280,7 @@ async zapProfile(id: string, amount: string, message: string) : Promise<Result<b
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
|
async zapEvent(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
|
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -262,25 +296,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getNotifications() : Promise<Result<string[], string>> {
|
async getNotifications(id: string) : Promise<Result<string[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
|
return { status: "ok", data: await TAURI_INVOKE("get_notifications", { id }) };
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getUserSettings() : Promise<Result<Settings, null>> {
|
|
||||||
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) {
|
} catch (e) {
|
||||||
if(e instanceof Error) throw e;
|
if(e instanceof Error) throw e;
|
||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
@@ -294,17 +312,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isTrustedUser(id: string) : Promise<Result<boolean, string>> {
|
async getMetaFromEvent(content: string) : Promise<Result<Meta, null>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("is_trusted_user", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_meta_from_event", { content }) };
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
|
|
||||||
} 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 };
|
||||||
@@ -318,14 +328,6 @@ async getEvent(id: string) : Promise<Result<RichEvent, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
||||||
@@ -334,14 +336,6 @@ async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async subscribeTo(id: string) : Promise<Result<null, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("subscribe_to", { id }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
|
async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
|
||||||
@@ -366,6 +360,46 @@ async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getAllEventsFrom(url: string, until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_from", { url, until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllEventsByKind(kind: number, until: string | null) : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_kind", { kind, until }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllProviders() : Promise<Result<string[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_providers") };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestEventsFromProvider(provider: string) : Promise<Result<string, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("request_events_from_provider", { provider }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getAllEventsByRequest(id: string, provider: string) : Promise<Result<RichEvent[], string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_request", { id, provider }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
|
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
|
||||||
@@ -382,22 +416,6 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async requestDelete(id: string) : Promise<Result<null, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async search(query: string) : Promise<Result<RichEvent[], string>> {
|
async search(query: string) : Promise<Result<RichEvent[], string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
|
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
|
||||||
@@ -414,9 +432,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> {
|
async reply(content: string, to: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
|
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
|
||||||
} 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 };
|
||||||
@@ -430,6 +448,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async isReposted(id: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async requestDelete(id: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async eventToBech32(id: string) : Promise<Result<string, string>> {
|
async eventToBech32(id: string) : Promise<Result<string, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
|
||||||
@@ -454,6 +496,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async reloadColumn(label: string) : Promise<Result<null, string>> {
|
||||||
|
try {
|
||||||
|
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
|
||||||
|
} catch (e) {
|
||||||
|
if(e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
},
|
||||||
async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
|
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
|
||||||
@@ -462,31 +512,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async repositionColumn(label: string, x: number, y: number) : Promise<Result<boolean, string>> {
|
async closeAllColumns() : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
|
return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
|
||||||
} 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 };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, string>> {
|
async openWindow(window: NewWindow) : Promise<Result<string, string>> {
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("resize_column", { label, width, height }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async reloadColumn(label: string) : Promise<Result<boolean, string>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async openWindow(window: Window) : Promise<Result<null, string>> {
|
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
|
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -494,24 +528,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
|
|||||||
else return { status: "error", error: e as any };
|
else return { status: "error", error: e as any };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async reopenLume() : Promise<void> {
|
async getAppSettings() : Promise<Result<Settings, string>> {
|
||||||
await TAURI_INVOKE("reopen_lume");
|
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 quit() : Promise<void> {
|
async setAppSettings(settings: string) : Promise<Result<null, string>> {
|
||||||
await TAURI_INVOKE("quit");
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** user-defined events **/
|
/** user-defined events **/
|
||||||
|
|
||||||
|
|
||||||
export const events = __makeEvents__<{
|
|
||||||
newSettings: NewSettings,
|
|
||||||
subscription: Subscription
|
|
||||||
}>({
|
|
||||||
newSettings: "new-settings",
|
|
||||||
subscription: "subscription"
|
|
||||||
})
|
|
||||||
|
|
||||||
/** user-defined constants **/
|
/** user-defined constants **/
|
||||||
|
|
||||||
@@ -522,14 +559,10 @@ subscription: "subscription"
|
|||||||
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
|
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
|
||||||
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
|
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
|
||||||
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
|
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
|
||||||
export type NewSettings = Settings
|
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 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 SubKind = "Subscribe" | "Unsubscribe"
|
export type TAURI_CHANNEL<TSend> = null
|
||||||
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
|
|
||||||
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
|
|
||||||
|
|
||||||
/** tauri-specta globals **/
|
/** tauri-specta globals **/
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ import type {
|
|||||||
MaybePromise,
|
MaybePromise,
|
||||||
PersistedQuery,
|
PersistedQuery,
|
||||||
} from "@tanstack/query-persist-client-core";
|
} from "@tanstack/query-persist-client-core";
|
||||||
import { Store } from "@tanstack/store";
|
import { 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 type { Store as TauriStore } from "@tauri-apps/plugin-store";
|
import type { Store as TauriStore } from "@tauri-apps/plugin-store";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
|
||||||
import { BitcoinUnit } from "bitcoin-units";
|
import { BitcoinUnit } from "bitcoin-units";
|
||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -16,76 +13,88 @@ 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 function isValidRelayUrl(string: string) {
|
||||||
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) {
|
try {
|
||||||
if (path.endsWith(suffix)) return true;
|
const newUrl = new URL(string);
|
||||||
|
return newUrl.protocol === "ws:" || newUrl.protocol === "wss:";
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isImagePath = (path: string) => {
|
||||||
|
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||||
|
|
||||||
|
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 +123,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,54 +154,26 @@ 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);
|
return null;
|
||||||
const displayValue = getBitcoinDisplayValues(amount);
|
|
||||||
|
|
||||||
return displayValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkForAppUpdates(silent: boolean) {
|
|
||||||
const update = await check();
|
|
||||||
|
|
||||||
if (!update) {
|
|
||||||
if (silent) return;
|
|
||||||
|
|
||||||
await message("You are on the latest version. Stay awesome!", {
|
|
||||||
title: "No Update Available",
|
|
||||||
kind: "info",
|
|
||||||
okLabel: "OK",
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update?.available) {
|
if (section.name === "amount") {
|
||||||
const yes = await ask(
|
const amount = Number.parseInt(section.value) / 1000;
|
||||||
`Update to ${update.version} is available!\n\nRelease notes: ${update.body}`,
|
const displayValue = getBitcoinDisplayValues(amount);
|
||||||
{
|
|
||||||
title: "Update Available",
|
|
||||||
kind: "info",
|
|
||||||
okLabel: "Update",
|
|
||||||
cancelLabel: "Cancel",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (yes) {
|
return displayValue;
|
||||||
await update.downloadAndInstall();
|
} else {
|
||||||
await relaunch();
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,18 +258,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[]>([]);
|
|
||||||
|
|||||||
@@ -1,132 +1,68 @@
|
|||||||
import { commands } from "@/commands.gen";
|
import { commands } from "@/commands.gen";
|
||||||
import { appColumns } from "@/commons";
|
import { useRect } from "@/system";
|
||||||
import type { LumeColumn } from "@/types";
|
import type { LumeColumn } from "@/types";
|
||||||
import { CaretDown, Check } from "@phosphor-icons/react";
|
import { CaretDown, Check } from "@phosphor-icons/react";
|
||||||
import { useParams } from "@tanstack/react-router";
|
|
||||||
import { useStore } from "@tanstack/react-store";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
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 { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Spinner } from "./spinner";
|
import { User } from "./user";
|
||||||
|
|
||||||
type WindowEvent = {
|
export function Column({ column }: { column: LumeColumn }) {
|
||||||
scroll: boolean;
|
const [rect, ref] = useRect();
|
||||||
resize: boolean;
|
const [error, setError] = useState("");
|
||||||
};
|
|
||||||
|
|
||||||
export const Column = memo(function Column({ column }: { column: LumeColumn }) {
|
|
||||||
const params = useParams({ strict: false });
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
const webviewLabel = `column-${params.account}_${column.label}`;
|
|
||||||
|
|
||||||
const [isCreated, setIsCreated] = useState(false);
|
|
||||||
|
|
||||||
const repositionWebview = useCallback(async () => {
|
|
||||||
if (!container.current) return;
|
|
||||||
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("reposition_column", {
|
|
||||||
label: webviewLabel,
|
|
||||||
x: newRect.x,
|
|
||||||
y: newRect.y,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resizeWebview = useCallback(async () => {
|
|
||||||
if (!container.current) return;
|
|
||||||
|
|
||||||
const newRect = container.current.getBoundingClientRect();
|
|
||||||
await invoke("resize_column", {
|
|
||||||
label: webviewLabel,
|
|
||||||
width: newRect.width,
|
|
||||||
height: newRect.height,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCreated) return;
|
(async () => {
|
||||||
|
if (rect) {
|
||||||
|
const res = await commands.createColumn({
|
||||||
|
label: column.label,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
url: `${column.url}?label=${column.label}&name=${column.name}&account=${column.account}`,
|
||||||
|
});
|
||||||
|
|
||||||
const unlisten = listen<WindowEvent>("child_webview", (data) => {
|
if (res.status === "error") {
|
||||||
if (data.payload.scroll) repositionWebview();
|
setError(res.error);
|
||||||
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
}
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
return () => {
|
}, [rect]);
|
||||||
unlisten.then((f) => f());
|
|
||||||
};
|
|
||||||
}, [isCreated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!container.current) return;
|
|
||||||
|
|
||||||
const rect = container.current.getBoundingClientRect();
|
|
||||||
const url = `${column.url}?account=${params.account}&label=${column.label}&name=${column.name}`;
|
|
||||||
|
|
||||||
const prop = {
|
|
||||||
label: webviewLabel,
|
|
||||||
x: rect.x,
|
|
||||||
y: rect.y,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
|
|
||||||
// create new webview
|
|
||||||
invoke("create_column", { column: prop }).then(() => {
|
|
||||||
console.log("created: ", webviewLabel);
|
|
||||||
setIsCreated(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// close webview when unmounted
|
|
||||||
return () => {
|
|
||||||
invoke("close_column", { label: webviewLabel }).then(() => {
|
|
||||||
console.log("closed: ", webviewLabel);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [params.account]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
|
<div className="h-full w-[440px] shrink-0 border-r border-black/5 dark:border-white/5">
|
||||||
<div className="flex flex-col gap-px size-full">
|
<div className="flex flex-col gap-px size-full">
|
||||||
<Header label={column.label} />
|
<Header
|
||||||
<div ref={container} className="flex-1 size-full">
|
label={column.label}
|
||||||
{!isCreated ? (
|
name={column.name}
|
||||||
<div className="size-full flex items-center justify-center">
|
account={column.account}
|
||||||
<Spinner />
|
/>
|
||||||
|
<div ref={ref} className="flex-1 size-full">
|
||||||
|
<div className="size-full flex flex-col items-center justify-center">
|
||||||
|
<div className="invisible text-red-500 text-sm break-all">
|
||||||
|
{error?.length ? error : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
function Header({ label }: { label: string }) {
|
function Header({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
account,
|
||||||
|
}: { label: string; name: string; account?: string }) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [isChanged, setIsChanged] = useState(false);
|
const [isChanged, setIsChanged] = useState(false);
|
||||||
|
|
||||||
const column = useStore(appColumns, (state) =>
|
|
||||||
state.find((col) => col.label === label),
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveNewTitle = async () => {
|
|
||||||
const mainWindow = getCurrentWindow();
|
|
||||||
await mainWindow.emit("columns", { type: "set_title", label, title });
|
|
||||||
|
|
||||||
// update search params
|
|
||||||
// @ts-ignore, hahaha
|
|
||||||
search.name = title;
|
|
||||||
|
|
||||||
// reset state
|
|
||||||
setIsChanged(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const window = getCurrentWindow();
|
const window = getCurrentWindow();
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
const menuItems = await Promise.all([
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Reload",
|
text: "Reload",
|
||||||
@@ -134,10 +70,6 @@ function Header({ label }: { label: string }) {
|
|||||||
await commands.reloadColumn(label);
|
await commands.reloadColumn(label);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
MenuItem.new({
|
|
||||||
text: "Open in new window",
|
|
||||||
action: () => console.log("not implemented."),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
MenuItem.new({
|
MenuItem.new({
|
||||||
text: "Move left",
|
text: "Move left",
|
||||||
@@ -178,6 +110,15 @@ function Header({ label }: { label: string }) {
|
|||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const saveNewTitle = async () => {
|
||||||
|
await getCurrentWindow().emit("columns", {
|
||||||
|
type: "set_title",
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
setIsChanged(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (title.length > 0) setIsChanged(true);
|
if (title.length > 0) setIsChanged(true);
|
||||||
}, [title.length]);
|
}, [title.length]);
|
||||||
@@ -186,13 +127,24 @@ function Header({ label }: { label: string }) {
|
|||||||
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
|
<div className="group flex items-center justify-center gap-2 w-full h-9 shrink-0">
|
||||||
<div className="flex items-center justify-center shrink-0 h-7">
|
<div className="flex items-center justify-center shrink-0 h-7">
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
|
{account?.length ? (
|
||||||
|
<User.Provider pubkey={account}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-6 rounded-full" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
onBlur={(e) => setTitle(e.currentTarget.textContent)}
|
onBlur={(e) => {
|
||||||
|
if (e.currentTarget.textContent) {
|
||||||
|
setTitle(e.currentTarget.textContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-[12px] font-semibold focus:outline-none"
|
className="text-[12px] font-semibold focus:outline-none"
|
||||||
>
|
>
|
||||||
{column.name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{isChanged ? (
|
{isChanged ? (
|
||||||
<button
|
<button
|
||||||
@@ -208,7 +160,7 @@ function Header({ label }: { label: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => showContextMenu(e)}
|
onClick={(e) => showContextMenu(e)}
|
||||||
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
|
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 bg-white dark:bg-neutral-800 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full"
|
||||||
>
|
>
|
||||||
<CaretDown className="size-3" weight="bold" />
|
<CaretDown className="size-3" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { cn } from "@/commons";
|
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@/system";
|
|
||||||
import { ChatsTeardrop } from "@phosphor-icons/react";
|
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
|
|
||||||
export const Conversation = memo(function Conversation({
|
|
||||||
event,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
event: LumeEvent;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const thread = useMemo(() => event.thread, [event]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root
|
|
||||||
className={cn(
|
|
||||||
"bg-white dark:bg-black/20 rounded-xl flex flex-col gap-3 shadow-primary dark:ring-1 ring-neutral-800/50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{thread?.root?.id ? <Note.Child event={thread?.root} isRoot /> : null}
|
|
||||||
<div className="flex items-center gap-2 px-3">
|
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
<ChatsTeardrop className="size-4" />
|
|
||||||
Thread
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
|
||||||
</div>
|
|
||||||
{thread?.reply?.id ? <Note.Child event={thread?.reply} /> : null}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
|
||||||
<Note.User />
|
|
||||||
</div>
|
|
||||||
<Note.Content className="px-3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-3 h-14">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cn } from "@/commons";
|
import { cn } from "@/commons";
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function Frame({
|
export function Frame({
|
||||||
@@ -7,17 +6,13 @@ export function Frame({
|
|||||||
shadow,
|
shadow,
|
||||||
className,
|
className,
|
||||||
}: { children: ReactNode; shadow?: boolean; className?: string }) {
|
}: { children: ReactNode; shadow?: boolean; className?: string }) {
|
||||||
const { platform } = useRouteContext({ strict: false });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
className,
|
className,
|
||||||
platform === "linux"
|
"bg-white dark:bg-neutral-800",
|
||||||
? "bg-white dark:bg-neutral-950"
|
|
||||||
: "bg-white dark:bg-white/10",
|
|
||||||
shadow
|
shadow
|
||||||
? "shadow-lg shadow-neutral-500/10 dark:shadow-none dark:ring-1 dark:ring-white/20"
|
? "shadow-primary dark:shadow-none dark:ring-1 dark:ring-neutral-700/50"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const PublishIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 01-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 01-.15-.015A32.702 32.702 0 005.5 21.25a.75.75 0 01-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 012.031.722z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export const QuoteIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.75 5.75a2 2 0 0 1 2-2h14.5a2 2 0 0 1 2 2v10.5a2 2 0 0 1-2 2h-3.874a1 1 0 0 0-.638.23l-2.098 1.738a1 1 0 0 1-1.28-.003l-2.066-1.731a1 1 0 0 0-.642-.234H4.75a2 2 0 0 1-2-2z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.523 8C8.406 8 7.5 8.91 7.5 10.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.157.251c-.353.502-.875.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .624.125c.67-.449 1.328-.913 1.79-1.569.474-.674.716-1.51.658-2.66A2.03 2.03 0 0 0 9.523 8m4.945 0c-1.117 0-2.023.91-2.023 2.033a2.028 2.028 0 0 0 2.81 1.874q-.072.132-.156.251c-.353.502-.876.885-1.554 1.34a.453.453 0 0 0-.125.626.45.45 0 0 0 .623.125c.67-.449 1.328-.913 1.79-1.569.474-.674.717-1.51.658-2.66A2.03 2.03 0 0 0 14.468 8"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -4,10 +4,8 @@ export * from "./spinner";
|
|||||||
export * from "./column";
|
export * from "./column";
|
||||||
|
|
||||||
// Newsfeed
|
// Newsfeed
|
||||||
export * from "./repost";
|
|
||||||
export * from "./conversation";
|
|
||||||
export * from "./quote";
|
|
||||||
export * from "./text";
|
export * from "./text";
|
||||||
|
export * from "./repost";
|
||||||
export * from "./reply";
|
export * from "./reply";
|
||||||
|
|
||||||
// Global components
|
// Global components
|
||||||
@@ -18,3 +16,5 @@ export * from "./user";
|
|||||||
export * from "./icons/reply";
|
export * from "./icons/reply";
|
||||||
export * from "./icons/repost";
|
export * from "./icons/repost";
|
||||||
export * from "./icons/zap";
|
export * from "./icons/zap";
|
||||||
|
export * from "./icons/quote";
|
||||||
|
export * from "./icons/publish";
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ export function NoteOpenThread() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={300}>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() =>
|
||||||
|
LumeWindow.openColumn({
|
||||||
|
name: "Thread",
|
||||||
|
label: event.id.slice(0, 6),
|
||||||
|
account: event.pubkey,
|
||||||
|
url: `/columns/events/${event.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<ListPlus className="shrink-0 size-4" />
|
<ListPlus className="shrink-0 size-4" />
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from "@/commons";
|
||||||
|
import { QuoteIcon } from "@/components";
|
||||||
|
import { LumeWindow } from "@/system";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
|
export function NoteQuote({
|
||||||
|
label = false,
|
||||||
|
smol = false,
|
||||||
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
|
const event = useNoteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => LumeWindow.openEditor(undefined, event.id)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||||
|
label
|
||||||
|
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
: "size-7",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||||
|
{label ? "Quote" : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
Quote
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,10 +18,8 @@ export function NoteReply({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEditor(event.id)}
|
onClick={() => LumeWindow.openEditor(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
label
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
: "size-7",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||||
|
|||||||
@@ -1,88 +1,162 @@
|
|||||||
import { appSettings, cn } from "@/commons";
|
import { commands } from "@/commands.gen";
|
||||||
|
import { cn, displayNpub } from "@/commons";
|
||||||
import { RepostIcon, Spinner } from "@/components";
|
import { RepostIcon, Spinner } from "@/components";
|
||||||
import { LumeWindow } from "@/system";
|
import { settingsQueryOptions } from "@/routes/__root";
|
||||||
import { useStore } from "@tanstack/react-store";
|
import type { Metadata } from "@/types";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import {
|
||||||
|
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, useState } from "react";
|
import { useCallback, useTransition } from "react";
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteRepost({
|
export function NoteRepost({
|
||||||
label = false,
|
label = false,
|
||||||
smol = false,
|
smol = false,
|
||||||
}: { label?: boolean; smol?: boolean }) {
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
const visible = useStore(appSettings, (state) => state.display_repost_button);
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const { isLoading, data: status } = useQuery({
|
||||||
const [isRepost, setIsRepost] = useState(false);
|
queryKey: ["is-reposted", event.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await commands.isReposted(event.id);
|
||||||
|
if (res.status === "ok") {
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: settings.data.display_repost_button,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Number.POSITIVE_INFINITY,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const repost = async () => {
|
const [isPending, startTransition] = useTransition();
|
||||||
if (isRepost) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// repost
|
|
||||||
await event.repost();
|
|
||||||
|
|
||||||
// update state
|
|
||||||
setLoading(false);
|
|
||||||
setIsRepost(true);
|
|
||||||
} catch {
|
|
||||||
setLoading(false);
|
|
||||||
await message("Repost failed, try again later", {
|
|
||||||
title: "Lume",
|
|
||||||
kind: "info",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
const accounts = await commands.getAccounts();
|
||||||
MenuItem.new({
|
const list: Promise<MenuItem>[] = [];
|
||||||
text: "Repost",
|
|
||||||
action: async () => repost(),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Quote",
|
|
||||||
action: () => LumeWindow.openEditor(null, event.id),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
for (const account of accounts) {
|
||||||
items: menuItems,
|
const res = await commands.getProfile(account);
|
||||||
});
|
let name = "unknown";
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
const profile: Metadata = JSON.parse(res.data);
|
||||||
|
name = profile.display_name ?? profile.name ?? "anon";
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push(
|
||||||
|
MenuItem.new({
|
||||||
|
text: `Repost as ${name} (${displayNpub(account, 16)})`,
|
||||||
|
action: async () => submit(account),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await Promise.all(list);
|
||||||
|
const menu = await Menu.new({ items });
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!visible) return null;
|
const repost = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(["is-reposted", event.id], true);
|
||||||
|
|
||||||
|
const res = await commands.repost(JSON.stringify(event.raw));
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.setQueryData(["is-reposted", event.id], false);
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
return await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["is-reposted", event.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (account: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
if (!status) {
|
||||||
|
const signer = await commands.hasSigner(account);
|
||||||
|
|
||||||
|
if (signer.status === "ok") {
|
||||||
|
if (!signer.data) {
|
||||||
|
const res = await commands.setSigner(account);
|
||||||
|
|
||||||
|
if (res.status === "error") {
|
||||||
|
await message(res.error, { kind: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repost.mutate();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings.data.display_repost_button) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip.Provider>
|
||||||
type="button"
|
<Tooltip.Root delayDuration={300}>
|
||||||
onClick={(e) => showContextMenu(e)}
|
<Tooltip.Trigger asChild>
|
||||||
className={cn(
|
<button
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
type="button"
|
||||||
label
|
onClick={(e) => showContextMenu(e)}
|
||||||
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
className={cn(
|
||||||
: "size-7",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
)}
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
>
|
)}
|
||||||
{loading ? (
|
>
|
||||||
<Spinner className="size-4" />
|
{isPending || isLoading ? (
|
||||||
) : (
|
<Spinner className="size-4" />
|
||||||
<RepostIcon
|
) : (
|
||||||
className={cn(
|
<RepostIcon
|
||||||
smol ? "size-4" : "size-5",
|
className={cn(
|
||||||
isRepost ? "text-blue-500" : "",
|
smol ? "size-4" : "size-5",
|
||||||
)}
|
status ? "text-blue-500" : "",
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
{label ? "Repost" : null}
|
)}
|
||||||
</button>
|
{label ? "Repost" : null}
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||||
|
Repost
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
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 { useSearch } from "@tanstack/react-router";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { useStore } from "@tanstack/react-store";
|
|
||||||
import { useNoteContext } from "../provider";
|
import { useNoteContext } from "../provider";
|
||||||
|
|
||||||
export function NoteZap({
|
export function NoteZap({
|
||||||
label = false,
|
label = false,
|
||||||
smol = false,
|
smol = false,
|
||||||
}: { label?: boolean; smol?: boolean }) {
|
}: { label?: boolean; smol?: boolean }) {
|
||||||
const search = useSearch({ strict: false });
|
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||||
const visible = useStore(appSettings, (state) => state.display_zap_button);
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!settings.data.display_zap_button) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openZap(event.id, search.account)}
|
onClick={() => LumeWindow.openZap(event.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
|
||||||
label
|
label ? "w-24 gap-1.5" : "w-14",
|
||||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
: "size-7",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ZapIcon className={smol ? "size-4" : "size-5"} />
|
<ZapIcon className={smol ? "size-4" : "size-5"} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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, memo, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { Hashtag } from "./mentions/hashtag";
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
import { MentionNote } from "./mentions/note";
|
import { MentionNote } from "./mentions/note";
|
||||||
@@ -21,15 +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 warning = useMemo(() => event.warning, [event]);
|
|
||||||
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");
|
||||||
@@ -88,50 +91,48 @@ export function NoteContent({
|
|||||||
}
|
}
|
||||||
}, [event.content]);
|
}, [event.content]);
|
||||||
|
|
||||||
|
const [blurred, setBlurred] = useState(() =>
|
||||||
|
event.warning ? event.warning.length > 0 : false,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-2">
|
<div className="relative flex flex-col gap-2">
|
||||||
<ContentWarning warning={warning} />
|
{!blurred ? (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
"select-text text-pretty content-break overflow-hidden",
|
className={cn(
|
||||||
className,
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
)}
|
className,
|
||||||
>
|
)}
|
||||||
{content}
|
>
|
||||||
</div>
|
{content}
|
||||||
{visible ? (
|
</div>
|
||||||
event.meta?.images.length ? (
|
{settings.data.display_media ? (
|
||||||
<Images urls={event.meta.images} />
|
event.meta?.images.length ? (
|
||||||
) : null
|
<Images urls={event.meta.images} />
|
||||||
) : null}
|
) : null
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-yellow-600 dark:text-yellow-400">
|
||||||
|
The content is hidden because the author marked it with a warning
|
||||||
|
for a reason: <span className="font-semibold">{event.warning}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBlurred(false)}
|
||||||
|
className="font-medium text-sm text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
View anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentWarning = memo(function ContentWarning({
|
|
||||||
warning,
|
|
||||||
}: { warning: string }) {
|
|
||||||
const [blurred, setBlurred] = useState(() => warning?.length > 0);
|
|
||||||
|
|
||||||
if (!blurred) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center w-full h-full bg-black/80 backdrop-blur-lg">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
|
||||||
<p className="text-sm text-white/60">
|
|
||||||
The content is hidden because the author
|
|
||||||
<br />
|
|
||||||
marked it with a warning for a reason:
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium text-white">{warning}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBlurred(false)}
|
|
||||||
className="inline-flex items-center justify-center px-2 mt-4 text-sm font-medium border rounded-lg text-white/70 h-9 w-max bg-white/20 hover:bg-white/30 border-white/5"
|
|
||||||
>
|
|
||||||
View anyway
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NoteOpenThread } from "./buttons/open";
|
import { NoteOpenThread } from "./buttons/open";
|
||||||
|
import { NoteQuote } from "./buttons/quote";
|
||||||
import { NoteReply } from "./buttons/reply";
|
import { NoteReply } from "./buttons/reply";
|
||||||
import { NoteRepost } from "./buttons/repost";
|
import { NoteRepost } from "./buttons/repost";
|
||||||
import { NoteZap } from "./buttons/zap";
|
import { NoteZap } from "./buttons/zap";
|
||||||
@@ -16,6 +17,7 @@ export const Note = {
|
|||||||
User: NoteUser,
|
User: NoteUser,
|
||||||
Menu: NoteMenu,
|
Menu: NoteMenu,
|
||||||
Reply: NoteReply,
|
Reply: NoteReply,
|
||||||
|
Quote: NoteQuote,
|
||||||
Repost: NoteRepost,
|
Repost: NoteRepost,
|
||||||
Content: NoteContent,
|
Content: NoteContent,
|
||||||
ContentLarge: NoteContentLarge,
|
ContentLarge: NoteContentLarge,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export function Hashtag({ tag }: { tag: string }) {
|
export function Hashtag({ tag }: { tag: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
|
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
|
||||||
{tag}
|
{tag.includes("#") ? tag : `#${tag}`}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -17,16 +16,19 @@ export const MentionNote = memo(function MentionNote({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative my-2">
|
<div className="relative my-2">
|
||||||
<div className="min-h-[64px] pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
<div className="pl-3 before:content-[''] before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:border-l-[2px] before:border-black/10 dark:before:border-white/10">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-[64px] flex items-center">
|
<div className="h-[32px] flex items-center gap-2 text-sm">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
Loadng note
|
||||||
</div>
|
</div>
|
||||||
) : isError || !event ? (
|
) : isError || !event ? (
|
||||||
<div className="h-[64px] flex items-center">
|
<div className="flex flex-col break-all">
|
||||||
<p className="text-sm font-medium text-red-500">
|
<p className="text-sm font-medium text-red-500">
|
||||||
{error.message || "Note can be found with your current relay set"}
|
{error?.message ??
|
||||||
|
"Cannot found this note within your current relay set"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm">{eventId}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
@@ -49,13 +51,20 @@ 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 gap-3">
|
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => LumeWindow.openEvent(event)}
|
onClick={() =>
|
||||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
LumeWindow.openColumn({
|
||||||
|
name: "Thread",
|
||||||
|
label: eventId.slice(0, 6),
|
||||||
|
account: event.pubkey,
|
||||||
|
url: `/columns/events/${eventId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mr-3 text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
Show all
|
Show all
|
||||||
</button>
|
</button>
|
||||||
@@ -98,33 +107,47 @@ function Content({ text, className }: { text: string; className?: string }) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
for (const word of nostr) {
|
for (const word of nostr) {
|
||||||
const bech32 = word.replace("nostr:", "");
|
const bech32 = word.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||||
const data = nip19.decode(bech32);
|
try {
|
||||||
|
const data = nip19.decode(bech32);
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "npub":
|
case "npub":
|
||||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
replacedText = reactStringReplace(
|
||||||
<MentionUser key={match + i} pubkey={data.data} />
|
replacedText,
|
||||||
));
|
word,
|
||||||
break;
|
(match, i) => <MentionUser key={match + i} pubkey={data.data} />,
|
||||||
case "nprofile":
|
);
|
||||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
break;
|
||||||
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
case "nprofile":
|
||||||
));
|
replacedText = reactStringReplace(
|
||||||
break;
|
replacedText,
|
||||||
default:
|
word,
|
||||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
(match, i) => (
|
||||||
<a
|
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
||||||
key={match + i}
|
),
|
||||||
href={`https://njump.me/${bech32}`}
|
);
|
||||||
target="_blank"
|
break;
|
||||||
rel="noreferrer"
|
default:
|
||||||
className="text-blue-600 dark:text-blue-400 !underline"
|
replacedText = reactStringReplace(
|
||||||
>
|
replacedText,
|
||||||
{match}
|
word,
|
||||||
</a>
|
(match, i) => (
|
||||||
));
|
<a
|
||||||
break;
|
key={match + i}
|
||||||
|
href={`https://njump.me/${bech32}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-600 dark:text-blue-400 !underline"
|
||||||
|
>
|
||||||
|
{match}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(word);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { memo } from "react";
|
|||||||
export const MentionUser = memo(function MentionUser({
|
export const MentionUser = memo(function MentionUser({
|
||||||
pubkey,
|
pubkey,
|
||||||
}: { pubkey: string }) {
|
}: { pubkey: string }) {
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
const { isLoading, profile } = useProfile(pubkey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -14,10 +14,8 @@ export const MentionUser = memo(function MentionUser({
|
|||||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||||
>
|
>
|
||||||
{isLoading
|
{isLoading
|
||||||
? "@anon"
|
? displayNpub(pubkey, 16)
|
||||||
: isError
|
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
||||||
? displayNpub(pubkey, 16)
|
|
||||||
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -39,10 +36,6 @@ export function ImagePreview({ url }: { url: string }) {
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ contentVisibility: "auto" }}
|
style={{ contentVisibility: "auto" }}
|
||||||
className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,40 +1,53 @@
|
|||||||
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) {
|
||||||
newUrls = urls.map(
|
newUrls = urls.map((url) => {
|
||||||
(url) => `${service}?url=${url}&ll&af&default=1&n=-1`,
|
if (url.includes("_next/")) {
|
||||||
);
|
return url;
|
||||||
|
}
|
||||||
|
if (url.includes("bsky.network")) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
newUrls = urls.map(
|
newUrls = urls.map((url) => {
|
||||||
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
if (url.includes("_next/")) {
|
||||||
);
|
return url;
|
||||||
|
}
|
||||||
|
if (url.includes("bsky.network")) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return newUrls;
|
return newUrls;
|
||||||
} 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();
|
||||||
@@ -44,23 +57,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);
|
||||||
@@ -83,10 +100,6 @@ export function Images({ urls }: { urls: string[] }) {
|
|||||||
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
|
||||||
onClick={() => urls[0]}
|
onClick={() => urls[0]}
|
||||||
onKeyDown={() => urls[0]}
|
onKeyDown={() => urls[0]}
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -162,10 +175,6 @@ function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
|||||||
onClick={() => open(url)}
|
onClick={() => open(url)}
|
||||||
onKeyDown={() => open(url)}
|
onKeyDown={() => open(url)}
|
||||||
onLoad={setLoaded}
|
onLoad={setLoaded}
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
currentTarget.src = "/404.jpg";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { cn } from "@/commons";
|
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import type { LumeEvent } from "@/system";
|
|
||||||
import { Quotes } from "@phosphor-icons/react";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
export const Quote = memo(function Quote({
|
|
||||||
event,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
event: LumeEvent;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Note.Provider event={event}>
|
|
||||||
<Note.Root className={cn("", className)}>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Note.Child event={event.quote} isRoot />
|
|
||||||
<div className="flex items-center gap-2 px-3">
|
|
||||||
<div className="inline-flex items-center gap-1.5 shrink-0 text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
<Quotes className="size-4" />
|
|
||||||
Quote
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-px bg-neutral-100 dark:bg-white/5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between px-3 h-14">
|
|
||||||
<Note.User />
|
|
||||||
</div>
|
|
||||||
<Note.Content className="px-3" quote={false} clean />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center px-3 h-14">
|
|
||||||
<Note.Open />
|
|
||||||
</div>
|
|
||||||
</Note.Root>
|
|
||||||
</Note.Provider>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
import { commands } from "@/commands.gen";
|
import { cn, replyAt } from "@/commons";
|
||||||
import { appSettings, cn, replyTime } from "@/commons";
|
import { Note, User } from "@/components";
|
||||||
import { Note } from "@/components/note";
|
|
||||||
import { type LumeEvent, LumeWindow } from "@/system";
|
import { type LumeEvent, LumeWindow } from "@/system";
|
||||||
import { CaretDown } from "@phosphor-icons/react";
|
import { CaretDown } from "@phosphor-icons/react";
|
||||||
import { Link, useSearch } from "@tanstack/react-router";
|
import { Link, useSearch } from "@tanstack/react-router";
|
||||||
import { useStore } from "@tanstack/react-store";
|
|
||||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import {
|
import { type ReactNode, memo, useCallback, useMemo } from "react";
|
||||||
type ReactNode,
|
|
||||||
memo,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { Hashtag } from "./note/mentions/hashtag";
|
import { Hashtag } from "./note/mentions/hashtag";
|
||||||
import { MentionUser } from "./note/mentions/user";
|
import { MentionUser } from "./note/mentions/user";
|
||||||
import { User } from "./user";
|
|
||||||
|
|
||||||
export const ReplyNote = memo(function ReplyNote({
|
export const ReplyNote = memo(function ReplyNote({
|
||||||
event,
|
event,
|
||||||
@@ -28,11 +18,7 @@ export const ReplyNote = memo(function ReplyNote({
|
|||||||
event: LumeEvent;
|
event: LumeEvent;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
|
|
||||||
const search = useSearch({ strict: false });
|
const search = useSearch({ strict: false });
|
||||||
|
|
||||||
const [isTrusted, setIsTrusted] = useState<boolean>(null);
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -57,24 +43,6 @@ export const ReplyNote = memo(function ReplyNote({
|
|||||||
await menu.popup().catch((e) => console.error(e));
|
await menu.popup().catch((e) => console.error(e));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function check() {
|
|
||||||
const res = await commands.isTrustedUser(event.pubkey);
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
setIsTrusted(res.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trustedOnly) {
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isTrusted !== null && isTrusted === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Note.Provider event={event}>
|
<Note.Provider event={event}>
|
||||||
<User.Provider pubkey={event.pubkey}>
|
<User.Provider pubkey={event.pubkey}>
|
||||||
@@ -97,9 +65,9 @@ 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 gap-5">
|
<div className="flex items-center justify-end">
|
||||||
<Note.Reply smol />
|
<Note.Reply smol />
|
||||||
<Note.Repost smol />
|
<Note.Repost smol />
|
||||||
<Note.Zap smol />
|
<Note.Zap smol />
|
||||||
@@ -178,9 +146,9 @@ 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 gap-5">
|
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||||
<Note.Reply smol />
|
<Note.Reply smol />
|
||||||
<Note.Repost smol />
|
<Note.Repost smol />
|
||||||
<Note.Zap smol />
|
<Note.Zap smol />
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ export const RepostNote = memo(function RepostNote({
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-20 gap-2">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
Loading event...
|
Loading event...
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : isError || !data ? (
|
) : isError || !data ? (
|
||||||
<div className="flex items-center justify-center h-20">
|
<div className="flex items-center justify-center h-20">
|
||||||
Event not found within your current relay set
|
<p className="text-sm">
|
||||||
|
Repost event not found within your current relay set
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={data}>
|
<Note.Provider event={data}>
|
||||||
@@ -36,7 +38,7 @@ export const RepostNote = memo(function RepostNote({
|
|||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
<div className="flex items-center justify-between px-3 mt-3 h-14">
|
||||||
<div className="inline-flex items-center gap-6">
|
<div className="inline-flex items-center gap-2">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const TextNote = memo(function TextNote({
|
|||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<Note.Content className="px-3" />
|
||||||
<div className="flex items-center gap-6 px-3 mt-3 h-14">
|
<div className="flex items-center gap-2 px-3 mt-3 h-14">
|
||||||
<Note.Open />
|
<Note.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
|||||||
@@ -1,35 +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;
|
||||||
}
|
}
|
||||||
return `${service}?url=${user.profile?.picture}&w=100&h=100&n=-1&default=${user.profile?.picture}`;
|
if (user.profile?.picture.includes("bsky.network")) {
|
||||||
|
return 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 (
|
||||||
@@ -39,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>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { commands } from "@/commands.gen";
|
||||||
|
import { cn, displayNpub } from "@/commons";
|
||||||
|
import { Spinner } from "@/components";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { useCallback, useTransition } from "react";
|
||||||
|
import { useUserContext } from "./provider";
|
||||||
|
import type { Metadata } from "@/types";
|
||||||
|
import { MenuItem, Menu } from "@tauri-apps/api/menu";
|
||||||
|
|
||||||
|
export function UserButton({ className }: { className?: string }) {
|
||||||
|
const user = useUserContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
data: isFollow,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["status", user?.pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await commands.isContact(user.pubkey);
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const accounts = await commands.getAccounts();
|
||||||
|
const list: Promise<MenuItem>[] = [];
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const res = await commands.getProfile(account);
|
||||||
|
let name = "unknown";
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
const profile: Metadata = JSON.parse(res.data);
|
||||||
|
name = profile.display_name ?? profile.name ?? "anon";
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push(
|
||||||
|
MenuItem.new({
|
||||||
|
text: `Follow as ${name} (${displayNpub(account, 16)})`,
|
||||||
|
action: async () => submit(account),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await Promise.all(list);
|
||||||
|
const menu = await Menu.new({ items });
|
||||||
|
|
||||||
|
await menu.popup().catch((e) => console.error(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFollow = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["status", user.pubkey] });
|
||||||
|
|
||||||
|
// Optimistically update to the new value
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["status", user.pubkey],
|
||||||
|
(data: boolean) => !data,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await commands.toggleContact(user.pubkey, null);
|
||||||
|
|
||||||
|
if (res.status === "ok") {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.setQueryData(["status", user?.pubkey], false);
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
return await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["status", user?.pubkey],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (account: string) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const signer = await commands.hasSigner(account);
|
||||||
|
|
||||||
|
if (signer.status === "ok") {
|
||||||
|
if (!signer.data) {
|
||||||
|
const res = await commands.setSigner(account);
|
||||||
|
|
||||||
|
if (res.status === "error") {
|
||||||
|
await message(res.error, { kind: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFollow.mutate();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending || isLoading}
|
||||||
|
onClick={(e) => showContextMenu(e)}
|
||||||
|
className={cn("w-max gap-1", className)}
|
||||||
|
>
|
||||||
|
{isError ? "Error" : null}
|
||||||
|
{isPending || isLoading ? <Spinner className="size-3" /> : null}
|
||||||
|
{isFollow ? "Unfollow" : "Follow"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { commands } from "@/commands.gen";
|
|
||||||
import { cn } from "@/commons";
|
|
||||||
import { Spinner } from "@/components";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import { useUserContext } from "./provider";
|
|
||||||
|
|
||||||
export function UserFollowButton({ className }: { className?: string }) {
|
|
||||||
const user = useUserContext();
|
|
||||||
|
|
||||||
const { queryClient } = useRouteContext({ strict: false });
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
data: isFollow,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["status", user.pubkey],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await commands.checkContact(user.pubkey);
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
return res.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(res.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const toggleFollow = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await commands.toggleContact(user.pubkey, null);
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
["status", user.pubkey],
|
|
||||||
(prev: boolean) => !prev,
|
|
||||||
);
|
|
||||||
|
|
||||||
// invalidate cache
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["status", user.pubkey],
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
await message(res.error, { kind: "error" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => toggleFollow()}
|
|
||||||
className={cn("w-max gap-1", className)}
|
|
||||||
>
|
|
||||||
{isError ? "Error" : null}
|
|
||||||
{isPending || isLoading ? <Spinner className="size-3" /> : null}
|
|
||||||
{isFollow ? "Unfollow" : "Follow"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { UserAbout } from "./about";
|
import { UserAbout } from "./about";
|
||||||
import { UserAvatar } from "./avatar";
|
import { UserAvatar } from "./avatar";
|
||||||
|
import { UserButton } from "./button";
|
||||||
import { UserCover } from "./cover";
|
import { UserCover } from "./cover";
|
||||||
import { UserFollowButton } from "./followButton";
|
|
||||||
import { UserName } from "./name";
|
import { UserName } from "./name";
|
||||||
import { UserNip05 } from "./nip05";
|
import { UserNip05 } from "./nip05";
|
||||||
import { UserProvider } from "./provider";
|
import { UserProvider } from "./provider";
|
||||||
@@ -17,5 +17,5 @@ export const User = {
|
|||||||
NIP05: UserNip05,
|
NIP05: UserNip05,
|
||||||
Time: UserTime,
|
Time: UserTime,
|
||||||
About: UserAbout,
|
About: UserAbout,
|
||||||
Button: UserFollowButton,
|
Button: UserButton,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import type { Metadata } from "@/types";
|
|
||||||
import { useProfile } from "@/system";
|
import { useProfile } from "@/system";
|
||||||
|
import type { Metadata } from "@/types";
|
||||||
import { type ReactNode, createContext, useContext } from "react";
|
import { type ReactNode, createContext, useContext } from "react";
|
||||||
|
|
||||||
const UserContext = createContext<{
|
interface UserContext {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
profile: Metadata;
|
profile: Metadata | undefined;
|
||||||
isError: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}>(null);
|
}
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContext | null>(null);
|
||||||
|
|
||||||
export function UserProvider({
|
export function UserProvider({
|
||||||
pubkey,
|
pubkey,
|
||||||
children,
|
children,
|
||||||
embedProfile,
|
data,
|
||||||
}: {
|
}: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
embedProfile?: string;
|
data?: string;
|
||||||
}) {
|
}) {
|
||||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
const { isLoading, profile } = useProfile(pubkey, data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
<UserContext.Provider value={{ pubkey, isLoading, profile }}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { cn } from "@/commons";
|
|
||||||
import { User } from "@/components/user";
|
|
||||||
import { LumeWindow } from "@/system";
|
|
||||||
import { CaretDown, Feather, MagnifyingGlass } from "@phosphor-icons/react";
|
|
||||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
|
||||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
|
||||||
import { memo, useCallback } from "react";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/_app")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const context = Route.useRouteContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-screen h-screen">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 shrink-0 items-center justify-between",
|
|
||||||
context.platform === "macos" ? "pl-[72px] pr-3" : "pr-[156px] pl-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="relative z-[200] flex-1 flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<Account />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openSearch()}
|
|
||||||
className="inline-flex items-center justify-center size-7 bg-black/5 dark:bg-white/5 rounded-full hover:bg-blue-500 hover:text-white"
|
|
||||||
>
|
|
||||||
<MagnifyingGlass className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openEditor()}
|
|
||||||
className="inline-flex items-center justify-center h-7 gap-1.5 px-2 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
|
||||||
>
|
|
||||||
<Feather className="size-4" weight="fill" />
|
|
||||||
New Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="toolbar"
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="relative z-[200] flex-1 flex items-center justify-end gap-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-neutral-100 dark:bg-neutral-900 border-t-[.5px] border-black/20 dark:border-white/20">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Account = memo(function Account() {
|
|
||||||
const params = Route.useParams();
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
|
|
||||||
const showContextMenu = useCallback(
|
|
||||||
async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
|
||||||
MenuItem.new({
|
|
||||||
text: "New Post",
|
|
||||||
action: () => LumeWindow.openEditor(),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Profile",
|
|
||||||
action: () => LumeWindow.openProfile(params.account),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Settings",
|
|
||||||
action: () => LumeWindow.openSettings(params.account),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Copy Public Key",
|
|
||||||
action: async () => await writeText(params.account),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Logout",
|
|
||||||
action: () => navigate({ to: "/" }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
|
||||||
},
|
|
||||||
[params.account],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => showContextMenu(e)}
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<User.Provider pubkey={params.account}>
|
|
||||||
<User.Root className="shrink-0 rounded-full">
|
|
||||||
<User.Avatar className="rounded-full size-7" />
|
|
||||||
</User.Root>
|
|
||||||
</User.Provider>
|
|
||||||
<CaretDown className="size-3" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/_app")();
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { appColumns } from "@/commons";
|
|
||||||
import { Spinner } from "@/components";
|
|
||||||
import { Column } from "@/components/column";
|
|
||||||
import { LumeWindow } from "@/system";
|
|
||||||
import type { ColumnEvent, LumeColumn } from "@/types";
|
|
||||||
import { ArrowLeft, ArrowRight, Plus, StackPlus } from "@phosphor-icons/react";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useStore } from "@tanstack/react-store";
|
|
||||||
import { listen } from "@tauri-apps/api/event";
|
|
||||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import {
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/_app/home")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const params = Route.useParams();
|
|
||||||
const columns = useStore(appColumns, (state) => state);
|
|
||||||
|
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
||||||
watchDrag: false,
|
|
||||||
loop: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollPrev = useCallback(() => {
|
|
||||||
if (emblaApi) emblaApi.scrollPrev(true);
|
|
||||||
}, [emblaApi]);
|
|
||||||
|
|
||||||
const scrollNext = useCallback(() => {
|
|
||||||
if (emblaApi) emblaApi.scrollNext(true);
|
|
||||||
}, [emblaApi]);
|
|
||||||
|
|
||||||
const emitScrollEvent = useCallback(() => {
|
|
||||||
getCurrentWindow().emit("child_webview", { scroll: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const emitResizeEvent = useCallback(() => {
|
|
||||||
getCurrentWindow().emit("child_webview", { resize: true, direction: "x" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
|
||||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
|
||||||
appColumns.setState((prev) => [column, ...prev]);
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
const remove = useDebouncedCallback((label: string) => {
|
|
||||||
appColumns.setState((prev) => prev.filter((t) => t.label !== label));
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
const move = useDebouncedCallback(
|
|
||||||
(label: string, direction: "left" | "right") => {
|
|
||||||
const newCols = [...columns];
|
|
||||||
|
|
||||||
const col = newCols.find((el) => el.label === label);
|
|
||||||
const colIndex = newCols.findIndex((el) => el.label === label);
|
|
||||||
|
|
||||||
newCols.splice(colIndex, 1);
|
|
||||||
|
|
||||||
if (direction === "left") newCols.splice(colIndex - 1, 0, col);
|
|
||||||
if (direction === "right") newCols.splice(colIndex + 1, 0, col);
|
|
||||||
|
|
||||||
appColumns.setState(() => newCols);
|
|
||||||
},
|
|
||||||
150,
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = useDebouncedCallback((label: string, title: string) => {
|
|
||||||
const currentColIndex = columns.findIndex((col) => col.label === label);
|
|
||||||
|
|
||||||
const updatedCol = Object.assign({}, columns[currentColIndex]);
|
|
||||||
updatedCol.name = title;
|
|
||||||
|
|
||||||
const newCols = columns.slice();
|
|
||||||
newCols[currentColIndex] = updatedCol;
|
|
||||||
|
|
||||||
appColumns.setState(() => newCols);
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
const reset = useDebouncedCallback(() => appColumns.setState(() => []), 150);
|
|
||||||
|
|
||||||
const handleKeyDown = useDebouncedCallback((event) => {
|
|
||||||
if (event.defaultPrevented) return;
|
|
||||||
|
|
||||||
switch (event.code) {
|
|
||||||
case "ArrowLeft":
|
|
||||||
if (emblaApi) emblaApi.scrollPrev();
|
|
||||||
break;
|
|
||||||
case "ArrowRight":
|
|
||||||
if (emblaApi) emblaApi.scrollNext();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (emblaApi) {
|
|
||||||
emblaApi.on("scroll", emitScrollEvent);
|
|
||||||
emblaApi.on("resize", emitResizeEvent);
|
|
||||||
emblaApi.on("slidesChanged", emitScrollEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
emblaApi?.off("scroll", emitScrollEvent);
|
|
||||||
emblaApi?.off("resize", emitResizeEvent);
|
|
||||||
emblaApi?.off("slidesChanged", emitScrollEvent);
|
|
||||||
};
|
|
||||||
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
|
||||||
|
|
||||||
// Listen for keyboard event
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [handleKeyDown]);
|
|
||||||
|
|
||||||
// Listen for columns event
|
|
||||||
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")
|
|
||||||
move(data.payload.label, data.payload.direction);
|
|
||||||
if (data.payload.type === "set_title")
|
|
||||||
update(data.payload.label, data.payload.title);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getSystemColumns() {
|
|
||||||
const systemPath = "resources/columns.json";
|
|
||||||
const resourcePath = await resolveResource(systemPath);
|
|
||||||
const resourceFile = await readTextFile(resourcePath);
|
|
||||||
const cols: LumeColumn[] = JSON.parse(resourceFile);
|
|
||||||
|
|
||||||
appColumns.setState(() => cols.filter((col) => col.default));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!columns.length) {
|
|
||||||
const prevColumns = window.localStorage.getItem(
|
|
||||||
`${params.account}_columns`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!prevColumns) {
|
|
||||||
getSystemColumns();
|
|
||||||
} else {
|
|
||||||
const parsed: LumeColumn[] = JSON.parse(prevColumns);
|
|
||||||
appColumns.setState(() => parsed);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
`${params.account}_columns`,
|
|
||||||
JSON.stringify(columns),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [columns.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="size-full">
|
|
||||||
<div ref={emblaRef} className="overflow-hidden size-full">
|
|
||||||
<div className="flex size-full">
|
|
||||||
{!columns ? (
|
|
||||||
<div className="size-full flex items-center justify-center">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
columns.map((column) => (
|
|
||||||
<Column key={column.label} column={column} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div className="shrink-0 p-2 h-full w-[440px]">
|
|
||||||
<div className="size-full flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => LumeWindow.openColumnsGallery()}
|
|
||||||
className="inline-flex items-center justify-center gap-1 rounded-full text-sm font-medium h-8 w-max pl-2 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
Add Column
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Toolbar>
|
|
||||||
<ManageButton />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => scrollPrev()}
|
|
||||||
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => scrollNext()}
|
|
||||||
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
</button>
|
|
||||||
</Toolbar>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ManageButton() {
|
|
||||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const menuItems = await Promise.all([
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Open Launchpad",
|
|
||||||
action: () => LumeWindow.openColumnsGallery(),
|
|
||||||
}),
|
|
||||||
PredefinedMenuItem.new({ item: "Separator" }),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Open Newsfeed",
|
|
||||||
action: () => LumeWindow.openLocalFeeds(),
|
|
||||||
}),
|
|
||||||
MenuItem.new({
|
|
||||||
text: "Open Notification",
|
|
||||||
action: () => LumeWindow.openNotification(),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const menu = await Menu.new({
|
|
||||||
items: menuItems,
|
|
||||||
});
|
|
||||||
|
|
||||||
await menu.popup().catch((e) => console.error(e));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => showContextMenu(e)}
|
|
||||||
className="inline-flex items-center justify-center rounded-full size-7 hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<StackPlus className="size-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children: ReactNode[] }) {
|
|
||||||
const [domReady, setDomReady] = useState(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setDomReady(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return domReady ? (
|
|
||||||
// @ts-ignore, react bug ???
|
|
||||||
createPortal(children, document.getElementById("toolbar"))
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/_app/home")();
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { commands } from "@/commands.gen";
|
|
||||||
import { NostrAccount } from "@/system";
|
|
||||||
import { Button } from "@getalby/bitcoin-connect-react";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/_settings/bitcoin-connect")(
|
|
||||||
{
|
|
||||||
component: Screen,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const setNwcUri = async (uri: string) => {
|
|
||||||
const res = await commands.setWallet(uri);
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
await getCurrentWebviewWindow().close();
|
|
||||||
} else {
|
|
||||||
throw new Error(res.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center size-full">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 text-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-black/70 dark:text-white/70">
|
|
||||||
Click to the button below to connect with your Bitcoin wallet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onConnected={(provider) =>
|
|
||||||
setNwcUri(provider.client.nostrWalletConnectUrl)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { commands } from "@/commands.gen";
|
|
||||||
import { appSettings } from "@/commons";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/$account/_settings/general")({
|
|
||||||
beforeLoad: async () => {
|
|
||||||
const res = await commands.getUserSettings();
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
appSettings.setState((state) => {
|
|
||||||
return { ...state, ...res.data };
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(res.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,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("/$account/_settings/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 { account } = Route.useParams();
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [isCopy, setIsCopy] = useState(false);
|
|
||||||
|
|
||||||
const copyPrivateKey = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
const res = await commands.getPrivateKey(account);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { commands } from "@/commands.gen";
|
|
||||||
import { Plus, X } from "@phosphor-icons/react";
|
|
||||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
|
||||||
import { message } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
|
||||||
|
|
||||||
export const Route = createLazyFileRoute("/$account/_settings/relay")({
|
|
||||||
component: Screen,
|
|
||||||
});
|
|
||||||
|
|
||||||
function Screen() {
|
|
||||||
const { relayList } = Route.useRouteContext();
|
|
||||||
|
|
||||||
const [relays, setRelays] = useState<string[]>([]);
|
|
||||||
const [newRelay, setNewRelay] = useState("");
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const removeRelay = async (relay: string) => {
|
|
||||||
const res = await commands.removeRelay(relay);
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
return res.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(res.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addNewRelay = () => {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
let url = newRelay;
|
|
||||||
|
|
||||||
if (!url.startsWith("wss://")) {
|
|
||||||
url = `wss://${url}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relay = new URL(url);
|
|
||||||
const res = await commands.connectRelay(relay.toString());
|
|
||||||
|
|
||||||
if (res.status === "ok") {
|
|
||||||
setRelays((prev) => [...prev, newRelay]);
|
|
||||||
setNewRelay("");
|
|
||||||
} else {
|
|
||||||
await message(res.error, { title: "Relay", kind: "error" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await message("URL is not valid.", { kind: "error" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRelays(relayList.connected);
|
|
||||||
}, [relayList]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-3 pb-3">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
Connected Relays
|
|
||||||
</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">
|
|
||||||
{relays.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay}
|
|
||||||
className="flex items-center justify-between h-11"
|
|
||||||
>
|
|
||||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span className="relative flex size-2">
|
|
||||||
<span className="absolute inline-flex w-full h-full bg-teal-400 rounded-full opacity-75 animate-ping" />
|
|
||||||
<span className="relative inline-flex bg-teal-500 rounded-full size-2" />
|
|
||||||
</span>
|
|
||||||
{relay}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeRelay(relay)}
|
|
||||||
className="inline-flex items-center justify-center rounded-md size-7 hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center h-14">
|
|
||||||
<div className="flex items-center w-full gap-2 mb-0">
|
|
||||||
<input
|
|
||||||
value={newRelay}
|
|
||||||
onChange={(e) => setNewRelay(e.target.value)}
|
|
||||||
name="url"
|
|
||||||
placeholder="wss://..."
|
|
||||||
spellCheck={false}
|
|
||||||
className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={() => addNewRelay()}
|
|
||||||
className="inline-flex items-center justify-center w-16 px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
|
||||||
User Relays (NIP-65)
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col px-3 py-2 bg-black/5 dark:bg-white/5 rounded-xl">
|
|
||||||
<p className="text-sm text-yellow-500">
|
|
||||||
Lume will automatically connect to the user's relay list, but the
|
|
||||||
manager function (like adding, removing, changing relay purpose)
|
|
||||||
is not yet available.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
{relayList.read?.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay}
|
|
||||||
className="flex items-center justify-between h-11"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{relay}</div>
|
|
||||||
<div className="text-xs font-semibold">READ</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{relayList.write?.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay}
|
|
||||||
className="flex items-center justify-between h-11"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{relay}</div>
|
|
||||||
<div className="text-xs font-semibold">WRITE</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{relayList.both?.map((relay) => (
|
|
||||||
<div
|
|
||||||
key={relay}
|
|
||||||
className="flex items-center justify-between h-11"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium">{relay}</div>
|
|
||||||
<div className="text-xs font-semibold">READ + WRITE</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||