Compare commits

..

32 Commits

Author SHA1 Message Date
f346282c3c fix: calver 2024-11-01 18:33:03 +07:00
f3e875eeea fix: some issues with publish new note 2024-11-01 17:57:01 +07:00
6ad8ffddf0 chore: clean up 2024-11-01 13:14:47 +07:00
d37e2a3c80 chore: update nostr-sdk and nostr-connect 2024-11-01 09:57:46 +07:00
18e1ac0e6c feat: add relay feeds 2024-11-01 09:25:12 +07:00
aa4f21a869 fix: dark mode 2024-10-31 16:25:16 +07:00
bbc052eebc chore: switch to calver 2024-10-31 13:46:41 +07:00
c201b5816c feat: add discover newsfeeds and interests 2024-10-31 13:34:25 +07:00
043cabfd4e fix: overload 2024-10-31 10:25:00 +07:00
9b02ab5842 feat: re-enable gossip and optimize 2024-10-30 16:02:09 +07:00
618a45d349 refactor: app settings 2024-10-30 10:57:43 +07:00
11dcef4e87 feat: group metadata query 2024-10-29 15:05:03 +07:00
d87371aec4 chore: use latest nostr sdk 2024-10-28 16:19:50 +07:00
cfb017f70b chore: update deps 2024-10-28 09:00:28 +07:00
9ba95301db update 2024-10-28 08:04:19 +07:00
0518389f50 update 2024-10-27 15:57:32 +07:00
eb6e3e52df update 2024-10-27 09:57:56 +07:00
1e95a2fd95 update 2024-10-27 08:14:15 +07:00
83d24351cd update 2024-10-26 17:24:39 +07:00
470dc1c759 fix: command 2024-10-26 13:11:07 +07:00
42b780ce6a update 2024-10-26 09:08:50 +07:00
5ab2b1ae31 feat: negentropy progress 2024-10-25 14:57:12 +07:00
055d73c829 feat: rework multi account 2024-10-24 15:50:45 +07:00
469296790e wip: rework multi account 2024-10-24 07:59:41 +07:00
c032dbea1a chore: clean up 2024-10-23 10:43:39 +07:00
172566028b feat: use latest nostr sdk 2024-10-23 10:16:56 +07:00
雨宮蓮
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
ba9c81a10a feat: use upstream rust nostr 2024-10-15 10:37:07 +07:00
e158f2e4d7 feat: improve column carousel 2024-10-15 09:59:26 +07:00
62bd689031 fix: async mutex lock forever 2024-10-14 14:59:42 +07:00
cb6006f596 feat: handle error when publish note 2024-10-11 14:09:15 +07:00
adad048873 feat: improve negentropy sync 2024-10-11 08:56:43 +07:00
166 changed files with 7187 additions and 6531 deletions

View File

@@ -15,68 +15,67 @@
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/query-persist-client-core": "^5.59.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.63.5",
"@tanstack/react-store": "^0.5.5",
"@tanstack/store": "^0.5.5",
"@tauri-apps/api": "^2.0.2",
"@tanstack/query-broadcast-client-experimental": "^5.59.16",
"@tanstack/query-persist-client-core": "^5.59.16",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-router": "^1.77.5",
"@tauri-apps/api": "^2.0.3",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-http": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-fs": "^2.0.1",
"@tauri-apps/plugin-http": "^2.0.1",
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.1",
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#a564510",
"@tauri-apps/plugin-updater": "^2.0.0",
"@tauri-apps/plugin-upload": "^2.0.0",
"@tauri-apps/plugin-window-state": "^2.0.0",
"bitcoin-units": "^1.0.0",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.3.0",
"i18next": "^23.15.2",
"i18next": "^23.16.4",
"i18next-resources-to-backend": "^1.2.1",
"light-bolt11-decoder": "^3.2.0",
"minidenticons": "^4.2.1",
"nanoid": "^5.0.7",
"nostr-tools": "^2.7.2",
"react": "19.0.0-rc-d025ddd3-20240722",
"nostr-tools": "^2.9.4",
"react": "19.0.0-rc-cae764ce-20241025",
"react-currency-input-field": "^3.8.0",
"react-dom": "19.0.0-rc-d025ddd3-20240722",
"react-hook-form": "^7.53.0",
"react-i18next": "^15.0.2",
"react-dom": "19.0.0-rc-cae764ce-20241025",
"react-hook-form": "^7.53.1",
"react-i18next": "^15.1.0",
"react-string-replace": "^1.1.1",
"rich-textarea": "^0.26.3",
"use-debounce": "^10.0.3",
"use-debounce": "^10.0.4",
"virtua": "^0.34.2"
},
"devDependencies": {
"@biomejs/biome": "^1.9.3",
"@evilmartians/harmony": "^1.2.0",
"@biomejs/biome": "^1.9.4",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/router-devtools": "^1.63.5",
"@tanstack/router-plugin": "^1.63.5",
"@tauri-apps/cli": "^2.0.2",
"@tanstack/router-devtools": "^1.77.5",
"@tanstack/router-plugin": "^1.76.4",
"@tauri-apps/cli": "^2.0.4",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@vitejs/plugin-react": "^4.3.2",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "0.0.0-experimental-b4db8c3-20241001",
"clsx": "^2.1.1",
"postcss": "^8.4.47",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.5.3",
"tailwind-merge": "^2.5.4",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.13",
"tailwindcss": "^3.4.14",
"tailwindcss-content-visibility": "^1.0.0",
"typescript": "^5.6.3",
"vite": "^5.4.8",
"vite": "^5.4.10",
"vite-tsconfig-paths": "^5.0.1"
},
"overrides": {

1687
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

634
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

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,13 +27,14 @@ tauri-plugin-process = "2.0.0"
tauri-plugin-shell = "2.0.0"
tauri-plugin-updater = "2.0.0"
tauri-plugin-upload = "2.0.0"
tauri-plugin-store = "2.0.0"
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum.git" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", rev = "8c67d44" }
tauri-plugin-prevent-default = "0.6"
tauri-plugin-theme = "2.1.2"
tauri-plugin-decorum = { git = "https://github.com/clearlysid/tauri-plugin-decorum" }
tauri-specta = { version = "2.0.0-rc.15", features = ["derive", "typescript"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb"] }
nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
specta = "^2.0.0-rc.20"
specta-typescript = "0.0.7"
@@ -48,15 +48,11 @@ linkify = "0.10.0"
regex = "1.10.4"
keyring = { version = "3", features = ["apple-native", "windows-native"] }
keyring-search = "1.2.0"
tracing-subscriber = "0.3.18"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
monitor = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
border = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
share-picker = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
[profile.release]
codegen-units = 1

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/*"
}
]
}
]
}

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,35 @@
"core:window:allow-set-size",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"core:webview:allow-create-webview-window",
"core:webview:allow-create-webview",
"core:webview:allow-set-webview-size",
"core:webview:allow-set-webview-position",
"core:webview:allow-webview-close",
"core:menu:allow-new",
"core:menu:allow-popup",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:default",
"os:allow-locale",
"os:allow-platform",
"os:allow-os-type",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"decorum:allow-show-snap-overlay",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text",
"dialog:allow-open",
"dialog:allow-ask",
"dialog:allow-message",
"process:allow-restart",
"process:allow-exit",
"fs:allow-read-file",
"core:menu:allow-new",
"core:menu:allow-popup",
"shell:allow-open",
"store:default",
"prevent-default:default",
"theme:default",
{
"identifier": "http:default",
"allow": [

File diff suppressed because one or more lines are too long

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:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","process:allow-exit","fs:allow-read-file","shell:allow-open","store:default","prevent-default:default","theme:default",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["macOS","windows"]}}

View File

@@ -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",

View File

@@ -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

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"
}
]

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,

View File

@@ -1,126 +1,81 @@
use keyring::Entry;
use keyring_search::{Limit, List, Search};
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
collections::HashSet,
fs::{self, File},
str::FromStr,
time::Duration,
};
use tauri::{Emitter, Manager, State};
use tokio::time::sleep;
use std::{str::FromStr, time::Duration};
use tauri::{Emitter, State};
use crate::{
common::{get_latest_event, init_nip65},
Nostr, NOTIFICATION_SUB_ID,
};
use crate::{common::get_all_accounts, Nostr, Settings};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
struct Account {
password: String,
secret_key: String,
nostr_connect: Option<String>,
}
#[tauri::command]
#[specta::specta]
pub fn get_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected.");
let results = search.by_service("Lume Secret Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> = list
.split_whitespace()
.filter(|v| v.starts_with("npub1"))
.map(String::from)
.collect();
accounts.into_iter().collect()
get_all_accounts()
}
#[tauri::command]
#[specta::specta]
pub async fn create_account(
name: String,
about: String,
picture: String,
password: String,
pub async fn watch_account(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let npub = public_key.to_bech32().map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
// Set empty password
keyring.set_password("").map_err(|e| e.to_string())?;
// Get user's profile
let _ = client
.fetch_metadata(public_key, Some(Duration::from_secs(4)))
.await;
Ok(npub)
}
#[tauri::command]
#[specta::specta]
pub async fn import_account(
key: String,
password: Option<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
let keys = Keys::generate();
let npub = keys.public_key().to_bech32().map_err(|e| e.to_string())?;
let secret_key = keys.secret_key();
let enc = EncryptedSecretKey::new(secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
// Save account
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
let account = Account {
password: enc_bech32,
nostr_connect: None,
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
let signer = NostrSigner::Keys(keys);
// Update signer
client.set_signer(Some(signer)).await;
let mut metadata = Metadata::new()
.display_name(name.clone())
.name(name.to_lowercase())
.about(about);
if let Ok(url) = Url::parse(&picture) {
metadata = metadata.picture(url)
}
match client.set_metadata(&metadata).await {
Ok(_) => Ok(npub),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn import_account(key: String, password: String) -> Result<String, String> {
let (npub, enc_bech32) = match key.starts_with("ncryptsec") {
true => {
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let secret_key = enc.to_secret_key(password).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key);
let npub = keys.public_key().to_bech32().unwrap();
(npub, enc_bech32)
}
false => {
let secret_key = SecretKey::from_bech32(key).map_err(|err| err.to_string())?;
let keys = Keys::new(secret_key.clone());
let npub = keys.public_key().to_bech32().unwrap();
let enc = EncryptedSecretKey::new(&secret_key, password, 16, KeySecurity::Medium)
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
(npub, enc_bech32)
}
// Create secret key
let secret_key = if let Some(pw) = password {
let enc = EncryptedSecretKey::from_bech32(key).map_err(|err| err.to_string())?;
enc.to_secret_key(pw).map_err(|err| err.to_string())?
} else {
SecretKey::from_str(&key).map_err(|err| err.to_string())?
};
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
let hex = secret_key.to_secret_hex();
let keys = Keys::new(secret_key);
let public_key = keys.public_key();
let npub = public_key.to_bech32().map_err(|err| err.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
let account = Account {
password: enc_bech32,
secret_key: hex,
nostr_connect: None,
};
// Save secret key to keyring
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
// Update signer
client.set_signer(keys).await;
Ok(npub)
}
@@ -128,46 +83,40 @@ pub async fn import_account(key: String, password: String) -> Result<String, Str
#[specta::specta]
pub async fn connect_account(uri: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let bunker_uri = NostrConnectURI::parse(&uri).map_err(|err| err.to_string())?;
match NostrConnectURI::parse(uri.clone()) {
Ok(bunker_uri) => {
// Local user
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().to_secret_hex();
// Local user
let app_keys = Keys::generate();
let app_secret = app_keys.secret_key().to_secret_hex();
// Get remote user
let remote_user = bunker_uri.signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().unwrap();
// Get remote user
let remote_user = bunker_uri.remote_signer_public_key().unwrap();
let remote_npub = remote_user.to_bech32().map_err(|err| err.to_string())?;
match Nip46Signer::new(bunker_uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
let mut url = Url::parse(&uri).unwrap();
let query: Vec<(String, String)> = url
.query_pairs()
.filter(|(name, _)| name != "secret")
.map(|(name, value)| (name.into_owned(), value.into_owned()))
.collect();
url.query_pairs_mut().clear().extend_pairs(&query);
// Init nostr connect
let nostr_connect = NostrConnect::new(bunker_uri, app_keys, Duration::from_secs(120), None)
.map_err(|err| err.to_string())?;
let key = format!("{}_nostrconnect", remote_npub);
let keyring = Entry::new("Lume Secret Storage", &key).unwrap();
let account = Account {
password: app_secret,
nostr_connect: Some(url.to_string()),
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
let _ = keyring.set_password(&j);
let bunker_uri = nostr_connect
.bunker_uri()
.await
.map_err(|err| err.to_string())?;
// Update signer
let _ = client.set_signer(Some(signer.into())).await;
let keyring = Entry::new("Lume Safe Storage", &remote_npub).map_err(|err| err.to_string())?;
Ok(remote_npub)
}
Err(err) => Err(err.to_string()),
}
}
Err(err) => Err(err.to_string()),
}
let account = Account {
secret_key: app_secret,
nostr_connect: Some(bunker_uri.to_string()),
};
// Save secret key to keyring
let pwd = serde_json::to_string(&account).map_err(|e| e.to_string())?;
keyring.set_password(&pwd).map_err(|e| e.to_string())?;
// Update signer
let _ = client.set_signer(nostr_connect).await;
Ok(remote_npub)
}
#[tauri::command]
@@ -181,9 +130,9 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
.map_err(|err| err.to_string())?;
let enc_bech32 = enc.to_bech32().map_err(|err| err.to_string())?;
let keyring = Entry::new("Lume Secret Storage", &npub).map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &npub).map_err(|e| e.to_string())?;
let account = Account {
password: enc_bech32,
secret_key: enc_bech32,
nostr_connect: None,
};
let j = serde_json::to_string(&account).map_err(|e| e.to_string())?;
@@ -195,16 +144,17 @@ pub async fn reset_password(key: String, password: String) -> Result<(), String>
#[tauri::command]
#[specta::specta]
pub fn get_private_key(id: String) -> Result<String, String> {
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let password = keyring.get_password().map_err(|e| e.to_string())?;
let account: Account = serde_json::from_str(&password).map_err(|e| e.to_string())?;
Ok(password)
Ok(account.secret_key)
}
#[tauri::command]
#[specta::specta]
pub fn delete_account(id: String) -> Result<(), String> {
let keyring = Entry::new("Lume Secret Storage", &id).map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let _ = keyring.delete_credential();
Ok(())
@@ -212,226 +162,89 @@ pub fn delete_account(id: String) -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub fn is_account_sync(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
pub async fn has_signer(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
fs::metadata(config_dir.join(id)).is_ok()
match client.signer().await {
Ok(signer) => {
let signer_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
let is_match = signer_key == public_key;
Ok(is_match)
}
Err(_) => Ok(false),
}
}
#[tauri::command]
#[specta::specta]
pub fn create_sync_file(id: String, handle: tauri::AppHandle) -> bool {
let config_dir = handle
.path()
.app_config_dir()
.expect("Error: app config directory not found.");
File::create(config_dir.join(id)).is_ok()
}
#[tauri::command]
#[specta::specta]
pub async fn login(
account: String,
password: String,
pub async fn set_signer(
id: String,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> Result<String, String> {
) -> Result<(), String> {
let client = &state.client;
let keyring = Entry::new("Lume Secret Storage", &account).map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", &id).map_err(|e| e.to_string())?;
let account = match keyring.get_password() {
Ok(pw) => {
let account: Account = serde_json::from_str(&pw).map_err(|e| e.to_string())?;
if account.secret_key.is_empty() {
return Err("Watch Only account".into());
};
account
}
Err(e) => return Err(e.to_string()),
};
let public_key = match account.nostr_connect {
match account.nostr_connect {
None => {
let ncryptsec =
EncryptedSecretKey::from_bech32(account.password).map_err(|e| e.to_string())?;
let secret_key = ncryptsec
.to_secret_key(password)
.map_err(|_| "Wrong password.")?;
let secret_key = SecretKey::from_str(&account.secret_key).map_err(|e| e.to_string())?;
let keys = Keys::new(secret_key);
let public_key = keys.public_key().to_bech32().unwrap();
let signer = NostrSigner::Keys(keys);
// Update signer
client.set_signer(Some(signer)).await;
client.set_signer(keys).await;
// Emit to front-end
handle.emit("signer-updated", ()).unwrap();
public_key
Ok(())
}
Some(bunker) => {
let uri = NostrConnectURI::parse(bunker).map_err(|e| e.to_string())?;
let public_key = uri.signer_public_key().unwrap().to_bech32().unwrap();
let app_keys = Keys::from_str(&account.password).map_err(|e| e.to_string())?;
let app_keys = Keys::from_str(&account.secret_key).map_err(|e| e.to_string())?;
match Nip46Signer::new(uri, app_keys, Duration::from_secs(120), None) {
match NostrConnect::new(uri, app_keys, Duration::from_secs(120), None) {
Ok(signer) => {
// Update signer
client.set_signer(Some(signer.into())).await;
public_key
client.set_signer(signer).await;
// Emit to front-end
handle.emit("signer-updated", ()).unwrap();
Ok(())
}
Err(e) => return Err(e.to_string()),
Err(e) => Err(e.to_string()),
}
}
};
// NIP-65: Connect to user's relay list
init_nip65(client, &public_key).await;
// NIP-03: Get user's contact list
let contact_list = {
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(5))).await {
state.contact_list.lock().await.clone_from(&contacts);
contacts
} else {
Vec::new()
}
};
let public_key_clone = public_key.clone();
// Run seperate thread for sync
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
let author = PublicKey::from_str(&public_key).unwrap();
// Subscribe for new notification
if let Ok(e) = client
.subscribe_with_id(
SubscriptionId::new(NOTIFICATION_SUB_ID),
vec![Filter::new().pubkey(author).since(Timestamp::now())],
None,
)
.await
{
println!("Subscribed: {}", e.success.len())
}
// Get events from contact list
if !contact_list.is_empty() {
let authors: Vec<PublicKey> = contact_list.iter().map(|f| f.public_key).collect();
// Syncing all metadata events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::Metadata, Kind::ContactList, Kind::EventDeletion])
.limit(authors.len() * 20),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Syncing all events from contact list
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(authors.clone())
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(authors.len() * 50),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len());
}
// Create the trusted public key list from contact list
// TODO: create a cached file
let mut trusted_list: HashSet<PublicKey> = HashSet::new();
for author in authors.into_iter() {
trusted_list.insert(author);
let filter = Filter::new()
.author(author)
.kind(Kind::ContactList)
.limit(1);
if let Ok(events) = client.database().query(vec![filter]).await {
if let Some(event) = get_latest_event(&events) {
for tag in event.tags.iter() {
if let Some(TagStandard::PublicKey {
public_key,
uppercase: false,
..
}) = tag.to_owned().to_standardized()
{
trusted_list.insert(public_key);
};
}
}
}
}
// Update app's state
state.trusted_list.lock().await.clone_from(&trusted_list);
// Syncing all user's events
if let Ok(report) = client
.reconcile(Filter::new().author(author), NegentropyOptions::default())
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all tagged events for current user
if let Ok(report) = client
.reconcile(
Filter::new().pubkey(author).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Syncing all events for trusted list
let trusted: Vec<PublicKey> = trusted_list.into_iter().collect();
if let Ok(report) = client
.reconcile(
Filter::new()
.authors(trusted)
.kinds(vec![
Kind::Metadata,
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.limit(30000),
NegentropyOptions::default(),
)
.await
{
println!("Received: {}", report.received.len())
}
// Wait a little longer
// TODO: remove?
sleep(Duration::from_secs(5)).await;
}
handle
.emit("neg_synchronized", ())
.expect("Something wrong!");
});
Ok(public_key_clone)
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_app_settings(state: State<'_, Nostr>) -> Result<Settings, String> {
let settings = state.settings.read().await.clone();
Ok(settings)
}
#[tauri::command]
#[specta::specta]
pub async fn set_app_settings(settings: String, state: State<'_, Nostr>) -> Result<(), String> {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
let mut settings = state.settings.write().await;
// Update state
settings.clone_from(&parsed);
Ok(())
}

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,130 +13,58 @@ pub struct RichEvent {
pub parsed: Option<Meta>,
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_meta(content: String) -> Result<Meta, ()> {
let meta = parse_event(&content).await;
Ok(meta)
}
#[tauri::command]
#[specta::specta]
pub async fn get_event(id: String, state: State<'_, Nostr>) -> Result<RichEvent, String> {
let client = &state.client;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client.database().query(vec![filter.clone()]).await {
Ok(events) => {
if let Some(event) = get_latest_event(&events) {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
let events = client
.database()
.event_by_id(&event_id)
.await
.map_err(|err| err.to_string())?;
Ok(RichEvent { raw, parsed })
} else {
println!("Not found, getting event from relays...");
match client
.stream_events_of(vec![filter], Some(Duration::from_secs(10)))
.await
{
Ok(mut rx) => {
let mut raw: String = String::new();
let mut parsed: Option<Meta> = None;
if let Some(event) = events {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
while let Some(event) = rx.next().await {
raw = event.as_json();
parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
}
Ok(RichEvent { raw, parsed })
} else {
let filter = Filter::new().id(event_id).limit(1);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(5)))
.await
.map_err(|e| e.to_string())?;
Ok(RichEvent { raw, parsed })
}
Err(err) => Err(err.to_string()),
}
}
while let Some(event) = rx.next().await {
events.insert(event);
}
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Event not found.".into())
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_from(
id: String,
relay_hint: String,
state: State<'_, Nostr>,
) -> Result<RichEvent, String> {
let client = &state.client;
let settings = state.settings.lock().await;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().id(event_id);
if !settings.use_relay_hint {
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
} else {
// Add relay hint to relay pool
if let Err(e) = client.add_relay(&relay_hint).await {
return Err(e.to_string());
}
if let Err(e) = client.connect_relay(&relay_hint).await {
return Err(e.to_string());
}
match client
.get_events_of(
vec![Filter::new().id(event_id)],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
}
pub async fn get_meta_from_event(content: String) -> Result<Meta, ()> {
Ok(parse_event(&content).await)
}
#[tauri::command]
@@ -145,54 +72,25 @@ pub async fn get_event_from(
pub async fn get_replies(id: String, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => {
let futures = events.iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn subscribe_to(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let subscription_id = SubscriptionId::new(&id);
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::TextNote])
.event(event_id)
.since(Timestamp::now());
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
.event(event_id);
match client
.subscribe_with_id(subscription_id, vec![filter], None)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, true).await;
Ok(alt_events)
}
#[tauri::command]
@@ -210,13 +108,20 @@ pub async fn get_all_events_by_author(
.author(author)
.limit(limit as usize);
match client
.get_events_of(vec![filter], EventSource::Database)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
@@ -229,28 +134,35 @@ pub async fn get_all_events_by_authors(
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let authors: Vec<PublicKey> = public_keys
.iter()
.map(|pk| PublicKey::from_str(pk).map_err(|err| err.to_string()))
.collect::<Result<Vec<_>, _>>()?;
.filter_map(|pk| PublicKey::from_str(pk).ok())
.collect();
let filter = Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of)
.authors(authors);
.until(as_of);
match client
.get_events_of(vec![filter], EventSource::Database)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
@@ -273,16 +185,58 @@ pub async fn get_all_events_by_hashtags(
.until(as_of)
.hashtags(hashtags);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
{
Ok(events) => Ok(process_event(client, events).await),
Err(err) => Err(err.to_string()),
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_events_from(
url: String,
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let _ = client.add_read_relay(&url).await;
let _ = client.connect_relay(&url).await;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).map_err(|err| err.to_string())?,
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(FETCH_LIMIT)
.until(as_of);
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events_from(vec![url], vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
@@ -304,7 +258,7 @@ pub async fn get_local_events(
.until(as_of);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Ok(events) => Ok(process_event(client, events, false).await),
Err(err) => Err(err.to_string()),
}
}
@@ -327,14 +281,8 @@ pub async fn get_global_events(
.limit(FETCH_LIMIT)
.until(as_of);
match client
.get_events_of(
vec![filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.await
{
Ok(events) => Ok(process_event(client, events).await),
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events, false).await),
Err(err) => Err(err.to_string()),
}
}
@@ -349,7 +297,7 @@ pub async fn publish(
) -> Result<String, String> {
let client = &state.client;
// Create tags from content
// Create event tags from content
let mut tags = create_tags(&content);
// Add client tag
@@ -369,85 +317,89 @@ pub async fn publish(
let builder =
EventBuilder::text_note(content, tags).pow(difficulty.unwrap_or(DEFAULT_DIFFICULTY));
// Publish
match client.send_event_builder(builder).await {
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
// Sign event
let event = client
.sign_event_builder(builder)
.await
.map_err(|err| err.to_string())?;
// Save to local database
match client.send_event(event).await {
Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn reply(
content: String,
to: String,
root: Option<String>,
state: State<'_, Nostr>,
) -> Result<String, String> {
pub async fn reply(content: String, to: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let database = client.database();
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
// Create event tags from content
let mut tags = create_tags(&content);
match database.query(vec![Filter::new().id(reply_id)]).await {
Ok(events) => {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
relays.into_iter().next().map(UncheckedUrl::new)
} else {
None
};
let t = TagStandard::Event {
event_id: event.id,
relay_url: relay_hint,
marker: Some(Marker::Reply),
public_key: Some(event.pubkey),
};
let tag = Tag::from(t);
tags.push(tag)
// Add client tag
// TODO: allow user config this setting
tags.push(Tag::custom(TagKind::custom("client"), vec!["Lume"]));
// Get reply event
let reply_id = EventId::parse(&to).map_err(|err| err.to_string())?;
let reply_to = match client.database().event_by_id(&reply_id).await {
Ok(event) => {
if let Some(event) = event {
event
} else {
return Err("Reply event is not found.".into());
return Err("Event not found in database, cannot reply.".into());
}
}
Err(err) => return Err(err.to_string()),
Err(e) => return Err(e.to_string()),
};
if let Some(id) = root {
let root_id = match EventId::from_hex(id) {
Ok(val) => val,
Err(_) => return Err("Event is not valid.".into()),
};
if let Ok(events) = database.query(vec![Filter::new().id(root_id)]).await {
if let Some(event) = events.first() {
let relay_hint = if let Some(relays) = database
.event_seen_on_relays(&event.id)
.await
.map_err(|err| err.to_string())?
{
relays.into_iter().next().map(UncheckedUrl::new)
// Detect root event from reply
let root_ids: Vec<&EventId> = reply_to
.tags
.filter_standardized(TagKind::e())
.filter_map(|t| match t {
TagStandard::Event {
event_id, marker, ..
} => {
if let Some(mkr) = marker {
match mkr {
Marker::Root => Some(event_id),
Marker::Reply => Some(event_id),
_ => None,
}
} else {
None
};
let t = TagStandard::Event {
event_id: event.id,
relay_url: relay_hint,
marker: Some(Marker::Root),
public_key: Some(event.pubkey),
};
let tag = Tag::from(t);
tags.push(tag)
Some(event_id)
}
}
}
_ => None,
})
.collect();
// Get root event if exist
let root = match root_ids.first() {
Some(&id) => client
.database()
.event_by_id(id)
.await
.map_err(|err| err.to_string())?,
None => None,
};
match client.publish_text_note(content, tags).await {
Ok(event_id) => Ok(event_id.to_bech32().map_err(|err| err.to_string())?),
let builder = EventBuilder::text_note_reply(content, &reply_to, root.as_ref(), None)
.add_tags(tags)
.pow(DEFAULT_DIFFICULTY);
// Sign event
let event = client
.sign_event_builder(builder)
.await
.map_err(|err| err.to_string())?;
match client.send_event(event).await {
Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()),
}
}
@@ -459,23 +411,69 @@ pub async fn repost(raw: String, state: State<'_, Nostr>) -> Result<String, Stri
let event = Event::from_json(raw).map_err(|err| err.to_string())?;
match client.repost(&event, None).await {
Ok(event_id) => Ok(event_id.to_string()),
Ok(output) => Ok(output.to_hex()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn delete(id: String, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn is_reposted(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer
.get_public_key()
.await
.map_err(|err| err.to_string())?;
let event_id = EventId::parse(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(event_id) => Ok(event_id.to_string()),
let filter = Filter::new()
.event(event_id)
.kind(Kind::Repost)
.author(public_key);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
match client.delete_event(event_id).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer
.get_public_key()
.await
.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn event_to_bech32(id: String, state: State<'_, Nostr>) -> Result<String, String> {
@@ -512,13 +510,11 @@ pub async fn user_to_bech32(user: String, state: State<'_, Nostr>) -> Result<Str
let public_key = PublicKey::parse(user).map_err(|err| err.to_string())?;
match client
.get_events_of(
vec![Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)],
EventSource::both(Some(Duration::from_secs(5))),
)
.database()
.query(vec![Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1)])
.await
{
Ok(events) => match events.first() {
@@ -552,38 +548,7 @@ pub async fn search(query: String, state: State<'_, Nostr>) -> Result<Vec<RichEv
let filter = Filter::new().search(query);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_deleted_event(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|err| err.to_string())?;
let public_key = signer.public_key().await.map_err(|err| err.to_string())?;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let filter = Filter::new()
.author(public_key)
.event(event_id)
.kind(Kind::EventDeletion);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(!events.is_empty()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn request_delete(id: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|err| err.to_string())?;
let builder = EventBuilder::delete(vec![event_id]);
match client.send_event_builder(builder).await {
Ok(_) => Ok(()),
Ok(events) => Ok(process_event(client, events, false).await),
Err(e) => Err(e.to_string()),
}
}

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,80 @@ pub async fn set_interest(
pub async fn get_interest(id: String, state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::Interests, Kind::InterestSet])
.id(event_id);
match client.database().query(vec![filter]).await {
Ok(events) => match get_latest_event(&events) {
Some(ev) => Ok(ev.as_json()),
None => Err("Not found.".to_string()),
},
match client.database().event_by_id(&event_id).await {
Ok(event) => {
if let Some(ev) = event {
Ok(ev.as_json())
} else {
Err("Event not found".into())
}
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_interests(state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
pub async fn get_all_interests(
id: String,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?;
let filter = Filter::new()
.kinds(vec![Kind::InterestSet, Kind::Interests])
.author(public_key);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(process_event(client, events).await),
Err(e) => Err(e.to_string()),
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_mention_list(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
pub async fn get_all_local_interests(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kinds(vec![Kind::Interests, Kind::InterestSet])
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|err| err.to_string())?;
let alt_events = process_event(client, events, false).await;
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn get_all_profiles(state: State<'_, Nostr>) -> Result<Vec<Mention>, String> {
let client = &state.client;
let filter = Filter::new().kind(Kind::Metadata);
@@ -399,7 +418,9 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(uri) {
let nwc = NWC::new(nwc_uri);
let keyring = Entry::new("Lume Secret", "Bitcoin Connect").map_err(|e| e.to_string())?;
let keyring =
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
keyring.set_password(uri).map_err(|e| e.to_string())?;
client.set_zapper(nwc).await;
@@ -411,37 +432,32 @@ pub async fn set_wallet(uri: &str, state: State<'_, Nostr>) -> Result<bool, Stri
#[tauri::command]
#[specta::specta]
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<String, String> {
pub async fn load_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri);
if client.zapper().await.is_err() {
let keyring =
Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
// Get current balance
let balance = nwc.get_balance().await;
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
let nwc = NWC::new(uri);
// Update zapper
client.set_zapper(nwc).await;
match balance {
Ok(val) => Ok(val.to_string()),
Err(_) => Err("Get balance failed.".into()),
client.set_zapper(nwc).await;
}
Err(_) => return Err("Wallet not found.".into()),
}
Err(_) => Err("NWC not found.".into()),
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let keyring =
Entry::new("Lume Secret Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
let keyring = Entry::new("Lume Safe Storage", "Bitcoin Connect").map_err(|e| e.to_string())?;
match keyring.delete_credential() {
Ok(_) => {
@@ -455,52 +471,40 @@ pub async fn remove_wallet(state: State<'_, Nostr>) -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn zap_profile(
id: &str,
amount: &str,
message: &str,
id: String,
amount: String,
message: Option<String>,
state: State<'_, Nostr>,
) -> Result<bool, String> {
) -> Result<(), String> {
let client = &state.client;
let public_key: PublicKey = PublicKey::parse(id).map_err(|e| e.to_string())?;
let details = ZapDetails::new(ZapType::Private).message(message);
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(public_key, num, Some(details)).await.is_ok() {
Ok(true)
} else {
Err("Zap profile failed".into())
match client.zap(public_key, num, details).await {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn zap_event(
id: &str,
amount: &str,
message: &str,
id: String,
amount: String,
message: Option<String>,
state: State<'_, Nostr>,
) -> Result<bool, String> {
) -> Result<(), String> {
let client = &state.client;
let event_id = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => id,
Nip19::Event(event) => event.event_id,
_ => return Err("Event ID is invalid.".into()),
},
Err(_) => match EventId::from_hex(id) {
Ok(val) => val,
Err(_) => return Err("Event ID is invalid.".into()),
},
};
let details = ZapDetails::new(ZapType::Private).message(message);
let event_id = EventId::from_str(&id).map_err(|e| e.to_string())?;
let num = amount.parse::<u64>().map_err(|e| e.to_string())?;
let details = message.map(|m| ZapDetails::new(ZapType::Public).message(m));
if client.zap(event_id, num, Some(details)).await.is_ok() {
Ok(true)
} else {
Err("Zap event failed".into())
match client.zap(event_id, num, details).await {
Ok(()) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
@@ -518,10 +522,7 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
.limit(1);
if let Ok(contact_list_events) = client
.get_events_of(
vec![contact_list_filter],
EventSource::both(Some(Duration::from_secs(5))),
)
.fetch_events(vec![contact_list_filter], Some(Duration::from_secs(5)))
.await
{
for event in contact_list_events.into_iter() {
@@ -550,62 +551,34 @@ pub async fn copy_friend(npub: &str, state: State<'_, Nostr>) -> Result<bool, St
#[tauri::command]
#[specta::specta]
pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, String> {
pub async fn get_notifications(id: String, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
match client.signer().await {
Ok(signer) => {
let public_key = signer.public_key().await.unwrap();
let filter = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(200);
let filter = Filter::new()
.pubkey(public_key)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
])
.limit(500);
match client.database().query(vec![filter]).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Err(err) => Err(err.to_string()),
}
}
Err(err) => Err(err.to_string()),
let mut events = Events::new(&[filter.clone()]);
let mut rx = client
.stream_events(vec![filter], Some(Duration::from_secs(3)))
.await
.map_err(|e| e.to_string())?;
while let Some(event) = rx.next().await {
events.insert(event);
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_user_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
Ok(state.settings.lock().await.clone())
}
let alt_events = events.into_iter().map(|ev| ev.as_json()).collect();
#[tauri::command]
#[specta::specta]
pub async fn set_user_settings(
settings: String,
state: State<'_, Nostr>,
handle: tauri::AppHandle,
) -> Result<(), String> {
let client = &state.client;
let tags = vec![Tag::identifier("lume_user_setting")];
let builder = EventBuilder::new(Kind::ApplicationSpecificData, &settings, tags);
match client.send_event_builder(builder).await {
Ok(_) => {
let parsed: Settings = serde_json::from_str(&settings).map_err(|e| e.to_string())?;
// Update state
state.settings.lock().await.clone_from(&parsed);
// Emit new changes to frontend
NewSettings(parsed).emit(&handle).unwrap();
Ok(())
}
Err(err) => Err(err.to_string()),
}
Ok(alt_events)
}
#[tauri::command]
@@ -619,12 +592,3 @@ pub async fn verify_nip05(id: String, nip05: &str) -> Result<bool, String> {
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn is_trusted_user(id: String, state: State<'_, Nostr>) -> Result<bool, String> {
let trusted_list = &state.trusted_list.lock().await;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
Ok(trusted_list.contains(&public_key))
}

View File

@@ -2,4 +2,5 @@ pub mod account;
pub mod event;
pub mod metadata;
pub mod relay;
pub mod sync;
pub mod window;

View File

@@ -1,14 +1,15 @@
use crate::Nostr;
use nostr_sdk::prelude::*;
use serde::Serialize;
use specta::Type;
use std::{
fs::OpenOptions,
io::{self, BufRead, Write},
time::Duration,
str::FromStr,
};
use tauri::{path::BaseDirectory, Manager, State};
use crate::{Nostr, FETCH_LIMIT};
#[derive(Serialize, Type)]
pub struct Relays {
connected: Vec<String>,
@@ -19,8 +20,9 @@ pub struct Relays {
#[tauri::command]
#[specta::specta]
pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
pub async fn get_relays(id: String, state: State<'_, Nostr>) -> Result<Relays, String> {
let client = &state.client;
let public_key = PublicKey::from_str(&id).map_err(|e| e.to_string())?;
let connected_relays = client
.relays()
@@ -29,24 +31,12 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
.map(|url| url.to_string())
.collect::<Vec<_>>();
let signer = client.signer().await.map_err(|e| e.to_string())?;
let public_key = signer.public_key().await.map_err(|e| e.to_string())?;
let filter = Filter::new()
.author(public_key)
.kind(Kind::RelayList)
.limit(1);
match client
.get_events_of(
vec![filter],
EventSource::Relays {
timeout: Some(Duration::from_secs(5)),
specific_relays: None,
},
)
.await
{
match client.database().query(vec![filter]).await {
Ok(events) => {
if let Some(event) = events.first() {
let nip65_list = nip65::extract_relay_list(event).collect::<Vec<_>>();
@@ -105,32 +95,59 @@ pub async fn get_relays(state: State<'_, Nostr>) -> Result<Relays, String> {
#[tauri::command]
#[specta::specta]
pub async fn connect_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
pub async fn get_all_relays(
until: Option<String>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
let client = &state.client;
let status = client.add_relay(relay).await.map_err(|e| e.to_string())?;
if status {
println!("Connecting to relay: {}", relay);
client
.connect_relay(relay)
.await
.map_err(|e| e.to_string())?;
}
let as_of = match until {
Some(until) => Timestamp::from_str(&until).unwrap_or(Timestamp::now()),
None => Timestamp::now(),
};
let filter = Filter::new()
.kind(Kind::RelayList)
.limit(FETCH_LIMIT)
.until(as_of);
let events = client
.database()
.query(vec![filter])
.await
.map_err(|e| e.to_string())?;
let alt_events: Vec<String> = events.iter().map(|ev| ev.as_json()).collect();
Ok(alt_events)
}
#[tauri::command]
#[specta::specta]
pub async fn is_relay_connected(relay: String, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
let status = client.add_relay(&relay).await.map_err(|e| e.to_string())?;
Ok(status)
}
#[tauri::command]
#[specta::specta]
pub async fn remove_relay(relay: &str, state: State<'_, Nostr>) -> Result<bool, String> {
pub async fn connect_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
client
.remove_relay(relay)
.await
.map_err(|e| e.to_string())?;
client
.disconnect_relay(relay)
.await
.map_err(|e| e.to_string())?;
Ok(true)
let _ = client.add_relay(&relay).await;
let _ = client.connect_relay(&relay).await;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn remove_relay(relay: String, state: State<'_, Nostr>) -> Result<(), String> {
let client = &state.client;
let _ = client.force_remove_relay(relay).await;
Ok(())
}
#[tauri::command]
@@ -152,7 +169,7 @@ pub fn get_bootstrap_relays(app: tauri::AppHandle) -> Result<Vec<String>, String
#[tauri::command]
#[specta::specta]
pub fn save_bootstrap_relays(relays: &str, app: tauri::AppHandle) -> Result<(), String> {
pub fn set_bootstrap_relays(relays: String, app: tauri::AppHandle) -> Result<(), String> {
let relays_path = app
.path()
.resolve("resources/relays.txt", BaseDirectory::Resource)

View File

@@ -0,0 +1,116 @@
use nostr_sdk::prelude::*;
use std::fs::{self, File};
use tauri::{ipc::Channel, Manager, State};
use crate::Nostr;
#[tauri::command]
#[specta::specta]
pub fn is_account_sync(id: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
let config_dir = app_handle
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
let exist = fs::metadata(config_dir.join(id)).is_ok();
Ok(exist)
}
#[tauri::command]
#[specta::specta]
pub async fn sync_account(
id: String,
state: State<'_, Nostr>,
reader: Channel<f64>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
let client = &state.client;
let public_key = PublicKey::from_bech32(&id).map_err(|e| e.to_string())?;
let filter = Filter::new().author(public_key).kinds(vec![
Kind::Metadata,
Kind::ContactList,
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::RelayList,
Kind::MuteList,
Kind::EventDeletion,
Kind::Bookmarks,
Kind::BookmarkSet,
Kind::TextNote,
Kind::Repost,
Kind::Custom(30315),
]);
let (tx, mut rx) = SyncProgress::channel();
let opts = SyncOptions::default().progress(tx);
tauri::async_runtime::spawn(async move {
while (rx.changed().await).is_ok() {
let SyncProgress { total, current } = *rx.borrow_and_update();
if total > 0 {
reader
.send((current as f64 / total as f64) * 100.0)
.unwrap()
}
}
});
if let Ok(output) = client.sync(filter, &opts).await {
println!("Success: {:?}", output.success);
println!("Failed: {:?}", output.failed);
let event_pubkeys = client
.database()
.query(vec![Filter::new().kinds(vec![
Kind::ContactList,
Kind::FollowSet,
Kind::MuteList,
Kind::Repost,
Kind::TextNote,
])])
.await
.map_err(|e| e.to_string())?;
if !event_pubkeys.is_empty() {
let pubkeys: Vec<PublicKey> = event_pubkeys
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
let filter = Filter::new()
.authors(pubkeys)
.kinds(vec![
Kind::Metadata,
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
Kind::Interests,
Kind::InterestSet,
Kind::FollowSet,
Kind::RelayList,
Kind::MuteList,
Kind::EventDeletion,
Kind::Bookmarks,
Kind::BookmarkSet,
Kind::Custom(30315),
])
.limit(10000);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Success: {:?}", output.success);
println!("Failed: {:?}", output.failed);
}
};
}
let config_dir = app_handle
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
let _ = File::create(config_dir.join(id));
Ok(())
}

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,147 @@ pub struct Column {
height: f32,
}
#[tauri::command(async)]
#[tauri::command]
#[specta::specta]
pub fn create_column(column: Column, app_handle: tauri::AppHandle) -> Result<String, String> {
match app_handle.get_window("main") {
Some(main_window) => match app_handle.get_webview(&column.label) {
Some(_) => Ok(column.label),
None => {
let path = PathBuf::from(column.url);
let webview_url = WebviewUrl::App(path);
let builder = WebviewBuilder::new(column.label, webview_url)
.incognito(true)
.transparent(true);
if let Ok(webview) = main_window.add_child(
builder,
LogicalPosition::new(column.x, column.y),
LogicalSize::new(column.width, column.height),
) {
Ok(webview.label().into())
} else {
Err("Create webview failed".into())
}
pub async fn create_column(
column: Column,
app_handle: tauri::AppHandle,
main_window: Window,
) -> Result<String, String> {
match app_handle.get_webview(&column.label) {
Some(webview) => {
if let Err(e) = webview.set_size(LogicalSize::new(column.width, column.height)) {
return Err(e.to_string());
}
},
None => Err("Main window not found".into()),
}
}
#[tauri::command(async)]
#[specta::specta]
pub fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.close().is_ok()),
None => Err("Not found.".into()),
}
}
if let Err(e) = webview.set_position(LogicalPosition::new(column.x, column.y)) {
return Err(e.to_string());
}
#[tauri::command(async)]
#[specta::specta]
pub fn reposition_column(
label: String,
x: f32,
y: f32,
app_handle: tauri::AppHandle,
) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.set_position(LogicalPosition::new(x, y)).is_ok()),
None => Err("Not found".into()),
}
}
Ok(column.label)
}
None => {
let path = PathBuf::from(column.url);
let webview_url = WebviewUrl::App(path);
#[tauri::command(async)]
#[specta::specta]
pub fn resize_column(
label: String,
width: f32,
height: f32,
app_handle: tauri::AppHandle,
) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.set_size(LogicalSize::new(width, height)).is_ok()),
None => Err("Not found".into()),
}
}
let builder = WebviewBuilder::new(column.label, webview_url)
.incognito(true)
.transparent(true)
.on_page_load(|webview, payload| match payload.event() {
PageLoadEvent::Started => {
if let Ok(id) = get_last_segment(payload.url()) {
if let Ok(public_key) = PublicKey::parse(&id) {
let is_newsfeed = payload.url().to_string().contains("newsfeed");
#[tauri::command(async)]
#[specta::specta]
pub fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.eval("window.location.reload()").is_ok()),
None => Err("Not found".into()),
tauri::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
if is_newsfeed {
if let Ok(contact_list) =
client.database().contacts_public_keys(public_key).await
{
let subscription_id =
SubscriptionId::new(webview.label());
let filter = Filter::new()
.authors(contact_list)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(
subscription_id,
vec![filter],
None,
)
.await
{
println!("Subscription error: {}", e);
}
}
}
});
} else if let Ok(event_id) = EventId::parse(&id) {
tauri::async_runtime::spawn(async move {
let state = webview.state::<Nostr>();
let client = &state.client;
let subscription_id = SubscriptionId::new(webview.label());
let filter = Filter::new()
.event(event_id)
.kinds(vec![Kind::TextNote, Kind::Custom(1111)])
.since(Timestamp::now());
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e);
}
});
}
}
}
PageLoadEvent::Finished => {
println!("{} finished loading", payload.url());
}
});
match main_window.add_child(
builder,
LogicalPosition::new(column.x, column.y),
LogicalSize::new(column.width, column.height),
) {
Ok(webview) => Ok(webview.label().into()),
Err(e) => Err(e.to_string()),
}
}
}
}
#[tauri::command]
#[specta::specta]
#[cfg(target_os = "macos")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
pub async fn close_column(label: String, app_handle: tauri::AppHandle) -> Result<bool, String> {
match app_handle.get_webview(&label) {
Some(webview) => Ok(webview.close().is_ok()),
None => Err(format!("Cannot close, column not found: {}", label)),
}
}
#[tauri::command]
#[specta::specta]
pub async fn close_all_columns(app_handle: tauri::AppHandle) -> Result<(), String> {
let mut webviews = app_handle.webviews();
webviews.remove("main");
for webview in webviews.values() {
webview.close().unwrap()
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn reload_column(label: String, app_handle: tauri::AppHandle) -> Result<(), String> {
match app_handle.get_webview(&label) {
Some(webview) => {
webview.eval("location.reload(true)").unwrap();
Ok(())
}
None => Err("Cannot reload, column not found.".into()),
}
}
#[tauri::command]
#[specta::specta]
pub fn open_window(window: NewWindow, app_handle: tauri::AppHandle) -> Result<String, String> {
if let Some(current_window) = app_handle.get_window(&window.label) {
if current_window.is_visible().unwrap_or_default() {
let _ = current_window.set_focus();
@@ -120,7 +187,10 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
let _ = current_window.show();
let _ = current_window.set_focus();
};
Ok(current_window.label().to_string())
} else {
#[cfg(target_os = "macos")]
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
@@ -134,6 +204,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.minimizable(window.minimizable)
.maximizable(window.maximizable)
.transparent(true)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::UnderWindowBackground],
@@ -143,26 +214,8 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.build()
.unwrap();
// Restore native border
new_window.add_border(None);
}
Ok(())
}
#[tauri::command(async)]
#[specta::specta]
#[cfg(target_os = "windows")]
pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app_handle.get_window(&window.label) {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let window = WebviewWindowBuilder::new(
#[cfg(target_os = "windows")]
let new_window = WebviewWindowBuilder::new(
&app_handle,
&window.label,
WebviewUrl::App(PathBuf::from(window.url)),
@@ -174,6 +227,7 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.maximizable(window.maximizable)
.transparent(true)
.decorations(false)
.closable(window.closable)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::Mica],
@@ -183,56 +237,14 @@ pub fn open_window(window: Window, app_handle: tauri::AppHandle) -> Result<(), S
.build()
.unwrap();
// Set decoration
window.create_overlay_titlebar().unwrap();
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn reopen_lume(app: tauri::AppHandle) {
if let Some(window) = app.get_window("main") {
if window.is_visible().unwrap_or_default() {
let _ = window.set_focus();
} else {
let _ = window.show();
let _ = window.set_focus();
};
} else {
let window =
WebviewWindowBuilder::from_config(&app, app.config().app.windows.first().unwrap())
.unwrap()
.build()
.unwrap();
// Set decoration
#[cfg(target_os = "windows")]
window.create_overlay_titlebar().unwrap();
new_window.create_overlay_titlebar().unwrap();
// Restore native border
#[cfg(target_os = "macos")]
window.add_border(None);
new_window.add_border(None);
// Set a custom inset to the traffic lights
#[cfg(target_os = "macos")]
window.set_traffic_lights_inset(7.0, 10.0).unwrap();
#[cfg(target_os = "macos")]
let win = window.clone();
#[cfg(target_os = "macos")]
window.on_window_event(move |event| {
if let tauri::WindowEvent::ThemeChanged(_) = event {
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
});
Ok(new_window.label().to_string())
}
}
#[tauri::command]
#[specta::specta]
pub fn quit() {
std::process::exit(0)
}

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()
}
@@ -67,7 +67,7 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.map(|&s| s.to_string().replace("#", "").to_lowercase())
.collect::<Vec<_>>();
for mention in mentions {
@@ -130,30 +130,55 @@ pub fn create_tags(content: &str) -> Vec<Tag> {
tags
}
pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent> {
// Remove event thread if event is TextNote
let events: Vec<Event> = events
.into_iter()
.filter_map(|ev| {
if ev.kind == Kind::TextNote {
let tags = ev
.tags
.iter()
.filter(|t| t.is_reply() || t.is_root())
.filter_map(|t| t.content())
.collect::<Vec<_>>();
if tags.is_empty() {
Some(ev)
} else {
None
}
} else {
Some(ev)
}
})
pub fn get_all_accounts() -> Vec<String> {
let search = Search::new().expect("Unexpected.");
let results = search.by_service("Lume Safe Storage");
let list = List::list_credentials(&results, Limit::All);
let accounts: HashSet<String> = list
.split_whitespace()
.filter(|v| v.starts_with("npub1") && !v.ends_with("Lume"))
.map(String::from)
.collect();
accounts.into_iter().collect()
}
pub fn get_last_segment(url: &Url) -> Result<String, String> {
url.path_segments()
.ok_or("No segments".to_string())?
.last()
.ok_or("No items".into())
.map(String::from)
}
pub async fn process_event(client: &Client, events: Events, is_reply: bool) -> Vec<RichEvent> {
// Remove event thread if event is TextNote
let events: Vec<Event> = if !is_reply {
events
.into_iter()
.filter_map(|ev| {
if ev.kind == Kind::TextNote {
let tags = ev
.tags
.iter()
.filter(|t| t.is_reply() || t.is_root())
.filter_map(|t| t.content())
.collect::<Vec<_>>();
if tags.is_empty() {
Some(ev)
} else {
None
}
} else {
Some(ev)
}
})
.collect()
} else {
events.into_iter().collect()
};
// Get deletion request by event's authors
let ids: Vec<EventId> = events.iter().map(|ev| ev.id).collect();
let filter = Filter::new().events(ids).kind(Kind::EventDeletion);
@@ -164,7 +189,14 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
if !requests.is_empty() {
let ids: Vec<&str> = requests
.iter()
.flat_map(|ev| ev.get_tags_content(TagKind::e()))
.flat_map(|event| {
event
.tags
.iter()
.filter(|t| t.kind() == TagKind::e())
.filter_map(|t| t.content())
.collect::<Vec<&str>>()
})
.collect();
// Remove event if event is deleted by author
@@ -196,41 +228,6 @@ pub async fn process_event(client: &Client, events: Vec<Event>) -> Vec<RichEvent
join_all(futures).await
}
pub async fn init_nip65(client: &Client, public_key: &str) {
let author = PublicKey::from_str(public_key).unwrap();
let filter = Filter::new().author(author).kind(Kind::RelayList).limit(1);
// client.add_relay("ws://127.0.0.1:1984").await.unwrap();
// client.connect_relay("ws://127.0.0.1:1984").await.unwrap();
if let Ok(events) = client
.get_events_of(
vec![filter],
EventSource::relays(Some(Duration::from_secs(5))),
)
.await
{
if let Some(event) = events.first() {
let relay_list = nip65::extract_relay_list(event);
for (url, metadata) in relay_list {
let opts = match metadata {
Some(RelayMetadata::Read) => RelayOptions::new().read(true).write(false),
Some(_) => RelayOptions::new().write(true).read(false),
None => RelayOptions::default(),
};
if let Err(e) = client.pool().add_relay(&url.to_string(), opts).await {
eprintln!("Failed to add relay {}: {:?}", url, e);
}
if let Err(e) = client.connect_relay(url.to_string()).await {
eprintln!("Failed to connect to relay {}: {:?}", url, e);
} else {
println!("Connecting to relay: {} - {:?}", url, metadata);
}
}
}
}
}
pub async fn parse_event(content: &str) -> Meta {
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);

View File

@@ -6,7 +6,7 @@
#[cfg(target_os = "macos")]
use border::WebviewWindowExt as BorderWebviewWindowExt;
use commands::{account::*, event::*, metadata::*, relay::*, window::*};
use common::parse_event;
use common::{get_all_accounts, parse_event};
use nostr_sdk::prelude::{Profile as DatabaseProfile, *};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -18,168 +18,140 @@ use std::{
str::FromStr,
time::Duration,
};
use tauri::{path::BaseDirectory, Emitter, EventTarget, Manager};
use tauri::{path::BaseDirectory, Emitter, EventTarget, Listener, Manager};
use tauri_plugin_decorum::WebviewWindowExt;
use tauri_plugin_notification::{NotificationExt, PermissionState};
use tauri_specta::{collect_commands, collect_events, Builder, Event as TauriEvent};
use tokio::sync::Mutex;
use tauri_specta::{collect_commands, Builder};
use tokio::{sync::RwLock, time::sleep};
pub mod commands;
pub mod common;
pub struct Nostr {
client: Client,
settings: Mutex<Settings>,
contact_list: Mutex<Vec<Contact>>,
trusted_list: Mutex<HashSet<PublicKey>>,
queue: RwLock<HashSet<PublicKey>>,
is_syncing: RwLock<bool>,
settings: RwLock<Settings>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Payload {
id: String,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Settings {
proxy: Option<String>,
image_resize_service: Option<String>,
use_relay_hint: bool,
resize_service: bool,
content_warning: bool,
trusted_only: bool,
display_avatar: bool,
display_zap_button: bool,
display_repost_button: bool,
display_media: bool,
transparent: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
proxy: None,
image_resize_service: Some("https://wsrv.nl".to_string()),
use_relay_hint: true,
content_warning: true,
trusted_only: false,
resize_service: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
display_media: true,
transparent: true,
}
}
}
#[derive(Serialize, Deserialize, Type)]
enum SubKind {
Subscribe,
Unsubscribe,
}
#[derive(Serialize, Deserialize, Type, TauriEvent)]
struct Subscription {
label: String,
kind: SubKind,
event_id: Option<String>,
}
#[derive(Serialize, Deserialize, Type, Clone, TauriEvent)]
struct NewSettings(Settings);
pub const DEFAULT_DIFFICULTY: u8 = 21;
pub const FETCH_LIMIT: usize = 100;
pub const NOTIFICATION_NEG_LIMIT: usize = 64;
pub const DEFAULT_DIFFICULTY: u8 = 0;
pub const FETCH_LIMIT: usize = 50;
pub const QUEUE_DELAY: u64 = 300;
pub const NOTIFICATION_SUB_ID: &str = "lume_notification";
fn main() {
#[cfg(debug_assertions)]
tracing_subscriber::fmt::init();
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![
get_relays,
connect_relay,
remove_relay,
get_bootstrap_relays,
save_bootstrap_relays,
get_accounts,
create_account,
import_account,
connect_account,
get_private_key,
delete_account,
reset_password,
is_account_sync,
create_sync_file,
login,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
check_contact,
toggle_contact,
get_mention_list,
set_group,
get_group,
get_all_groups,
set_interest,
get_interest,
get_all_interests,
set_wallet,
load_wallet,
remove_wallet,
zap_profile,
zap_event,
copy_friend,
get_notifications,
get_user_settings,
set_user_settings,
verify_nip05,
is_trusted_user,
get_event_meta,
get_event,
get_event_from,
get_replies,
subscribe_to,
get_all_events_by_author,
get_all_events_by_authors,
get_all_events_by_hashtags,
get_local_events,
get_global_events,
is_deleted_event,
request_delete,
search,
publish,
reply,
repost,
event_to_bech32,
user_to_bech32,
create_column,
close_column,
reposition_column,
resize_column,
reload_column,
open_window,
reopen_lume,
quit
])
.events(collect_events![Subscription, NewSettings]);
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
get_relays,
get_all_relays,
is_relay_connected,
connect_relay,
remove_relay,
get_bootstrap_relays,
set_bootstrap_relays,
get_accounts,
watch_account,
import_account,
connect_account,
get_private_key,
delete_account,
reset_password,
has_signer,
set_signer,
get_profile,
set_profile,
get_contact_list,
set_contact_list,
is_contact,
toggle_contact,
get_all_profiles,
set_group,
get_group,
get_all_newsfeeds,
get_all_local_newsfeeds,
set_interest,
get_interest,
get_all_interests,
get_all_local_interests,
set_wallet,
load_wallet,
remove_wallet,
zap_profile,
zap_event,
copy_friend,
get_notifications,
verify_nip05,
get_meta_from_event,
get_event,
get_replies,
get_all_events_by_author,
get_all_events_by_authors,
get_all_events_by_hashtags,
get_all_events_from,
get_local_events,
get_global_events,
search,
publish,
reply,
repost,
is_reposted,
request_delete,
is_deleted_event,
event_to_bech32,
user_to_bech32,
create_column,
reload_column,
close_column,
close_all_columns,
open_window,
get_app_settings,
set_app_settings,
]);
#[cfg(debug_assertions)]
builder
.export(Typescript::default(), "../src/commands.gen.ts")
.expect("Failed to export typescript bindings");
#[cfg(target_os = "macos")]
let tauri_builder = tauri::Builder::default().plugin(tauri_nspanel::init());
#[cfg(not(target_os = "macos"))]
let tauri_builder = tauri::Builder::default();
let mut ctx = tauri::generate_context!();
tauri_builder
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
builder.mount_events(app);
let handle = app.handle();
let handle_clone = handle.clone();
let handle_clone_child = handle_clone.clone();
let handle_clone_event = handle_clone_child.clone();
let main_window = app.get_webview_window("main").unwrap();
let config_dir = handle
@@ -187,13 +159,7 @@ fn main() {
.app_config_dir()
.expect("Error: app config directory not found.");
let data_dir = handle
.path()
.app_data_dir()
.expect("Error: app data directory not found.");
let _ = fs::create_dir_all(&config_dir);
let _ = fs::create_dir_all(&data_dir);
// Set custom decoration for Windows
#[cfg(target_os = "windows")]
@@ -207,29 +173,17 @@ fn main() {
#[cfg(target_os = "macos")]
main_window.set_traffic_lights_inset(7.0, 10.0).unwrap();
#[cfg(target_os = "macos")]
let win = main_window.clone();
#[cfg(target_os = "macos")]
main_window.on_window_event(move |event| {
if let tauri::WindowEvent::ThemeChanged(_) = event {
win.set_traffic_lights_inset(7.0, 10.0).unwrap();
}
});
let client = tauri::async_runtime::block_on(async move {
// Setup database
let database = NostrLMDB::open(config_dir.join("nostr-lmdb"))
let database = NostrLMDB::open(config_dir.join("nostr"))
.expect("Error: cannot create database.");
// Config
let opts = Options::new()
.gossip(true)
.max_avg_latency(Duration::from_millis(500))
.automatic_authentication(false)
.connection_timeout(Some(Duration::from_secs(20)))
.send_timeout(Some(Duration::from_secs(10)))
.timeout(Duration::from_secs(20));
.max_avg_latency(Duration::from_millis(300))
.automatic_authentication(true)
.timeout(Duration::from_secs(5));
// Setup nostr client
let client = ClientBuilder::default()
@@ -265,16 +219,10 @@ fn main() {
}
}
if let Err(e) = client.add_discovery_relay("wss://purplepag.es/").await {
println!("Add discovery relay failed: {}", e)
}
if let Err(e) = client.add_discovery_relay("wss://directory.yabu.me/").await {
println!("Add discovery relay failed: {}", e)
}
let _ = client.add_discovery_relay("wss://user.kindpag.es/").await;
// Connect
client.connect_with_timeout(Duration::from_secs(20)).await;
client.connect().await;
client
});
@@ -282,88 +230,188 @@ fn main() {
// Create global state
app.manage(Nostr {
client,
settings: Mutex::new(Settings::default()),
contact_list: Mutex::new(Vec::new()),
trusted_list: Mutex::new(HashSet::new()),
queue: RwLock::new(HashSet::new()),
is_syncing: RwLock::new(false),
settings: RwLock::new(Settings::default()),
});
Subscription::listen_any(app, move |event| {
let handle = handle_clone_child.to_owned();
let payload = event.payload;
// Trigger some actions for window events
main_window.on_window_event(move |event| match event {
tauri::WindowEvent::Focused(focused) => {
if !focused {
let handle = handle_clone_event.clone();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if *state.is_syncing.read().await {
return;
}
let mut is_syncing = state.is_syncing.write().await;
// Mark sync in progress
*is_syncing = true;
let opts = SyncOptions::default();
let accounts = get_all_accounts();
if !accounts.is_empty() {
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| PublicKey::from_str(acc).ok())
.collect();
let filter = Filter::new().pubkeys(public_keys).kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
]);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
}
let filter = Filter::new().kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::ContactList,
Kind::FollowSet,
]);
// Get all public keys in database
if let Ok(events) = client.database().query(vec![filter]).await {
let public_keys: HashSet<PublicKey> = events
.iter()
.flat_map(|ev| ev.tags.public_keys().copied())
.collect();
let pk_vec: Vec<PublicKey> = public_keys.into_iter().collect();
for chunk in pk_vec.chunks(500) {
if chunk.is_empty() {
return;
}
let authors = chunk.to_owned();
let filter = Filter::new()
.authors(authors.clone())
.kinds(vec![
Kind::Metadata,
Kind::FollowSet,
Kind::Interests,
Kind::InterestSet,
])
.limit(1000);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
let filter = Filter::new()
.authors(authors)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::EventDeletion,
])
.limit(500);
if let Ok(output) = client.sync(filter, &opts).await {
println!("Received: {}", output.received.len())
}
}
}
// Mark sync is done
*is_syncing = false;
});
}
}
tauri::WindowEvent::Moved(_size) => {}
_ => {}
});
// Listen for request metadata
app.listen_any("request_metadata", move |event| {
let payload = event.payload();
let parsed_payload: Payload = serde_json::from_str(payload).expect("Parse failed");
let handle = handle_clone_child.clone();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
match payload.kind {
SubKind::Subscribe => {
let subscription_id = SubscriptionId::new(payload.label);
if let Ok(public_key) = PublicKey::parse(parsed_payload.id) {
let mut write_queue = state.queue.write().await;
write_queue.insert(public_key);
};
match payload.event_id {
Some(id) => {
let event_id = EventId::from_str(&id).unwrap();
let filter =
Filter::new().event(event_id).since(Timestamp::now());
// Wait for [QUEUE_DELAY]
sleep(Duration::from_millis(QUEUE_DELAY)).await;
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
None => {
let contact_list = client
.get_contact_list(Some(Duration::from_secs(5)))
.await
.unwrap();
let read_queue = state.queue.read().await;
if !contact_list.is_empty() {
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if !read_queue.is_empty() {
let authors: HashSet<PublicKey> = read_queue.iter().copied().collect();
let filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Repost])
.authors(authors)
.since(Timestamp::now());
let filter = Filter::new()
.authors(authors)
.kind(Kind::Metadata)
.limit(200);
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscription error: {}", e)
}
}
}
};
}
SubKind::Unsubscribe => {
let subscription_id = SubscriptionId::new(payload.label);
client.unsubscribe(subscription_id).await
let opts = SubscribeAutoCloseOptions::default()
.filter(FilterOptions::WaitDurationAfterEOSE(Duration::from_secs(2)));
// Drop queue, you don't need it at this time anymore
drop(read_queue);
// Clear queue
let mut write_queue = state.queue.write().await;
write_queue.clear();
if let Err(e) = client.subscribe(vec![filter], Some(opts)).await {
println!("Subscribe error: {}", e);
}
}
});
});
// Run local relay thread
//tauri::async_runtime::spawn(async move {
// let database = NostrLMDB::open(data_dir.join("local-relay"))
// .expect("Error: cannot create database.");
// let builder = RelayBuilder::default().database(database).port(1984);
//
// if let Ok(relay) = LocalRelay::run(builder).await {
// println!("Running local relay: {}", relay.url())
// }
//
// loop {
// tokio::time::sleep(Duration::from_secs(60)).await;
// }
//});
// Run notification thread
tauri::async_runtime::spawn(async move {
let state = handle_clone.state::<Nostr>();
let client = &state.client;
let accounts = get_all_accounts();
if !accounts.is_empty() {
let subscription_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let public_keys: Vec<PublicKey> = accounts
.iter()
.filter_map(|acc| PublicKey::from_str(acc).ok())
.collect();
let filter = Filter::new()
.pubkeys(public_keys)
.kinds(vec![
Kind::TextNote,
Kind::Repost,
Kind::Reaction,
Kind::ZapReceipt,
Kind::Custom(1111),
])
.since(Timestamp::now());
// Subscribe for new notification
if let Err(e) = client
.subscribe_with_id(subscription_id, vec![filter], None)
.await
{
println!("Subscribe error: {}", e)
}
}
let allow_notification = match handle_clone.notification().request_permission() {
Ok(_) => {
@@ -377,57 +425,17 @@ fn main() {
};
let notification_id = SubscriptionId::new(NOTIFICATION_SUB_ID);
let mut notifications = client.pool().notifications();
while let Ok(notification) = notifications.recv().await {
match notification {
RelayPoolNotification::Message { relay_url, message } => {
if let RelayMessage::Auth { challenge } = message {
match client.auth(challenge, relay_url.clone()).await {
Ok(..) => {
if let Ok(relay) = client.relay(&relay_url).await {
let msg =
format!("Authenticated to {} relay.", relay_url);
let opts = RelaySendOptions::new()
.skip_send_confirmation(true);
if let Err(e) = relay.resubscribe(opts).await {
println!("Error: {}", e);
}
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(&msg)
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
Err(e) => {
if allow_notification {
if let Err(e) = &handle_clone
.notification()
.builder()
.body(e.to_string())
.title("Lume")
.show()
{
println!("Error: {}", e);
}
}
}
}
} else if let RelayMessage::Event {
subscription_id,
let _ = client
.handle_notifications(|notification| async {
#[allow(clippy::collapsible_match)]
if let RelayPoolNotification::Message { message, .. } = notification {
if let RelayMessage::Event {
event,
subscription_id,
..
} = message
{
// Handle events from notification subscription
if subscription_id == notification_id {
// Send native notification
if allow_notification {
@@ -438,79 +446,46 @@ fn main() {
.unwrap_or_else(|_| {
DatabaseProfile::new(event.pubkey, Metadata::new())
});
let metadata = author.metadata();
send_event_notification(&event, metadata, &handle_clone);
send_event_notification(
&event,
author.metadata(),
&handle_clone,
);
}
}
} else if event.kind == Kind::Metadata {
if let Err(e) = handle_clone.emit("metadata", event.as_json()) {
println!("Emit error: {}", e)
}
} else if event.kind == Kind::TextNote {
let payload = RichEvent {
raw: event.as_json(),
parsed: if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
},
};
let label = subscription_id.to_string();
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
handle_clone
.emit_to(
EventTarget::labeled(label),
if let Err(e) = handle_clone.emit_to(
EventTarget::labeled(subscription_id.to_string()),
"event",
RichEvent { raw, parsed },
)
.unwrap();
};
}
RelayPoolNotification::Shutdown => break,
_ => (),
}
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::Focused(focused) = event {
if !focused {
let handle = window.app_handle().to_owned();
let config_dir = handle.path().app_config_dir().unwrap();
tauri::async_runtime::spawn(async move {
let state = handle.state::<Nostr>();
let client = &state.client;
if let Ok(signer) = client.signer().await {
let public_key = signer.public_key().await.unwrap();
let bech32 = public_key.to_bech32().unwrap();
if fs::metadata(config_dir.join(bech32)).is_ok() {
if let Ok(contact_list) =
client.get_contact_list(Some(Duration::from_secs(5))).await
{
let authors: Vec<PublicKey> =
contact_list.iter().map(|f| f.public_key).collect();
if client
.reconcile(
Filter::new()
.authors(authors)
.kinds(vec![Kind::TextNote, Kind::Repost])
.limit(1000),
NegentropyOptions::default(),
)
.await
.is_ok()
{
handle.emit("synchronized", ()).unwrap();
payload,
) {
println!("Emit error: {}", e)
}
}
}
}
});
}
}
Ok(false)
})
.await;
});
Ok(())
})
.plugin(prevent_default())
.plugin(tauri_plugin_theme::init(ctx.config_mut()))
.plugin(tauri_plugin_decorum::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
@@ -524,7 +499,7 @@ fn main() {
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::default().build())
.run(tauri::generate_context!())
.run(ctx)
.expect("error while running tauri application");
}

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.0",
"identifier": "nu.lume.Lume",
"build": {
"beforeDevCommand": "pnpm dev",

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": [
{

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": [
{

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>,
);

View File

@@ -5,15 +5,31 @@
export const commands = {
async getRelays() : Promise<Result<Relays, string>> {
async getRelays(id: string) : Promise<Result<Relays, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_relays") };
return { status: "ok", data: await TAURI_INVOKE("get_relays", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<boolean, string>> {
async getAllRelays(until: string | null) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_relays", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isRelayConnected(relay: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_relay_connected", { relay }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRelay(relay: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("connect_relay", { relay }) };
} catch (e) {
@@ -21,7 +37,7 @@ async connectRelay(relay: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async removeRelay(relay: string) : Promise<Result<boolean, string>> {
async removeRelay(relay: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("remove_relay", { relay }) };
} catch (e) {
@@ -37,9 +53,9 @@ async getBootstrapRelays() : Promise<Result<string[], string>> {
else return { status: "error", error: e as any };
}
},
async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
async setBootstrapRelays(relays: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("save_bootstrap_relays", { relays }) };
return { status: "ok", data: await TAURI_INVOKE("set_bootstrap_relays", { relays }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -48,15 +64,15 @@ async saveBootstrapRelays(relays: string) : Promise<Result<null, string>> {
async getAccounts() : Promise<string[]> {
return await TAURI_INVOKE("get_accounts");
},
async createAccount(name: string, about: string, picture: string, password: string) : Promise<Result<string, string>> {
async watchAccount(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("create_account", { name, about, picture, password }) };
return { status: "ok", data: await TAURI_INVOKE("watch_account", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async importAccount(key: string, password: string) : Promise<Result<string, string>> {
async importAccount(key: string, password: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("import_account", { key, password }) };
} catch (e) {
@@ -96,21 +112,23 @@ async resetPassword(key: string, password: string) : Promise<Result<null, string
else return { status: "error", error: e as any };
}
},
async isAccountSync(id: string) : Promise<boolean> {
return await TAURI_INVOKE("is_account_sync", { id });
},
async createSyncFile(id: string) : Promise<boolean> {
return await TAURI_INVOKE("create_sync_file", { id });
},
async login(account: string, password: string) : Promise<Result<string, string>> {
async hasSigner(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("login", { account, password }) };
return { status: "ok", data: await TAURI_INVOKE("has_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getProfile(id: string | null) : Promise<Result<string, string>> {
async setSigner(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_signer", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getProfile(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) };
} catch (e) {
@@ -118,17 +136,17 @@ async getProfile(id: string | null) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async setProfile(profile: Profile) : Promise<Result<string, string>> {
async setProfile(newProfile: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_profile", { profile }) };
return { status: "ok", data: await TAURI_INVOKE("set_profile", { newProfile }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getContactList() : Promise<Result<string[], string>> {
async getContactList(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
return { status: "ok", data: await TAURI_INVOKE("get_contact_list", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -142,9 +160,9 @@ async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async checkContact(id: string) : Promise<Result<boolean, string>> {
async isContact(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("check_contact", { id }) };
return { status: "ok", data: await TAURI_INVOKE("is_contact", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -158,9 +176,9 @@ async toggleContact(id: string, alias: string | null) : Promise<Result<string, s
else return { status: "error", error: e as any };
}
},
async getMentionList() : Promise<Result<Mention[], string>> {
async getAllProfiles() : Promise<Result<Mention[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_mention_list") };
return { status: "ok", data: await TAURI_INVOKE("get_all_profiles") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -182,9 +200,17 @@ async getGroup(id: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async getAllGroups() : Promise<Result<RichEvent[], string>> {
async getAllNewsfeeds(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_groups") };
return { status: "ok", data: await TAURI_INVOKE("get_all_newsfeeds", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllLocalNewsfeeds(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_local_newsfeeds", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -206,9 +232,17 @@ async getInterest(id: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async getAllInterests() : Promise<Result<RichEvent[], string>> {
async getAllInterests(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_interests") };
return { status: "ok", data: await TAURI_INVOKE("get_all_interests", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllLocalInterests(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_local_interests", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -222,7 +256,7 @@ async setWallet(uri: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async loadWallet() : Promise<Result<string, string>> {
async loadWallet() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("load_wallet") };
} catch (e) {
@@ -238,7 +272,7 @@ async removeWallet() : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async zapProfile(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
async zapProfile(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("zap_profile", { id, amount, message }) };
} catch (e) {
@@ -246,7 +280,7 @@ async zapProfile(id: string, amount: string, message: string) : Promise<Result<b
else return { status: "error", error: e as any };
}
},
async zapEvent(id: string, amount: string, message: string) : Promise<Result<boolean, string>> {
async zapEvent(id: string, amount: string, message: string | null) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("zap_event", { id, amount, message }) };
} catch (e) {
@@ -262,25 +296,9 @@ async copyFriend(npub: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async getNotifications() : Promise<Result<string[], string>> {
async getNotifications(id: string) : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_notifications") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getUserSettings() : Promise<Result<Settings, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_user_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setUserSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_user_settings", { settings }) };
return { status: "ok", data: await TAURI_INVOKE("get_notifications", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -294,17 +312,9 @@ async verifyNip05(id: string, nip05: string) : Promise<Result<boolean, string>>
else return { status: "error", error: e as any };
}
},
async isTrustedUser(id: string) : Promise<Result<boolean, string>> {
async getMetaFromEvent(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_trusted_user", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
return { status: "ok", data: await TAURI_INVOKE("get_meta_from_event", { content }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -318,14 +328,6 @@ async getEvent(id: string) : Promise<Result<RichEvent, string>> {
else return { status: "error", error: e as any };
}
},
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
@@ -334,14 +336,6 @@ async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
else return { status: "error", error: e as any };
}
},
async subscribeTo(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("subscribe_to", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getAllEventsByAuthor(publicKey: string, limit: number) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_by_author", { publicKey, limit }) };
@@ -366,6 +360,14 @@ async getAllEventsByHashtags(hashtags: string[], until: string | null) : Promise
else return { status: "error", error: e as any };
}
},
async getAllEventsFrom(url: string, until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_all_events_from", { url, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
@@ -382,22 +384,6 @@ async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async search(query: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("search", { query }) };
@@ -414,9 +400,9 @@ async publish(content: string, warning: string | null, difficulty: number | null
else return { status: "error", error: e as any };
}
},
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> {
async reply(content: string, to: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -430,6 +416,30 @@ async repost(raw: string) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async isReposted(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_reposted", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async requestDelete(id: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("request_delete", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async isDeletedEvent(id: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("is_deleted_event", { id }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async eventToBech32(id: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("event_to_bech32", { id }) };
@@ -454,6 +464,14 @@ async createColumn(column: Column) : Promise<Result<string, string>> {
else return { status: "error", error: e as any };
}
},
async reloadColumn(label: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async closeColumn(label: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("close_column", { label }) };
@@ -462,31 +480,15 @@ async closeColumn(label: string) : Promise<Result<boolean, string>> {
else return { status: "error", error: e as any };
}
},
async repositionColumn(label: string, x: number, y: number) : Promise<Result<boolean, string>> {
async closeAllColumns() : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reposition_column", { label, x, y }) };
return { status: "ok", data: await TAURI_INVOKE("close_all_columns") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async resizeColumn(label: string, width: number, height: number) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("resize_column", { label, width, height }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async reloadColumn(label: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reload_column", { label }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async openWindow(window: Window) : Promise<Result<null, string>> {
async openWindow(window: NewWindow) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("open_window", { window }) };
} catch (e) {
@@ -494,24 +496,27 @@ async openWindow(window: Window) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async reopenLume() : Promise<void> {
await TAURI_INVOKE("reopen_lume");
async getAppSettings() : Promise<Result<Settings, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_app_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async quit() : Promise<void> {
await TAURI_INVOKE("quit");
async setAppSettings(settings: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_app_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
export const events = __makeEvents__<{
newSettings: NewSettings,
subscription: Subscription
}>({
newSettings: "new-settings",
subscription: "subscription"
})
/** user-defined constants **/
@@ -522,14 +527,10 @@ subscription: "subscription"
export type Column = { label: string; url: string; x: number; y: number; width: number; height: number }
export type Mention = { pubkey: string; avatar: string; display_name: string; name: string }
export type Meta = { content: string; images: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type NewSettings = Settings
export type Profile = { name: string; display_name: string; about: string | null; picture: string; banner: string | null; nip05: string | null; lud16: string | null; website: string | null }
export type NewWindow = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean; closable: boolean }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; trusted_only: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean; transparent: boolean }
export type SubKind = "Subscribe" | "Unsubscribe"
export type Subscription = { label: string; kind: SubKind; event_id: string | null }
export type Window = { label: string; title: string; url: string; width: number; height: number; maximizable: boolean; minimizable: boolean; hidden_title: boolean }
export type Settings = { resize_service: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
/** tauri-specta globals **/

View File

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

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}`,
});
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

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

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"
: "",
)}
>

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

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

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";

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" />

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(null, event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
<QuoteIcon className={cn("shrink-0", smol ? "size-4" : "size-5")} />
{label ? "Quote" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Quote
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

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")} />

View File

@@ -1,88 +1,164 @@
import { appSettings, cn } from "@/commons";
import { commands } from "@/commands.gen";
import { cn, displayNpub } from "@/commons";
import { RepostIcon, Spinner } from "@/components";
import { LumeWindow } from "@/system";
import { useStore } from "@tanstack/react-store";
import { settingsQueryOptions } from "@/routes/__root";
import type { Metadata } from "@/types";
import * as Tooltip from "@radix-ui/react-tooltip";
import {
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { Menu, MenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import { useCallback, useState } from "react";
import { useCallback, useTransition } from "react";
import { useNoteContext } from "../provider";
export function NoteRepost({
label = false,
smol = false,
}: { label?: boolean; smol?: boolean }) {
const visible = useStore(appSettings, (state) => state.display_repost_button);
const event = useNoteContext();
const settings = useSuspenseQuery(settingsQueryOptions);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const { isLoading, data: status } = useQuery({
queryKey: ["is-reposted", event.id],
queryFn: async () => {
const res = await commands.isReposted(event.id);
if (res.status === "ok") {
return res.data;
} else {
return false;
}
},
enabled: settings.data.display_repost_button,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: false,
});
const repost = async () => {
if (isRepost) return;
try {
setLoading(true);
// repost
await event.repost();
// update state
setLoading(false);
setIsRepost(true);
} catch {
setLoading(false);
await message("Repost failed, try again later", {
title: "Lume",
kind: "info",
});
}
};
const [isPending, startTransition] = useTransition();
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Repost",
action: async () => repost(),
}),
MenuItem.new({
text: "Quote",
action: () => LumeWindow.openEditor(null, event.id),
}),
]);
const accounts = await commands.getAccounts();
const list: Promise<MenuItem>[] = [];
const menu = await Menu.new({
items: menuItems,
});
for (const account of accounts) {
const res = await commands.getProfile(account);
let name = "unknown";
if (res.status === "ok") {
const profile: Metadata = JSON.parse(res.data);
name = profile.display_name ?? profile.name ?? "anon";
}
list.push(
MenuItem.new({
text: `Repost as ${name} (${displayNpub(account, 16)})`,
action: async () => submit(account),
}),
);
}
const items = await Promise.all(list);
const menu = await Menu.new({ items });
await menu.popup().catch((e) => console.error(e));
}, []);
if (!visible) return null;
const repost = useMutation({
mutationFn: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ["is-reposted", event.id] });
// Optimistically update to the new value
queryClient.setQueryData(["is-reposted", event.id], true);
const res = await commands.repost(JSON.stringify(event.raw));
if (res.status === "ok") {
return;
} else {
throw new Error(res.error);
}
},
onError: () => {
queryClient.setQueryData(["is-reposted", event.id], false);
},
onSettled: async () => {
return await queryClient.invalidateQueries({
queryKey: ["is-reposted", event.id],
});
},
});
const submit = (account: string) => {
startTransition(async () => {
if (!status) {
const signer = await commands.hasSigner(account);
if (signer.status === "ok") {
if (!signer.data) {
if (!signer.data) {
const res = await commands.setSigner(account);
if (res.status === "error") {
await message(res.error, { kind: "error" });
return;
}
}
}
repost.mutate();
} else {
return;
}
} else {
return;
}
});
};
if (!settings.data.display_repost_button) return null;
return (
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-24 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
isRepost ? "text-blue-500" : "",
)}
/>
)}
{label ? "Repost" : null}
</button>
<Tooltip.Provider>
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className={cn(
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label ? "w-24 gap-1.5" : "w-14",
)}
>
{isPending || isLoading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
smol ? "size-4" : "size-5",
status ? "text-blue-500" : "",
)}
/>
)}
{label ? "Repost" : null}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Repost
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,8 +1,9 @@
import { appSettings, cn } from "@/commons";
import { cn } from "@/commons";
import { ZapIcon } from "@/components";
import { settingsQueryOptions } from "@/routes/__root";
import { LumeWindow } from "@/system";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { useStore } from "@tanstack/react-store";
import { useNoteContext } from "../provider";
export function NoteZap({
@@ -10,20 +11,18 @@ export function NoteZap({
smol = false,
}: { label?: boolean; smol?: boolean }) {
const search = useSearch({ strict: false });
const visible = useStore(appSettings, (state) => state.display_zap_button);
const settings = useSuspenseQuery(settingsQueryOptions);
const event = useNoteContext();
if (!visible) return null;
if (!settings.data.display_zap_button) return null;
return (
<button
type="button"
onClick={() => LumeWindow.openZap(event.id, search.account)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
label
? "rounded-full h-7 gap-1.5 w-20 text-sm font-medium hover:bg-black/10 dark:hover:bg-white/10"
: "size-7",
"h-7 rounded-full inline-flex items-center justify-center text-neutral-800 hover:bg-black/5 dark:hover:bg-white/5 dark:text-neutral-200 text-sm font-medium",
label ? "w-24 gap-1.5" : "w-14",
)}
>
<ZapIcon className={smol ? "size-4" : "size-5"} />

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

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,

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

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

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

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

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

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}

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

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 />

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 />

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 />

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>

View File

@@ -7,7 +7,7 @@ import { message } from "@tauri-apps/plugin-dialog";
import { useTransition } from "react";
import { useUserContext } from "./provider";
export function UserFollowButton({ className }: { className?: string }) {
export function UserButton({ className }: { className?: string }) {
const user = useUserContext();
const { queryClient } = useRouteContext({ strict: false });
@@ -18,7 +18,7 @@ export function UserFollowButton({ className }: { className?: string }) {
} = useQuery({
queryKey: ["status", user.pubkey],
queryFn: async () => {
const res = await commands.checkContact(user.pubkey);
const res = await commands.isContact(user.pubkey);
if (res.status === "ok") {
return res.data;

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,
};

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

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

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,3 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app")();

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"))
) : (
<></>
);
}

View File

@@ -1,3 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_app/home")();

View File

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

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

View File

@@ -1,245 +0,0 @@
import { type Profile, commands } from "@/commands.gen";
import { cn, upload } from "@/commons";
import { Spinner } from "@/components";
import { Plus } from "@phosphor-icons/react";
import { createLazyFileRoute } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import {
type Dispatch,
type ReactNode,
type SetStateAction,
useState,
useTransition,
} from "react";
import { useForm } from "react-hook-form";
export const Route = createLazyFileRoute("/$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>
);
}

View File

@@ -1,15 +0,0 @@
import { commands } from "@/commands.gen";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/relay")({
beforeLoad: async () => {
const res = await commands.getRelays();
if (res.status === "ok") {
const relayList = res.data;
return { relayList };
} else {
throw new Error(res.error);
}
},
});

View File

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

View File

@@ -1,21 +0,0 @@
import { commands } from "@/commands.gen";
import { getBitcoinDisplayValues } from "@/commons";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/_settings/wallet")({
beforeLoad: async ({ params }) => {
const query = await commands.loadWallet();
if (query.status === "ok") {
const wallet = Number.parseInt(query.data);
const balance = getBitcoinDisplayValues(wallet);
return { balance };
} else {
throw redirect({
to: "/$account/bitcoin-connect",
params: { account: params.account },
});
}
},
});

View File

@@ -1,177 +0,0 @@
import { displayNsec } from "@/commons";
import { Spinner } from "@/components";
import { Check } from "@phosphor-icons/react";
import * as Checkbox from "@radix-ui/react-checkbox";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { message } from "@tauri-apps/plugin-dialog";
import { useState } from "react";
export const Route = createFileRoute("/$account/backup")({
component: Screen,
});
function Screen() {
const { account } = Route.useParams();
const navigate = useNavigate();
const [key, setKey] = useState(null);
const [passphase, setPassphase] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [confirm, setConfirm] = useState({ c1: false, c2: false });
const submit = async () => {
try {
if (key) {
if (!confirm.c1 || !confirm.c2) {
return await message("You need to confirm before continue", {
title: "Backup",
kind: "info",
});
}
navigate({ to: "/", replace: true });
}
// start loading
setLoading(true);
invoke("get_encrypted_key", {
npub: account,
password: passphase,
}).then((encrypted: string) => {
// update state
setKey(encrypted);
setLoading(false);
});
} catch (e) {
setLoading(false);
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
const copyKey = async () => {
try {
await writeText(key);
setCopied(true);
} catch (e) {
await message(String(e), {
title: "Backup",
kind: "error",
});
}
};
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
<div className="flex flex-col text-center">
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
<p className="text-neutral-700 dark:text-neutral-300">
It's use for login to Lume or other Nostr clients. You will lost
access to your account if you lose this key.
</p>
</div>
<div className="flex flex-col w-full gap-5">
<div className="flex flex-col gap-2">
<label htmlFor="passphase" className="font-medium">
Set a passphase to secure your key
</label>
<div className="relative">
<input
name="passphase"
type="password"
value={passphase}
onChange={(e) => setPassphase(e.target.value)}
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
</div>
</div>
{key ? (
<>
<div className="flex flex-col gap-2">
<label htmlFor="nsec" className="font-medium">
Copy this key and keep it in safe place
</label>
<div className="flex items-center gap-2">
<input
name="nsec"
type="text"
value={key}
readOnly
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => copyKey()}
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="font-medium">Before you continue:</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c1}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c1: !state.c1 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm1"
>
<Checkbox.Indicator className="text-blue-500">
<Check className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm1"
>
I will make sure keep it safe and not sharing with anyone.
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox.Root
checked={confirm.c2}
onCheckedChange={() =>
setConfirm((state) => ({ ...state, c2: !state.c2 }))
}
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
id="confirm2"
>
<Checkbox.Indicator className="text-blue-500">
<Check className="size-4" />
</Checkbox.Indicator>
</Checkbox.Root>
<label
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
htmlFor="confirm2"
>
I understand I cannot recover private key.
</label>
</div>
</div>
</div>
</>
) : null}
<div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
>
{loading ? <Spinner /> : "Continue"}
</button>
</div>
</div>
</div>
);
}

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