diff --git a/package.json b/package.json index 1f07580..e67b2bf 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.7", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-scroll-area": "^1.1.0", "@tanstack/react-query": "^5.51.11", "@tanstack/react-router": "^1.45.8", "@tauri-apps/api": ">=2.0.0-beta.0", @@ -19,7 +20,9 @@ "@tauri-apps/plugin-dialog": "2.0.0-beta.7", "@tauri-apps/plugin-os": "2.0.0-beta.7", "@tauri-apps/plugin-shell": ">=2.0.0-beta.0", + "dayjs": "^1.11.12", "minidenticons": "^4.2.1", + "nostr-tools": "^2.7.1", "react": "19.0.0-rc-d025ddd3-20240722", "react-dom": "19.0.0-rc-d025ddd3-20240722", "virtua": "^0.33.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 918f319..7d8fd64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.0 version: 1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-scroll-area': + specifier: ^1.1.0 + version: 1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) '@tanstack/react-query': specifier: ^5.51.11 version: 5.51.11(react@19.0.0-rc-d025ddd3-20240722) @@ -35,9 +38,15 @@ importers: '@tauri-apps/plugin-shell': specifier: '>=2.0.0-beta.0' version: 2.0.0-beta.8 + dayjs: + specifier: ^1.11.12 + version: 1.11.12 minidenticons: specifier: ^4.2.1 version: 4.2.1 + nostr-tools: + specifier: ^2.7.1 + version: 2.7.1(typescript@5.5.4) react: specifier: 19.0.0-rc-d025ddd3-20240722 version: 19.0.0-rc-d025ddd3-20240722 @@ -439,6 +448,23 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/curves@1.1.0': + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.1': + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -462,6 +488,12 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + + '@radix-ui/primitive@1.1.0': + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/react-avatar@1.1.0': resolution: {integrity: sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==} peerDependencies: @@ -493,6 +525,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-presence@1.1.0': + resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.0.0': resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -506,6 +560,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.1.0': + resolution: {integrity: sha512-9ArIZ9HWhsrfqS765h+GZuLoxaRHD/j0ZWOWilsCvYTpYJp8XwCqNG7Dt9Nu/TItKOdgLGkOPCodQvDc+UMwYg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -613,6 +680,15 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/bip32@1.3.1': + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@tanstack/history@1.45.3': resolution: {integrity: sha512-n4XXInV9irIq0obRvINIkESkGk280Q+xkIIbswmM0z9nAu2wsIRZNvlmPrtYh6bgNWtItOWWoihFUjLTW8g6Jg==} engines: {node: '>=12'} @@ -909,6 +985,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dayjs@1.11.12: + resolution: {integrity: sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==} + debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -1126,6 +1205,17 @@ packages: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} engines: {node: '>=0.10.0'} + nostr-tools@2.7.1: + resolution: {integrity: sha512-4qAvlHSqBAA8lQMwRWE6dalSNdQT77Xut9lPiJZgEcb9RAlR69wR2+KVBAgnZVaabVYH7FJ7gOQXLw/jQBAYBg==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1791,6 +1881,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@noble/ciphers@0.5.3': {} + + '@noble/curves@1.1.0': + dependencies: + '@noble/hashes': 1.3.1 + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.1': {} + + '@noble/hashes@1.3.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1811,6 +1915,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@radix-ui/number@1.1.0': {} + + '@radix-ui/primitive@1.1.0': {} + '@radix-ui/react-avatar@1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': dependencies: '@radix-ui/react-context': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) @@ -1835,6 +1943,22 @@ snapshots: optionalDependencies: '@types/react': types-react@19.0.0-rc.1 + '@radix-ui/react-direction@1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)': + dependencies: + react: 19.0.0-rc-d025ddd3-20240722 + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + + '@radix-ui/react-presence@1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-d025ddd3-20240722 + react-dom: 19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + '@radix-ui/react-primitive@2.0.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': dependencies: '@radix-ui/react-slot': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) @@ -1844,6 +1968,23 @@ snapshots: '@types/react': types-react@19.0.0-rc.1 '@types/react-dom': types-react-dom@19.0.0-rc.1 + '@radix-ui/react-scroll-area@1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + '@radix-ui/react-context': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + '@radix-ui/react-direction': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + '@radix-ui/react-presence': 1.1.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-primitive': 2.0.0(react-dom@19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722))(react@19.0.0-rc-d025ddd3-20240722)(types-react-dom@19.0.0-rc.1)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + '@radix-ui/react-use-layout-effect': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) + react: 19.0.0-rc-d025ddd3-20240722 + react-dom: 19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722) + optionalDependencies: + '@types/react': types-react@19.0.0-rc.1 + '@types/react-dom': types-react-dom@19.0.0-rc.1 + '@radix-ui/react-slot@1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1) @@ -1911,6 +2052,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.19.0': optional: true + '@scure/base@1.1.1': {} + + '@scure/bip32@1.3.1': + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@tanstack/history@1.45.3': {} '@tanstack/query-core@5.51.9': {} @@ -2216,6 +2370,8 @@ snapshots: csstype@3.1.3: {} + dayjs@1.11.12: {} + debug@4.3.5: dependencies: ms: 2.1.2 @@ -2408,6 +2564,21 @@ snapshots: normalize-range@0.1.2: {} + nostr-tools@2.7.1(typescript@5.5.4): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + optionalDependencies: + nostr-wasm: 0.1.0 + typescript: 5.5.4 + + nostr-wasm@0.1.0: + optional: true + object-assign@4.1.1: {} object-hash@3.0.0: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 20f8f86..5d206ac 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -837,6 +837,7 @@ name = "coop" version = "0.0.0" dependencies = [ "border", + "futures", "itertools", "keyring", "keyring-search", @@ -2610,8 +2611,7 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "nostr" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f08db214560a34bf7c4c1fea09a8461b9412bae58ba06e99ce3177d89fa1e0a6" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "aes", "base64 0.21.7", @@ -2640,8 +2640,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f6c72d0d0842de637f7fba6e70764f719257d29dad8fc5f7352810b0f117ad" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-trait", "flatbuffers", @@ -2655,8 +2654,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afa5502a3df456790ca16d90cc688a677117d57ab56b079dcfa091390ac9f202" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-utility", "async-wsocket", @@ -2671,8 +2669,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b427dceefbbb49a9dd98abb8c4e40d25fdd467e99821aaad88615252bdb915bd" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-utility", "atomic-destructor", @@ -2692,8 +2689,7 @@ dependencies = [ [[package]] name = "nostr-signer" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "665268b316f41cd8fa791be54b6c7935c5a239461708c380a699d6677be9af38" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-utility", "nostr", @@ -2706,8 +2702,7 @@ dependencies = [ [[package]] name = "nostr-sqlite" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f643ba919864f3a9bb004244c0d5c958646b07fe760823fdc33aae1c8fc0fc" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-trait", "nostr", @@ -2721,8 +2716,7 @@ dependencies = [ [[package]] name = "nostr-zapper" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69922e74f8eab1f9d287008c0c06acdec87277a2d8f44bd9d38e003422aea0ab" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-trait", "nostr", @@ -2852,8 +2846,7 @@ dependencies = [ [[package]] name = "nwc" version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2e04b3edb5e9572e95b62842430625f1718e8a4a3596a30aeb04e6734764ea" +source = "git+https://github.com/rust-nostr/nostr#9e8fea62f3a8c4ab943291a3f265c0a42b457c77" dependencies = [ "async-utility", "nostr", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 379cc8d..3b39ead 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,9 @@ edition = "2021" tauri-build = { version = "2.0.0-beta", features = [] } [dependencies] -nostr-sdk = { version = "0.33", features = ["sqlite"] } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ + "sqlite", +] } tauri = { version = "2.0.0-beta", features = [ "tray-icon", "macos-private-api", @@ -34,6 +36,7 @@ keyring = { version = "3", features = [ ] } keyring-search = "1.2.0" itertools = "0.13.0" +futures = "0.3.30" specta = "^2.0.0-rc.12" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 987fcbc..17d2665 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -2,11 +2,18 @@ use itertools::Itertools; use keyring::Entry; use keyring_search::{Limit, List, Search}; use nostr_sdk::prelude::*; -use std::{collections::HashSet, str::FromStr}; -use tauri::{Manager, State}; +use serde::Serialize; +use std::{collections::HashSet, time::Duration}; +use tauri::{Emitter, Manager, State}; use crate::Nostr; +#[derive(Clone, Serialize)] +struct Payload { + event: String, + sender: String, +} + #[tauri::command] #[specta::specta] pub fn get_accounts() -> Vec { @@ -21,17 +28,20 @@ pub fn get_accounts() -> Vec { #[tauri::command] #[specta::specta] -pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result { +pub async fn get_profile(id: String, state: State<'_, Nostr>) -> Result { let client = &state.client; - let public_key = PublicKey::from_str(&id).unwrap(); + let public_key = PublicKey::parse(&id).map_err(|e| e.to_string())?; let filter = Filter::new().author(public_key).kind(Kind::Metadata).limit(1); - let events = client.get_events_of(vec![filter], None).await.unwrap(); - - if let Some(event) = events.first() { - Ok(Metadata::from_json(&event.content).unwrap().as_json()) - } else { - Ok(Metadata::new().as_json()) + match client.get_events_of(vec![filter], Some(Duration::from_secs(1))).await { + Ok(events) => { + if let Some(event) = events.first() { + Ok(Metadata::from_json(&event.content).unwrap_or(Metadata::new()).as_json()) + } else { + Ok(Metadata::new().as_json()) + } + } + Err(e) => Err(e.to_string()), } } @@ -41,7 +51,7 @@ pub async fn login( id: String, state: State<'_, Nostr>, handle: tauri::AppHandle, -) -> Result<(), String> { +) -> Result { let client = &state.client; let keyring = Entry::new(&id, "nostr_secret").expect("Unexpected."); @@ -50,37 +60,44 @@ pub async fn login( Err(_) => return Err("Cancelled".into()), }; + let id_clone = id.clone(); let keys = Keys::parse(password).expect("Secret Key is modified, please check again."); let signer = NostrSigner::Keys(keys); // Set signer client.set_signer(Some(signer)).await; - let public_key = PublicKey::from_str(&id).unwrap(); - let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1); - - if let Ok(events) = client.get_events_of(vec![inbox], None).await { - if let Some(event) = events.into_iter().next() { - for tag in &event.tags { - if let Some(TagStandard::Relay(url)) = tag.as_standardized() { - let relay = url.to_string(); - let _ = client.add_relay(&relay).await; - let _ = client.connect_relay(&relay).await; - - println!("Connecting to {} ...", relay); - } - } - } - } - tauri::async_runtime::spawn(async move { let window = handle.get_webview_window("main").unwrap(); let state = window.state::(); let client = &state.client; - let incoming = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let public_key = PublicKey::parse(&id_clone).unwrap(); + let inbox = Filter::new().kind(Kind::Custom(10050)).author(public_key).limit(1); - if let Ok(report) = client.reconcile(incoming.clone(), NegentropyOptions::default()).await { + if let Ok(events) = client.get_events_of(vec![inbox], None).await { + if let Some(event) = events.into_iter().next() { + for tag in &event.tags { + if let Some(TagStandard::Relay(url)) = tag.as_standardized() { + let opts = RelayOptions::new().retry_sec(5); + let url = url.to_string(); + + if client.add_relay_with_opts(&url, opts).await.is_ok() { + println!("Adding relay {} ...", url); + + if client.connect_relay(&url).await.is_ok() { + println!("Connecting relay {} ...", url); + } + } + } + } + } + } + + let old = Filter::new().kind(Kind::GiftWrap).pubkey(public_key).until(Timestamp::now()); + let new = Filter::new().kind(Kind::GiftWrap).pubkey(public_key).limit(0); + + if let Ok(report) = client.reconcile(old, NegentropyOptions::default()).await { let receives = report.received.clone(); let ids = receives.into_iter().collect::>(); @@ -104,12 +121,37 @@ pub async fn login( println!("Sync done.") } } - } + }; - if client.subscribe(vec![incoming.limit(0)], None).await.is_ok() { + if client.subscribe(vec![new], None).await.is_ok() { println!("Waiting for new message...") - } + }; + + client + .handle_notifications(|notification| async { + if let RelayPoolNotification::Message { message, .. } = notification { + if let RelayMessage::Event { event, .. } = message { + if event.kind == Kind::GiftWrap { + if let Ok(UnwrappedGift { rumor, sender }) = + client.unwrap_gift_wrap(&event).await + { + window + .emit( + "event", + Payload { event: rumor.as_json(), sender: sender.to_hex() }, + ) + .unwrap(); + } + } + } + } + Ok(false) + }) + .await }); - Ok(()) + let public_key = PublicKey::parse(&id).unwrap(); + let hex = public_key.to_hex(); + + Ok(hex) } diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs new file mode 100644 index 0000000..420df90 --- /dev/null +++ b/src-tauri/src/commands/chat.rs @@ -0,0 +1,79 @@ +use futures::stream::{self, StreamExt}; +use itertools::Itertools; +use nostr_sdk::prelude::*; +use tauri::State; + +use crate::{common::is_target, Nostr}; + +#[tauri::command] +#[specta::specta] +pub async fn get_chats(state: State<'_, Nostr>) -> Result, String> { + let client = &state.client; + let signer = client.signer().await.expect("Unexpected"); + let public_key = signer.public_key().await.expect("Unexpected"); + + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + match client.database().query(vec![filter], Order::Desc).await { + Ok(events) => { + let rumors = stream::iter(events) + .filter_map(|ev| async move { + if let Ok(UnwrappedGift { rumor, .. }) = client.unwrap_gift_wrap(&ev).await { + if rumor.kind == Kind::PrivateDirectMessage { + return Some(rumor); + } + } + None + }) + .collect::>() + .await; + + let uniqs = rumors + .into_iter() + .unique_by(|ev| ev.pubkey) + .map(|ev| ev.as_json()) + .collect::>(); + + Ok(uniqs) + } + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +#[specta::specta] +pub async fn get_chat_messages( + sender: String, + state: State<'_, Nostr>, +) -> Result, String> { + let client = &state.client; + let database = client.database(); + let signer = client.signer().await.map_err(|e| e.to_string())?; + let receiver_pk = signer.public_key().await.map_err(|e| e.to_string())?; + let sender_pk = PublicKey::parse(sender).map_err(|e| e.to_string())?; + + let filter = Filter::new().kind(Kind::GiftWrap).pubkeys(vec![receiver_pk, sender_pk]); + + match database.query(vec![filter], Order::Desc).await { + Ok(events) => { + let rumors = stream::iter(events) + .filter_map(|ev| async move { + if let Ok(UnwrappedGift { rumor, sender }) = client.unwrap_gift_wrap(&ev).await + { + if rumor.kind == Kind::PrivateDirectMessage + && (sender == sender_pk || is_target(&sender_pk, &rumor.tags)) + { + return Some(rumor); + } + } + None + }) + .map(|ev| ev.as_json()) + .collect::>() + .await; + + Ok(rumors) + } + Err(e) => Err(e.to_string()), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b0edc6c..d0f897b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1 +1,2 @@ pub mod account; +pub mod chat; diff --git a/src-tauri/src/common.rs b/src-tauri/src/common.rs new file mode 100644 index 0000000..f952db6 --- /dev/null +++ b/src-tauri/src/common.rs @@ -0,0 +1,12 @@ +use nostr_sdk::prelude::*; + +pub fn is_target(target: &PublicKey, tags: &Vec) -> bool { + for tag in tags { + if let Some(TagStandard::PublicKey { public_key, .. }) = tag.as_standardized() { + if public_key == target { + return true; + } + } + } + false +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c05e1f3..ca3a911 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,14 +2,19 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use border::WebviewWindowExt as WebviewWindowExtAlt; -use commands::account::{get_accounts, get_profile, login}; use nostr_sdk::prelude::*; use serde::Serialize; -use std::{fs, sync::Mutex}; +use std::{fs, sync::Mutex, time::Duration}; use tauri::Manager; use tauri_plugin_decorum::WebviewWindowExt; +use commands::{ + account::{get_accounts, get_profile, login}, + chat::{get_chat_messages, get_chats}, +}; + mod commands; +mod common; #[derive(Serialize)] pub struct Nostr { @@ -19,12 +24,13 @@ pub struct Nostr { } fn main() { - let mut ctx = tauri::generate_context!(); let invoke_handler = { let builder = tauri_specta::ts::builder().commands(tauri_specta::collect_commands![ - get_accounts, login, - get_profile + get_accounts, + get_profile, + get_chats, + get_chat_messages ]); #[cfg(debug_assertions)] @@ -39,8 +45,13 @@ fn main() { let main_window = app.get_webview_window("main").unwrap(); // Set custom decoration + #[cfg(target_os = "windows")] main_window.create_overlay_titlebar().unwrap(); + // Set traffic light inset + #[cfg(target_os = "macos")] + main_window.set_traffic_lights_inset(12.0, 18.0).unwrap(); + // Restore native border #[cfg(target_os = "macos")] main_window.add_border(None); @@ -53,10 +64,17 @@ fn main() { // Setup database let database = SQLiteDatabase::open(dir.join("Coop/coop.db")).await; + // Config + let opts = Options::new() + .automatic_authentication(true) + .timeout(Duration::from_secs(5)) + .send_timeout(Some(Duration::from_secs(10))) + .connection_timeout(Some(Duration::from_secs(10))); + // Setup nostr client let client = match database { - Ok(db) => ClientBuilder::default().database(db).build(), - Err(_) => ClientBuilder::default().build(), + Ok(db) => ClientBuilder::default().opts(opts).database(db).build(), + Err(_) => ClientBuilder::default().opts(opts).build(), }; // Add bootstrap relay @@ -79,6 +97,6 @@ fn main() { .plugin(tauri_plugin_decorum::init()) .plugin(tauri_plugin_shell::init()) .invoke_handler(invoke_handler) - .run(ctx) + .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.css b/src/App.css index f72aac9..8ac02ee 100644 --- a/src/App.css +++ b/src/App.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +@layer utilities { + .break-message { + word-break: break-word; + word-wrap: break-word; + overflow-wrap: break-word; + } +} + html { font-size: 14px; } diff --git a/src/commands.ts b/src/commands.ts index 54efebb..6e8d57c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,10 +4,7 @@ /** user-defined commands **/ export const commands = { -async getAccounts() : Promise { -return await TAURI_INVOKE("get_accounts"); -}, -async login(id: string) : Promise> { +async login(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("login", { id }) }; } catch (e) { @@ -15,13 +12,32 @@ try { else return { status: "error", error: e as any }; } }, -async getProfile(id: string) : Promise> { +async getAccounts() : Promise { +return await TAURI_INVOKE("get_accounts"); +}, +async getProfile(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_profile", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async getChats() : Promise> { +try { + return { status: "ok", data: await TAURI_INVOKE("get_chats") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async getChatMessages(sender: string) : Promise> { +try { + return { status: "ok", data: await TAURI_INVOKE("get_chat_messages", { sender }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } diff --git a/src/commons.ts b/src/commons.ts index ab18028..463d509 100644 --- a/src/commons.ts +++ b/src/commons.ts @@ -1,6 +1,25 @@ import { type ClassValue, clsx } from "clsx"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import updateLocale from "dayjs/plugin/updateLocale"; import { twMerge } from "tailwind-merge"; +dayjs.extend(relativeTime); +dayjs.extend(updateLocale); + +dayjs.updateLocale("en", { + relativeTime: { + past: "%s", + s: "now", + m: "1m", + mm: "%dm", + h: "1h", + hh: "%dh", + d: "1d", + dd: "%dd", + }, +}); + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } @@ -21,3 +40,35 @@ export function npub(pubkey: string, len: number) { pubkey.substring(pubkey.length - backChars) ); } + +export function ago(time: number) { + let formated: string; + + const now = dayjs(); + const inputTime = dayjs.unix(time); + const diff = now.diff(inputTime, "hour"); + + if (diff < 24) { + formated = inputTime.from(now, true); + } else { + formated = inputTime.format("MMM DD"); + } + + return formated; +} + +export function time(time: number) { + const input = new Date(time * 1000); + const formattedTime = input.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + + return formattedTime; +} + +export function getReceivers(tags: string[][]) { + const p = tags.map((tag) => tag[0] === "p" && tag[1]); + return p; +} diff --git a/src/routes.gen.ts b/src/routes.gen.ts index 9331b58..726ce09 100644 --- a/src/routes.gen.ts +++ b/src/routes.gen.ts @@ -15,6 +15,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRoute } from './routes/__root' import { Route as IndexImport } from './routes/index' import { Route as AccountChatsImport } from './routes/$account.chats' +import { Route as AccountChatsIdImport } from './routes/$account.chats.$id' // Create Virtual Routes @@ -37,6 +38,11 @@ const AccountChatsRoute = AccountChatsImport.update({ getParentRoute: () => rootRoute, } as any) +const AccountChatsIdRoute = AccountChatsIdImport.update({ + path: '/$id', + getParentRoute: () => AccountChatsRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -62,6 +68,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountChatsImport parentRoute: typeof rootRoute } + '/$account/chats/$id': { + id: '/$account/chats/$id' + path: '/$id' + fullPath: '/$account/chats/$id' + preLoaderRoute: typeof AccountChatsIdImport + parentRoute: typeof AccountChatsImport + } } } @@ -70,7 +83,7 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ IndexRoute, NewLazyRoute, - AccountChatsRoute, + AccountChatsRoute: AccountChatsRoute.addChildren({ AccountChatsIdRoute }), }) /* prettier-ignore-end */ @@ -93,7 +106,14 @@ export const routeTree = rootRoute.addChildren({ "filePath": "new.lazy.tsx" }, "/$account/chats": { - "filePath": "$account.chats.tsx" + "filePath": "$account.chats.tsx", + "children": [ + "/$account/chats/$id" + ] + }, + "/$account/chats/$id": { + "filePath": "$account.chats.$id.tsx", + "parent": "/$account/chats" } } } diff --git a/src/routes/$account.chats.$id.tsx b/src/routes/$account.chats.$id.tsx new file mode 100644 index 0000000..4ffed8c --- /dev/null +++ b/src/routes/$account.chats.$id.tsx @@ -0,0 +1,152 @@ +import { commands } from "@/commands"; +import { cn, getReceivers, time } from "@/commons"; +import { ArrowUp } from "@phosphor-icons/react"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { listen } from "@tauri-apps/api/event"; +import type { NostrEvent } from "nostr-tools"; +import { useCallback, useRef } from "react"; +import { useEffect } from "react"; +import { Virtualizer } from "virtua"; + +type Payload = { + event: string; + sender: string; +}; + +export const Route = createFileRoute("/$account/chats/$id")({ + component: Screen, +}); + +function Screen() { + const { account, id } = Route.useParams(); + const { isLoading, isError, data } = useQuery({ + queryKey: ["chats", id], + queryFn: async () => { + const res = await commands.getChatMessages(id); + + if (res.status === "ok") { + const raw = res.data; + const events = raw + .map((item) => JSON.parse(item) as NostrEvent) + .sort((a, b) => a.created_at - b.created_at); + + return events; + } else { + throw new Error(res.error); + } + }, + }); + + const queryClient = useQueryClient(); + const ref = useRef(null); + + const renderItem = useCallback( + (item: NostrEvent) => { + const self = account === item.pubkey; + + return ( +
+
+
+ {item.content} +
+
+
+ + {time(item.created_at)} + +
+
+ ); + }, + [data], + ); + + useEffect(() => { + const unlisten = listen("event", async (data) => { + const event: NostrEvent = JSON.parse(data.payload.event); + const sender = data.payload.sender; + const receivers = getReceivers(event.tags); + + if (sender !== account || sender !== id) return; + if (!receivers.includes(account) || !receivers.includes(id)) return; + + await queryClient.setQueryData( + ["chats", id], + (prevEvents: NostrEvent[]) => { + if (!prevEvents) { + return prevEvents; + } + return [...prevEvents, event]; + // queryClient.invalidateQueries(['chats', id]); + }, + ); + }); + + return () => { + unlisten.then((f) => f()); + }; + }, []); + + return ( +
+
+ + + + {isLoading ? ( +

Loading...

+ ) : isError || !data ? ( +

Error

+ ) : ( + data.map((item) => renderItem(item)) + )} +
+
+ + + + +
+
+ + +
+
+ ); +} diff --git a/src/routes/$account.chats.tsx b/src/routes/$account.chats.tsx index 23f1026..daca201 100644 --- a/src/routes/$account.chats.tsx +++ b/src/routes/$account.chats.tsx @@ -1,5 +1,181 @@ -import { createFileRoute } from '@tanstack/react-router' +import { commands } from "@/commands"; +import { ago, cn } from "@/commons"; +import { User } from "@/components/user"; +import { Plus, UsersThree } from "@phosphor-icons/react"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, Outlet, createFileRoute } from "@tanstack/react-router"; +import { listen } from "@tauri-apps/api/event"; +import type { NostrEvent } from "nostr-tools"; +import { useEffect } from "react"; -export const Route = createFileRoute('/$account/chats')({ - component: () =>
Hello /$account/chats!
-}) \ No newline at end of file +type Payload = { + event: string; + sender: string; +}; + +export const Route = createFileRoute("/$account/chats")({ + component: Screen, +}); + +function Screen() { + return ( +
+
+
+
+ +
+
+ +
+
+
+ +
+
+ ); +} + +function Header() { + return ( +
+
+ + + + + + +
+
+ ); +} + +function ChatList() { + const { account } = Route.useParams(); + const { isLoading, isError, data } = useQuery({ + queryKey: ["chats"], + queryFn: async () => { + const res = await commands.getChats(); + + if (res.status === "ok") { + const raw = res.data; + const events = raw + .map((item) => JSON.parse(item) as NostrEvent) + .sort((a, b) => b.created_at - a.created_at); + + return events; + } else { + throw new Error(res.error); + } + }, + }); + + const queryClient = useQueryClient(); + + useEffect(() => { + const unlisten = listen("event", async (data) => { + const event: NostrEvent = JSON.parse(data.payload.event); + const chats: NostrEvent[] = await queryClient.getQueryData(["chats"]); + const exist = chats.find((ev) => ev.pubkey === event.pubkey); + + if (!exist) { + await queryClient.setQueryData( + ["chats"], + (prevEvents: NostrEvent[]) => { + if (!prevEvents) { + return prevEvents; + } + return [event, ...prevEvents]; + // queryClient.invalidateQueries(['chats', id]); + }, + ); + } + }); + + return () => { + unlisten.then((f) => f()); + }; + }, []); + + return ( + + + {isLoading ? ( +

Loading...

+ ) : isError ? ( +

Error

+ ) : ( + data.map((item) => ( + + {({ isActive }) => ( + + + +
+
+ + + {account === item.pubkey ? "(you)" : ""} + +
+ + {ago(item.created_at)} + +
+
+
+ )} + + )) + )} +
+ + + + +
+ ); +} + +function CurrentUser() { + const { account } = Route.useParams(); + + return ( + + + + + + + ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 47f33ad..3985dbd 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -44,17 +44,19 @@ function Screen() { const loginWith = async (npub: string) => { setValue(npub); startTransition(async () => { - const run = await commands.login(npub); + try { + const res = await commands.login(npub); - if (run.status === "ok") { - navigate({ - to: "/$account/chats", - params: { account: npub }, - replace: true, - }); - } else { + if (res.status === "ok") { + navigate({ + to: "/$account/chats", + params: { account: res.data }, + replace: true, + }); + } + } catch (e) { setValue(""); - await message(run.error, { + message(String(e), { title: "Login", kind: "error", }); diff --git a/tsconfig.json b/tsconfig.json index 3b5fd27..21f42b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "strictNullChecks": false }, "include": [ "src"