feat: update ui and add compose dialog

This commit is contained in:
reya
2024-07-29 14:49:13 +07:00
parent a65d5d0c1a
commit 6ceac40394
16 changed files with 913 additions and 345 deletions

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@tanstack/query-sync-storage-persister": "^5.51.15",
"@tanstack/react-query": "^5.51.15",

291
pnpm-lock.yaml generated
View File

@@ -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-dialog':
specifier: ^1.1.1
version: 1.1.1(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)
@@ -521,6 +524,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.1':
resolution: {integrity: sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==}
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-direction@1.1.0':
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
@@ -530,6 +546,63 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.0':
resolution: {integrity: sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==}
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-focus-guards@1.1.0':
resolution: {integrity: sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.0':
resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==}
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-id@1.1.0':
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-portal@1.1.1':
resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==}
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-presence@1.1.0':
resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==}
peerDependencies:
@@ -587,6 +660,24 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.1.0':
resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.0':
resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.0':
resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==}
peerDependencies:
@@ -906,6 +997,10 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
aria-hidden@1.2.4:
resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==}
engines: {node: '>=10'}
autoprefixer@10.4.19:
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
engines: {node: ^10 || ^12 || >=14}
@@ -1005,6 +1100,9 @@ packages:
supports-color:
optional: true
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -1066,6 +1164,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1330,6 +1432,36 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
react-remove-scroll-bar@2.3.6:
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.5.7:
resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.1:
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react@19.0.0-rc-d025ddd3-20240722:
resolution: {integrity: sha512-pZZ3zHponPdVhwgOoQoI7qoHh+Hn3GAVc+/g4LICRv/XZ0IEJPsPOehV/Iu8Ssl/DoruEO4EdeK6kTSmcaAo8A==}
engines: {node: '>=0.10.0'}
@@ -1463,6 +1595,9 @@ packages:
typescript:
optional: true
tslib@2.6.3:
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
types-react-dom@19.0.0-rc.1:
resolution: {integrity: sha512-VSLZJl8VXCD0fAWp7DUTFUDCcZ8DVXOQmjhJMD03odgeFmu14ZQJHCXeETm3BEAhJqfgJaFkLnGkQv88sRx0fQ==}
@@ -1488,6 +1623,26 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
use-callback-ref@1.3.2:
resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.2:
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
use-sync-external-store@1.2.2:
resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==}
peerDependencies:
@@ -1938,12 +2093,81 @@ snapshots:
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@radix-ui/react-dialog@1.1.1(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/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-dismissable-layer': 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-focus-guards': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
'@radix-ui/react-focus-scope': 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-id': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
'@radix-ui/react-portal': 1.1.1(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-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-slot': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
'@radix-ui/react-use-controllable-state': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
aria-hidden: 1.2.4
react: 19.0.0-rc-d025ddd3-20240722
react-dom: 19.0.0-rc-d025ddd3-20240722(react@19.0.0-rc-d025ddd3-20240722)
react-remove-scroll: 2.5.7(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@types/react-dom': types-react-dom@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-dismissable-layer@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/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-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-escape-keydown': 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-focus-guards@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-focus-scope@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-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)
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-id@1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)':
dependencies:
'@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
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@radix-ui/react-portal@1.1.1(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-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-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-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)
@@ -1993,6 +2217,20 @@ snapshots:
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@radix-ui/react-use-controllable-state@1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
react: 19.0.0-rc-d025ddd3-20240722
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@radix-ui/react-use-escape-keydown@1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
react: 19.0.0-rc-d025ddd3-20240722
optionalDependencies:
'@types/react': 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)':
dependencies:
react: 19.0.0-rc-d025ddd3-20240722
@@ -2277,6 +2515,10 @@ snapshots:
arg@5.0.2: {}
aria-hidden@1.2.4:
dependencies:
tslib: 2.6.3
autoprefixer@10.4.19(postcss@8.4.40):
dependencies:
browserslist: 4.23.2
@@ -2386,6 +2628,8 @@ snapshots:
dependencies:
ms: 2.1.2
detect-node-es@1.1.0: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}
@@ -2458,6 +2702,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-nonce@1.0.1: {}
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -2671,6 +2917,34 @@ snapshots:
react-refresh@0.14.2: {}
react-remove-scroll-bar@2.3.6(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1):
dependencies:
react: 19.0.0-rc-d025ddd3-20240722
react-style-singleton: 2.2.1(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
tslib: 2.6.3
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
react-remove-scroll@2.5.7(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1):
dependencies:
react: 19.0.0-rc-d025ddd3-20240722
react-remove-scroll-bar: 2.3.6(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
react-style-singleton: 2.2.1(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
tslib: 2.6.3
use-callback-ref: 1.3.2(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
use-sidecar: 1.1.2(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1)
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
react-style-singleton@2.2.1(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1):
dependencies:
get-nonce: 1.0.1
invariant: 2.2.4
react: 19.0.0-rc-d025ddd3-20240722
tslib: 2.6.3
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
react@19.0.0-rc-d025ddd3-20240722: {}
read-cache@1.0.0:
@@ -2826,6 +3100,8 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
tslib@2.6.3: {}
types-react-dom@19.0.0-rc.1:
dependencies:
'@types/react': 18.3.3
@@ -2851,6 +3127,21 @@ snapshots:
escalade: 3.1.2
picocolors: 1.0.1
use-callback-ref@1.3.2(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1):
dependencies:
react: 19.0.0-rc-d025ddd3-20240722
tslib: 2.6.3
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
use-sidecar@1.1.2(react@19.0.0-rc-d025ddd3-20240722)(types-react@19.0.0-rc.1):
dependencies:
detect-node-es: 1.1.0
react: 19.0.0-rc-d025ddd3-20240722
tslib: 2.6.3
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
use-sync-external-store@1.2.2(react@19.0.0-rc-d025ddd3-20240722):
dependencies:
react: 19.0.0-rc-d025ddd3-20240722

View File

@@ -141,6 +141,15 @@ pub async fn connect_account(uri: &str, state: State<'_, Nostr>) -> Result<Strin
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_contact_list(state: State<'_, Nostr>) -> Result<Vec<String>, ()> {
let contact_list = state.contact_list.lock().await;
let list = contact_list.clone().into_iter().map(|c| c.public_key.to_hex()).collect::<Vec<_>>();
Ok(list)
}
#[tauri::command]
#[specta::specta]
pub async fn login(
@@ -185,6 +194,11 @@ pub async fn login(
}
}
if let Ok(contacts) = client.get_contact_list(Some(Duration::from_secs(10))).await {
let mut contact_list = state.contact_list.lock().await;
*contact_list = contacts;
};
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 {

View File

@@ -54,7 +54,7 @@ pub async fn get_chat_messages(id: String, state: State<'_, Nostr>) -> Result<Ve
let filter = Filter::new().kind(Kind::GiftWrap).pubkeys(vec![receiver_pk, sender_pk]);
let rumors = match client.get_events_of(vec![filter], Some(Duration::from_secs(10))).await {
let rumors = match client.database().query(vec![filter], Order::Desc).await {
Ok(events) => {
stream::iter(events)
.filter_map(|ev| async move {

View File

@@ -27,6 +27,7 @@ fn main() {
connect_account,
get_accounts,
get_metadata,
get_contact_list,
get_chats,
get_chat_messages,
connect_inbox,

View File

@@ -47,6 +47,14 @@ try {
else return { status: "error", error: e as any };
}
},
async getContactList() : Promise<Result<string[], null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_contact_list") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getChats() : Promise<Result<string[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_chats") };

View File

@@ -87,3 +87,10 @@ export function groupEventByDate(events: NostrEvent[]) {
return groups;
}
export function isEmojiOnly(str: string) {
const stringToTest = str.replace(/ /g, "");
const emojiRegex =
/^(?:(?:\p{RI}\p{RI}|\p{Emoji}(?:\p{Emoji_Modifier}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u{200D}\p{Emoji}(?:\p{Emoji_Modifier}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)*)|[\u{1f900}-\u{1f9ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}])+$/u;
return emojiRegex.test(stringToTest) && Number.isNaN(Number(stringToTest));
}

View File

@@ -24,18 +24,20 @@ export function UserAvatar({ className }: { className?: string }) {
>
{!user.isLoading ? (
<>
<Avatar.Image
src={`//wsrv.nl/?url=${user.profile?.picture}&w=200&h=200`}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
{user.profile?.picture ? (
<Avatar.Image
src={`//wsrv.nl/?url=${user.profile?.picture}&w=200&h=200`}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="w-full aspect-square object-cover outline-[.5px] outline-black/15"
/>
) : null}
<Avatar.Fallback>
<img
src={fallback}
alt={user.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5"
/>
</Avatar.Fallback>
</>

View File

@@ -14,6 +14,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as AccountContactsImport } from './routes/$account.contacts'
import { Route as AccountChatsIdImport } from './routes/$account.chats.$id'
// Create Virtual Routes
@@ -22,7 +23,6 @@ const NostrConnectLazyImport = createFileRoute('/nostr-connect')()
const NewLazyImport = createFileRoute('/new')()
const ImportKeyLazyImport = createFileRoute('/import-key')()
const CreateAccountLazyImport = createFileRoute('/create-account')()
const ContactsLazyImport = createFileRoute('/contacts')()
const AccountChatsLazyImport = createFileRoute('/$account/chats')()
const AccountChatsNewLazyImport = createFileRoute('/$account/chats/new')()
@@ -50,11 +50,6 @@ const CreateAccountLazyRoute = CreateAccountLazyImport.update({
import('./routes/create-account.lazy').then((d) => d.Route),
)
const ContactsLazyRoute = ContactsLazyImport.update({
path: '/contacts',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/contacts.lazy').then((d) => d.Route))
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
@@ -67,6 +62,13 @@ const AccountChatsLazyRoute = AccountChatsLazyImport.update({
import('./routes/$account.chats.lazy').then((d) => d.Route),
)
const AccountContactsRoute = AccountContactsImport.update({
path: '/$account/contacts',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/$account.contacts.lazy').then((d) => d.Route),
)
const AccountChatsNewLazyRoute = AccountChatsNewLazyImport.update({
path: '/new',
getParentRoute: () => AccountChatsLazyRoute,
@@ -77,7 +79,9 @@ const AccountChatsNewLazyRoute = AccountChatsNewLazyImport.update({
const AccountChatsIdRoute = AccountChatsIdImport.update({
path: '/$id',
getParentRoute: () => AccountChatsLazyRoute,
} as any)
} as any).lazy(() =>
import('./routes/$account.chats.$id.lazy').then((d) => d.Route),
)
// Populate the FileRoutesByPath interface
@@ -90,13 +94,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/contacts': {
id: '/contacts'
path: '/contacts'
fullPath: '/contacts'
preLoaderRoute: typeof ContactsLazyImport
parentRoute: typeof rootRoute
}
'/create-account': {
id: '/create-account'
path: '/create-account'
@@ -125,6 +122,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NostrConnectLazyImport
parentRoute: typeof rootRoute
}
'/$account/contacts': {
id: '/$account/contacts'
path: '/$account/contacts'
fullPath: '/$account/contacts'
preLoaderRoute: typeof AccountContactsImport
parentRoute: typeof rootRoute
}
'/$account/chats': {
id: '/$account/chats'
path: '/$account/chats'
@@ -153,11 +157,11 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({
IndexRoute,
ContactsLazyRoute,
CreateAccountLazyRoute,
ImportKeyLazyRoute,
NewLazyRoute,
NostrConnectLazyRoute,
AccountContactsRoute,
AccountChatsLazyRoute: AccountChatsLazyRoute.addChildren({
AccountChatsIdRoute,
AccountChatsNewLazyRoute,
@@ -173,20 +177,17 @@ export const routeTree = rootRoute.addChildren({
"filePath": "__root.tsx",
"children": [
"/",
"/contacts",
"/create-account",
"/import-key",
"/new",
"/nostr-connect",
"/$account/contacts",
"/$account/chats"
]
},
"/": {
"filePath": "index.tsx"
},
"/contacts": {
"filePath": "contacts.lazy.tsx"
},
"/create-account": {
"filePath": "create-account.lazy.tsx"
},
@@ -199,6 +200,9 @@ export const routeTree = rootRoute.addChildren({
"/nostr-connect": {
"filePath": "nostr-connect.lazy.tsx"
},
"/$account/contacts": {
"filePath": "$account.contacts.tsx"
},
"/$account/chats": {
"filePath": "$account.chats.lazy.tsx",
"children": [

View File

@@ -0,0 +1,288 @@
import { commands } from "@/commands";
import { cn, getReceivers, groupEventByDate, time } from "@/commons";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { ArrowUp, Paperclip } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { message } from "@tauri-apps/plugin-dialog";
import type { NostrEvent } from "nostr-tools";
import { useCallback, useRef, useState, useTransition } from "react";
import { useEffect } from "react";
import { Virtualizer } from "virtua";
type Payload = {
event: string;
sender: string;
};
export const Route = createLazyFileRoute("/$account/chats/$id")({
component: Screen,
pendingComponent: Pending,
});
function Pending() {
return (
<div className="size-full flex items-center justify-center">
<Spinner />
</div>
);
}
function Screen() {
return (
<div className="size-full flex flex-col">
<Header />
<List />
<Form />
</div>
);
}
function Header() {
const { account, id } = Route.useParams();
return (
<div
data-tauri-drag-region
className="h-12 shrink-0 flex items-center justify-between px-3.5 border-b border-neutral-100 dark:border-neutral-800"
>
<div>
<div className="flex -space-x-1 overflow-hidden">
<User.Provider pubkey={account}>
<User.Root className="size-8 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
<User.Avatar className="size-8 rounded-full" />
</User.Root>
</User.Provider>
<User.Provider pubkey={id}>
<User.Root className="size-8 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
<User.Avatar className="size-8 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-7 inline-flex items-center justify-center gap-1.5 px-2 rounded-full bg-neutral-100 dark:bg-neutral-900">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex size-full rounded-full bg-teal-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-teal-500" />
</span>
<div className="text-xs leading-tight">Connected</div>
</div>
</div>
</div>
);
}
function List() {
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: NostrEvent[] = raw.map((item) => JSON.parse(item));
return events;
} else {
throw new Error(res.error);
}
},
select: (data) => {
const groups = groupEventByDate(data);
return Object.entries(groups).reverse();
},
refetchOnWindowFocus: false,
});
const queryClient = useQueryClient();
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(item: NostrEvent, idx: number) => {
const self = account === item.pubkey;
return (
<div
key={idx + item.id}
className="flex items-center justify-between gap-3 my-1.5 px-3 border-l-2 border-transparent hover:border-blue-400"
>
<div
className={cn(
"flex-1 min-w-0 inline-flex",
self ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"py-2 px-3 w-fit max-w-[400px] text-pretty break-message",
!self
? "bg-neutral-100 dark:bg-neutral-800 rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md"
: "bg-blue-500 text-white rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl",
)}
>
{item.content}
</div>
</div>
<div className="shrink-0 w-16 flex items-center justify-end">
<span className="text-xs text-right text-neutral-600 dark:text-neutral-400">
{time(item.created_at)}
</span>
</div>
</div>
);
},
[data],
);
useEffect(() => {
const unlisten = listen<Payload>("event", async (data) => {
const event: NostrEvent = JSON.parse(data.payload.event);
const sender = data.payload.sender;
const receivers = getReceivers(event.tags);
const group = [account, id];
if (!group.includes(sender)) return;
if (!group.some((item) => receivers.includes(item))) return;
await queryClient.setQueryData(
["chats", id],
(prevEvents: NostrEvent[]) => {
if (!prevEvents) return prevEvents;
return [...prevEvents, event];
},
);
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden flex-1 w-full"
>
<ScrollArea.Viewport
ref={ref}
className="relative h-full py-2 [&>div]:!flex [&>div]:flex-col [&>div]:justify-end [&>div]:min-h-full"
>
<Virtualizer scrollRef={ref} shift>
{isLoading ? (
<>
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
<div className="flex-1 min-w-0 inline-flex">
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-neutral-100 dark:bg-neutral-800 animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md" />
</div>
<div className="shrink-0 w-16 flex items-center justify-end" />
</div>
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
<div className="flex-1 min-w-0 inline-flex justify-end">
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-blue-500 text-white animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl" />
</div>
<div className="shrink-0 w-16 flex items-center justify-end" />
</div>
</>
) : isError ? (
<div className="w-full h-56 flex items-center justify-center">
<div className="text-sm flex items-center gap-1.5">
Cannot load message. Please try again later.
</div>
</div>
) : (
data.map((item) => (
<div
key={item[0]}
className="w-full flex flex-col items-center mt-3 gap-3"
>
<div className="text-xs text-center text-neutral-600 dark:text-neutral-400">
{item[0]}
</div>
<div className="w-full">
{item[1]
.sort((a, b) => a.created_at - b.created_at)
.map((item, idx) => renderItem(item, idx))}
</div>
</div>
))
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function Form() {
const { id } = Route.useParams();
const { inbox } = Route.useRouteContext();
const [newMessage, setNewMessage] = useState("");
const [isPending, startTransition] = useTransition();
const submit = async () => {
startTransition(async () => {
if (!newMessage.length) return;
const res = await commands.sendMessage(id, newMessage);
if (res.status === "error") {
await message(res.error, { title: "Coop", kind: "error" });
return;
}
setNewMessage("");
});
};
return (
<div className="h-12 shrink-0 flex items-center justify-center px-3.5">
{!inbox.length ? (
<div className="text-xs">
This user doesn't have inbox relays. You cannot send messages to them.
</div>
) : (
<div className="flex-1 flex items-center gap-2">
<div className="inline-flex gap-1">
<div
title="Attach media"
className="size-9 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full"
>
<Paperclip className="size-5" />
</div>
</div>
<input
placeholder="Message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
className="flex-1 h-9 rounded-full px-3.5 bg-transparent border border-neutral-200 dark:border-neutral-800 focus:outline-none focus:border-blue-500 placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
/>
<button
type="button"
title="Send message"
disabled={isPending}
onClick={() => submit()}
className="rounded-full size-9 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
>
{isPending ? <Spinner /> : <ArrowUp className="size-5" />}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,293 +1,9 @@
import { commands } from "@/commands";
import { cn, getReceivers, groupEventByDate, time } from "@/commons";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { ArrowUp, Paperclip } 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 { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { message } from "@tauri-apps/plugin-dialog";
import type { NostrEvent } from "nostr-tools";
import { useCallback, useRef, useState, useTransition } from "react";
import { useEffect } from "react";
import { Virtualizer } from "virtua";
type Payload = {
event: string;
sender: string;
};
export const Route = createFileRoute("/$account/chats/$id")({
beforeLoad: async ({ params }) => {
const inbox: string[] = await invoke("connect_inbox", { id: params.id });
return { inbox };
},
component: Screen,
pendingComponent: Pending,
});
function Pending() {
return (
<div className="size-full flex items-center justify-center">
<Spinner />
</div>
);
}
function Screen() {
return (
<div className="size-full flex flex-col">
<Header />
<List />
<Form />
</div>
);
}
function Header() {
const { account, id } = Route.useParams();
return (
<div
data-tauri-drag-region
className="h-12 shrink-0 flex items-center justify-between px-3.5 border-b border-neutral-100 dark:border-neutral-800"
>
<div>
<div className="flex -space-x-1 overflow-hidden">
<User.Provider pubkey={account}>
<User.Root className="size-7 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
<User.Provider pubkey={id}>
<User.Root className="size-7 rounded-full inline-block ring-2 ring-white dark:ring-neutral-900">
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-7 inline-flex items-center justify-center gap-1.5 px-2 rounded-full bg-neutral-100 dark:bg-neutral-900">
<span className="relative flex size-2">
<span className="animate-ping absolute inline-flex size-full rounded-full bg-teal-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-teal-500" />
</span>
<div className="text-xs leading-tight">Connected</div>
</div>
</div>
</div>
);
}
function List() {
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: NostrEvent[] = raw.map((item) => JSON.parse(item));
return events;
} else {
throw new Error(res.error);
}
},
select: (data) => {
const groups = groupEventByDate(data);
return Object.entries(groups).reverse();
},
refetchOnWindowFocus: false,
});
const queryClient = useQueryClient();
const ref = useRef<HTMLDivElement>(null);
const renderItem = useCallback(
(item: NostrEvent, idx: number) => {
const self = account === item.pubkey;
return (
<div
key={idx + item.id}
className="flex items-center justify-between gap-3 my-1.5 px-3 border-l-2 border-transparent hover:border-blue-400"
>
<div
className={cn(
"flex-1 min-w-0 inline-flex",
self ? "justify-end" : "justify-start",
)}
>
<div
className={cn(
"py-2 px-3 w-fit max-w-[400px] text-pretty break-message",
!self
? "bg-neutral-100 dark:bg-neutral-800 rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md"
: "bg-blue-500 text-white rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl",
)}
>
{item.content}
</div>
</div>
<div className="shrink-0 w-16 flex items-center justify-end">
<span className="text-xs text-right text-neutral-600 dark:text-neutral-400">
{time(item.created_at)}
</span>
</div>
</div>
);
},
[data],
);
useEffect(() => {
const unlisten = listen<Payload>("event", async (data) => {
const event: NostrEvent = JSON.parse(data.payload.event);
const sender = data.payload.sender;
const receivers = getReceivers(event.tags);
const group = [account, id];
if (!group.includes(sender)) return;
if (!group.some((item) => receivers.includes(item))) return;
await queryClient.setQueryData(
["chats", id],
(prevEvents: NostrEvent[]) => {
if (!prevEvents) return prevEvents;
return [...prevEvents, event];
},
);
});
return () => {
unlisten.then((f) => f());
};
}, []);
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden flex-1 w-full"
>
<ScrollArea.Viewport
ref={ref}
className="relative h-full py-2 [&>div]:!flex [&>div]:flex-col [&>div]:justify-end [&>div]:min-h-full"
>
<Virtualizer scrollRef={ref} shift>
{isLoading ? (
<>
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
<div className="flex-1 min-w-0 inline-flex">
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-neutral-100 dark:bg-neutral-800 animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-3xl rounded-bl-md" />
</div>
<div className="shrink-0 w-16 flex items-center justify-end" />
</div>
<div className="flex items-center justify-between gap-3 my-1.5 px-3">
<div className="flex-1 min-w-0 inline-flex justify-end">
<div className="w-44 h-[35px] py-2 max-w-[400px] bg-blue-500 text-white animate-pulse rounded-tl-3xl rounded-tr-3xl rounded-br-md rounded-bl-3xl" />
</div>
<div className="shrink-0 w-16 flex items-center justify-end" />
</div>
</>
) : isError ? (
<div className="w-full h-56 flex items-center justify-center">
<div className="flex items-center gap-1.5">
Cannot load message. Please try again later.
</div>
</div>
) : (
data.map((item) => (
<div
key={item[0]}
className="w-full flex flex-col items-center mt-3 gap-3"
>
<div className="text-xs text-center text-neutral-600 dark:text-neutral-400">
{item[0]}
</div>
<div className="w-full">
{item[1]
.sort((a, b) => a.created_at - b.created_at)
.map((item, idx) => renderItem(item, idx))}
</div>
</div>
))
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function Form() {
const { id } = Route.useParams();
const { inbox } = Route.useRouteContext();
const [newMessage, setNewMessage] = useState("");
const [isPending, startTransition] = useTransition();
const submit = async () => {
startTransition(async () => {
if (!newMessage.length) return;
const res = await commands.sendMessage(id, newMessage);
if (res.status === "error") {
await message(res.error, { title: "Coop", kind: "error" });
return;
}
setNewMessage("");
});
};
return (
<div className="h-12 shrink-0 flex items-center justify-center px-3.5">
{!inbox.length ? (
<div className="text-xs">
This user doesn't have inbox relays. You cannot send messages to them.
</div>
) : (
<div className="flex-1 flex items-center gap-2">
<div className="inline-flex gap-1">
<div
title="Attach media"
className="size-9 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full"
>
<Paperclip className="size-5" />
</div>
</div>
<input
placeholder="Message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
className="flex-1 h-9 rounded-full px-3.5 bg-transparent border border-neutral-200 dark:border-neutral-800 focus:outline-none focus:border-blue-500"
/>
<button
type="button"
title="Send message"
disabled={isPending}
onClick={() => submit()}
className="rounded-full size-9 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
>
{isPending ? <Spinner /> : <ArrowUp className="size-5" />}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,14 +1,17 @@
import { commands } from "@/commands";
import { ago, cn } from "@/commons";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { DotsThree, Plus, UsersThree } from "@phosphor-icons/react";
import { ArrowRight, CirclesFour, Plus, X } from "@phosphor-icons/react";
import * as Dialog from "@radix-ui/react-dialog";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, createLazyFileRoute } from "@tanstack/react-router";
import { listen } from "@tauri-apps/api/event";
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
import { message } from "@tauri-apps/plugin-dialog";
import type { NostrEvent } from "nostr-tools";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState, useTransition } from "react";
type Payload = {
event: string;
@@ -28,7 +31,6 @@ function Screen() {
>
<Header />
<ChatList />
<CurrentUser />
</div>
<div className="flex-1 min-w-0 min-h-0 bg-white dark:bg-neutral-900 overflow-auto">
<Outlet />
@@ -38,24 +40,27 @@ function Screen() {
}
function Header() {
const { platform } = Route.useRouteContext();
const { account } = Route.useParams();
return (
<div
data-tauri-drag-region
className="shrink-0 h-12 px-3.5 flex items-center justify-end"
className={cn(
"shrink-0 h-12 flex items-center justify-between",
platform === "macos" ? "pl-24 pr-3.5" : "px-3.5",
)}
>
<div className="flex items-center gap-2">
<CurrentUser />
<div className="flex items-center justify-end gap-2">
<Link
to="/contacts"
className="size-7 rounded-lg inline-flex items-center justify-center text-neutral-600 dark:text-neutral-400 hover:bg-black/5 dark:hover:bg-white/5"
to="/$account/contacts"
params={{ account }}
className="size-8 rounded-full inline-flex items-center justify-center bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<UsersThree className="size-4" />
</Link>
<Link
to="/new"
className="h-7 w-12 rounded-t-lg rounded-l-lg rounded-r inline-flex items-center justify-center bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<Plus className="size-4" />
<CirclesFour className="size-4" />
</Link>
<Compose />
</div>
</div>
);
@@ -83,7 +88,7 @@ function ChatList() {
useEffect(() => {
const unlisten = listen("synchronized", async () => {
await queryClient.refetchQueries({ queryKey: ["chats"] })
await queryClient.refetchQueries({ queryKey: ["chats"] });
});
return () => {
@@ -193,6 +198,146 @@ function ChatList() {
);
}
function Compose() {
const [isOpen, setIsOpen] = useState(false);
const [target, setTarget] = useState("");
const [newMessage, setNewMessage] = useState("");
const [isPending, startTransition] = useTransition();
const { account } = Route.useParams();
const { isLoading, data: contacts } = useQuery({
queryKey: ["contacts", account],
queryFn: async () => {
const res = await commands.getContactList();
if (res.status === "ok") {
return res.data;
} else {
return [];
}
},
refetchOnWindowFocus: false,
enabled: isOpen,
});
const navigate = Route.useNavigate();
const sendMessage = async () => {
startTransition(async () => {
if (!newMessage.length) return;
if (!target.length) return;
const res = await commands.sendMessage(target, newMessage);
if (res.status === "ok") {
navigate({
to: "/$account/chats/$id",
params: { account, id: target },
});
} else {
await message(res.error, { title: "Coop", kind: "error" });
return;
}
});
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
>
<Plus className="size-4" weight="bold" />
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="bg-black/20 dark:bg-white/20 data-[state=open]:animate-overlay fixed inset-0" />
<Dialog.Content className="flex flex-col data-[state=open]:animate-content fixed top-[50%] left-[50%] w-full h-full max-h-[500px] max-w-[400px] translate-x-[-50%] translate-y-[-50%] rounded-xl bg-white dark:bg-neutral-900 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] focus:outline-none">
<div className="h-28 shrink-0 flex flex-col justify-end">
<div className="h-10 inline-flex items-center justify-between px-3.5 text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Send to
<Dialog.Close asChild>
<button type="button">
<X className="size-4" />
</button>
</Dialog.Close>
</div>
<div className="flex items-center gap-1 px-3.5 border-b border-neutral-100 dark:border-neutral-800">
<span className="shrink-0 font-medium">To:</span>
<input
placeholder="npub1..."
value={target}
onChange={(e) => setTarget(e.target.value)}
disabled={isPending || isLoading}
className="flex-1 h-9 bg-transparent focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
/>
</div>
<div className="flex items-center gap-1 px-3.5 border-b border-neutral-100 dark:border-neutral-800">
<span className="shrink-0 font-medium">Message:</span>
<input
placeholder="hello..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
disabled={isPending || isLoading}
className="flex-1 h-9 bg-transparent focus:outline-none placeholder:text-neutral-400 dark:placeholder:text-neutral-600"
/>
<button
type="button"
disabled={isPending || isLoading || !newMessage.length}
onClick={() => sendMessage()}
className="rounded-full size-7 inline-flex items-center justify-center bg-blue-300 hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-800 text-white"
>
<ArrowRight className="size-4" />
</button>
</div>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden flex-1 size-full"
>
<ScrollArea.Viewport className="relative h-full p-2">
{isLoading ? (
<div className="h-[400px] flex items-center justify-center">
<Spinner className="size-4" />
</div>
) : !contacts?.length ? (
<div className="h-[400px] flex items-center justify-center">
<p className="text-sm">Contact is empty.</p>
</div>
) : (
contacts?.map((contact) => (
<button
key={contact}
type="button"
onClick={() => setTarget(contact)}
className="block w-full p-2 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="size-10 rounded-full" />
<User.Name className="font-medium" />
</User.Root>
</User.Provider>
</button>
))
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function CurrentUser() {
const params = Route.useParams();
const navigate = Route.useNavigate();
@@ -201,6 +346,14 @@ function CurrentUser() {
e.preventDefault();
const menuItems = await Promise.all([
MenuItem.new({
text: "Contacts",
action: () =>
navigate({
to: "/$account/contacts",
params: { account: params.account },
}),
}),
MenuItem.new({
text: "Settings",
action: () => navigate({ to: "/" }),
@@ -224,20 +377,16 @@ function CurrentUser() {
}, []);
return (
<div className="shrink-0 h-12 flex items-center justify-between px-3.5 border-t border-black/5 dark:border-white/5">
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="shrink-0 size-8 flex items-center justify-center rounded-full ring-1 ring-teal-500"
>
<User.Provider pubkey={params.account}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="size-8 rounded-full" />
<User.Name className="text-sm font-medium leading-tight" />
<User.Root className="shrink-0">
<User.Avatar className="size-7 rounded-full" />
</User.Root>
</User.Provider>
<button
type="button"
onClick={(e) => showContextMenu(e)}
className="size-7 inline-flex items-center justify-center rounded-md text-neutral-700 dark:text-neutral-300 hover:bg-black/5 dark:hover:bg-white/5"
>
<DotsThree className="size-5" />
</button>
</div>
</button>
);
}

View File

@@ -0,0 +1,63 @@
import { User } from "@/components/user";
import { X } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Link, createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/$account/contacts")({
component: Screen,
});
function Screen() {
const params = Route.useParams();
const contacts = Route.useLoaderData();
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full flex flex-col"
>
<div
data-tauri-drag-region
className="h-12 shrink-0 flex items-center justify-between px-3.5"
>
<div />
<div className="text-sm font-semibold uppercase">Contact List</div>
<div className="inline-flex items-center justify-end">
<Link
to="/$account/chats/new"
params={{ account: params.account }}
className="size-7 inline-flex items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/5"
>
<X className="size-5" />
</Link>
</div>
</div>
<ScrollArea.Viewport className="relative h-full flex-1 px-3.5 pb-3.5">
<div className="grid grid-cols-4 gap-3">
{contacts.map((contact) => (
<Link
key={contact}
to="/$account/chats/$id"
params={{ account: params.account, id: contact }}
>
<User.Provider key={contact} pubkey={contact}>
<User.Root className="h-44 flex flex-col items-center justify-center gap-3 p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5">
<User.Avatar className="size-16 rounded-full" />
<User.Name className="text-sm font-medium" />
</User.Root>
</User.Provider>
</Link>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/40 dark:bg-white/40 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}

View File

@@ -0,0 +1,14 @@
import { commands } from "@/commands";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/contacts")({
loader: async () => {
const res = await commands.getContactList();
if (res.status === "ok") {
return res.data;
} else {
return [];
}
},
});

View File

@@ -1,5 +0,0 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/contacts')({
component: () => <div>Hello /contacts!</div>
})

View File

@@ -2,7 +2,22 @@
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
extend: {
keyframes: {
overlay: {
from: { opacity: '0' },
to: { opacity: '1' },
},
content: {
from: { opacity: '0', transform: 'translate(-50%, -48%) scale(0.96)' },
to: { opacity: '1', transform: 'translate(-50%, -50%) scale(1)' },
},
},
animation: {
overlay: 'overlay 150ms cubic-bezier(0.16, 1, 0.3, 1)',
content: 'content 150ms cubic-bezier(0.16, 1, 0.3, 1)',
},
},
},
plugins: [],
};