Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
53
package.json
@@ -15,68 +15,67 @@
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/query-persist-client-core": "^5.59.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tanstack/react-router": "^1.63.5",
|
||||
"@tanstack/react-store": "^0.5.5",
|
||||
"@tanstack/store": "^0.5.5",
|
||||
"@tauri-apps/api": "^2.0.2",
|
||||
"@tanstack/query-broadcast-client-experimental": "^5.59.16",
|
||||
"@tanstack/query-persist-client-core": "^5.59.16",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"@tanstack/react-router": "^1.77.5",
|
||||
"@tauri-apps/api": "^2.0.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-http": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.1",
|
||||
"@tauri-apps/plugin-fs": "^2.0.1",
|
||||
"@tauri-apps/plugin-http": "^2.0.1",
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-store": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"@tauri-apps/plugin-upload": "^2.0.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.0.0",
|
||||
"bitcoin-units": "^1.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"i18next": "^23.15.2",
|
||||
"i18next": "^23.16.4",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"light-bolt11-decoder": "^3.2.0",
|
||||
"minidenticons": "^4.2.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nostr-tools": "^2.7.2",
|
||||
"react": "19.0.0-rc-d025ddd3-20240722",
|
||||
"nostr-tools": "^2.9.4",
|
||||
"react": "19.0.0-rc-cae764ce-20241025",
|
||||
"react-currency-input-field": "^3.8.0",
|
||||
"react-dom": "19.0.0-rc-d025ddd3-20240722",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^15.0.2",
|
||||
"react-dom": "19.0.0-rc-cae764ce-20241025",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-i18next": "^15.1.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"rich-textarea": "^0.26.3",
|
||||
"use-debounce": "^10.0.3",
|
||||
"use-debounce": "^10.0.4",
|
||||
"virtua": "^0.34.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.3",
|
||||
"@evilmartians/harmony": "^1.2.0",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/router-devtools": "^1.63.5",
|
||||
"@tanstack/router-plugin": "^1.63.5",
|
||||
"@tauri-apps/cli": "^2.0.2",
|
||||
"@tanstack/router-devtools": "^1.77.5",
|
||||
"@tanstack/router-plugin": "^1.76.4",
|
||||
"@tauri-apps/cli": "^2.0.4",
|
||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwind-gradient-mask-image": "^1.2.0",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-content-visibility": "^1.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.8",
|
||||
"vite": "^5.4.10",
|
||||
"vite-tsconfig-paths": "^5.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
1687
pnpm-lock.yaml
generated
634
src-tauri/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Lume"
|
||||
version = "4.0.0"
|
||||
version = "24.11.0"
|
||||
description = "nostr client"
|
||||
authors = ["npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445"]
|
||||
repository = "https://github.com/lumehq/lume"
|
||||
@@ -11,11 +11,10 @@ rust-version = "1.70"
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0.0", features = [
|
||||
tauri = { version = "2.0.0", features = [ "protocol-asset",
|
||||
"unstable",
|
||||
"tray-icon",
|
||||
"macos-private-api",
|
||||
"protocol-asset",
|
||||
"macos-private-api"
|
||||
] }
|
||||
tauri-plugin-window-state = "2.0.0"
|
||||
tauri-plugin-clipboard-manager = "2.0.0"
|
||||
@@ -28,13 +27,14 @@ tauri-plugin-process = "2.0.0"
|
||||
tauri-plugin-shell = "2.0.0"
|
||||
tauri-plugin-updater = "2.0.0"
|
||||
tauri-plugin-upload = "2.0.0"
|
||||
tauri-plugin-store = "2.0.0"
|
||||
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum.git" }
|
||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
|
||||
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"] }
|
||||
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
|
||||
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
specta = "^2.0.0-rc.20"
|
||||
specta-typescript = "0.0.7"
|
||||
@@ -48,15 +48,11 @@ linkify = "0.10.0"
|
||||
regex = "1.10.4"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native"] }
|
||||
keyring-search = "1.2.0"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||
|
||||
[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" }
|
||||
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
|
||||
|
||||
[profile.release]
|
||||
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": [
|
||||
"main",
|
||||
"panel",
|
||||
"settings",
|
||||
"search-*",
|
||||
"zap-*",
|
||||
"event-*",
|
||||
"user-*",
|
||||
"editor-*",
|
||||
"popup-*"
|
||||
"*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:path:default",
|
||||
@@ -25,15 +17,6 @@
|
||||
"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",
|
||||
@@ -44,25 +27,35 @@
|
||||
"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",
|
||||
"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: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",
|
||||
"theme:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"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:allow-open","dialog:allow-ask","dialog:allow-message","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"]}}
|
||||
@@ -5444,11 +5444,6 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5464,6 +5459,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5484,6 +5484,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5509,11 +5514,6 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5529,6 +5529,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5549,6 +5554,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5569,6 +5579,31 @@
|
||||
"type": "string",
|
||||
"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",
|
||||
"type": "string",
|
||||
|
||||
@@ -5444,11 +5444,6 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5464,6 +5459,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5484,6 +5484,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5509,11 +5514,6 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5529,6 +5529,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5549,6 +5554,11 @@
|
||||
"type": "string",
|
||||
"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.",
|
||||
"type": "string",
|
||||
@@ -5569,6 +5579,31 @@
|
||||
"type": "string",
|
||||
"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",
|
||||
"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 |
@@ -1,74 +1,37 @@
|
||||
[
|
||||
{
|
||||
"default": true,
|
||||
"official": true,
|
||||
"label": "onboarding",
|
||||
"name": "Onboarding",
|
||||
"description": "Tips for Mastering Lume.",
|
||||
"url": "/columns/onboarding",
|
||||
"picture": ""
|
||||
"url": "/columns/onboarding"
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"official": true,
|
||||
"label": "Launchpad",
|
||||
"label": "launchpad",
|
||||
"name": "Launchpad",
|
||||
"description": "Expand your experiences.",
|
||||
"url": "/columns/launchpad",
|
||||
"picture": ""
|
||||
"url": "/columns/launchpad"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "Search",
|
||||
"description": "Find anything.",
|
||||
"url": "/columns/search",
|
||||
"picture": ""
|
||||
"url": "/columns/search"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "Global Feeds",
|
||||
"description": "All global notes from all connected relays.",
|
||||
"url": "/columns/global",
|
||||
"picture": ""
|
||||
"url": "/columns/global"
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"official": true,
|
||||
"label": "trending",
|
||||
"name": "Trending",
|
||||
"description": "Discover all trending notes.",
|
||||
"url": "/columns/trending",
|
||||
"picture": ""
|
||||
"url": "/columns/trending"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
wss://relay.damus.io,
|
||||
wss://relay.nostr.net,
|
||||
wss://relay.primal.net,
|
||||
wss://nostr.fmt.wiz.biz,
|
||||
wss://offchain.pub,
|
||||
wss://directory.yabu.me,
|
||||
wss://purplepag.es,
|
||||
|
||||
@@ -1,126 +1,81 @@
|
||||
use keyring::Entry;
|
||||
use keyring_search::{Limit, List, Search};
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::{self, File},
|
||||
str::FromStr,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tokio::time::sleep;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::{
|
||||
common::{get_latest_event, init_nip65},
|
||||
Nostr, NOTIFICATION_SUB_ID,
|
||||
};
|
||||
use crate::{common::get_all_accounts, Nostr, Settings};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
struct Account {
|
||||
password: String,
|
||||
secret_key: String,
|
||||
nostr_connect: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_accounts() -> Vec<String> {
|
||||
let search = Search::new().expect("Unexpected.");
|
||||
let results = search.by_service("Lume Secret Storage");
|
||||
let list = List::list_credentials(&results, Limit::All);
|
||||
let accounts: HashSet<String> = list
|
||||
.split_whitespace()
|
||||
.filter(|v| v.starts_with("npub1"))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
accounts.into_iter().collect()
|
||||
get_all_accounts()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn create_account(
|
||||
name: String,
|
||||
about: String,
|
||||
picture: String,
|
||||
password: String,
|
||||
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
let npub = public_key.to_bech32().map_err(|e| e.to_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>,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let keys = Keys::generate();
|
||||
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
|
||||
let secret_key = keys.secret_key();
|
||||
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
|
||||
|
||||
// Save account
|
||||
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
|
||||
let account = Account {
|
||||
password: enc_bech32,
|
||||
nostr_connect: None,
|
||||
};
|
||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||
let _ = keyring.set_password(&j);
|
||||
|
||||
let signer = NostrSigner::Keys(keys);
|
||||
|
||||
// Update signer
|
||||
client.set_signer(Some(signer)).await;
|
||||
|
||||
let mut metadata = Metadata::new()
|
||||
.display_name(name.clone())
|
||||
.name(name.to_lowercase())
|
||||
.about(about);
|
||||
|
||||
if let Ok(url) = Url::parse(&picture) {
|
||||
metadata = metadata.picture(url)
|
||||
}
|
||||
|
||||
match client.set_metadata(&metadata).await {
|
||||
Ok(_) => Ok(npub),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn import_account(key: String, password: String) -> Result<String, String> {
|
||||
let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
|
||||
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)
|
||||
}
|
||||
// Create secret key
|
||||
let secret_key = if let Some(pw) = password {
|
||||
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
|
||||
enc.to_secret_key(pw).map_err(|err| err.to_string())?
|
||||
} else {
|
||||
SecretKey::from_str(&key).map_err(|err| err.to_string())?
|
||||
};
|
||||
|
||||
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 {
|
||||
password: enc_bech32,
|
||||
secret_key: hex,
|
||||
nostr_connect: None,
|
||||
};
|
||||
|
||||
// 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
|
||||
client.set_signer(keys).await;
|
||||
|
||||
Ok(npub)
|
||||
}
|
||||
|
||||
@@ -128,46 +83,40 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
|
||||
#[specta::specta]
|
||||
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
|
||||
|
||||
match NostrConnectURI::parse(uri.clone()) {
|
||||
Ok(bunker_uri) => {
|
||||
// Local user
|
||||
let app_keys = Keys::generate();
|
||||
let app_secret = app_keys.secret_key().to_secret_hex();
|
||||
// Local user
|
||||
let app_keys = Keys::generate();
|
||||
let app_secret = app_keys.secret_key().to_secret_hex();
|
||||
|
||||
// Get remote user
|
||||
let remote_user = bunker_uri.signer_public_key().unwrap();
|
||||
let remote_npub = remote_user.to_bech32().unwrap();
|
||||
// Get remote user
|
||||
let remote_user = bunker_uri.remote_signer_public_key().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) {
|
||||
Ok(signer) => {
|
||||
let mut url = Url::parse(&uri).unwrap();
|
||||
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);
|
||||
// Init nostr connect
|
||||
let nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let key = format!("{}_nostrconnect", remote_npub);
|
||||
let keyring = Entry::new("Lume Secret Storage", &key).unwrap();
|
||||
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);
|
||||
let bunker_uri = nostr_connect
|
||||
.bunker_uri()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Update signer
|
||||
let _ = client.set_signer(Some(signer.into())).await;
|
||||
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(remote_npub)
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
let account = Account {
|
||||
secret_key: app_secret,
|
||||
nostr_connect: Some(bunker_uri.to_string()),
|
||||
};
|
||||
|
||||
// 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]
|
||||
@@ -181,9 +130,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
||||
.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 {
|
||||
password: enc_bech32,
|
||||
secret_key: enc_bech32,
|
||||
nostr_connect: None,
|
||||
};
|
||||
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
|
||||
@@ -195,16 +144,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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 account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(password)
|
||||
Ok(account.secret_key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
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();
|
||||
|
||||
Ok(())
|
||||
@@ -212,226 +162,89 @@ pub fn delete_account(id: String) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.expect("Error: app config directory not found.");
|
||||
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool {
|
||||
let config_dir = handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.expect("Error: app config directory not found.");
|
||||
|
||||
File::create(config_dir.join(id)).is_ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn login(
|
||||
account: String,
|
||||
password: String,
|
||||
pub async fn set_signer(
|
||||
id: String,
|
||||
state: State<'_, Nostr>,
|
||||
handle: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<(), String> {
|
||||
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() {
|
||||
Ok(pw) => {
|
||||
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
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
|
||||
let public_key = match account.nostr_connect {
|
||||
match account.nostr_connect {
|
||||
None => {
|
||||
let ncryptsec =
|
||||
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
|
||||
let secret_key = ncryptsec
|
||||
.to_secret_key(password)
|
||||
.map_err(|_| "Wrong password.")?;
|
||||
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
|
||||
let keys = Keys::new(secret_key);
|
||||
let public_key = keys.public_key().to_bech32().unwrap();
|
||||
let signer = NostrSigner::Keys(keys);
|
||||
|
||||
// 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) => {
|
||||
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
|
||||
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
|
||||
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
|
||||
let app_keys = Keys::from_str(&account.secret_key).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) => {
|
||||
// Update signer
|
||||
client.set_signer(Some(signer.into())).await;
|
||||
public_key
|
||||
client.set_signer(signer).await;
|
||||
// Emit to front-end
|
||||
handle.emit("signer-updated", ()).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// NIP-65: Connect to user's relay list
|
||||
init_nip65(client, &public_key).await;
|
||||
|
||||
// NIP-03: Get user's contact list
|
||||
let contact_list = {
|
||||
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
|
||||
state.contact_list.lock().await.clone_from(&contacts);
|
||||
contacts
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let public_key_clone = public_key.clone();
|
||||
|
||||
// Run seperate thread for sync
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
let author = PublicKey::from_str(&public_key).unwrap();
|
||||
|
||||
// Subscribe for new notification
|
||||
if let Ok(e) = client
|
||||
.subscribe_with_id(
|
||||
SubscriptionId::new(NOTIFICATION_SUB_ID),
|
||||
vec![Filter::new().pubkey(author).since(Timestamp::now())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
println!("Subscribed: {}", e.success.len())
|
||||
}
|
||||
|
||||
// Get events from contact list
|
||||
if !contact_list.is_empty() {
|
||||
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
|
||||
|
||||
// Syncing all metadata events from contact list
|
||||
if let Ok(report) = client
|
||||
.reconcile(
|
||||
Filter::new()
|
||||
.authors(authors.clone())
|
||||
.kinds(vec![Kind::Metadata, Kind::ContactList, 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
|
||||
let mut settings = state.settings.write().await;
|
||||
// Update state
|
||||
settings.clone_from(&parsed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use futures::future::join_all;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type)]
|
||||
@@ -14,130 +13,58 @@ pub struct RichEvent {
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
|
||||
let client = &state.client;
|
||||
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
|
||||
let filter = Filter::new().id(event_id);
|
||||
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
|
||||
|
||||
match client.database().query(vec![filter.clone()]).await {
|
||||
Ok(events) => {
|
||||
if let Some(event) = get_latest_event(&events) {
|
||||
let raw = event.as_json();
|
||||
let parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let events = client
|
||||
.database()
|
||||
.event_by_id(&event_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(RichEvent { raw, parsed })
|
||||
} 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 raw: String = String::new();
|
||||
let mut parsed: Option<Meta> = None;
|
||||
if let Some(event) = events {
|
||||
let raw = event.as_json();
|
||||
let parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
raw = event.as_json();
|
||||
parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
Ok(RichEvent { raw, parsed })
|
||||
} else {
|
||||
let filter = Filter::new().id(event_id).limit(1);
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(RichEvent { raw, parsed })
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
while let Some(event) = rx.next().await {
|
||||
events.insert(event);
|
||||
}
|
||||
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn get_event_from(
|
||||
id: String,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
pub async fn get_meta_from_event(content: String) -> Result<Meta, ()> {
|
||||
Ok(parse_event(&content).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -145,54 +72,25 @@ pub async fn get_event_from(
|
||||
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
|
||||
let client = &state.client;
|
||||
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()
|
||||
.kinds(vec![Kind::TextNote])
|
||||
.event(event_id)
|
||||
.since(Timestamp::now());
|
||||
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
|
||||
.event(event_id);
|
||||
|
||||
match client
|
||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
.map_err(|e| 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]
|
||||
@@ -210,13 +108,20 @@ pub async fn get_all_events_by_author(
|
||||
.author(author)
|
||||
.limit(limit as usize);
|
||||
|
||||
match client
|
||||
.get_events_of(vec![filter], EventSource::Database)
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
.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]
|
||||
@@ -229,28 +134,35 @@ pub async fn get_all_events_by_authors(
|
||||
let client = &state.client;
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
let authors: Vec<PublicKey> = public_keys
|
||||
.iter()
|
||||
.map(|pk| PublicKey::from_str(pk).map_err(|err| err.to_string()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
.filter_map(|pk| PublicKey::from_str(pk).ok())
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of)
|
||||
.authors(authors);
|
||||
.until(as_of);
|
||||
|
||||
match client
|
||||
.get_events_of(vec![filter], EventSource::Database)
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
.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]
|
||||
@@ -273,16 +185,58 @@ pub async fn get_all_events_by_hashtags(
|
||||
.until(as_of)
|
||||
.hashtags(hashtags);
|
||||
|
||||
match client
|
||||
.get_events_of(
|
||||
vec![filter],
|
||||
EventSource::both(Some(Duration::from_secs(5))),
|
||||
)
|
||||
let mut events = Events::new(&[filter.clone()]);
|
||||
|
||||
let mut rx = client
|
||||
.stream_events(vec![filter], Some(Duration::from_secs(3)))
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
.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_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]
|
||||
@@ -304,7 +258,7 @@ pub async fn get_local_events(
|
||||
.until(as_of);
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -327,14 +281,8 @@ pub async fn get_global_events(
|
||||
.limit(FETCH_LIMIT)
|
||||
.until(as_of);
|
||||
|
||||
match client
|
||||
.get_events_of(
|
||||
vec![filter],
|
||||
EventSource::both(Some(Duration::from_secs(5))),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events, false).await),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -349,7 +297,7 @@ pub async fn publish(
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
|
||||
// Create tags from content
|
||||
// Create event tags from content
|
||||
let mut tags = create_tags(&content);
|
||||
|
||||
// Add client tag
|
||||
@@ -369,85 +317,89 @@ pub async fn publish(
|
||||
let builder =
|
||||
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
|
||||
|
||||
// Publish
|
||||
match client.send_event_builder(builder).await {
|
||||
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
|
||||
// Sign event
|
||||
let event = client
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn reply(
|
||||
content: String,
|
||||
to: String,
|
||||
root: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<String, String> {
|
||||
pub async fn reply(content: String, to: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
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);
|
||||
|
||||
match database.query(vec![Filter::new().id(reply_id)]).await {
|
||||
Ok(events) => {
|
||||
if let Some(event) = events.first() {
|
||||
let relay_hint = if let Some(relays) = database
|
||||
.event_seen_on_relays(&event.id)
|
||||
.await
|
||||
.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::Reply),
|
||||
public_key: Some(event.pubkey),
|
||||
};
|
||||
let tag = Tag::from(t);
|
||||
tags.push(tag)
|
||||
// Add client tag
|
||||
// TODO: allow user config this setting
|
||||
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
|
||||
|
||||
// Get reply event
|
||||
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
|
||||
|
||||
let reply_to = match client.database().event_by_id(&reply_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(event) = event {
|
||||
event
|
||||
} 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 {
|
||||
let root_id = match EventId::from_hex(id) {
|
||||
Ok(val) => val,
|
||||
Err(_) => return Err("Event is not valid.".into()),
|
||||
};
|
||||
|
||||
if let Ok(events) = database.query(vec![Filter::new().id(root_id)]).await {
|
||||
if let Some(event) = events.first() {
|
||||
let relay_hint = if let Some(relays) = database
|
||||
.event_seen_on_relays(&event.id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
{
|
||||
relays.into_iter().next().map(UncheckedUrl::new)
|
||||
// Detect root event from reply
|
||||
let root_ids: Vec<&EventId> = reply_to
|
||||
.tags
|
||||
.filter_standardized(TagKind::e())
|
||||
.filter_map(|t| match t {
|
||||
TagStandard::Event {
|
||||
event_id, marker, ..
|
||||
} => {
|
||||
if let Some(mkr) = marker {
|
||||
match mkr {
|
||||
Marker::Root => Some(event_id),
|
||||
Marker::Reply => Some(event_id),
|
||||
_ => None,
|
||||
}
|
||||
} 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)
|
||||
Some(event_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Get root event if exist
|
||||
let root = match root_ids.first() {
|
||||
Some(&id) => client
|
||||
.database()
|
||||
.event_by_id(id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
match client.publish_text_note(content, tags).await {
|
||||
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
|
||||
let builder = EventBuilder::text_note_reply(content, &reply_to, root.as_ref(), None)
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
@@ -459,23 +411,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())?;
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[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 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())?;
|
||||
|
||||
match client.delete_event(event_id).await {
|
||||
Ok(event_id) => Ok(event_id.to_string()),
|
||||
let filter = Filter::new()
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
#[specta::specta]
|
||||
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
@@ -512,13 +510,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())?;
|
||||
|
||||
match client
|
||||
.get_events_of(
|
||||
vec![Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1)],
|
||||
EventSource::both(Some(Duration::from_secs(5))),
|
||||
)
|
||||
.database()
|
||||
.query(vec![Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::RelayList)
|
||||
.limit(1)])
|
||||
.await
|
||||
{
|
||||
Ok(events) => match events.first() {
|
||||
@@ -552,38 +548,7 @@ pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEv
|
||||
let filter = Filter::new().search(query);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events).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(()),
|
||||
Ok(events) => Ok(process_event(client, events, false).await),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,9 @@ use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::{Emitter, Manager, State};
|
||||
use tauri_specta::Event;
|
||||
use tauri::State;
|
||||
|
||||
use crate::{
|
||||
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>,
|
||||
}
|
||||
use crate::{common::process_event, Nostr, RichEvent, FETCH_LIMIT};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
pub struct Mention {
|
||||
@@ -33,54 +17,27 @@ pub struct Mention {
|
||||
|
||||
#[tauri::command]
|
||||
#[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 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 public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.author(public_key)
|
||||
.kind(Kind::Metadata)
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(vec![filter.clone()]).await {
|
||||
Ok(events) => {
|
||||
if let Some(event) = get_latest_event(&events) {
|
||||
if let Ok(metadata) = Metadata::from_json(&event.content) {
|
||||
Ok(metadata.as_json())
|
||||
} 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();
|
||||
let events = client
|
||||
.database()
|
||||
.query(vec![filter])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
while let Some(event) = rx.next().await {
|
||||
println!("Event: {}", event.as_json());
|
||||
if let Ok(m) = Metadata::from_json(&event.content) {
|
||||
metadata = m.as_json();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
match events.first() {
|
||||
Some(event) => match Metadata::from_json(&event.content) {
|
||||
Ok(metadata) => Ok(metadata.as_json()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
None => Err("Metadata not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,42 +64,26 @@ pub async fn set_contact_list(
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let contact_list = state.contact_list.lock().await.clone();
|
||||
let vec: Vec<String> = contact_list
|
||||
.into_iter()
|
||||
.map(|f| f.public_key.to_hex())
|
||||
.collect();
|
||||
pub async fn get_contact_list(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
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]
|
||||
#[specta::specta]
|
||||
pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
pub async fn set_profile(new_profile: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let mut metadata = Metadata::new()
|
||||
.name(profile.name)
|
||||
.display_name(profile.display_name)
|
||||
.about(profile.about.unwrap_or_default())
|
||||
.nip05(profile.nip05.unwrap_or_default())
|
||||
.lud16(profile.lud16.unwrap_or_default());
|
||||
|
||||
if let Ok(url) = Url::parse(&profile.picture) {
|
||||
metadata = metadata.picture(url)
|
||||
}
|
||||
|
||||
if let Some(b) = profile.banner {
|
||||
if let Ok(url) = Url::parse(&b) {
|
||||
metadata = metadata.banner(url)
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(w) = profile.website {
|
||||
if let Ok(url) = Url::parse(&w) {
|
||||
metadata = metadata.website(url)
|
||||
}
|
||||
}
|
||||
let metadata = Metadata::from_json(new_profile).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.set_metadata(&metadata).await {
|
||||
Ok(id) => Ok(id.to_string()),
|
||||
@@ -152,13 +93,13 @@ pub async fn set_profile(profile: Profile, state: State<'_, Nostr>) -> Result<St
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn check_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let contact_list = &state.contact_list.lock().await;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
pub async fn is_contact(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match contact_list.iter().position(|x| x.public_key == public_key) {
|
||||
Some(_) => Ok(true),
|
||||
None => Ok(false),
|
||||
match client.database().contacts_public_keys(public_key).await {
|
||||
Ok(public_keys) => Ok(public_keys.iter().any(|i| i == &public_key)),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,12 +147,17 @@ pub async fn set_group(
|
||||
image: Option<String>,
|
||||
users: Vec<String>,
|
||||
state: State<'_, Nostr>,
|
||||
handle: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
let public_keys: Vec<PublicKey> = users
|
||||
.iter()
|
||||
.map(|u| PublicKey::from_str(u).unwrap())
|
||||
.filter_map(|u| {
|
||||
if let Ok(pk) = PublicKey::from_str(u) {
|
||||
Some(pk)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let label = title.to_lowercase().replace(" ", "-");
|
||||
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);
|
||||
|
||||
match client.send_event_builder(builder).await {
|
||||
Ok(report) => {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.authors(public_keys)
|
||||
.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()),
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,29 +190,83 @@ pub async fn set_group(
|
||||
pub async fn get_group(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
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 {
|
||||
Ok(events) => match get_latest_event(&events) {
|
||||
Some(ev) => Ok(ev.as_json()),
|
||||
None => Err("Not found.".to_string()),
|
||||
},
|
||||
match client.database().event_by_id(&event_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(ev) = event {
|
||||
Ok(ev.as_json())
|
||||
} else {
|
||||
Err("Event not found".into())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[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 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().kind(Kind::FollowSet).author(public_key);
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(e) => Err(e.to_string()),
|
||||
let groups = Filter::new().kind(Kind::FollowSet).author(public_key);
|
||||
let contacts = Filter::new()
|
||||
.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]
|
||||
@@ -288,7 +277,6 @@ pub async fn set_interest(
|
||||
image: Option<String>,
|
||||
hashtags: Vec<String>,
|
||||
state: State<'_, Nostr>,
|
||||
handle: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
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);
|
||||
|
||||
match client.send_event_builder(builder).await {
|
||||
Ok(report) => {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
// Sign event
|
||||
let event = client
|
||||
.sign_event_builder(builder)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.hashtags(hashtags)
|
||||
.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()),
|
||||
match client.send_event(event).await {
|
||||
Ok(output) => Ok(output.to_hex()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,38 +310,80 @@ pub async fn set_interest(
|
||||
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
|
||||
let client = &state.client;
|
||||
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 {
|
||||
Ok(events) => match get_latest_event(&events) {
|
||||
Some(ev) => Ok(ev.as_json()),
|
||||
None => Err("Not found.".to_string()),
|
||||
},
|
||||
match client.database().event_by_id(&event_id).await {
|
||||
Ok(event) => {
|
||||
if let Some(ev) = event {
|
||||
Ok(ev.as_json())
|
||||
} else {
|
||||
Err("Event not found".into())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[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 signer = client.signer().await.map_err(|e| e.to_string())?;
|
||||
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
|
||||
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::InterestSet, Kind::Interests])
|
||||
.author(public_key);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(process_event(client, events).await),
|
||||
Err(e) => Err(e.to_string()),
|
||||
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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||
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_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
|
||||
let client = &state.client;
|
||||
let filter = Filter::new().kind(Kind::Metadata);
|
||||
|
||||
@@ -399,7 +418,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
||||
|
||||
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(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())?;
|
||||
client.set_zapper(nwc).await;
|
||||
|
||||
@@ -411,37 +432,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
|
||||
|
||||
#[tauri::command]
|
||||
#[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 keyring =
|
||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
match keyring.get_password() {
|
||||
Ok(val) => {
|
||||
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
||||
let nwc = NWC::new(uri);
|
||||
if client.zapper().await.is_err() {
|
||||
let keyring =
|
||||
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
// Get current balance
|
||||
let balance = nwc.get_balance().await;
|
||||
match keyring.get_password() {
|
||||
Ok(val) => {
|
||||
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
|
||||
let nwc = NWC::new(uri);
|
||||
|
||||
// Update zapper
|
||||
client.set_zapper(nwc).await;
|
||||
|
||||
match balance {
|
||||
Ok(val) => Ok(val.to_string()),
|
||||
Err(_) => Err("Get balance failed.".into()),
|
||||
client.set_zapper(nwc).await;
|
||||
}
|
||||
Err(_) => return Err("Wallet not found.".into()),
|
||||
}
|
||||
Err(_) => Err("NWC not found.".into()),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let keyring =
|
||||
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
|
||||
|
||||
match keyring.delete_credential() {
|
||||
Ok(_) => {
|
||||
@@ -455,52 +471,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn zap_profile(
|
||||
id: &str,
|
||||
amount: &str,
|
||||
message: &str,
|
||||
id: String,
|
||||
amount: String,
|
||||
message: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
|
||||
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 details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||
|
||||
if client.zap(public_key, num, Some(details)).await.is_ok() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("Zap profile failed".into())
|
||||
match client.zap(public_key, num, details).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn zap_event(
|
||||
id: &str,
|
||||
amount: &str,
|
||||
message: &str,
|
||||
id: String,
|
||||
amount: String,
|
||||
message: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<(), String> {
|
||||
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 details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
|
||||
|
||||
if client.zap(event_id, num, Some(details)).await.is_ok() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("Zap event failed".into())
|
||||
match client.zap(event_id, num, details).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,10 +522,7 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
||||
.limit(1);
|
||||
|
||||
if let Ok(contact_list_events) = client
|
||||
.get_events_of(
|
||||
vec![contact_list_filter],
|
||||
EventSource::both(Some(Duration::from_secs(5))),
|
||||
)
|
||||
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
|
||||
.await
|
||||
{
|
||||
for event in contact_list_events.into_iter() {
|
||||
@@ -550,62 +551,34 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
|
||||
|
||||
#[tauri::command]
|
||||
#[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 public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
match client.signer().await {
|
||||
Ok(signer) => {
|
||||
let public_key = signer.public_key().await.unwrap();
|
||||
let filter = Filter::new()
|
||||
.pubkey(public_key)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Reaction,
|
||||
Kind::ZapReceipt,
|
||||
])
|
||||
.limit(200);
|
||||
let filter = Filter::new()
|
||||
.pubkey(public_key)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Reaction,
|
||||
Kind::ZapReceipt,
|
||||
])
|
||||
.limit(500);
|
||||
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
|
||||
Ok(state.settings.lock().await.clone())
|
||||
}
|
||||
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
|
||||
|
||||
#[tauri::command]
|
||||
#[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()),
|
||||
}
|
||||
Ok(alt_events)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -619,12 +592,3 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
let 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 metadata;
|
||||
pub mod relay;
|
||||
pub mod sync;
|
||||
pub mod window;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use crate::Nostr;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::{
|
||||
fs::OpenOptions,
|
||||
io::{self, BufRead, Write},
|
||||
time::Duration,
|
||||
str::FromStr,
|
||||
};
|
||||
use tauri::{path::BaseDirectory, Manager, State};
|
||||
|
||||
use crate::{Nostr, FETCH_LIMIT};
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
pub struct Relays {
|
||||
connected: Vec<String>,
|
||||
@@ -19,8 +20,9 @@ pub struct Relays {
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
|
||||
pub async fn get_relays(id: String, state: State<'_, Nostr>) -> Result<Relays, String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let connected_relays = client
|
||||
.relays()
|
||||
@@ -29,24 +31,12 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
|
||||
.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
|
||||
{
|
||||
match client.database().query(vec![filter]).await {
|
||||
Ok(events) => {
|
||||
if let Some(event) = events.first() {
|
||||
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
|
||||
@@ -105,32 +95,59 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
|
||||
pub async fn get_all_relays(
|
||||
until: Option<String>,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let client = &state.client;
|
||||
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
|
||||
if status {
|
||||
println!("Connecting to relay: {}", relay);
|
||||
client
|
||||
.connect_relay(relay)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let as_of = match until {
|
||||
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
|
||||
None => Timestamp::now(),
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[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;
|
||||
client
|
||||
.remove_relay(relay)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
client
|
||||
.disconnect_relay(relay)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
let _ = client.add_relay(&relay).await;
|
||||
let _ = client.connect_relay(&relay).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let _ = client.force_remove_relay(relay).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -152,7 +169,7 @@ pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
|
||||
pub fn set_bootstrap_relays(relays: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let relays_path = app
|
||||
.path()
|
||||
.resolve("resources/relays.txt", BaseDirectory::Resource)
|
||||
|
||||
116
src-tauri/src/commands/sync.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::fs::{self, File};
|
||||
use tauri::{ipc::Channel, Manager, State};
|
||||
|
||||
use crate::Nostr;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn is_account_sync(id: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
let config_dir = app_handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let exist = fs::metadata(config_dir.join(id)).is_ok();
|
||||
|
||||
Ok(exist)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_account(
|
||||
id: String,
|
||||
state: State<'_, Nostr>,
|
||||
reader: Channel<f64>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let client = &state.client;
|
||||
let public_key = PublicKey::from_bech32(&id).map_err(|e| e.to_string())?;
|
||||
|
||||
let filter = Filter::new().author(public_key).kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::ContactList,
|
||||
Kind::Interests,
|
||||
Kind::InterestSet,
|
||||
Kind::FollowSet,
|
||||
Kind::RelayList,
|
||||
Kind::MuteList,
|
||||
Kind::EventDeletion,
|
||||
Kind::Bookmarks,
|
||||
Kind::BookmarkSet,
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::Custom(30315),
|
||||
]);
|
||||
|
||||
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 SyncProgress { total, current } = *rx.borrow_and_update();
|
||||
|
||||
if total > 0 {
|
||||
reader
|
||||
.send((current as f64 / total as f64) * 100.0)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Ok(output) = client.sync(filter, &opts).await {
|
||||
println!("Success: {:?}", output.success);
|
||||
println!("Failed: {:?}", output.failed);
|
||||
|
||||
let event_pubkeys = client
|
||||
.database()
|
||||
.query(vec![Filter::new().kinds(vec![
|
||||
Kind::ContactList,
|
||||
Kind::FollowSet,
|
||||
Kind::MuteList,
|
||||
Kind::Repost,
|
||||
Kind::TextNote,
|
||||
])])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !event_pubkeys.is_empty() {
|
||||
let pubkeys: Vec<PublicKey> = event_pubkeys
|
||||
.iter()
|
||||
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||
.collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(pubkeys)
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::EventDeletion,
|
||||
Kind::Interests,
|
||||
Kind::InterestSet,
|
||||
Kind::FollowSet,
|
||||
Kind::RelayList,
|
||||
Kind::MuteList,
|
||||
Kind::EventDeletion,
|
||||
Kind::Bookmarks,
|
||||
Kind::BookmarkSet,
|
||||
Kind::Custom(30315),
|
||||
])
|
||||
.limit(10000);
|
||||
|
||||
if let Ok(output) = client.sync(filter, &opts).await {
|
||||
println!("Success: {:?}", output.success);
|
||||
println!("Failed: {:?}", output.failed);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let config_dir = app_handle
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let _ = File::create(config_dir.join(id));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::utils::config::WindowEffectsConfig;
|
||||
use tauri::window::Effect;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::TitleBarStyle;
|
||||
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl};
|
||||
use tauri::{WebviewBuilder, WebviewWindowBuilder};
|
||||
use tauri::{
|
||||
utils::config::WindowEffectsConfig, webview::PageLoadEvent, window::Effect, LogicalPosition,
|
||||
LogicalSize, Manager, WebviewBuilder, WebviewUrl, WebviewWindowBuilder, Window,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
|
||||
use crate::common::get_last_segment;
|
||||
use crate::Nostr;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
pub struct Window {
|
||||
pub struct NewWindow {
|
||||
label: String,
|
||||
title: String,
|
||||
url: String,
|
||||
@@ -22,6 +26,7 @@ pub struct Window {
|
||||
maximizable: bool,
|
||||
minimizable: bool,
|
||||
hidden_title: bool,
|
||||
closable: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
@@ -34,85 +39,147 @@ pub struct Column {
|
||||
height: f32,
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
match app_handle.get_window("main") {
|
||||
Some(main_window) => match app_handle.get_webview(&column.label) {
|
||||
Some(_) => Ok(column.label),
|
||||
None => {
|
||||
let path = PathBuf::from(column.url);
|
||||
let webview_url = WebviewUrl::App(path);
|
||||
|
||||
let builder = WebviewBuilder::new(column.label, webview_url)
|
||||
.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())
|
||||
}
|
||||
pub async fn create_column(
|
||||
column: Column,
|
||||
app_handle: tauri::AppHandle,
|
||||
main_window: Window,
|
||||
) -> Result<String, String> {
|
||||
match app_handle.get_webview(&column.label) {
|
||||
Some(webview) => {
|
||||
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
},
|
||||
None => Err("Main window not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
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()),
|
||||
}
|
||||
}
|
||||
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn reposition_column(
|
||||
label: String,
|
||||
x: f32,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Ok(column.label)
|
||||
}
|
||||
None => {
|
||||
let path = PathBuf::from(column.url);
|
||||
let webview_url = WebviewUrl::App(path);
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn resize_column(
|
||||
label: String,
|
||||
width: f32,
|
||||
height: f32,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<bool, String> {
|
||||
match app_handle.get_webview(&label) {
|
||||
Some(webview) => Ok(webview.set_size(LogicalSize::new(width, height)).is_ok()),
|
||||
None => Err("Not found".into()),
|
||||
}
|
||||
}
|
||||
let builder = WebviewBuilder::new(column.label, webview_url)
|
||||
.incognito(true)
|
||||
.transparent(true)
|
||||
.on_page_load(|webview, payload| match payload.event() {
|
||||
PageLoadEvent::Started => {
|
||||
if let Ok(id) = get_last_segment(payload.url()) {
|
||||
if let Ok(public_key) = PublicKey::parse(&id) {
|
||||
let is_newsfeed = payload.url().to_string().contains("newsfeed");
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[specta::specta]
|
||||
pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
match app_handle.get_webview(&label) {
|
||||
Some(webview) => Ok(webview.eval("window.location.reload()").is_ok()),
|
||||
None => Err("Not found".into()),
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = webview.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
if is_newsfeed {
|
||||
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()
|
||||
.event(event_id)
|
||||
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
|
||||
.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]
|
||||
#[specta::specta]
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
pub async 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(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 current_window.is_visible().unwrap_or_default() {
|
||||
let _ = current_window.set_focus();
|
||||
@@ -120,7 +187,10 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
||||
let _ = current_window.show();
|
||||
let _ = current_window.set_focus();
|
||||
};
|
||||
|
||||
Ok(current_window.label().to_string())
|
||||
} else {
|
||||
#[cfg(target_os = "macos")]
|
||||
let new_window = WebviewWindowBuilder::new(
|
||||
&app_handle,
|
||||
&window.label,
|
||||
@@ -134,6 +204,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
||||
.minimizable(window.minimizable)
|
||||
.maximizable(window.maximizable)
|
||||
.transparent(true)
|
||||
.closable(window.closable)
|
||||
.effects(WindowEffectsConfig {
|
||||
state: None,
|
||||
effects: vec![Effect::UnderWindowBackground],
|
||||
@@ -143,26 +214,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Restore native border
|
||||
new_window.add_border(None);
|
||||
}
|
||||
|
||||
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(
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_window = WebviewWindowBuilder::new(
|
||||
&app_handle,
|
||||
&window.label,
|
||||
WebviewUrl::App(PathBuf::from(window.url)),
|
||||
@@ -174,6 +227,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
||||
.maximizable(window.maximizable)
|
||||
.transparent(true)
|
||||
.decorations(false)
|
||||
.closable(window.closable)
|
||||
.effects(WindowEffectsConfig {
|
||||
state: None,
|
||||
effects: vec![Effect::Mica],
|
||||
@@ -183,56 +237,14 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
|
||||
.build()
|
||||
.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
|
||||
#[cfg(target_os = "windows")]
|
||||
window.create_overlay_titlebar().unwrap();
|
||||
new_window.create_overlay_titlebar().unwrap();
|
||||
|
||||
// Restore native border
|
||||
#[cfg(target_os = "macos")]
|
||||
window.add_border(None);
|
||||
new_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();
|
||||
|
||||
#[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();
|
||||
}
|
||||
});
|
||||
Ok(new_window.label().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn quit() {
|
||||
std::process::exit(0)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use futures::future::join_all;
|
||||
use keyring_search::{Limit, List, Search};
|
||||
use linkify::LinkFinder;
|
||||
use nostr_sdk::prelude::*;
|
||||
use reqwest::Client as ReqClient;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashSet, str::FromStr};
|
||||
|
||||
use crate::RichEvent;
|
||||
|
||||
@@ -20,6 +19,8 @@ pub struct Meta {
|
||||
}
|
||||
|
||||
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] = [
|
||||
"@nevent1",
|
||||
"@note1",
|
||||
@@ -32,20 +33,19 @@ const NOSTR_EVENTS: [&str; 10] = [
|
||||
"Nostr:note1",
|
||||
"Nostr:nevent1",
|
||||
];
|
||||
const NOSTR_MENTIONS: [&str; 10] = [
|
||||
|
||||
const NOSTR_MENTIONS: [&str; 8] = [
|
||||
"@npub1",
|
||||
"nostr:npub1",
|
||||
"nostr:nprofile1",
|
||||
"nostr:naddr1",
|
||||
"npub1",
|
||||
"nprofile1",
|
||||
"naddr1",
|
||||
"Nostr:npub1",
|
||||
"Nostr:nprofile1",
|
||||
"Nostr:naddr1",
|
||||
];
|
||||
|
||||
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
|
||||
pub fn get_latest_event(events: &Events) -> Option<&Event> {
|
||||
events.iter().next()
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
||||
let hashtags = words
|
||||
.iter()
|
||||
.filter(|&&word| word.starts_with('#'))
|
||||
.map(|&s| s.to_string())
|
||||
.map(|&s| s.to_string().replace("#", "").to_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for mention in mentions {
|
||||
@@ -130,30 +130,55 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
|
||||
tags
|
||||
}
|
||||
|
||||
pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent> {
|
||||
// Remove event thread if event is TextNote
|
||||
let events: Vec<Event> = 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)
|
||||
}
|
||||
})
|
||||
pub fn get_all_accounts() -> Vec<String> {
|
||||
let search = Search::new().expect("Unexpected.");
|
||||
let results = search.by_service("Lume Safe Storage");
|
||||
let list = List::list_credentials(&results, Limit::All);
|
||||
let accounts: HashSet<String> = list
|
||||
.split_whitespace()
|
||||
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
accounts.into_iter().collect()
|
||||
}
|
||||
|
||||
pub 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
|
||||
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
|
||||
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
|
||||
@@ -164,7 +189,14 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
|
||||
if !requests.is_empty() {
|
||||
let ids: Vec<&str> = requests
|
||||
.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();
|
||||
|
||||
// Remove event if event is deleted by author
|
||||
@@ -196,41 +228,6 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
|
||||
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 {
|
||||
let mut finder = LinkFinder::new();
|
||||
finder.url_must_have_scheme(false);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use border::WebviewWindowExt as BorderWebviewWindowExt;
|
||||
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
|
||||
use common::parse_event;
|
||||
use common::{get_all_accounts, parse_event};
|
||||
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -18,168 +18,140 @@ use std::{
|
||||
str::FromStr,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{path::BaseDirectory, Emitter, EventTarget, Manager};
|
||||
use tauri::{path::BaseDirectory, Emitter, EventTarget, Listener, Manager};
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
use tauri_plugin_notification::{NotificationExt, PermissionState};
|
||||
use tauri_specta::{collect_commands, collect_events, Builder, Event as TauriEvent};
|
||||
use tokio::sync::Mutex;
|
||||
use tauri_specta::{collect_commands, Builder};
|
||||
use tokio::{sync::RwLock, time::sleep};
|
||||
|
||||
pub mod commands;
|
||||
pub mod common;
|
||||
|
||||
pub struct Nostr {
|
||||
client: Client,
|
||||
settings: Mutex<Settings>,
|
||||
contact_list: Mutex<Vec<Contact>>,
|
||||
trusted_list: Mutex<HashSet<PublicKey>>,
|
||||
queue: RwLock<HashSet<PublicKey>>,
|
||||
is_syncing: RwLock<bool>,
|
||||
settings: RwLock<Settings>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Payload {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
pub struct Settings {
|
||||
proxy: Option<String>,
|
||||
image_resize_service: Option<String>,
|
||||
use_relay_hint: bool,
|
||||
resize_service: bool,
|
||||
content_warning: bool,
|
||||
trusted_only: bool,
|
||||
display_avatar: bool,
|
||||
display_zap_button: bool,
|
||||
display_repost_button: bool,
|
||||
display_media: bool,
|
||||
transparent: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: None,
|
||||
image_resize_service: Some("https://wsrv.nl".to_string()),
|
||||
use_relay_hint: true,
|
||||
content_warning: true,
|
||||
trusted_only: false,
|
||||
resize_service: true,
|
||||
display_avatar: true,
|
||||
display_zap_button: true,
|
||||
display_repost_button: true,
|
||||
display_media: true,
|
||||
transparent: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
enum SubKind {
|
||||
Subscribe,
|
||||
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 DEFAULT_DIFFICULTY: u8 = 0;
|
||||
pub const FETCH_LIMIT: usize = 50;
|
||||
pub const QUEUE_DELAY: u64 = 300;
|
||||
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
|
||||
|
||||
fn main() {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let builder = Builder::<tauri::Wry>::new()
|
||||
// Then register them (separated by a comma)
|
||||
.commands(collect_commands![
|
||||
get_relays,
|
||||
connect_relay,
|
||||
remove_relay,
|
||||
get_bootstrap_relays,
|
||||
save_bootstrap_relays,
|
||||
get_accounts,
|
||||
create_account,
|
||||
import_account,
|
||||
connect_account,
|
||||
get_private_key,
|
||||
delete_account,
|
||||
reset_password,
|
||||
is_account_sync,
|
||||
create_sync_file,
|
||||
login,
|
||||
get_profile,
|
||||
set_profile,
|
||||
get_contact_list,
|
||||
set_contact_list,
|
||||
check_contact,
|
||||
toggle_contact,
|
||||
get_mention_list,
|
||||
set_group,
|
||||
get_group,
|
||||
get_all_groups,
|
||||
set_interest,
|
||||
get_interest,
|
||||
get_all_interests,
|
||||
set_wallet,
|
||||
load_wallet,
|
||||
remove_wallet,
|
||||
zap_profile,
|
||||
zap_event,
|
||||
copy_friend,
|
||||
get_notifications,
|
||||
get_user_settings,
|
||||
set_user_settings,
|
||||
verify_nip05,
|
||||
is_trusted_user,
|
||||
get_event_meta,
|
||||
get_event,
|
||||
get_event_from,
|
||||
get_replies,
|
||||
subscribe_to,
|
||||
get_all_events_by_author,
|
||||
get_all_events_by_authors,
|
||||
get_all_events_by_hashtags,
|
||||
get_local_events,
|
||||
get_global_events,
|
||||
is_deleted_event,
|
||||
request_delete,
|
||||
search,
|
||||
publish,
|
||||
reply,
|
||||
repost,
|
||||
event_to_bech32,
|
||||
user_to_bech32,
|
||||
create_column,
|
||||
close_column,
|
||||
reposition_column,
|
||||
resize_column,
|
||||
reload_column,
|
||||
open_window,
|
||||
reopen_lume,
|
||||
quit
|
||||
])
|
||||
.events(collect_events![Subscription, NewSettings]);
|
||||
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
get_relays,
|
||||
get_all_relays,
|
||||
is_relay_connected,
|
||||
connect_relay,
|
||||
remove_relay,
|
||||
get_bootstrap_relays,
|
||||
set_bootstrap_relays,
|
||||
get_accounts,
|
||||
watch_account,
|
||||
import_account,
|
||||
connect_account,
|
||||
get_private_key,
|
||||
delete_account,
|
||||
reset_password,
|
||||
has_signer,
|
||||
set_signer,
|
||||
get_profile,
|
||||
set_profile,
|
||||
get_contact_list,
|
||||
set_contact_list,
|
||||
is_contact,
|
||||
toggle_contact,
|
||||
get_all_profiles,
|
||||
set_group,
|
||||
get_group,
|
||||
get_all_newsfeeds,
|
||||
get_all_local_newsfeeds,
|
||||
set_interest,
|
||||
get_interest,
|
||||
get_all_interests,
|
||||
get_all_local_interests,
|
||||
set_wallet,
|
||||
load_wallet,
|
||||
remove_wallet,
|
||||
zap_profile,
|
||||
zap_event,
|
||||
copy_friend,
|
||||
get_notifications,
|
||||
verify_nip05,
|
||||
get_meta_from_event,
|
||||
get_event,
|
||||
get_replies,
|
||||
get_all_events_by_author,
|
||||
get_all_events_by_authors,
|
||||
get_all_events_by_hashtags,
|
||||
get_all_events_from,
|
||||
get_local_events,
|
||||
get_global_events,
|
||||
search,
|
||||
publish,
|
||||
reply,
|
||||
repost,
|
||||
is_reposted,
|
||||
request_delete,
|
||||
is_deleted_event,
|
||||
event_to_bech32,
|
||||
user_to_bech32,
|
||||
create_column,
|
||||
reload_column,
|
||||
close_column,
|
||||
close_all_columns,
|
||||
open_window,
|
||||
get_app_settings,
|
||||
set_app_settings,
|
||||
]);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
builder
|
||||
.export(Typescript::default(), "../src/commands.gen.ts")
|
||||
.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 mut ctx = tauri::generate_context!();
|
||||
|
||||
tauri_builder
|
||||
.invoke_handler(builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
|
||||
let handle = app.handle();
|
||||
let handle_clone = handle.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 config_dir = handle
|
||||
@@ -187,13 +159,7 @@ fn main() {
|
||||
.app_config_dir()
|
||||
.expect("Error: app config directory not found.");
|
||||
|
||||
let data_dir = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("Error: app data directory not found.");
|
||||
|
||||
let _ = fs::create_dir_all(&config_dir);
|
||||
let _ = fs::create_dir_all(&data_dir);
|
||||
|
||||
// Set custom decoration for Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -207,29 +173,17 @@ fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let win = main_window.clone();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
main_window.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::ThemeChanged(_) = event {
|
||||
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let client = tauri::async_runtime::block_on(async move {
|
||||
// Setup database
|
||||
let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
|
||||
let database = NostrLMDB::open(config_dir.join("nostr"))
|
||||
.expect("Error: cannot create database.");
|
||||
|
||||
// Config
|
||||
let opts = Options::new()
|
||||
.gossip(true)
|
||||
.max_avg_latency(Duration::from_millis(500))
|
||||
.automatic_authentication(false)
|
||||
.connection_timeout(Some(Duration::from_secs(20)))
|
||||
.send_timeout(Some(Duration::from_secs(10)))
|
||||
.timeout(Duration::from_secs(20));
|
||||
.max_avg_latency(Duration::from_millis(300))
|
||||
.automatic_authentication(true)
|
||||
.timeout(Duration::from_secs(5));
|
||||
|
||||
// Setup nostr client
|
||||
let client = ClientBuilder::default()
|
||||
@@ -265,16 +219,10 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
|
||||
println!("Add discovery relay failed: {}", e)
|
||||
}
|
||||
|
||||
if let Err(e) = client.add_discovery_relay("wss://directory.yabu.me/").await {
|
||||
println!("Add discovery relay failed: {}", e)
|
||||
}
|
||||
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
|
||||
|
||||
// Connect
|
||||
client.connect_with_timeout(Duration::from_secs(20)).await;
|
||||
client.connect().await;
|
||||
|
||||
client
|
||||
});
|
||||
@@ -282,88 +230,188 @@ fn main() {
|
||||
// Create global state
|
||||
app.manage(Nostr {
|
||||
client,
|
||||
settings: Mutex::new(Settings::default()),
|
||||
contact_list: Mutex::new(Vec::new()),
|
||||
trusted_list: Mutex::new(HashSet::new()),
|
||||
queue: RwLock::new(HashSet::new()),
|
||||
is_syncing: RwLock::new(false),
|
||||
settings: RwLock::new(Settings::default()),
|
||||
});
|
||||
|
||||
Subscription::listen_any(app, move |event| {
|
||||
let handle = handle_clone_child.to_owned();
|
||||
let payload = event.payload;
|
||||
// Trigger some actions for window events
|
||||
main_window.on_window_event(move |event| match event {
|
||||
tauri::WindowEvent::Focused(focused) => {
|
||||
if !focused {
|
||||
let handle = handle_clone_event.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
if *state.is_syncing.read().await {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut is_syncing = state.is_syncing.write().await;
|
||||
|
||||
// Mark sync in progress
|
||||
*is_syncing = true;
|
||||
|
||||
let opts = SyncOptions::default();
|
||||
let accounts = get_all_accounts();
|
||||
|
||||
if !accounts.is_empty() {
|
||||
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,
|
||||
]);
|
||||
|
||||
if let Ok(output) = client.sync(filter, &opts).await {
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new().kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::ContactList,
|
||||
Kind::FollowSet,
|
||||
]);
|
||||
|
||||
// Get all public keys in database
|
||||
if let Ok(events) = client.database().query(vec![filter]).await {
|
||||
let public_keys: HashSet<PublicKey> = events
|
||||
.iter()
|
||||
.flat_map(|ev| ev.tags.public_keys().copied())
|
||||
.collect();
|
||||
let pk_vec: Vec<PublicKey> = public_keys.into_iter().collect();
|
||||
|
||||
for chunk in pk_vec.chunks(500) {
|
||||
if chunk.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let authors = chunk.to_owned();
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(authors.clone())
|
||||
.kinds(vec![
|
||||
Kind::Metadata,
|
||||
Kind::FollowSet,
|
||||
Kind::Interests,
|
||||
Kind::InterestSet,
|
||||
])
|
||||
.limit(1000);
|
||||
|
||||
if let Ok(output) = client.sync(filter, &opts).await {
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kinds(vec![
|
||||
Kind::TextNote,
|
||||
Kind::Repost,
|
||||
Kind::EventDeletion,
|
||||
])
|
||||
.limit(500);
|
||||
|
||||
if let Ok(output) = client.sync(filter, &opts).await {
|
||||
println!("Received: {}", output.received.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark sync is done
|
||||
*is_syncing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Moved(_size) => {}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Listen for request metadata
|
||||
app.listen_any("request_metadata", move |event| {
|
||||
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 {
|
||||
let state = handle.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
|
||||
match payload.kind {
|
||||
SubKind::Subscribe => {
|
||||
let subscription_id = SubscriptionId::new(payload.label);
|
||||
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
|
||||
let mut write_queue = state.queue.write().await;
|
||||
write_queue.insert(public_key);
|
||||
};
|
||||
|
||||
match payload.event_id {
|
||||
Some(id) => {
|
||||
let event_id = EventId::from_str(&id).unwrap();
|
||||
let filter =
|
||||
Filter::new().event(event_id).since(Timestamp::now());
|
||||
// Wait for [QUEUE_DELAY]
|
||||
sleep(Duration::from_millis(QUEUE_DELAY)).await;
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||
.await
|
||||
{
|
||||
println!("Subscription error: {}", e)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let contact_list = client
|
||||
.get_contact_list(Some(Duration::from_secs(5)))
|
||||
.await
|
||||
.unwrap();
|
||||
let read_queue = state.queue.read().await;
|
||||
|
||||
if !contact_list.is_empty() {
|
||||
let authors: Vec<PublicKey> =
|
||||
contact_list.iter().map(|f| f.public_key).collect();
|
||||
if !read_queue.is_empty() {
|
||||
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kinds(vec![Kind::TextNote, Kind::Repost])
|
||||
.authors(authors)
|
||||
.since(Timestamp::now());
|
||||
let filter = Filter::new()
|
||||
.authors(authors)
|
||||
.kind(Kind::Metadata)
|
||||
.limit(200);
|
||||
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id(subscription_id, vec![filter], None)
|
||||
.await
|
||||
{
|
||||
println!("Subscription error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
SubKind::Unsubscribe => {
|
||||
let subscription_id = SubscriptionId::new(payload.label);
|
||||
client.unsubscribe(subscription_id).await
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
|
||||
|
||||
// Drop queue, you don't need it at this time anymore
|
||||
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 {
|
||||
println!("Subscribe error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Run local relay thread
|
||||
//tauri::async_runtime::spawn(async move {
|
||||
// let database = NostrLMDB::open(data_dir.join("local-relay"))
|
||||
// .expect("Error: cannot create database.");
|
||||
// let builder = RelayBuilder::default().database(database).port(1984);
|
||||
//
|
||||
// if let Ok(relay) = LocalRelay::run(builder).await {
|
||||
// println!("Running local relay: {}", relay.url())
|
||||
// }
|
||||
//
|
||||
// loop {
|
||||
// tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
// }
|
||||
//});
|
||||
|
||||
// Run notification thread
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = handle_clone.state::<Nostr>();
|
||||
let client = &state.client;
|
||||
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::Custom(1111),
|
||||
])
|
||||
.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() {
|
||||
Ok(_) => {
|
||||
@@ -377,57 +425,17 @@ fn main() {
|
||||
};
|
||||
|
||||
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
|
||||
let mut notifications = client.pool().notifications();
|
||||
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
match notification {
|
||||
RelayPoolNotification::Message { relay_url, message } => {
|
||||
if let RelayMessage::Auth { challenge } = message {
|
||||
match client.auth(challenge, relay_url.clone()).await {
|
||||
Ok(..) => {
|
||||
if let Ok(relay) = client.relay(&relay_url).await {
|
||||
let msg =
|
||||
format!("Authenticated to {} relay.", relay_url);
|
||||
let opts = RelaySendOptions::new()
|
||||
.skip_send_confirmation(true);
|
||||
|
||||
if let Err(e) = relay.resubscribe(opts).await {
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
|
||||
if allow_notification {
|
||||
if let Err(e) = &handle_clone
|
||||
.notification()
|
||||
.builder()
|
||||
.body(&msg)
|
||||
.title("Lume")
|
||||
.show()
|
||||
{
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if allow_notification {
|
||||
if let Err(e) = &handle_clone
|
||||
.notification()
|
||||
.builder()
|
||||
.body(e.to_string())
|
||||
.title("Lume")
|
||||
.show()
|
||||
{
|
||||
println!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let RelayMessage::Event {
|
||||
subscription_id,
|
||||
let _ = client
|
||||
.handle_notifications(|notification| async {
|
||||
#[allow(clippy::collapsible_match)]
|
||||
if let RelayPoolNotification::Message { message, .. } = notification {
|
||||
if let RelayMessage::Event {
|
||||
event,
|
||||
subscription_id,
|
||||
..
|
||||
} = message
|
||||
{
|
||||
// Handle events from notification subscription
|
||||
if subscription_id == notification_id {
|
||||
// Send native notification
|
||||
if allow_notification {
|
||||
@@ -438,79 +446,46 @@ fn main() {
|
||||
.unwrap_or_else(|_| {
|
||||
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::TextNote {
|
||||
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();
|
||||
let raw = event.as_json();
|
||||
let parsed = if event.kind == Kind::TextNote {
|
||||
Some(parse_event(&event.content).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
handle_clone
|
||||
.emit_to(
|
||||
EventTarget::labeled(label),
|
||||
if let Err(e) = handle_clone.emit_to(
|
||||
EventTarget::labeled(subscription_id.to_string()),
|
||||
"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();
|
||||
payload,
|
||||
) {
|
||||
println!("Emit error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(prevent_default())
|
||||
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
|
||||
.plugin(tauri_plugin_decorum::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -524,7 +499,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_upload::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.run(tauri::generate_context!())
|
||||
.run(ctx)
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Lume",
|
||||
"version": "4.3.0",
|
||||
"version": "24.11.0",
|
||||
"identifier": "nu.lume.Lume",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
|
||||
83
src/app.tsx
@@ -1,43 +1,17 @@
|
||||
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { Store } from "@tauri-apps/plugin-store";
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { newQueryStorage } from "./commons";
|
||||
import type { LumeEvent } from "./system";
|
||||
|
||||
import { routeTree } from "./routes.gen"; // auto generated file
|
||||
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" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
@@ -47,18 +21,43 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
const platform = type();
|
||||
// @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
|
||||
const rootElement = document.getElementById("root")!;
|
||||
// Make sure all webviews use same query client
|
||||
broadcastQueryClient({
|
||||
queryClient,
|
||||
broadcastChannel: "lume",
|
||||
});
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { store, queryClient, platform },
|
||||
Wrap: ({ children }) => {
|
||||
return (
|
||||
<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,31 @@
|
||||
|
||||
|
||||
export const commands = {
|
||||
async getRelays() : Promise<Result<Relays, string>> {
|
||||
async getRelays(id: string) : Promise<Result<Relays, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_relays", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
||||
async getAllRelays(until: string | null) : Promise<Result<string[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_relays", { 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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
|
||||
} catch (e) {
|
||||
@@ -21,7 +37,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
|
||||
} catch (e) {
|
||||
@@ -37,9 +53,9 @@ async getBootstrapRelays() : Promise<Result<string[], string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
|
||||
async setBootstrapRelays(relays: string) : Promise<Result<null, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_bootstrap_relays", { relays }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -48,15 +64,15 @@ async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
|
||||
async getAccounts() : Promise<string[]> {
|
||||
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 {
|
||||
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) {
|
||||
if(e instanceof Error) throw e;
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
|
||||
} catch (e) {
|
||||
@@ -96,21 +112,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async isAccountSync(id: string) : Promise<boolean> {
|
||||
return await TAURI_INVOKE("is_account_sync", { id });
|
||||
},
|
||||
async createSyncFile(id: string) : Promise<boolean> {
|
||||
return await TAURI_INVOKE("create_sync_file", { id });
|
||||
},
|
||||
async login(account: string, password: string) : Promise<Result<string, string>> {
|
||||
async hasSigner(id: string) : Promise<Result<boolean, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async 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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
|
||||
} catch (e) {
|
||||
@@ -118,17 +136,17 @@ async getProfile(id: string | null) : Promise<Result<string, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async setProfile(profile: Profile) : Promise<Result<string, string>> {
|
||||
async setProfile(newProfile: string) : Promise<Result<string, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getContactList() : Promise<Result<string[], string>> {
|
||||
async getContactList(id: string) : Promise<Result<string[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -142,9 +160,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -158,9 +176,9 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getMentionList() : Promise<Result<Mention[], string>> {
|
||||
async getAllProfiles() : Promise<Result<Mention[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -182,9 +200,17 @@ async getGroup(id: string) : Promise<Result<string, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllGroups() : Promise<Result<RichEvent[], string>> {
|
||||
async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
|
||||
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) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -206,9 +232,17 @@ async getInterest(id: string) : Promise<Result<string, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllInterests() : Promise<Result<RichEvent[], string>> {
|
||||
async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
|
||||
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 };
|
||||
@@ -222,7 +256,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async loadWallet() : Promise<Result<string, string>> {
|
||||
async loadWallet() : Promise<Result<null, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
|
||||
} catch (e) {
|
||||
@@ -238,7 +272,7 @@ async removeWallet() : Promise<Result<null, string>> {
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
|
||||
} 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 };
|
||||
}
|
||||
},
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
|
||||
} catch (e) {
|
||||
@@ -262,25 +296,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getNotifications() : Promise<Result<string[], string>> {
|
||||
async getNotifications(id: string) : Promise<Result<string[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
|
||||
} 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 }) };
|
||||
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 };
|
||||
@@ -294,17 +312,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async isTrustedUser(id: string) : Promise<Result<boolean, string>> {
|
||||
async getMetaFromEvent(content: string) : Promise<Result<Meta, null>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("is_trusted_user", { id }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
|
||||
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 };
|
||||
@@ -318,14 +328,6 @@ async getEvent(id: string) : Promise<Result<RichEvent, string>> {
|
||||
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>> {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
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>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
|
||||
@@ -366,6 +360,14 @@ async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise
|
||||
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 getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
|
||||
@@ -382,22 +384,6 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
|
||||
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>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
|
||||
@@ -414,9 +400,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
@@ -430,6 +416,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
|
||||
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>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
|
||||
@@ -454,6 +464,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
|
||||
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>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
|
||||
@@ -462,31 +480,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
|
||||
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 {
|
||||
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
|
||||
return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, 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>> {
|
||||
async openWindow(window: NewWindow) : Promise<Result<string, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
|
||||
} catch (e) {
|
||||
@@ -494,24 +496,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async reopenLume() : Promise<void> {
|
||||
await TAURI_INVOKE("reopen_lume");
|
||||
async getAppSettings() : Promise<Result<Settings, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async quit() : Promise<void> {
|
||||
await TAURI_INVOKE("quit");
|
||||
async setAppSettings(settings: string) : Promise<Result<null, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_app_settings", { settings }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** user-defined events **/
|
||||
|
||||
|
||||
export const events = __makeEvents__<{
|
||||
newSettings: NewSettings,
|
||||
subscription: Subscription
|
||||
}>({
|
||||
newSettings: "new-settings",
|
||||
subscription: "subscription"
|
||||
})
|
||||
|
||||
/** user-defined constants **/
|
||||
|
||||
@@ -522,14 +527,10 @@ subscription: "subscription"
|
||||
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 Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
|
||||
export type NewSettings = Settings
|
||||
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
|
||||
export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
|
||||
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
|
||||
export type RichEvent = { raw: string; parsed: Meta | null }
|
||||
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
|
||||
export type SubKind = "Subscribe" | "Unsubscribe"
|
||||
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
|
||||
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
|
||||
export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
|
||||
|
||||
/** tauri-specta globals **/
|
||||
|
||||
|
||||
136
src/commons.ts
@@ -3,7 +3,6 @@ import type {
|
||||
MaybePromise,
|
||||
PersistedQuery,
|
||||
} from "@tanstack/query-persist-client-core";
|
||||
import { Store } from "@tanstack/store";
|
||||
import { ask, message, open } from "@tauri-apps/plugin-dialog";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
@@ -16,76 +15,79 @@ import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import updateLocale from "dayjs/plugin/updateLocale";
|
||||
import { decode } from "light-bolt11-decoder";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { RichEvent, Settings } from "./commands.gen";
|
||||
import type { RichEvent } from "./commands.gen";
|
||||
import { LumeEvent } from "./system";
|
||||
import type { LumeColumn, NostrEvent } from "./types";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
past: "%s ago",
|
||||
s: "just now",
|
||||
m: "1m",
|
||||
mm: "%dm",
|
||||
h: "1h",
|
||||
hh: "%dh",
|
||||
d: "1d",
|
||||
dd: "%dd",
|
||||
},
|
||||
});
|
||||
import type { NostrEvent } from "./types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const isImagePath = (path: string) => {
|
||||
for (const suffix of ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]) {
|
||||
if (path.endsWith(suffix)) return true;
|
||||
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
|
||||
|
||||
for (const suffix of exts) {
|
||||
if (path.endsWith(suffix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isImageUrl = (url: string) => {
|
||||
try {
|
||||
if (!url) return false;
|
||||
const ext = new URL(url).pathname.split(".").pop();
|
||||
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export function createdAt(time: number) {
|
||||
// Config for dayjs
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
|
||||
export function formatCreatedAt(time: number, message = false) {
|
||||
let formated: string;
|
||||
// Config locale text
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
past: "%s ago",
|
||||
s: "just now",
|
||||
m: "1m",
|
||||
mm: "%dm",
|
||||
h: "1h",
|
||||
hh: "%dh",
|
||||
d: "1d",
|
||||
dd: "%dd",
|
||||
},
|
||||
});
|
||||
|
||||
const now = dayjs();
|
||||
const inputTime = dayjs.unix(time);
|
||||
const diff = now.diff(inputTime, "hour");
|
||||
|
||||
if (message) {
|
||||
if (diff < 12) {
|
||||
formated = inputTime.format("HH:mm A");
|
||||
} else {
|
||||
formated = inputTime.format("MMM DD");
|
||||
}
|
||||
if (diff < 24) {
|
||||
return inputTime.from(now, true);
|
||||
} else {
|
||||
if (diff < 24) {
|
||||
formated = inputTime.from(now, true);
|
||||
} else {
|
||||
formated = inputTime.format("MMM DD");
|
||||
}
|
||||
return inputTime.format("MMM DD");
|
||||
}
|
||||
|
||||
return formated;
|
||||
}
|
||||
|
||||
export function replyTime(time: number) {
|
||||
const inputTime = dayjs.unix(time);
|
||||
const formated = inputTime.format("MM-DD-YY HH:mm");
|
||||
export function replyAt(time: number) {
|
||||
// Config for dayjs
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
|
||||
return formated;
|
||||
// Config locale text
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
past: "%s ago",
|
||||
s: "just now",
|
||||
m: "1m",
|
||||
mm: "%dm",
|
||||
h: "1h",
|
||||
hh: "%dh",
|
||||
d: "1d",
|
||||
dd: "%dd",
|
||||
},
|
||||
});
|
||||
|
||||
const inputTime = dayjs.unix(time);
|
||||
const format = inputTime.format("MM-DD-YY HH:mm");
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
export function displayNpub(pubkey: string, len: number) {
|
||||
@@ -114,7 +116,7 @@ export function displayLongHandle(str: string) {
|
||||
return `${handle.substring(0, 16)}...@${service}`;
|
||||
}
|
||||
|
||||
// source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
|
||||
// Source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
|
||||
export function getBitcoinDisplayValues(satoshis: number) {
|
||||
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
|
||||
.getValue()
|
||||
@@ -145,20 +147,27 @@ export function getBitcoinDisplayValues(satoshis: number) {
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeZapInvoice(tags?: string[][]) {
|
||||
export function decodeZapInvoice(tags: string[][]) {
|
||||
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
|
||||
if (!invoice) return;
|
||||
|
||||
const decodedInvoice = decode(invoice);
|
||||
const amountSection = decodedInvoice.sections.find(
|
||||
const section = decodedInvoice.sections.find(
|
||||
(s: { name: string }) => s.name === "amount",
|
||||
);
|
||||
|
||||
// @ts-ignore, its fine.
|
||||
const amount = Number.parseInt(amountSection.value);
|
||||
const displayValue = getBitcoinDisplayValues(amount);
|
||||
if (!section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
if (section.name === "amount") {
|
||||
const amount = Number.parseInt(section.value) / 1000;
|
||||
const displayValue = getBitcoinDisplayValues(amount);
|
||||
|
||||
return displayValue;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForAppUpdates(silent: boolean) {
|
||||
@@ -277,18 +286,3 @@ export function newQueryStorage(
|
||||
(await store.delete(key)) as unknown as MaybePromise<void>,
|
||||
};
|
||||
}
|
||||
|
||||
export const appSettings = new Store<Settings>({
|
||||
proxy: null,
|
||||
image_resize_service: "https://wsrv.nl",
|
||||
use_relay_hint: true,
|
||||
content_warning: true,
|
||||
trusted_only: true,
|
||||
display_avatar: true,
|
||||
display_zap_button: true,
|
||||
display_repost_button: true,
|
||||
display_media: true,
|
||||
transparent: true,
|
||||
});
|
||||
|
||||
export const appColumns = new Store<LumeColumn[]>([]);
|
||||
|
||||
@@ -1,132 +1,68 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { appColumns } from "@/commons";
|
||||
import { useRect } from "@/system";
|
||||
import type { LumeColumn } from "@/types";
|
||||
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 { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "./spinner";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { User } from "./user";
|
||||
|
||||
type WindowEvent = {
|
||||
scroll: boolean;
|
||||
resize: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}, []);
|
||||
export function Column({ column }: { column: LumeColumn }) {
|
||||
const [rect, ref] = useRect();
|
||||
const [error, setError] = useState("");
|
||||
|
||||
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}`,
|
||||
});
|
||||
|
||||
const unlisten = listen<WindowEvent>("child_webview", (data) => {
|
||||
if (data.payload.scroll) repositionWebview();
|
||||
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||
});
|
||||
|
||||
return () => {
|
||||
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]);
|
||||
if (res.status === "error") {
|
||||
setError(res.error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [rect]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Header label={column.label} />
|
||||
<div ref={container} className="flex-1 size-full">
|
||||
{!isCreated ? (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
<Header
|
||||
label={column.label}
|
||||
name={column.name}
|
||||
account={column.account}
|
||||
/>
|
||||
<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>
|
||||
) : null}
|
||||
</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 [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) => {
|
||||
e.preventDefault();
|
||||
|
||||
const window = getCurrentWindow();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Reload",
|
||||
@@ -134,10 +70,6 @@ function Header({ label }: { label: string }) {
|
||||
await commands.reloadColumn(label);
|
||||
},
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Open in new window",
|
||||
action: () => console.log("not implemented."),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "Move left",
|
||||
@@ -178,6 +110,15 @@ function Header({ label }: { label: string }) {
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
const saveNewTitle = async () => {
|
||||
await getCurrentWindow().emit("columns", {
|
||||
type: "set_title",
|
||||
label,
|
||||
title,
|
||||
});
|
||||
setIsChanged(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (title.length > 0) setIsChanged(true);
|
||||
}, [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="flex items-center justify-center shrink-0 h-7">
|
||||
<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
|
||||
contentEditable
|
||||
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"
|
||||
>
|
||||
{column.name}
|
||||
{name}
|
||||
</div>
|
||||
{isChanged ? (
|
||||
<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 { useRouteContext } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Frame({
|
||||
@@ -7,17 +6,13 @@ export function Frame({
|
||||
shadow,
|
||||
className,
|
||||
}: { children: ReactNode; shadow?: boolean; className?: string }) {
|
||||
const { platform } = useRouteContext({ strict: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
platform === "linux"
|
||||
? "bg-white dark:bg-neutral-950"
|
||||
: "bg-white dark:bg-white/10",
|
||||
"bg-white dark:bg-neutral-800",
|
||||
shadow
|
||||
? "shadow-lg shadow-neutral-500/10 dark:shadow-none dark:ring-1 dark:ring-white/20"
|
||||
? "shadow-primary dark:shadow-none dark:ring-1 dark:ring-neutral-700/50"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
|
||||
19
src/components/icons/publish.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export const PublishIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M19.432 2.738c.505.54.728 1.327.443 2.133-.606 1.713-1.798 3.124-2.797 4.087a15.74 15.74 0 01-1.045.921l.137.1c.93.684 1.416 1.975.757 3.118-1.221 2.12-4.356 5.803-11.192 5.803a.753.753 0 01-.15-.015A32.702 32.702 0 005.5 21.25a.75.75 0 01-1.5 0c0-4.43.821-8.93 2.909-12.485 2.106-3.587 5.49-6.182 10.492-6.749a2.404 2.404 0 012.031.722z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
23
src/components/icons/quote.tsx
Normal file
@@ -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";
|
||||
|
||||
// Newsfeed
|
||||
export * from "./repost";
|
||||
export * from "./conversation";
|
||||
export * from "./quote";
|
||||
export * from "./text";
|
||||
export * from "./repost";
|
||||
export * from "./reply";
|
||||
|
||||
// Global components
|
||||
@@ -18,3 +16,5 @@ export * from "./user";
|
||||
export * from "./icons/reply";
|
||||
export * from "./icons/repost";
|
||||
export * from "./icons/zap";
|
||||
export * from "./icons/quote";
|
||||
export * from "./icons/publish";
|
||||
|
||||
@@ -8,11 +8,18 @@ export function NoteOpenThread() {
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<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"
|
||||
>
|
||||
<ListPlus className="shrink-0 size-4" />
|
||||
|
||||
40
src/components/note/buttons/quote.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { cn } from "@/commons";
|
||||
import { QuoteIcon } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteQuote({
|
||||
label = false,
|
||||
smol = false,
|
||||
}: { label?: boolean; smol?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor(null, event.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
label
|
||||
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||
{label ? "Quote" : null}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
|
||||
Quote
|
||||
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
@@ -18,10 +18,8 @@ export function NoteReply({
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor(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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<ReplyIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
|
||||
|
||||
@@ -1,88 +1,164 @@
|
||||
import { appSettings, cn } from "@/commons";
|
||||
import { commands } from "@/commands.gen";
|
||||
import { cn, displayNpub } from "@/commons";
|
||||
import { RepostIcon, Spinner } from "@/components";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
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 { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useTransition } from "react";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({
|
||||
label = false,
|
||||
smol = false,
|
||||
}: { label?: boolean; smol?: boolean }) {
|
||||
const visible = useStore(appSettings, (state) => state.display_repost_button);
|
||||
const event = useNoteContext();
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
const { isLoading, data: status } = useQuery({
|
||||
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 () => {
|
||||
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 [isPending, startTransition] = useTransition();
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Repost",
|
||||
action: async () => repost(),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Quote",
|
||||
action: () => LumeWindow.openEditor(null, event.id),
|
||||
}),
|
||||
]);
|
||||
const accounts = await commands.getAccounts();
|
||||
const list: Promise<MenuItem>[] = [];
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
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: `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));
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
|
||||
label
|
||||
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||
: "size-7",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon
|
||||
className={cn(
|
||||
smol ? "size-4" : "size-5",
|
||||
isRepost ? "text-blue-500" : "",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{label ? "Repost" : null}
|
||||
</button>
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{isPending || isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<RepostIcon
|
||||
className={cn(
|
||||
smol ? "size-4" : "size-5",
|
||||
status ? "text-blue-500" : "",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{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,8 +1,9 @@
|
||||
import { appSettings, cn } from "@/commons";
|
||||
import { cn } from "@/commons";
|
||||
import { ZapIcon } from "@/components";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
import { LumeWindow } from "@/system";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteZap({
|
||||
@@ -10,20 +11,18 @@ export function NoteZap({
|
||||
smol = false,
|
||||
}: { label?: boolean; smol?: boolean }) {
|
||||
const search = useSearch({ strict: false });
|
||||
const visible = useStore(appSettings, (state) => state.display_zap_button);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
const event = useNoteContext();
|
||||
|
||||
if (!visible) return null;
|
||||
if (!settings.data.display_zap_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openZap(event.id, search.account)}
|
||||
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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<ZapIcon className={smol ? "size-4" : "size-5"} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { appSettings, cn } from "@/commons";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { cn } from "@/commons";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { nanoid } from "nanoid";
|
||||
import { type ReactNode, memo, useMemo, useState } from "react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./mentions/hashtag";
|
||||
import { MentionNote } from "./mentions/note";
|
||||
@@ -21,15 +22,17 @@ export function NoteContent({
|
||||
className?: string;
|
||||
}) {
|
||||
const event = useNoteContext();
|
||||
const visible = useStore(appSettings, (state) => state.display_media);
|
||||
const warning = useMemo(() => event.warning, [event]);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
|
||||
const content = useMemo(() => {
|
||||
try {
|
||||
// Get parsed meta
|
||||
const { content, hashtags, events, mentions } = event.meta;
|
||||
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = visible ? content : event.content;
|
||||
let richContent: ReactNode[] | string = settings.data.display_media
|
||||
? content
|
||||
: event.content;
|
||||
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
@@ -88,50 +91,48 @@ export function NoteContent({
|
||||
}
|
||||
}, [event.content]);
|
||||
|
||||
const [blurred, setBlurred] = useState(() =>
|
||||
event.warning ? event.warning.length > 0 : false,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<ContentWarning warning={warning} />
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-pretty content-break overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{visible ? (
|
||||
event.meta?.images.length ? (
|
||||
<Images urls={event.meta.images} />
|
||||
) : null
|
||||
) : null}
|
||||
{!blurred ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"select-text text-pretty content-break overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{settings.data.display_media ? (
|
||||
event.meta?.images.length ? (
|
||||
<Images urls={event.meta.images} />
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { NoteQuote } from "./buttons/quote";
|
||||
import { NoteReply } from "./buttons/reply";
|
||||
import { NoteRepost } from "./buttons/repost";
|
||||
import { NoteZap } from "./buttons/zap";
|
||||
@@ -16,6 +17,7 @@ export const Note = {
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Reply: NoteReply,
|
||||
Quote: NoteQuote,
|
||||
Repost: NoteRepost,
|
||||
Content: NoteContent,
|
||||
ContentLarge: NoteContentLarge,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
return (
|
||||
<span className="leading-normal cursor-default text-blue-500 hover:text-blue-600 font-normal">
|
||||
{tag}
|
||||
{tag.includes("#") ? tag : `#${tag}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { replyTime } from "@/commons";
|
||||
import { Note, Spinner } from "@/components";
|
||||
import { User } from "@/components/user";
|
||||
import { replyAt } from "@/commons";
|
||||
import { Note, Spinner, User } from "@/components";
|
||||
import { LumeWindow, useEvent } from "@/system";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { type ReactNode, memo, useMemo } from "react";
|
||||
@@ -17,16 +16,19 @@ export const MentionNote = memo(function MentionNote({
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<div className="h-[64px] flex items-center">
|
||||
<div className="h-[32px] flex items-center gap-2 text-sm">
|
||||
<Spinner />
|
||||
Loadng note
|
||||
</div>
|
||||
) : 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">
|
||||
{error.message || "Note can be found with your current relay set"}
|
||||
{error?.message ??
|
||||
"Cannot found this note within your current relay set"}
|
||||
</p>
|
||||
<p className="text-sm">{eventId}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Note.Provider event={event}>
|
||||
@@ -49,13 +51,20 @@ export const MentionNote = memo(function MentionNote({
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-500">
|
||||
{replyTime(event.created_at)}
|
||||
{replyAt(event.created_at)}
|
||||
</span>
|
||||
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
|
||||
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEvent(event)}
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||
onClick={() =>
|
||||
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
|
||||
</button>
|
||||
@@ -98,33 +107,47 @@ function Content({ text, className }: { text: string; className?: string }) {
|
||||
));
|
||||
|
||||
for (const word of nostr) {
|
||||
const bech32 = word.replace("nostr:", "");
|
||||
const data = nip19.decode(bech32);
|
||||
const bech32 = word.replace("nostr:", "").replace(/[^\w\s]/gi, "");
|
||||
try {
|
||||
const data = nip19.decode(bech32);
|
||||
|
||||
switch (data.type) {
|
||||
case "npub":
|
||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||
<MentionUser key={match + i} pubkey={data.data} />
|
||||
));
|
||||
break;
|
||||
case "nprofile":
|
||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
||||
));
|
||||
break;
|
||||
default:
|
||||
replacedText = reactStringReplace(replacedText, word, (match, i) => (
|
||||
<a
|
||||
key={match + i}
|
||||
href={`https://njump.me/${bech32}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 !underline"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
));
|
||||
break;
|
||||
switch (data.type) {
|
||||
case "npub":
|
||||
replacedText = reactStringReplace(
|
||||
replacedText,
|
||||
word,
|
||||
(match, i) => <MentionUser key={match + i} pubkey={data.data} />,
|
||||
);
|
||||
break;
|
||||
case "nprofile":
|
||||
replacedText = reactStringReplace(
|
||||
replacedText,
|
||||
word,
|
||||
(match, i) => (
|
||||
<MentionUser key={match + i} pubkey={data.data.pubkey} />
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
replacedText = reactStringReplace(
|
||||
replacedText,
|
||||
word,
|
||||
(match, i) => (
|
||||
<a
|
||||
key={match + i}
|
||||
href={`https://njump.me/${bech32}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 !underline"
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
console.log(word);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { memo } from "react";
|
||||
export const MentionUser = memo(function MentionUser({
|
||||
pubkey,
|
||||
}: { pubkey: string }) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey);
|
||||
const { isLoading, profile } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -14,10 +14,8 @@ export const MentionUser = memo(function MentionUser({
|
||||
className="break-words text-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isLoading
|
||||
? "@anon"
|
||||
: isError
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
||||
? displayNpub(pubkey, 16)
|
||||
: `@${profile?.name || profile?.display_name || displayNpub(pubkey, 16)}`}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { appSettings } from "@/commons";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: string }) {
|
||||
const [service, visible] = useStore(appSettings, (state) => [
|
||||
state.image_resize_service,
|
||||
state.display_media,
|
||||
]);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (service?.length) {
|
||||
const newUrl = `${service}?url=${url}&ll&af&default=1&n=-1`;
|
||||
if (settings.data.resize_service) {
|
||||
const newUrl = `https://wsrv.nl?url=${url}&ll&af&default=1&n=-1`;
|
||||
return newUrl;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}, [service]);
|
||||
}, [settings.data.resize_service]);
|
||||
|
||||
if (!visible) {
|
||||
if (!settings.data.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
@@ -39,10 +36,6 @@ export function ImagePreview({ url }: { url: string }) {
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import { appSettings, cn } from "@/commons";
|
||||
import { cn } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export function Images({ urls }: { urls: string[] }) {
|
||||
const [slidesInView, setSlidesInView] = useState([]);
|
||||
const [slidesInView, setSlidesInView] = useState<number[]>([]);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
dragFree: true,
|
||||
align: "start",
|
||||
watchSlides: false,
|
||||
});
|
||||
|
||||
const service = useStore(appSettings, (state) => state.image_resize_service);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
|
||||
const imageUrls = useMemo(() => {
|
||||
if (service?.length) {
|
||||
if (settings.data.resize_service) {
|
||||
let newUrls: string[];
|
||||
|
||||
if (urls.length === 1) {
|
||||
newUrls = urls.map(
|
||||
(url) => `${service}?url=${url}&ll&af&default=1&n=-1`,
|
||||
);
|
||||
newUrls = urls.map((url) => {
|
||||
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 {
|
||||
newUrls = urls.map(
|
||||
(url) => `${service}?url=${url}&w=480&h=640&ll&af&default=1&n=-1`,
|
||||
);
|
||||
newUrls = urls.map((url) => {
|
||||
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;
|
||||
} else {
|
||||
return urls;
|
||||
}
|
||||
}, [service]);
|
||||
}, [settings.data.resize_service]);
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev();
|
||||
@@ -44,23 +57,27 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
if (emblaApi) emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const updateSlidesInView = useCallback((emblaApi) => {
|
||||
const updateSlidesInView = useCallback(() => {
|
||||
setSlidesInView((slidesInView) => {
|
||||
if (slidesInView.length === emblaApi.slideNodes().length) {
|
||||
emblaApi.off("slidesInView", updateSlidesInView);
|
||||
if (slidesInView.length === emblaApi?.slideNodes().length) {
|
||||
emblaApi?.off("slidesInView", updateSlidesInView);
|
||||
}
|
||||
|
||||
const inView = emblaApi
|
||||
.slidesInView()
|
||||
?.slidesInView()
|
||||
.filter((index) => !slidesInView.includes(index));
|
||||
|
||||
return slidesInView.concat(inView);
|
||||
if (inView) {
|
||||
return slidesInView.concat(inView);
|
||||
} else {
|
||||
return slidesInView;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [emblaApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emblaApi && urls.length > 1) {
|
||||
updateSlidesInView(emblaApi);
|
||||
updateSlidesInView();
|
||||
|
||||
emblaApi.on("slidesInView", updateSlidesInView);
|
||||
emblaApi.on("reInit", updateSlidesInView);
|
||||
@@ -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"
|
||||
onClick={() => urls[0]}
|
||||
onKeyDown={() => urls[0]}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -162,10 +175,6 @@ function LazyImage({ url, inView }: { url: string; inView: boolean }) {
|
||||
onClick={() => open(url)}
|
||||
onKeyDown={() => open(url)}
|
||||
onLoad={setLoaded}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { appSettings } from "@/commons";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { settingsQueryOptions } from "@/routes/__root";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
const visible = useStore(appSettings, (state) => state.display_media);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
|
||||
if (!visible) {
|
||||
if (!settings.data.display_zap_button) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
|
||||
@@ -1,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 { appSettings, cn, replyTime } from "@/commons";
|
||||
import { Note } from "@/components/note";
|
||||
import { cn, replyAt } from "@/commons";
|
||||
import { Note, User } from "@/components";
|
||||
import { type LumeEvent, LumeWindow } from "@/system";
|
||||
import { CaretDown } from "@phosphor-icons/react";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { useStore } from "@tanstack/react-store";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
type ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type ReactNode, memo, useCallback, useMemo } from "react";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
import { Hashtag } from "./note/mentions/hashtag";
|
||||
import { MentionUser } from "./note/mentions/user";
|
||||
import { User } from "./user";
|
||||
|
||||
export const ReplyNote = memo(function ReplyNote({
|
||||
event,
|
||||
@@ -28,11 +18,7 @@ export const ReplyNote = memo(function ReplyNote({
|
||||
event: LumeEvent;
|
||||
className?: string;
|
||||
}) {
|
||||
const trustedOnly = useStore(appSettings, (state) => state.trusted_only);
|
||||
const search = useSearch({ strict: false });
|
||||
|
||||
const [isTrusted, setIsTrusted] = useState<boolean>(null);
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -57,24 +43,6 @@ export const ReplyNote = memo(function ReplyNote({
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
const res = await commands.isTrustedUser(event.pubkey);
|
||||
|
||||
if (res.status === "ok") {
|
||||
setIsTrusted(res.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (trustedOnly) {
|
||||
check();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isTrusted !== null && isTrusted === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Provider event={event}>
|
||||
<User.Provider pubkey={event.pubkey}>
|
||||
@@ -97,9 +65,9 @@ export const ReplyNote = memo(function ReplyNote({
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-500">
|
||||
{replyTime(event.created_at)}
|
||||
{replyAt(event.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-5">
|
||||
<div className="flex items-center justify-end">
|
||||
<Note.Reply smol />
|
||||
<Note.Repost smol />
|
||||
<Note.Zap smol />
|
||||
@@ -178,9 +146,9 @@ function ChildReply({ event }: { event: LumeEvent }) {
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-500">
|
||||
{replyTime(event.created_at)}
|
||||
{replyAt(event.created_at)}
|
||||
</span>
|
||||
<div className="invisible group-hover:visible flex items-center justify-end gap-5">
|
||||
<div className="invisible group-hover:visible flex items-center justify-end">
|
||||
<Note.Reply smol />
|
||||
<Note.Repost smol />
|
||||
<Note.Zap smol />
|
||||
|
||||
@@ -19,13 +19,15 @@ export const RepostNote = memo(function RepostNote({
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-20 gap-2">
|
||||
<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...
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : isError || !data ? (
|
||||
<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>
|
||||
) : (
|
||||
<Note.Provider event={data}>
|
||||
@@ -36,7 +38,7 @@ export const RepostNote = memo(function RepostNote({
|
||||
</div>
|
||||
<Note.Content className="px-3" />
|
||||
<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.Reply />
|
||||
<Note.Repost />
|
||||
|
||||
@@ -18,7 +18,7 @@ export const TextNote = memo(function TextNote({
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<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.Reply />
|
||||
<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 { useStore } from "@tanstack/react-store";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { useMemo } from "react";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAvatar({ className }: { className?: string }) {
|
||||
const [service, visible] = useStore(appSettings, (state) => [
|
||||
state.image_resize_service,
|
||||
state.display_avatar,
|
||||
]);
|
||||
const settings = useSuspenseQuery(settingsQueryOptions);
|
||||
|
||||
const user = useUserContext();
|
||||
|
||||
const picture = useMemo(() => {
|
||||
if (service?.length && user.profile?.picture?.length) {
|
||||
if (settings.data.resize_service && user?.profile?.picture?.length) {
|
||||
if (user.profile?.picture.includes("_next/")) {
|
||||
return user.profile?.picture;
|
||||
}
|
||||
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 {
|
||||
return user.profile?.picture;
|
||||
return user?.profile?.picture;
|
||||
}
|
||||
}, [user.profile?.picture]);
|
||||
}, [user]);
|
||||
|
||||
const fallback = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(user.pubkey, 60, 50),
|
||||
minidenticon(user ? user.pubkey : "lume", 60, 50),
|
||||
)}`,
|
||||
[user.pubkey],
|
||||
[user],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -39,18 +40,19 @@ export function UserAvatar({ className }: { className?: string }) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{visible ? (
|
||||
{settings.data.display_avatar ? (
|
||||
<Avatar.Image
|
||||
src={picture}
|
||||
alt={user.pubkey}
|
||||
alt={user?.pubkey}
|
||||
decoding="async"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
/>
|
||||
) : null}
|
||||
<Avatar.Fallback>
|
||||
<img
|
||||
src={fallback}
|
||||
alt={user.pubkey}
|
||||
alt={user?.pubkey}
|
||||
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useTransition } from "react";
|
||||
import { useUserContext } from "./provider";
|
||||
|
||||
export function UserFollowButton({ className }: { className?: string }) {
|
||||
export function UserButton({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
|
||||
const { queryClient } = useRouteContext({ strict: false });
|
||||
@@ -18,7 +18,7 @@ export function UserFollowButton({ className }: { className?: string }) {
|
||||
} = useQuery({
|
||||
queryKey: ["status", user.pubkey],
|
||||
queryFn: async () => {
|
||||
const res = await commands.checkContact(user.pubkey);
|
||||
const res = await commands.isContact(user.pubkey);
|
||||
|
||||
if (res.status === "ok") {
|
||||
return res.data;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UserAbout } from "./about";
|
||||
import { UserAvatar } from "./avatar";
|
||||
import { UserButton } from "./button";
|
||||
import { UserCover } from "./cover";
|
||||
import { UserFollowButton } from "./followButton";
|
||||
import { UserName } from "./name";
|
||||
import { UserNip05 } from "./nip05";
|
||||
import { UserProvider } from "./provider";
|
||||
@@ -17,5 +17,5 @@ export const User = {
|
||||
NIP05: UserNip05,
|
||||
Time: UserTime,
|
||||
About: UserAbout,
|
||||
Button: UserFollowButton,
|
||||
Button: UserButton,
|
||||
};
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import type { Metadata } from "@/types";
|
||||
import { useProfile } from "@/system";
|
||||
import type { Metadata } from "@/types";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const UserContext = createContext<{
|
||||
interface UserContext {
|
||||
pubkey: string;
|
||||
profile: Metadata;
|
||||
isError: boolean;
|
||||
profile: Metadata | undefined;
|
||||
isLoading: boolean;
|
||||
}>(null);
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContext | null>(null);
|
||||
|
||||
export function UserProvider({
|
||||
pubkey,
|
||||
children,
|
||||
embedProfile,
|
||||
data,
|
||||
}: {
|
||||
pubkey: string;
|
||||
children: ReactNode;
|
||||
embedProfile?: string;
|
||||
data?: string;
|
||||
}) {
|
||||
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
|
||||
const { isLoading, profile } = useProfile(pubkey, data);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
|
||||
<UserContext.Provider value={{ pubkey, isLoading, profile }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn, formatCreatedAt } from "@/commons";
|
||||
import { cn, createdAt } from "@/commons";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function UserTime({
|
||||
@@ -8,11 +8,11 @@ export function UserTime({
|
||||
time: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||
const displayCreatedAt = useMemo(() => createdAt(time), [time]);
|
||||
|
||||
return (
|
||||
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
|
||||
{createdAt}
|
||||
{displayCreatedAt}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1170
src/routes.gen.ts
@@ -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,15 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/relay")({
|
||||
beforeLoad: async () => {
|
||||
const res = await commands.getRelays();
|
||||
|
||||
if (res.status === "ok") {
|
||||
const relayList = res.data;
|
||||
return { relayList };
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { createLazyFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/_settings/wallet")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const { balance } = Route.useRouteContext();
|
||||
|
||||
const disconnect = async () => {
|
||||
const res = await commands.removeWallet();
|
||||
|
||||
if (res.status === "ok") {
|
||||
window.localStorage.removeItem("bc:config");
|
||||
return redirect({ to: "/$account/bitcoin-connect", params: { account } });
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-3 pb-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Connection</h3>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
className="h-8 w-max px-2.5 text-sm rounded-lg inline-flex items-center justify-center bg-black/10 dark:bg-white/10 hover:bg-black/20 dark:hover:bg-white/20"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full px-3 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-center justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Current Balance</h3>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
₿ {balance.bitcoinFormatted}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { commands } from "@/commands.gen";
|
||||
import { getBitcoinDisplayValues } from "@/commons";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/_settings/wallet")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const query = await commands.loadWallet();
|
||||
|
||||
if (query.status === "ok") {
|
||||
const wallet = Number.parseInt(query.data);
|
||||
const balance = getBitcoinDisplayValues(wallet);
|
||||
|
||||
return { balance };
|
||||
} else {
|
||||
throw redirect({
|
||||
to: "/$account/bitcoin-connect",
|
||||
params: { account: params.account },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { displayNsec } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { Check } from "@phosphor-icons/react";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/$account/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false });
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2) {
|
||||
return await message("You need to confirm before continue", {
|
||||
title: "Backup",
|
||||
kind: "info",
|
||||
});
|
||||
}
|
||||
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
}).then((encrypted: string) => {
|
||||
// update state
|
||||
setKey(encrypted);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col text-center">
|
||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
It's use for login to Lume or other Nostr clients. You will lost
|
||||
access to your account if you lose this key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="passphase" className="font-medium">
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{key ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nsec" className="font-medium">
|
||||
Copy this key and keep it in safe place
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={key}
|
||||
readOnly
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium">Before you continue:</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c1}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm1"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<Check className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
I will make sure keep it safe and not sharing with anyone.
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c2}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm2"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<Check className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
I understand I cannot recover private key.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||