Compare commits

...
56 Commits
Author SHA1 Message Date
reya b0c49c5141 chore: release 2024-11-10 13:23:57 +07:00
reya 2ed2cb3afd chore: update dependencies 2024-11-10 10:43:38 +07:00
reya 2bcda1f2ef feat: support NIP22 for comment 2024-11-10 08:51:07 +07:00
reya ca20bbd298 chore: release 2024-11-08 09:19:51 +07:00
reya c6da06cd4d chore: clean up 2024-11-08 09:02:57 +07:00
reya 50bf6c04c1 chore: use upstream nostr-sdk instead of fork 2024-11-08 07:51:48 +07:00
reya 490417771c chore: release 2024-11-07 15:02:05 +07:00
reya 0cb491eaf9 feat: auto open browser if receive auth_url 2024-11-07 14:26:13 +07:00
reya ece6bcc125 feat: add DVM feeds 2024-11-07 09:26:28 +07:00
reya 4b79e559d2 chore: clean up 2024-11-06 09:43:31 +07:00
reya 322e510db2 fix: settings 2024-11-06 09:16:22 +07:00
reya 4e279f127d chore: release 2024-11-06 07:45:00 +07:00
reya 5655a8136d feat: improve dark mode and some copy 2024-11-05 19:33:47 +07:00
reya d80534c51f feat: allow user enter custom relay for relayfeeds 2024-11-05 15:04:39 +07:00
reya 0b97248fb8 chore: release 2024-11-04 10:42:46 +07:00
reya f54f448ecb feat: add tray 2024-11-04 10:40:50 +07:00
reya bd1f2b899d refactor: sync based on interval not window event 2024-11-04 09:40:33 +07:00
reya efd3c83193 fix: missing check for update 2024-11-03 13:50:52 +07:00
reya 85fa1e2359 fix: missing check for update 2024-11-03 13:50:31 +07:00
reya cd6ba5884f chore: release 2024-11-03 13:18:36 +07:00
reya bfed56ba13 fix: crash on windows 2024-11-03 13:05:24 +07:00
reya d1018ba8d1 feat: remove mica effect and support windows 10 2024-11-02 13:43:13 +07:00
reya a42542c16e fix: performance 2024-11-02 13:33:48 +07:00
reya 26a6ec954c fix: thread not reload after reply 2024-11-02 08:26:46 +07:00
reya f346282c3c fix: calver 2024-11-01 18:33:03 +07:00
reya f3e875eeea fix: some issues with publish new note 2024-11-01 17:57:01 +07:00
reya 6ad8ffddf0 chore: clean up 2024-11-01 13:14:47 +07:00
reya d37e2a3c80 chore: update nostr-sdk and nostr-connect 2024-11-01 09:57:46 +07:00
reya 18e1ac0e6c feat: add relay feeds 2024-11-01 09:25:12 +07:00
reya aa4f21a869 fix: dark mode 2024-10-31 16:25:16 +07:00
reya bbc052eebc chore: switch to calver 2024-10-31 13:46:41 +07:00
reya c201b5816c feat: add discover newsfeeds and interests 2024-10-31 13:34:25 +07:00
reya 043cabfd4e fix: overload 2024-10-31 10:25:00 +07:00
reya 9b02ab5842 feat: re-enable gossip and optimize 2024-10-30 16:02:09 +07:00
reya 618a45d349 refactor: app settings 2024-10-30 10:57:43 +07:00
reya 11dcef4e87 feat: group metadata query 2024-10-29 15:05:03 +07:00
reya d87371aec4 chore: use latest nostr sdk 2024-10-28 16:19:50 +07:00
reya cfb017f70b chore: update deps 2024-10-28 09:00:28 +07:00
reya 9ba95301db update 2024-10-28 08:04:19 +07:00
reya 0518389f50 update 2024-10-27 15:57:32 +07:00
reya eb6e3e52df update 2024-10-27 09:57:56 +07:00
reya 1e95a2fd95 update 2024-10-27 08:14:15 +07:00
reya 83d24351cd update 2024-10-26 17:24:39 +07:00
reya 470dc1c759 fix: command 2024-10-26 13:11:07 +07:00
reya 42b780ce6a update 2024-10-26 09:08:50 +07:00
reya 5ab2b1ae31 feat: negentropy progress 2024-10-25 14:57:12 +07:00
reya 055d73c829 feat: rework multi account 2024-10-24 15:50:45 +07:00
reya 469296790e wip: rework multi account 2024-10-24 07:59:41 +07:00
reya c032dbea1a chore: clean up 2024-10-23 10:43:39 +07:00
reya 172566028b feat: use latest nostr sdk 2024-10-23 10:16:56 +07:00
雨宮蓮andGitHub cc7de41bfd feat: Multi Accounts (#237)
* wip: new sync

* wip: restructure routes

* update

* feat: improve sync

* feat: repost with multi-account

* feat: improve sync

* feat: publish with multi account

* fix: settings screen

* feat: add zap for multi accounts
2024-10-22 16:00:06 +07:00
reya ba9c81a10a feat: use upstream rust nostr 2024-10-15 10:37:07 +07:00
reya e158f2e4d7 feat: improve column carousel 2024-10-15 09:59:26 +07:00
reya 62bd689031 fix: async mutex lock forever 2024-10-14 14:59:42 +07:00
reya cb6006f596 feat: handle error when publish note 2024-10-11 14:09:15 +07:00
reya adad048873 feat: improve negentropy sync 2024-10-11 08:56:43 +07:00
176 changed files with 9084 additions and 8068 deletions
+1 -4
View File
@@ -4,10 +4,7 @@
"enabled": true
},
"files": {
"ignore": [
"./src/routes.gen.ts",
"./src/commands.gen.ts"
]
"ignore": ["./src/routes.gen.ts", "./src/commands.gen.ts"]
},
"linter": {
"enabled": true,
+31 -32
View File
@@ -15,69 +15,68 @@
"@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.20",
"@tanstack/query-persist-client-core": "^5.59.20",
"@tanstack/react-query": "^5.59.20",
"@tanstack/react-router": "^1.81.0",
"@tauri-apps/api": "^2.1.0",
"@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.2",
"@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-upload": "^2.1.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",
"embla-carousel-react": "^8.3.1",
"i18next": "^23.16.5",
"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",
"nanoid": "^5.0.8",
"nostr-tools": "^2.10.1",
"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.2",
"react-i18next": "^15.1.1",
"react-string-replace": "^1.1.1",
"rich-textarea": "^0.26.3",
"use-debounce": "^10.0.3",
"rich-textarea": "^0.26.4",
"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.81.0",
"@tanstack/router-plugin": "^1.79.0",
"@tauri-apps/cli": "^2.1.0",
"@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-tsconfig-paths": "^5.0.1"
"vite": "^5.4.10",
"vite-tsconfig-paths": "^5.1.1"
},
"overrides": {
"@types/react": "npm:types-react@rc",
+906 -865
View File
File diff suppressed because it is too large Load Diff
+1212 -878
View File
File diff suppressed because it is too large Load Diff
+18 -16
View File
@@ -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,14 +27,12 @@ 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" }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
tokio = { version = "1", features = ["full"] }
@@ -47,16 +44,21 @@ futures = "0.3.30"
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"
keyring-search = { git = "https://github.com/reyamir/keyring-search" }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
async-trait = "0.1.83"
webbrowser = "1.0.2"
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "webln", "all-nips"] }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
[patch.'https://github.com/rust-nostr/nostr']
nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/nip-22", features = ["lmdb", "webln", "all-nips"] }
nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/nip-22" }
[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
-54
View File
@@ -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/*"
}
]
}
]
}
+17 -26
View File
@@ -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,33 @@
"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",
"core:menu:allow-new",
"core:menu:allow-popup",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:default",
"os:allow-locale",
"os:allow-platform",
"os:allow-os-type",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"dialog:default",
"process:allow-restart",
"process:allow-exit",
"fs:allow-read-file",
"core:menu:allow-new",
"core:menu:allow-popup",
"shell:allow-open",
"store:default",
"prevent-default:default",
"theme:default",
{
"identifier": "http:default",
"allow": [
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"column":{"identifier":"column","description":"Capability for the column","local":true,"windows":["column-*"],"permissions":["core:resources:default","core:tray:default","os:allow-locale","os:allow-os-type","clipboard-manager:allow-write-text","dialog:allow-open","dialog:allow-ask","dialog:allow-message","fs:allow-read-file","core:menu:default","core:menu:allow-new","core:menu:allow-popup","http:default","shell:allow-open","store:allow-get","store:allow-set","store:allow-delete",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]},"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["main","panel","settings","search-*","zap-*","event-*","user-*","editor-*","popup-*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","core:menu:allow-new","core:menu:allow-popup","shell:allow-open","store:default","prevent-default:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
{"window":{"identifier":"window","description":"Capability for the desktop","local":true,"windows":["*"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-close","core:window:allow-destroy","core:window:allow-set-focus","core:window:allow-center","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-set-size","core:window:allow-start-dragging","core:window:allow-toggle-maximize","core:webview:allow-create-webview-window","core:webview:allow-create-webview","core:webview:allow-set-webview-size","core:webview:allow-set-webview-position","core:webview:allow-webview-close","core:menu:allow-new","core:menu:allow-popup","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","dialog:default","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}
+47 -12
View File
@@ -37,7 +37,7 @@
],
"definitions": {
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```",
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
@@ -84,7 +84,7 @@
}
},
"permissions": {
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionEntry"
@@ -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",
+47 -12
View File
@@ -37,7 +37,7 @@
],
"definitions": {
"Capability": {
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```",
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
"type": "object",
"required": [
"identifier",
@@ -84,7 +84,7 @@
}
},
"permissions": {
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ```",
"description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionEntry"
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

+6 -43
View File
@@ -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"
}
]
+3 -2
View File
@@ -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,
+157 -321
View File
@@ -1,126 +1,92 @@
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>,
}
#[derive(Debug, Clone)]
struct AuthHandler;
#[async_trait::async_trait]
impl AuthUrlHandler for AuthHandler {
async fn on_auth_url(&self, auth_url: Url) -> Result<()> {
webbrowser::open(auth_url.as_str())?;
Ok(())
}
}
#[tauri::command]
#[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 +94,50 @@ 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 mut 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);
// Handle auth url
nostr_connect.auth_url_handler(AuthHandler);
// 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 reuse_bunker = nostr_connect
.bunker_uri()
.await
.map_err(|err| err.to_string())?;
let mut reuse_uri = reuse_bunker.to_string();
if let Some(secret) = reuse_bunker.secret() {
let replace = format!("&secret={}", secret);
reuse_uri = reuse_uri.replace(replace.as_str(), "");
}
let account = Account {
secret_key: app_secret,
nostr_connect: Some(reuse_uri),
};
// Save secret key to keyring
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
// Update signer
let _ = client.set_signer(nostr_connect).await;
Ok(remote_npub)
}
#[tauri::command]
@@ -181,9 +151,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 +165,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 +183,91 @@ 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) {
Ok(signer) => {
match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(mut signer) => {
// Handle auth url
signer.auth_url_handler(AuthHandler);
// 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(())
}
+414 -290
View File
@@ -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,185 +13,84 @@ 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]
#[specta::specta]
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());
.kind(Kind::Comment)
.custom_tag(SingleLetterTag::uppercase(Alphabet::E), [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,15 +185,224 @@ 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]
#[specta::specta]
pub async fn get_all_events_by_kind(
kind: u16,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kind(Kind::Custom(kind))
.limit(FETCH_LIMIT)
.until(as_of);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_providers(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
let filter = Filter::new()
.kind(Kind::Custom(31990))
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), vec!["5300"]);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn request_events_from_provider(
provider: String,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer
.get_public_key()
.await
.map_err(|err| err.to_string())?;
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
// Get current user's relay list
let relay_list = client
.database()
.relay_list(public_key)
.await
.map_err(|err| err.to_string())?;
let relay_list: Vec<String> = relay_list.iter().map(|item| item.0.to_string()).collect();
// Create job request
let builder = EventBuilder::job_request(
Kind::JobRequest(5300),
vec![
Tag::public_key(provider),
Tag::custom(TagKind::Relays, relay_list),
],
)
.map_err(|err| err.to_string())?;
match client.send_event_builder(builder).await {
Ok(output) => {
let filter = Filter::new()
.kind(Kind::JobResult(6300))
.author(provider)
.pubkey(public_key)
.since(Timestamp::now());
let opts = SubscribeAutoCloseOptions::default()
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
let _ = client.subscribe(vec![filter], Some(opts)).await;
Ok(output.val.to_hex())
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_events_by_request(
id: String,
provider: String,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|err| err.to_string())?;
let provider = PublicKey::parse(&provider).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kind(Kind::JobResult(6300))
.author(provider)
.pubkey(public_key)
.limit(1);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|err| err.to_string())?;
if let Some(event) = events.first() {
let parsed: Vec<Vec<String>> =
serde_json::from_str(&event.content).map_err(|err| err.to_string())?;
let vec: Vec<Tag> = parsed
.into_iter()
.filter_map(|item| Tag::parse(&item).ok())
.collect::<Vec<_>>();
let tags = Tags::new(vec);
let ids: Vec<EventId> = tags.event_ids().copied().collect();
let filter = Filter::new().ids(ids);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
} else {
Err("Job result not found.".into())
}
}
@@ -304,7 +425,7 @@ pub async fn get_local_events(
.until(as_of);
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 +448,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 +464,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 +484,81 @@ 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()),
};
// Find root event from reply
let root_tag = reply_to
.tags
.find(TagKind::SingleLetter(SingleLetterTag::uppercase(
Alphabet::E,
)));
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)
// Get root event if exist
let root = match root_tag {
Some(tag) => match tag.content() {
Some(content) => {
let id = EventId::parse(content).map_err(|err| err.to_string())?;
client
.database()
.event_by_id(&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::Root),
public_key: Some(event.pubkey),
};
let tag = Tag::from(t);
tags.push(tag)
}
}
None => None,
},
None => None,
};
match client.publish_text_note(content, tags).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
let builder = EventBuilder::comment(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 +570,69 @@ pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, Stri
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
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 +669,11 @@ pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<Str
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
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 +707,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()),
}
}
+268 -275
View File
@@ -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,109 @@ 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_all_local_interests(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::Interests, Kind::InterestSet])
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|err| err.to_string())?;
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_relay_list(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
if let Some(event) = events.first() {
Ok(event.as_json())
} else {
Err("Relay list not found".into())
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
@@ -399,7 +447,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
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 +461,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 +500,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 +551,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 +580,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 +621,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))
}
+49 -134
View File
@@ -1,13 +1,10 @@
use crate::Nostr;
use nostr_sdk::prelude::*;
use serde::Serialize;
use specta::Type;
use std::{
fs::OpenOptions,
io::{self, BufRead, Write},
time::Duration,
};
use tauri::{path::BaseDirectory, Manager, State};
use std::str::FromStr;
use tauri::State;
use crate::{Nostr, FETCH_LIMIT};
#[derive(Serialize, Type)]
pub struct Relays {
@@ -19,149 +16,67 @@ pub struct Relays {
#[tauri::command]
#[specta::specta]
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
pub async fn get_all_relays(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
let relays = client.pool().all_relays().await;
let v: Vec<String> = relays.iter().map(|item| item.0.to_string()).collect();
let connected_relays = client
.relays()
.await
.into_keys()
.map(|url| url.to_string())
.collect::<Vec<_>>();
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::Relays {
timeout: Some(Duration::from_secs(5)),
specific_relays: None,
},
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
let read = nip65_list
.iter()
.filter_map(|(url, meta)| {
if let Some(RelayMetadata::Read) = meta {
Some(url.to_string())
} else {
None
}
})
.collect();
let write = nip65_list
.iter()
.filter_map(|(url, meta)| {
if let Some(RelayMetadata::Write) = meta {
Some(url.to_string())
} else {
None
}
})
.collect();
let both = nip65_list
.iter()
.filter_map(|(url, meta)| {
if meta.is_none() {
Some(url.to_string())
} else {
None
}
})
.collect();
Ok(Relays {
connected: connected_relays,
read: Some(read),
write: Some(write),
both: Some(both),
})
} else {
Ok(Relays {
connected: connected_relays,
read: None,
write: None,
both: None,
})
}
}
Err(e) => Err(e.to_string()),
}
Ok(v)
}
#[tauri::command]
#[specta::specta]
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
pub async fn get_all_relay_lists(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
let client = &state.client;
let 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 fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String> {
let relays_path = app
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)
.map_err(|e| e.to_string())?;
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let _ = client.force_remove_relay(relay).await;
let file = std::fs::File::open(relays_path).map_err(|e| e.to_string())?;
let reader = io::BufReader::new(file);
reader
.lines()
.collect::<Result<Vec<String>, io::Error>>()
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
let relays_path = app
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)
.map_err(|e| e.to_string())?;
let mut file = OpenOptions::new()
.write(true)
.open(relays_path)
.map_err(|e| e.to_string())?;
file.write_all(relays.as_bytes()).map_err(|e| e.to_string())
Ok(())
}
+155 -146
View File
@@ -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,150 @@ 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()),
if is_newsfeed {
tauri::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
if let Ok(contact_list) =
client.database().contacts_public_keys(public_key).await
{
let subscription_id =
SubscriptionId::new(webview.label());
let filter = Filter::new()
.authors(contact_list)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(
subscription_id,
vec![filter],
None,
)
.await
{
println!("Subscription error: {}", e);
}
}
});
}
} else if let Ok(event_id) = EventId::parse(&id) {
tauri::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
let subscription_id = SubscriptionId::new(webview.label());
let filter = Filter::new()
.custom_tag(
SingleLetterTag::uppercase(Alphabet::E),
[event_id],
)
.kind(Kind::Comment)
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e);
}
});
}
}
}
PageLoadEvent::Finished => {
println!("{} finished loading", payload.url());
}
});
match main_window.add_child(
builder,
LogicalPosition::new(column.x, column.y),
LogicalSize::new(column.width, column.height),
) {
Ok(webview) => Ok(webview.label().into()),
Err(e) => Err(e.to_string()),
}
}
}
}
#[tauri::command]
#[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 +190,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 +207,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 +217,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,65 +230,18 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable)
.transparent(true)
.decorations(false)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::Mica],
radius: None,
color: None,
})
.closable(window.closable)
.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)
}
+80 -82
View File
@@ -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()
}
@@ -56,17 +56,17 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
// Get words
let words: Vec<_> = content.split_whitespace().collect();
// Get mentions
let mentions = words
.iter()
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
// Get hashtags
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string().replace("#", "").to_lowercase())
.collect::<Vec<_>>();
// Get mentions
let mentions = words
.iter()
.filter(|&&word| ["nostr:", "@"].iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
@@ -92,8 +92,11 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
}
if entity.starts_with("note") {
if let Ok(event_id) = EventId::from_bech32(&entity) {
let hex = event_id.to_hex();
let tag = Tag::parse(&["e", &hex, "", "mention"]).unwrap();
let tag = Tag::from_standardized(TagStandard::Quote {
event_id,
relay_url: None,
public_key: None,
});
tags.push(tag);
} else {
continue;
@@ -101,14 +104,12 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
}
if entity.starts_with("nevent") {
if let Ok(event) = Nip19Event::from_bech32(&entity) {
let hex = event.event_id.to_hex();
let relay = event.clone().relays.into_iter().next().unwrap_or("".into());
let tag = Tag::parse(&["e", &hex, &relay, "mention"]).unwrap();
if let Some(author) = event.author {
let tag = Tag::public_key(author);
tags.push(tag);
}
let relay_url = event.relays.first().map(UncheckedUrl::from);
let tag = Tag::from_standardized(TagStandard::Quote {
event_id: event.event_id,
relay_url,
public_key: event.author,
});
tags.push(tag);
} else {
@@ -130,30 +131,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 +190,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 +229,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);
+374 -319
View File
@@ -6,180 +6,160 @@
#[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;
use specta_typescript::Typescript;
use std::{
collections::HashSet,
fs,
io::{self, BufRead},
str::FromStr,
time::Duration,
use std::{collections::HashSet, fs, str::FromStr, time::Duration};
use tauri::{
menu::{Menu, MenuItem},
Emitter, EventTarget, Listener, Manager, WebviewWindowBuilder,
};
use tauri::{path::BaseDirectory, Emitter, EventTarget, 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_plugin_store::StoreExt;
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>>,
settings: RwLock<Settings>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
#[derive(Serialize, Deserialize, Debug)]
pub struct Payload {
id: String,
}
#[derive(Debug, 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 = 150;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
// Will be removed when almost relays support negentropy
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nostr.fmt.wiz.biz",
"wss://directory.yabu.me",
"wss://purplepag.es",
];
fn main() {
#[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_all_relays,
get_all_relay_lists,
is_relay_connected,
connect_relay,
remove_relay,
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,
get_relay_list,
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_all_events_by_kind,
get_all_providers,
request_events_from_provider,
get_all_events_by_request,
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 +167,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 +181,63 @@ 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();
// Setup tray menu item
let open_i = MenuItem::with_id(app, "open", "Open Lume", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
// Create tray menu
let menu = Menu::with_items(app, &[&open_i, &quit_i])?;
// Get main tray
let tray = app.tray_by_id("main").unwrap();
// Set menu
tray.set_menu(Some(menu)).unwrap();
// Listen to tray events
tray.on_menu_event(|handle, event| match event.id().as_ref() {
"open" => {
if let Some(window) = handle.get_window("main") {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let window = WebviewWindowBuilder::from_config(
handle,
handle.config().app.windows.first().unwrap(),
)
.unwrap()
.build()
.unwrap();
#[cfg(target_os = "macos")]
main_window.on_window_event(move |event| {
if let tauri::WindowEvent::ThemeChanged(_) = event {
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
// Set decoration
#[cfg(target_os = "windows")]
window.create_overlay_titlebar().unwrap();
// Restore native border
#[cfg(target_os = "macos")]
window.add_border(None);
// Set a custom inset to the traffic lights
#[cfg(target_os = "macos")]
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
}
"quit" => {
std::process::exit(0);
}
_ => {}
});
let client = tauri::async_runtime::block_on(async move {
// 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_secs(2))
.timeout(Duration::from_secs(10));
// Setup nostr client
let client = ClientBuilder::default()
@@ -237,7 +245,7 @@ fn main() {
.opts(opts)
.build();
// Get bootstrap relays
/* Get bootstrap relays
if let Ok(path) = handle
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)
@@ -264,106 +272,221 @@ fn main() {
}
}
}
*/
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
println!("Add discovery relay failed: {}", e)
for relay in BOOTSTRAP_RELAYS {
let _ = client.add_relay(relay).await;
}
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
});
// Load app settings
let store = app.store(".data")?;
// Parse app settings if exist
let settings = if let Some(data) = store.get("tanstack-query-[\"settings\"]") {
if let Some(str) = data.as_str() {
let v: Value = serde_json::from_str(str).unwrap();
let data = v["state"]["data"].clone();
let parse: Settings = serde_json::from_value(data).unwrap();
RwLock::new(parse)
} else {
RwLock::new(Settings::default())
}
} else {
RwLock::new(Settings::default())
};
// Create global state
app.manage(Nostr {
client,
settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()),
trusted_list: Mutex::new(HashSet::new()),
settings,
queue: RwLock::new(HashSet::new()),
});
Subscription::listen_any(app, move |event| {
let handle = handle_clone_child.to_owned();
let payload = event.payload;
// 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 a thread for negentropy
tauri::async_runtime::spawn(async move {
let state = handle_clone_event.state::<Nostr>();
let client = &state.client;
// Run notification thread
// Use default sync options
let opts = SyncOptions::default();
// Set interval
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(600));
loop {
interval.tick().await;
let accounts = get_all_accounts();
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| PublicKey::from_str(acc).ok())
.collect();
if !public_keys.is_empty() {
// Create filter for notification
//
let filter = Filter::new().pubkeys(public_keys.clone()).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]);
// Sync notification
//
if let Ok(output) = client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
// Create filter for contact list
//
let filter = Filter::new()
.authors(public_keys)
.kinds(vec![Kind::ContactList, Kind::FollowSet]);
// Sync events for contact list
//
if let Ok(events) = client.database().query(vec![filter]).await {
// Get unique public keys
let public_keys: HashSet<PublicKey> = events
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
// Convert to vector
let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
for chunk in public_keys.chunks(1000) {
if chunk.is_empty() {
return;
}
let authors = chunk.to_owned();
// Create filter for metadata
//
let filter = Filter::new().authors(authors.clone()).kinds(vec![
Kind::Metadata,
Kind::FollowSet,
Kind::Interests,
Kind::InterestSet,
]);
// Sync metadata
//
if let Ok(output) =
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
// Create filter for text note
//
let filter = Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost, Kind::EventDeletion])
.limit(100);
// Sync text note
//
if let Ok(output) =
client.sync_with(BOOTSTRAP_RELAYS, filter, &opts).await
{
println!("Received: {}", output.received.len())
}
}
}
}
}
});
// Run a thread for handle notification
tauri::async_runtime::spawn(async move {
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 +500,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 +521,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::Comment {
let payload = RichEvent {
raw: event.as_json(),
parsed: if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
},
};
let label = subscription_id.to_string();
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),
"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();
if let Err(e) = handle_clone.emit_to(
EventTarget::labeled(subscription_id.to_string()),
"comment",
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,8 +574,13 @@ 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!())
.expect("error while running tauri application");
.build(ctx)
.expect("error while running tauri application")
.run(|_app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
api.prevent_exit();
}
});
}
#[cfg(debug_assertions)]
+10 -6
View File
@@ -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.7",
"identifier": "nu.lume.Lume",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -30,6 +30,13 @@
"$RESOURCE/*"
]
}
},
"trayIcon": {
"id": "main",
"iconAsTemplate": true,
"menuOnLeftClick": true,
"tooltip": "Lume",
"iconPath": "./icons/tray.png"
}
},
"bundle": {
@@ -39,10 +46,7 @@
"targets": "all",
"active": true,
"category": "SocialNetworking",
"resources": [
"resources/*",
"locales/*"
],
"resources": ["resources/*", "locales/*"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+2 -4
View File
@@ -1,5 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": {
"windows": [
{
@@ -13,9 +13,7 @@
"hiddenTitle": true,
"transparent": true,
"windowEffects": {
"effects": [
"underWindowBackground"
]
"effects": ["underWindowBackground"]
}
}
]
+2 -7
View File
@@ -1,5 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"app": {
"windows": [
{
@@ -10,12 +10,7 @@
"minWidth": 480,
"minHeight": 700,
"transparent": true,
"decorations": false,
"windowEffects": {
"effects": [
"mica"
]
}
"decorations": false
}
]
}
+41 -42
View File
@@ -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>,
);
+170 -146
View File
@@ -5,15 +5,31 @@
export const commands = {
async getRelays() : Promise<Result<Relays, string>> {
async getAllRelays() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
return { status: "ok", data: await TAURI_INVOKE("get_all_relays") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
async getAllRelayLists(until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_relay_lists", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isRelayConnected(relay: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_relay_connected", { relay }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<null, string>> {
try {
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) {
@@ -29,34 +45,18 @@ async removeRelay(relay: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async getBootstrapRelays() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_bootstrap_relays") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAccounts() : Promise<string[]> {
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 +96,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 +120,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 +144,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 +160,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 +184,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 +216,25 @@ 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 };
}
},
async getRelayList(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relay_list", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -222,7 +248,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 +264,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 +272,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 +288,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 +304,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 +320,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 +328,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 +352,46 @@ 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 getAllEventsByKind(kind: number, until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_kind", { kind, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllProviders() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_providers") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestEventsFromProvider(provider: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_events_from_provider", { provider }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllEventsByRequest(id: string, provider: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_request", { id, provider }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
@@ -382,22 +408,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 +424,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 +440,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 +488,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 +504,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 +520,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 +551,9 @@ 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 Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: 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 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 **/
+73 -107
View File
@@ -3,12 +3,9 @@ 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 { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import type { Store as TauriStore } from "@tauri-apps/plugin-store";
import { check } from "@tauri-apps/plugin-updater";
import { BitcoinUnit } from "bitcoin-units";
import { type ClassValue, clsx } from "clsx";
import dayjs from "dayjs";
@@ -16,76 +13,88 @@ 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;
export function isValidRelayUrl(string: string) {
try {
const newUrl = new URL(string);
return newUrl.protocol === "ws:" || newUrl.protocol === "wss:";
} catch (err) {
return false;
}
}
export const isImagePath = (path: string) => {
const exts = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
for (const suffix of exts) {
if (path.endsWith(suffix)) {
return true;
}
}
return false;
};
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 +123,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,54 +154,26 @@ export function getBitcoinDisplayValues(satoshis: number) {
};
}
export function decodeZapInvoice(tags?: string[][]) {
export function decodeZapInvoice(tags: string[][]) {
const invoice = tags.find((tag) => tag[0] === "bolt11")?.[1];
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);
return displayValue;
}
export async function checkForAppUpdates(silent: boolean) {
const update = await check();
if (!update) {
if (silent) return;
await message("You are on the latest version. Stay awesome!", {
title: "No Update Available",
kind: "info",
okLabel: "OK",
});
return;
if (!section) {
return null;
}
if (update?.available) {
const yes = await ask(
`Update to ${update.version} is available!\n\nRelease notes: ${update.body}`,
{
title: "Update Available",
kind: "info",
okLabel: "Update",
cancelLabel: "Cancel",
},
);
if (section.name === "amount") {
const amount = Number.parseInt(section.value) / 1000;
const displayValue = getBitcoinDisplayValues(amount);
if (yes) {
await update.downloadAndInstall();
await relaunch();
}
return;
return displayValue;
} else {
return null;
}
}
@@ -277,18 +258,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[]>([]);
+62 -110
View File
@@ -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}&account=${column.account}`,
});
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
@@ -208,7 +160,7 @@ function Header({ label }: { label: string }) {
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full bg-white dark:bg-black"
className="hidden shrink-0 group-hover:inline-flex items-center justify-center size-6 bg-white dark:bg-neutral-800 border-[.5px] border-neutral-200 dark:border-neutral-800 shadow shadow-neutral-200/50 dark:shadow-none rounded-full"
>
<CaretDown className="size-3" weight="bold" />
</button>
-47
View File
@@ -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>
);
});
+2 -7
View File
@@ -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
View 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
View 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>
);
+3 -3
View File
@@ -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";
+9 -2
View File
@@ -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
View 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(undefined, event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Quote" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Quote
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
+2 -4
View File
@@ -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")} />
+137 -63
View File
@@ -1,88 +1,162 @@
import { appSettings, cn } from "@/commons";
import { commands } from "@/commands.gen";
import { cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components";
import { 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) {
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>
);
}
+8 -11
View File
@@ -1,29 +1,26 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { ZapIcon } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { LumeWindow } from "@/system";
import { useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNoteContext } from "../provider";
export function NoteZap({
label = false,
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)}
onClick={() => LumeWindow.openZap(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",
)}
>
<ZapIcon className={smol ? "size-4" : "size-5"} />
+49 -48
View File
@@ -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>
);
});
+2
View File
@@ -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 -1
View File
@@ -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>
);
}
+60 -37
View File
@@ -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);
}
}
+3 -5
View File
@@ -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>
);
});
+7 -14
View File
@@ -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>
);
+36 -27
View File
@@ -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>
);
+4 -4
View File
@@ -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}
-39
View File
@@ -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>
);
});
+7 -39
View File
@@ -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 />
+6 -4
View File
@@ -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 />
+1 -1
View File
@@ -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 />
+17 -15
View File
@@ -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>
+135
View File
@@ -0,0 +1,135 @@
import { commands } from "@/commands.gen";
import { cn, displayNpub } from "@/commons";
import { Spinner } from "@/components";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useTransition } from "react";
import { useUserContext } from "./provider";
import type { Metadata } from "@/types";
import { MenuItem, Menu } from "@tauri-apps/api/menu";
export function UserButton({ className }: { className?: string }) {
const user = useUserContext();
const queryClient = useQueryClient();
const {
isLoading,
isError,
data: isFollow,
} = useQuery({
queryKey: ["status", user?.pubkey],
queryFn: async () => {
if (!user) {
throw new Error("User not found");
}
const res = await commands.isContact(user.pubkey);
if (res.status === "ok") {
return res.data;
} else {
throw new Error(res.error);
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 2,
});
const [isPending, startTransition] = useTransition();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const accounts = await commands.getAccounts();
const list: Promise<MenuItem>[] = [];
for (const account of accounts) {
const res = await commands.getProfile(account);
let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name ?? "anon";
}
list.push(
MenuItem.new({
text: `Follow as ${name} (${displayNpub(account, 16)})`,
action: async () => submit(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
const toggleFollow = useMutation({
mutationFn: async () => {
if (!user) return;
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["status", user.pubkey] });
// Optimistically update to the new value
queryClient.setQueryData(
["status", user.pubkey],
(data: boolean) => !data,
);
const res = await commands.toggleContact(user.pubkey, null);
if (res.status === "ok") {
return;
} else {
throw new Error(res.error);
}
},
onError: () => {
queryClient.setQueryData(["status", user?.pubkey], false);
},
onSettled: async () => {
return await queryClient.invalidateQueries({
queryKey: ["status", user?.pubkey],
});
},
});
const submit = (account: string) => {
startTransition(async () => {
const signer = await commands.hasSigner(account);
if (signer.status === "ok") {
if (!signer.data) {
const res = await commands.setSigner(account);
if (res.status === "error") {
await message(res.error, { kind: "error" });
return;
}
}
toggleFollow.mutate();
} else {
return;
}
});
};
return (
<button
type="button"
disabled={isPending || isLoading}
onClick={(e) => showContextMenu(e)}
className={cn("w-max gap-1", className)}
>
{isError ? "Error" : null}
{isPending || isLoading ? <Spinner className="size-3" /> : null}
{isFollow ? "Unfollow" : "Follow"}
</button>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { commands } from "@/commands.gen";
import { cn } from "@/commons";
import { Spinner } from "@/components";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useTransition } from "react";
import { useUserContext } from "./provider";
export function UserFollowButton({ className }: { className?: string }) {
const user = useUserContext();
const { queryClient } = useRouteContext({ strict: false });
const {
isLoading,
isError,
data: isFollow,
} = useQuery({
queryKey: ["status", user.pubkey],
queryFn: async () => {
const res = await commands.checkContact(user.pubkey);
if (res.status === "ok") {
return res.data;
} else {
throw new Error(res.error);
}
},
refetchOnWindowFocus: false,
});
const [isPending, startTransition] = useTransition();
const toggleFollow = () => {
startTransition(async () => {
const res = await commands.toggleContact(user.pubkey, null);
if (res.status === "ok") {
queryClient.setQueryData(
["status", user.pubkey],
(prev: boolean) => !prev,
);
// invalidate cache
await queryClient.invalidateQueries({
queryKey: ["status", user.pubkey],
});
return;
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<button
type="button"
disabled={isPending}
onClick={() => toggleFollow()}
className={cn("w-max gap-1", className)}
>
{isError ? "Error" : null}
{isPending || isLoading ? <Spinner className="size-3" /> : null}
{isFollow ? "Unfollow" : "Follow"}
</button>
);
}
+2 -2
View File
@@ -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,
};
+10 -9
View File
@@ -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>
);
+3 -3
View File
@@ -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>
);
}
+638 -629
View File
File diff suppressed because it is too large Load Diff
-117
View File
@@ -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>
);
});
-3
View File
@@ -1,3 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app")();
-280
View File
@@ -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"))
) : (
<></>
);
}
-3
View File
@@ -1,3 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app/home")();
-111
View File
@@ -1,111 +0,0 @@
import { cn } from "@/commons";
import { CurrencyBtc, GearSix, HardDrives, User } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Link } from "@tanstack/react-router";
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/_settings")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const { platform } = Route.useRouteContext();
return (
<div className="flex size-full">
<div
data-tauri-drag-region
className={cn(
"w-[250px] shrink-0 flex flex-col gap-1 border-r border-black/10 dark:border-white/10 p-2",
platform === "macos" ? "pt-11" : "",
)}
>
<Link to="/$account/general" params={{ account }}>
{({ isActive }) => {
return (
<div
className={cn(
"h-9 w-full inline-flex items-center gap-1.5 rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<GearSix className="size-5 shrink-0" />
<p className="text-sm font-medium">General</p>
</div>
);
}}
</Link>
<Link to="/$account/profile" params={{ account }}>
{({ isActive }) => {
return (
<div
className={cn(
"h-9 w-full inline-flex items-center gap-1.5 rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<User className="size-5 shrink-0" />
<p className="text-sm font-medium">Profile</p>
</div>
);
}}
</Link>
<Link to="/$account/relay" params={{ account }}>
{({ isActive }) => {
return (
<div
className={cn(
"h-9 w-full inline-flex items-center gap-1.5 rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<HardDrives className="size-5 shrink-0" />
<p className="text-sm font-medium">Relay</p>
</div>
);
}}
</Link>
<Link to="/$account/wallet" params={{ account }}>
{({ isActive }) => {
return (
<div
className={cn(
"h-9 w-full inline-flex items-center gap-1.5 rounded-lg p-2",
isActive
? "bg-black/10 hover:bg-black/20 dark:bg-white/10 text-neutral-900 dark:text-neutral-100 dark:hover:bg-bg-white/20"
: "text-neutral-700 hover:bg-black/10 dark:text-neutral-300 dark:hover:bg-white/10",
)}
>
<CurrencyBtc className="size-5 shrink-0" />
<p className="text-sm font-medium">Wallet</p>
</div>
);
}}
</Link>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="flex-1 overflow-hidden size-full"
>
<ScrollArea.Viewport className="relative h-full pt-12">
<Outlet />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
@@ -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>
);
}
-17
View File
@@ -1,17 +0,0 @@
import { commands } from "@/commands.gen";
import { appSettings } from "@/commons";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/general")({
beforeLoad: async () => {
const res = await commands.getUserSettings();
if (res.status === "ok") {
appSettings.setState((state) => {
return { ...state, ...res.data };
});
} else {
throw new Error(res.error);
}
},
});
@@ -1,245 +0,0 @@
import { type Profile, commands } from "@/commands.gen";
import { cn, upload } from "@/commons";
import { Spinner } from "@/components";
import { Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
useTransition,
} from "react";
import { useForm } from "react-hook-form";
export const Route = createLazyFileRoute("/$account/_settings/profile")({
component: Screen,
});
function Screen() {
const { profile } = Route.useRouteContext();
const { register, handleSubmit } = useForm({ defaultValues: profile });
const [isPending, startTransition] = useTransition();
const [picture, setPicture] = useState<string>("");
const onSubmit = (data: Profile) => {
startTransition(async () => {
const newProfile: Profile = { ...profile, ...data, picture };
const res = await commands.setProfile(newProfile);
if (res.status === "error") {
await message(res.error, { title: "Profile", kind: "error" });
}
return;
});
};
return (
<div className="relative flex flex-col gap-6 px-3 pb-3">
<div className="flex items-center flex-1 h-full gap-3">
<div className="relative rounded-full size-20 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
{profile.picture ? (
<img
src={picture || profile.picture}
alt="avatar"
loading="lazy"
decoding="async"
className="absolute inset-0 z-10 object-cover size-20 rounded-full"
/>
) : null}
<AvatarUploader
setPicture={setPicture}
className="absolute inset-0 z-20 flex items-center justify-center size-full text-white rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<Plus className="size-5" />
</AvatarUploader>
</div>
<div className="flex-1 flex items-center justify-between">
<div>
<div className="text-lg font-semibold">{profile.display_name}</div>
<div className="text-neutral-700 dark:text-neutral-300">
{profile.nip05}
</div>
</div>
<PrivkeyButton />
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-3 mb-0"
>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="display_name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Display Name
</label>
<input
name="display_name"
{...register("display_name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="name"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Name
</label>
<input
name="name"
{...register("name")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="website"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Website
</label>
<input
name="website"
type="url"
{...register("website")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="banner"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Cover
</label>
<input
name="banner"
type="url"
{...register("banner")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="nip05"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
NIP-05
</label>
<input
name="nip05"
type="email"
{...register("nip05")}
spellCheck={false}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col w-full gap-1">
<label
htmlFor="lnaddress"
className="text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Lightning Address
</label>
<input
name="lnaddress"
type="email"
{...register("lud16")}
className="w-full px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-neutral-700 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="inline-flex items-center justify-center w-32 px-2 text-sm font-medium text-white bg-blue-500 rounded-lg h-9 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? <Spinner className="size-4" /> : "Update Profile"}
</button>
</div>
</form>
</div>
);
}
function PrivkeyButton() {
const { account } = Route.useParams();
const [isPending, startTransition] = useTransition();
const [isCopy, setIsCopy] = useState(false);
const copyPrivateKey = () => {
startTransition(async () => {
const res = await commands.getPrivateKey(account);
if (res.status === "ok") {
await writeText(res.data);
setIsCopy(true);
} else {
await message(res.error, { kind: "error" });
return;
}
});
};
return (
<button
type="button"
onClick={() => copyPrivateKey()}
className="inline-flex items-center justify-center px-3 text-sm font-medium text-blue-500 bg-blue-100 border border-blue-300 rounded-full h-7 hover:bg-blue-200 dark:bg-blue-900 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-800"
>
{isPending ? (
<Spinner className="size-4" />
) : isCopy ? (
"Copied"
) : (
"Copy Private Key"
)}
</button>
);
}
function AvatarUploader({
setPicture,
children,
className,
}: {
setPicture: Dispatch<SetStateAction<string>>;
children: ReactNode;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
const uploadAvatar = () => {
startTransition(async () => {
try {
const image = await upload();
setPicture(image);
} catch (e) {
await message(String(e));
return;
}
});
};
return (
<button
type="button"
onClick={() => uploadAvatar()}
className={cn("size-4", className)}
>
{isPending ? <Spinner className="size-4" /> : children}
</button>
);
}
@@ -1,155 +0,0 @@
import { commands } from "@/commands.gen";
import { Plus, X } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { message } from "@tauri-apps/plugin-dialog";
import { useEffect, useState, useTransition } from "react";
export const Route = createLazyFileRoute("/$account/_settings/relay")({
component: Screen,
});
function Screen() {
const { relayList } = Route.useRouteContext();
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [isPending, startTransition] = useTransition();
const removeRelay = async (relay: string) => {
const res = await commands.removeRelay(relay);
if (res.status === "ok") {
return res.data;
} else {
throw new Error(res.error);
}
};
const addNewRelay = () => {
startTransition(async () => {
try {
let url = newRelay;
if (!url.startsWith("wss://")) {
url = `wss://${url}`;
}
const relay = new URL(url);
const res = await commands.connectRelay(relay.toString());
if (res.status === "ok") {
setRelays((prev) => [...prev, newRelay]);
setNewRelay("");
} else {
await message(res.error, { title: "Relay", kind: "error" });
return;
}
} catch {
await message("URL is not valid.", { kind: "error" });
return;
}
});
};
useEffect(() => {
setRelays(relayList.connected);
}, [relayList]);
return (
<div className="w-full px-3 pb-3">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Connected Relays
</h2>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relays.map((relay) => (
<div
key={relay}
className="flex items-center justify-between h-11"
>
<div className="inline-flex items-center gap-2 text-sm font-medium">
<span className="relative flex size-2">
<span className="absolute inline-flex w-full h-full bg-teal-400 rounded-full opacity-75 animate-ping" />
<span className="relative inline-flex bg-teal-500 rounded-full size-2" />
</span>
{relay}
</div>
<div>
<button
type="button"
onClick={() => removeRelay(relay)}
className="inline-flex items-center justify-center rounded-md size-7 hover:bg-black/10 dark:hover:bg-white/10"
>
<X className="size-4" />
</button>
</div>
</div>
))}
<div className="flex items-center h-14">
<div className="flex items-center w-full gap-2 mb-0">
<input
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
name="url"
placeholder="wss://..."
spellCheck={false}
className="flex-1 px-3 bg-transparent rounded-lg h-9 border-neutral-300 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:border-neutral-700 dark:placeholder:text-neutral-400"
/>
<button
type="button"
disabled={isPending}
onClick={() => addNewRelay()}
className="inline-flex items-center justify-center w-16 px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
>
<Plus className="size-5" />
</button>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
User Relays (NIP-65)
</h2>
<div className="flex flex-col px-3 py-2 bg-black/5 dark:bg-white/5 rounded-xl">
<p className="text-sm text-yellow-500">
Lume will automatically connect to the user's relay list, but the
manager function (like adding, removing, changing relay purpose)
is not yet available.
</p>
</div>
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
{relayList.read?.map((relay) => (
<div
key={relay}
className="flex items-center justify-between h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ</div>
</div>
))}
{relayList.write?.map((relay) => (
<div
key={relay}
className="flex items-center justify-between h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">WRITE</div>
</div>
))}
{relayList.both?.map((relay) => (
<div
key={relay}
className="flex items-center justify-between h-11"
>
<div className="text-sm font-medium">{relay}</div>
<div className="text-xs font-semibold">READ + WRITE</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More