Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 595f7c3888 | |||
|
825be4eb33 |
|||
|
1c90a0c859 |
|||
| 6dee7e8dcc | |||
| 41258a8483 |
32 changed files with 766 additions and 368 deletions
236
apps/tauri/src-tauri/Cargo.lock
generated
236
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -685,6 +685,12 @@ dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
|
|
@ -917,6 +923,7 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
|
"digest 0.10.7",
|
||||||
"fiat-crypto 0.2.9",
|
"fiat-crypto 0.2.9",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -1001,14 +1008,25 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.9.6",
|
||||||
|
"pem-rfc7468 0.7.0",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
|
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468 1.0.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1073,7 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
]
|
]
|
||||||
|
|
@ -1235,14 +1253,38 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8 0.10.2",
|
||||||
|
"signature 2.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8 0.11.0",
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek 4.1.3",
|
||||||
|
"ed25519 2.2.3",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1252,10 +1294,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek 5.0.0",
|
"curve25519-dalek 5.0.0",
|
||||||
"ed25519",
|
"ed25519 3.0.0",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
@ -2775,14 +2817,14 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
|
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"module-lattice",
|
"module-lattice",
|
||||||
"pkcs8",
|
"pkcs8 0.11.0",
|
||||||
"shake",
|
"shake",
|
||||||
"signature",
|
"signature 3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2825,31 +2867,44 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp"
|
name = "mtp"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-client",
|
"mtp-client",
|
||||||
"mtp-codec",
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-crypto",
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-host",
|
"mtp-host",
|
||||||
"mtp-transport",
|
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-type-map",
|
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-webserver",
|
"mtp-webserver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-client"
|
name = "mtp-client"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-crypto",
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-transport",
|
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-codec"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"byteorder",
|
||||||
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"rand 0.10.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-codec"
|
name = "mtp-codec"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2857,12 +2912,22 @@ source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856b
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"mtp-crypto",
|
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"mtp-type-map",
|
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-common"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
|
dependencies = [
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"wtransport",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-common"
|
name = "mtp-common"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2877,11 +2942,11 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-crypto"
|
name = "mtp-crypto"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek 2.2.0",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"ml-dsa",
|
"ml-dsa",
|
||||||
|
|
@ -2891,6 +2956,26 @@ dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-crypto"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"chacha20poly1305",
|
||||||
|
"ed25519-dalek 3.0.0",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"hkdf",
|
||||||
|
"ml-dsa",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"rustls",
|
||||||
|
"sha2 0.11.0",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|
@ -2899,27 +2984,45 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-host"
|
name = "mtp-host"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-crypto",
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-transport",
|
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wtransport",
|
"wtransport",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-transport"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
|
"rcgen",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"wtransport",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-transport"
|
name = "mtp-transport"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"mtp-codec",
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"mtp-crypto",
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
|
|
@ -2929,6 +3032,15 @@ dependencies = [
|
||||||
"wtransport",
|
"wtransport",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-type-map"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_yaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-type-map"
|
name = "mtp-type-map"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2941,7 +3053,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-webserver"
|
name = "mtp-webserver"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2952,11 +3064,11 @@ dependencies = [
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"mtp-codec",
|
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-common",
|
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-crypto",
|
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"mtp-host",
|
"mtp-host",
|
||||||
"mtp-transport",
|
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|
@ -3460,6 +3572,15 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pem-rfc7468"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -3545,14 +3666,24 @@ dependencies = [
|
||||||
"futures-io",
|
"futures-io",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der 0.7.10",
|
||||||
|
"spki 0.7.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der",
|
"der 0.8.1",
|
||||||
"spki",
|
"spki 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4604,6 +4735,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
|
|
@ -4712,6 +4852,16 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der 0.7.10",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spki"
|
name = "spki"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -4719,7 +4869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"der",
|
"der 0.8.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5352,7 +5502,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"mtp",
|
"mtp",
|
||||||
"mtp-transport",
|
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b", features = ["client", "crypto"] }
|
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b", features = ["client", "crypto"] }
|
||||||
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
|
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ export function Basic({
|
||||||
user,
|
user,
|
||||||
extra,
|
extra,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: Pick<
|
||||||
|
User,
|
||||||
|
"Display" | "Username" | "Avatar" | "OnlineStatus" | "Status"
|
||||||
|
>;
|
||||||
extra?: React.ReactNode;
|
extra?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const display = user.Display || user.Username || "Unknown";
|
const display = user.Display || user.Username || "Unknown";
|
||||||
|
|
@ -57,8 +60,9 @@ export function Basic({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]">
|
<div className="flex w-full flex-col items-start justify-center text-[15px]">
|
||||||
<p>{display}</p>
|
<p>{display}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.Status}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pr-1">{extra}</div>
|
<div className="pr-1">{extra}</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,20 @@ import { Text } from "@methanium/ui/markdown";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function Profile({ user }: { user: User }) {
|
export default function Profile({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: Pick<
|
||||||
|
User,
|
||||||
|
| "UserId"
|
||||||
|
| "Display"
|
||||||
|
| "Username"
|
||||||
|
| "Avatar"
|
||||||
|
| "About"
|
||||||
|
| "IotaId"
|
||||||
|
| "PublicKey"
|
||||||
|
>;
|
||||||
|
}) {
|
||||||
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
{pathname === "/chat" && id && (
|
{pathname === "/chat" && id && (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={id}
|
userId={id}
|
||||||
|
fields={[
|
||||||
|
"UserId",
|
||||||
|
"Display",
|
||||||
|
"Username",
|
||||||
|
"Avatar",
|
||||||
|
"About",
|
||||||
|
"IotaId",
|
||||||
|
"PublicKey",
|
||||||
|
]}
|
||||||
component={(user) =>
|
component={(user) =>
|
||||||
isMobile ? (
|
isMobile ? (
|
||||||
<p className="font-medium text-[1.07rem]">{user?.Display}</p>
|
<p className="font-medium text-[1.07rem]">{user?.Display}</p>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import SidebarBox from "@tensamin/call/sidebarBox";
|
||||||
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
||||||
import { Ellipsis, Check } from "lucide-react";
|
import { Ellipsis, Check } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
|
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,7 +64,7 @@ function StatusDialog({
|
||||||
saveSucceeded,
|
saveSucceeded,
|
||||||
setSaveSucceeded,
|
setSaveSucceeded,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: Pick<User, "UserId" | "OnlineStatus" | "Status">;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
send: ReturnType<typeof useMTP>["send"];
|
send: ReturnType<typeof useMTP>["send"];
|
||||||
|
|
@ -77,6 +77,8 @@ function StatusDialog({
|
||||||
saveSucceeded: boolean;
|
saveSucceeded: boolean;
|
||||||
setSaveSucceeded: (value: boolean) => void;
|
setSaveSucceeded: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { updateProfile, updateState } = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -137,9 +139,10 @@ function StatusDialog({
|
||||||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const payload = { UserState: draftOnlineStatus };
|
const validation = mtp.ChangeUserData.request.safeParse({
|
||||||
|
OnlineStatus: draftOnlineStatus,
|
||||||
const validation = mtp.SetUserState.request.safeParse(payload);
|
Status: draftStatus,
|
||||||
|
});
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
setSaveSucceeded(false);
|
setSaveSucceeded(false);
|
||||||
|
|
@ -150,17 +153,9 @@ function StatusDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await send("SetUserState", validation.data);
|
await send("ChangeUserData", validation.data);
|
||||||
const profileValidation = mtp.ChangeUserData.request.safeParse({
|
updateState(user.UserId, draftOnlineStatus);
|
||||||
Status: draftStatus,
|
await updateProfile(user.UserId, { Status: draftStatus });
|
||||||
});
|
|
||||||
if (!profileValidation.success) {
|
|
||||||
throw new Error(
|
|
||||||
profileValidation.error.issues[0]?.message ??
|
|
||||||
"Invalid status data",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await send("ChangeUserData", profileValidation.data);
|
|
||||||
setSaveSucceeded(true);
|
setSaveSucceeded(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -209,6 +204,14 @@ export default function Sidebar() {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
userId={"own"}
|
userId={"own"}
|
||||||
|
fields={[
|
||||||
|
"UserId",
|
||||||
|
"Display",
|
||||||
|
"Username",
|
||||||
|
"Avatar",
|
||||||
|
"OnlineStatus",
|
||||||
|
"Status",
|
||||||
|
]}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<>
|
<>
|
||||||
<Basic
|
<Basic
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export const onlineStatusOptions = [
|
||||||
{ label: "Online", value: "user_online" },
|
{ label: "Online", value: "user_online" },
|
||||||
{ label: "Idle", value: "user_idle" },
|
{ label: "Idle", value: "user_idle" },
|
||||||
{ label: "Do not disturb", value: "user_dnd" },
|
{ label: "Do not disturb", value: "user_dnd" },
|
||||||
{ label: "Away", value: "user_wc" },
|
{ label: "On the toilet", value: "user_wc" },
|
||||||
{ label: "Offline", value: "user_invisible" },
|
{ label: "Offline", value: "user_invisible" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ export default function ConversationModal({ userId }: { userId: number }) {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
loading={<Loading />}
|
loading={<Loading />}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
fields={[
|
||||||
|
"Display",
|
||||||
|
"Username",
|
||||||
|
"Avatar",
|
||||||
|
"OnlineStatus",
|
||||||
|
"Status",
|
||||||
|
]}
|
||||||
component={(user) => <Basic user={user} />}
|
component={(user) => <Basic user={user} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export default function InviteButton({
|
||||||
<Wrapper
|
<Wrapper
|
||||||
key={contact.UserId}
|
key={contact.UserId}
|
||||||
userId={contact.UserId}
|
userId={contact.UserId}
|
||||||
|
fields={["Display"]}
|
||||||
loading={<div>Loading...</div>}
|
loading={<div>Loading...</div>}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export default function InvitePopup({
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={user}
|
userId={user}
|
||||||
|
fields={["Avatar", "Display"]}
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,15 @@ import {
|
||||||
} from "../../store";
|
} from "../../store";
|
||||||
import { Track, type Participant } from "livekit-client";
|
import { Track, type Participant } from "livekit-client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import { type SelectedUser, useUserFields } from "@tensamin/user/context";
|
||||||
import { useIsSpeaking } from "../../speakingState";
|
import { useIsSpeaking } from "../../speakingState";
|
||||||
import VideoViewer from "../videoViewer";
|
import VideoViewer from "../videoViewer";
|
||||||
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
|
||||||
import ContextMenu from "./contextMenu";
|
import ContextMenu from "./contextMenu";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
|
const USER_FIELDS = ["UserId", "Avatar", "Display"] as const;
|
||||||
|
|
||||||
function getTrackPublicationBySource(
|
function getTrackPublicationBySource(
|
||||||
participant: Participant | undefined,
|
participant: Participant | undefined,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
|
|
@ -113,7 +115,7 @@ function Overlay({
|
||||||
participant,
|
participant,
|
||||||
}: {
|
}: {
|
||||||
type: "user" | "stream";
|
type: "user" | "stream";
|
||||||
user: User;
|
user: SelectedUser<typeof USER_FIELDS>;
|
||||||
participant: Participant;
|
participant: Participant;
|
||||||
}) {
|
}) {
|
||||||
const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true;
|
const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true;
|
||||||
|
|
@ -163,12 +165,16 @@ export default function Base({
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
flush?: boolean;
|
flush?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { get } = useUser();
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
|
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
const view = useCall((state) => state.view);
|
const view = useCall((state) => state.view);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const participantId = Number(participant?.identity);
|
||||||
|
const validParticipantId =
|
||||||
|
participant && Number.isInteger(participantId) && participantId > 0
|
||||||
|
? participantId
|
||||||
|
: null;
|
||||||
|
const { data: user } = useUserFields(validParticipantId, USER_FIELDS);
|
||||||
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
@ -191,31 +197,6 @@ export default function Base({
|
||||||
load("user_id").then(setOwnId);
|
load("user_id").then(setOwnId);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const participantId = Number(participant?.identity);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!participant ||
|
|
||||||
!Number.isInteger(participantId) ||
|
|
||||||
participantId <= 0
|
|
||||||
) {
|
|
||||||
setUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void get(participantId).then((nextUser) => {
|
|
||||||
if (active) {
|
|
||||||
setUser(nextUser);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [participant, get]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type !== "user" || !user?.Avatar) {
|
if (type !== "user" || !user?.Avatar) {
|
||||||
setAvatarBackgroundColor(undefined);
|
setAvatarBackgroundColor(undefined);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
Slider,
|
Slider,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import type { User } from "@tensamin/user/context";
|
|
||||||
import { setParticipantCameraDisabled, useCall } from "../../store";
|
import { setParticipantCameraDisabled, useCall } from "../../store";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
|
@ -23,7 +22,7 @@ export default function ContextMenu({
|
||||||
user,
|
user,
|
||||||
ownId,
|
ownId,
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: Readonly<{ UserId: number }>;
|
||||||
ownId: number;
|
ownId: number;
|
||||||
}) {
|
}) {
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@ import {
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { ScreenShareOff } from "lucide-react";
|
import { ScreenShareOff } from "lucide-react";
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import { useUserFields } from "@tensamin/user/context";
|
||||||
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
|
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
|
||||||
import { getAverageImageColor } from "./modals/base";
|
import { getAverageImageColor } from "./modals/base";
|
||||||
|
|
||||||
|
const USER_FIELDS = ["Avatar", "Display"] as const;
|
||||||
|
|
||||||
function getTrackPublicationBySource(
|
function getTrackPublicationBySource(
|
||||||
participant: Participant | undefined,
|
participant: Participant | undefined,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
|
|
@ -62,10 +64,12 @@ function MobileCallPill({
|
||||||
callId: string;
|
callId: string;
|
||||||
}) {
|
}) {
|
||||||
const { setOpenMobile } = useSidebar();
|
const { setOpenMobile } = useSidebar();
|
||||||
const { get } = useUser();
|
|
||||||
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
|
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
|
||||||
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
|
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
|
||||||
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
|
const { data: lastSpeakingUser } = useUserFields(
|
||||||
|
lastSpeakingParticipantId,
|
||||||
|
USER_FIELDS,
|
||||||
|
);
|
||||||
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
@ -83,25 +87,6 @@ function MobileCallPill({
|
||||||
const [coords, setCoords] = useState(initialCoords);
|
const [coords, setCoords] = useState(initialCoords);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastSpeakingParticipantId == null) {
|
|
||||||
setLastSpeakingUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
void get(lastSpeakingParticipantId).then((user) => {
|
|
||||||
if (mounted) {
|
|
||||||
setLastSpeakingUser(user);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [get, lastSpeakingParticipantId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastSpeakingUser?.Avatar) {
|
if (!lastSpeakingUser?.Avatar) {
|
||||||
setAvatarBackgroundColor(undefined);
|
setAvatarBackgroundColor(undefined);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import { useUserFields } from "@tensamin/user/context";
|
||||||
import { useCall, getRoom } from "../store";
|
import { useCall, getRoom } from "../store";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
@ -11,8 +11,43 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
|
|
||||||
|
const USER_FIELDS = ["Avatar", "Display"] as const;
|
||||||
|
|
||||||
|
function ParticipantAvatar({
|
||||||
|
userId,
|
||||||
|
portalContainer,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
portalContainer?: HTMLElement;
|
||||||
|
}) {
|
||||||
|
const { data: user } = useUserFields(userId, USER_FIELDS);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-ml-4">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Avatar className="size-7">
|
||||||
|
<AvatarImage src={user.Avatar} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
portalProps={{ container: portalContainer }}
|
||||||
|
>
|
||||||
|
{user.Display}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { get } = useUser();
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
const screenRef = useCall((state) => state.screenRef);
|
const screenRef = useCall((state) => state.screenRef);
|
||||||
|
|
@ -31,65 +66,23 @@ export default function TopBar() {
|
||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
).filter((participantId): participantId is number => participantId != null);
|
).filter((participantId): participantId is number => participantId != null);
|
||||||
const userIdsKey = userIds.join(",");
|
const [ownId, setOwnId] = useState<number>();
|
||||||
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
void load("user_id").then(setOwnId);
|
||||||
const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number);
|
}, [load]);
|
||||||
|
|
||||||
void Promise.all(ids.map((id) => get(id)))
|
const participantIds = ownId === undefined ? userIds : [ownId, ...userIds];
|
||||||
.then(async (users) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownId = await load("user_id");
|
|
||||||
const ownUser = await get(ownId);
|
|
||||||
|
|
||||||
setUsers([ownUser, ...users]);
|
|
||||||
})
|
|
||||||
.catch(async () => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownId = await load("user_id");
|
|
||||||
const ownUser = await get(ownId);
|
|
||||||
|
|
||||||
setUsers([ownUser]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [get, userIdsKey, load]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-between h-12">
|
<div className="w-full flex justify-between h-12">
|
||||||
<div className="flex gap-1 m-3 ml-7">
|
<div className="flex gap-1 m-3 ml-7">
|
||||||
{users.map((user) => (
|
{participantIds.map((userId) => (
|
||||||
<div key={user.UserId} className="-ml-4">
|
<ParticipantAvatar
|
||||||
<Tooltip>
|
key={userId}
|
||||||
<TooltipTrigger
|
userId={userId}
|
||||||
render={
|
portalContainer={portalContainer}
|
||||||
<Avatar className="size-7">
|
/>
|
||||||
<AvatarImage src={user.Avatar} />
|
|
||||||
<AvatarFallback className="text-xs">
|
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent
|
|
||||||
side="bottom"
|
|
||||||
portalProps={{ container: portalContainer }}
|
|
||||||
>
|
|
||||||
{user.Display}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ type SendFn = (
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
) => Promise<{ data: unknown }>;
|
) => Promise<{ data: unknown }>;
|
||||||
type LoadFn = (key: string) => Promise<unknown>;
|
type LoadFn = (key: string) => Promise<unknown>;
|
||||||
type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
|
|
||||||
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
|
||||||
|
|
||||||
type Runtime = {
|
type Runtime = {
|
||||||
|
|
@ -80,7 +79,7 @@ type Runtime = {
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
send: SendFn;
|
send: SendFn;
|
||||||
load: LoadFn;
|
load: LoadFn;
|
||||||
getUser: GetUserFn;
|
getPublicKey: (userId: number) => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
||||||
|
|
@ -671,9 +670,7 @@ export async function sendCallInvite(userId: number) {
|
||||||
throw new Error("Cannot send call invite without an active call.");
|
throw new Error("Cannot send call invite without an active call.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const remotePublicKey = await runtime
|
const remotePublicKey = await runtime.getPublicKey(userId);
|
||||||
.getUser(userId)
|
|
||||||
.then((data) => data.PublicKey);
|
|
||||||
const secretId = deriveCallSecretId(callId);
|
const secretId = deriveCallSecretId(callId);
|
||||||
const wrapped = await wrapCallSecret({
|
const wrapped = await wrapCallSecret({
|
||||||
callSecret,
|
callSecret,
|
||||||
|
|
@ -1255,7 +1252,8 @@ export function useInitializeCall() {
|
||||||
navigate,
|
navigate,
|
||||||
send: send as SendFn,
|
send: send as SendFn,
|
||||||
load: load as LoadFn,
|
load: load as LoadFn,
|
||||||
getUser: get as GetUserFn,
|
getPublicKey: (userId) =>
|
||||||
|
get(userId, ["PublicKey"]).then((user) => user.PublicKey),
|
||||||
});
|
});
|
||||||
}, [get, load, navigate, send]);
|
}, [get, load, navigate, send]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,23 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useCall } from "../store";
|
import { useCall } from "../store";
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import { useUserFields } from "@tensamin/user/context";
|
||||||
|
|
||||||
|
const USER_FIELDS = ["Display"] as const;
|
||||||
|
|
||||||
|
function Participant({ userId }: { userId: number }) {
|
||||||
|
const { data: user } = useUserFields(userId, USER_FIELDS);
|
||||||
|
return user ? <p className="text-2xl">User: {user.Display}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Preview() {
|
export default function Preview() {
|
||||||
const { get } = useUser();
|
|
||||||
const currentCallData = useCall((state) => state.currentCallData);
|
const currentCallData = useCall((state) => state.currentCallData);
|
||||||
|
|
||||||
const [data, setData] = useState<User[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
if (!currentCallData?.exists) {
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void Promise.all(currentCallData.UserIds.map((id) => get(id)))
|
|
||||||
.then((users) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(users);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setData([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [currentCallData, get]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
{currentCallData?.exists ? (
|
{currentCallData?.exists ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{data.map((user) => {
|
{currentCallData.UserIds.map((userId) => (
|
||||||
return (
|
<Participant key={userId} userId={userId} />
|
||||||
<p key={user.UserId} className="text-2xl">
|
))}
|
||||||
User: {user.Display}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-2xl">Call expired</p>
|
<p className="text-2xl">Call expired</p>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react";
|
import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react";
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
import MediaSaveButton from "./mediaSaveButton";
|
||||||
import { type User, useUser } from "@tensamin/user/context";
|
import { useUserFields } from "@tensamin/user/context";
|
||||||
|
|
||||||
const zoomLevels = [1, 1.5, 2, 3];
|
const zoomLevels = [1, 1.5, 2, 3];
|
||||||
|
|
||||||
|
|
@ -46,12 +46,7 @@ export default function Media({
|
||||||
const wheelDelta = useRef(0);
|
const wheelDelta = useRef(0);
|
||||||
const hostname = useMemo(() => new URL(link).hostname, [link]);
|
const hostname = useMemo(() => new URL(link).hostname, [link]);
|
||||||
|
|
||||||
// Get user's
|
const { data: user } = useUserFields(senderId, ["Avatar", "Display"]);
|
||||||
const { get } = useUser();
|
|
||||||
const [user, setUser] = useState<undefined | User>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
get(senderId).then(setUser);
|
|
||||||
}, [senderId, get]);
|
|
||||||
const avatar = user?.Avatar
|
const avatar = user?.Avatar
|
||||||
? `data:image/webp;base64,${user.Avatar}`
|
? `data:image/webp;base64,${user.Avatar}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import { type SelectedUser, useUserFields } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -21,7 +21,6 @@ import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
|
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
|
||||||
import { useRecordEmojiUse } from "./emojiRanks";
|
import { useRecordEmojiUse } from "./emojiRanks";
|
||||||
import { useUser } from "@tensamin/user/context";
|
|
||||||
import ReplyBox from "./replyBox";
|
import ReplyBox from "./replyBox";
|
||||||
import { useHotkey } from "@tensamin/hotkeys";
|
import { useHotkey } from "@tensamin/hotkeys";
|
||||||
import { cancelMessageEditHotkey } from "../hotkeys";
|
import { cancelMessageEditHotkey } from "../hotkeys";
|
||||||
|
|
@ -40,7 +39,7 @@ function MessageComponent({
|
||||||
decryptionFailed?: boolean;
|
decryptionFailed?: boolean;
|
||||||
};
|
};
|
||||||
onSetEditing: (editing: boolean) => void;
|
onSetEditing: (editing: boolean) => void;
|
||||||
user: User | null;
|
user: SelectedUser<readonly ["UserId", "Avatar", "Display"]> | null;
|
||||||
}) {
|
}) {
|
||||||
const actuallyFailed =
|
const actuallyFailed =
|
||||||
(message.failed && message.MessageState === "awaiting") ||
|
(message.failed && message.MessageState === "awaiting") ||
|
||||||
|
|
@ -150,13 +149,14 @@ function MessageComponent({
|
||||||
removeReaction,
|
removeReaction,
|
||||||
replyTo,
|
replyTo,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
const { get: getUser } = useUser();
|
|
||||||
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
|
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
|
||||||
const [replyUser, setReplyUser] = useState<User | null>(null);
|
const { data: replyUser } = useUserFields(replyMessage?.SenderId ?? null, [
|
||||||
|
"Avatar",
|
||||||
|
"Display",
|
||||||
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!message.ReplyId || !ownId || !chatSecret) {
|
if (!message.ReplyId || !ownId || !chatSecret) {
|
||||||
setReplyMessage(null);
|
setReplyMessage(null);
|
||||||
setReplyUser(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,25 +168,20 @@ function MessageComponent({
|
||||||
send,
|
send,
|
||||||
})
|
})
|
||||||
.then(async (reply) => {
|
.then(async (reply) => {
|
||||||
const [Content, author] = await Promise.all([
|
const Content = await decryptChatText(chatSecret, reply.Content);
|
||||||
decryptChatText(chatSecret, reply.Content),
|
|
||||||
getUser(reply.SenderId),
|
|
||||||
]);
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setReplyMessage({ ...reply, Content });
|
setReplyMessage({ ...reply, Content });
|
||||||
setReplyUser(author);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setReplyMessage(null);
|
setReplyMessage(null);
|
||||||
setReplyUser(null);
|
|
||||||
log(1, "chat", "red", "Failed to get replied-to message", err);
|
log(1, "chat", "red", "Failed to get replied-to message", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
}, [chatSecret, message.ReplyId, ownId, send, userId]);
|
||||||
const recordUse = useRecordEmojiUse();
|
const recordUse = useRecordEmojiUse();
|
||||||
const editingRef = useRef(editing);
|
const editingRef = useRef(editing);
|
||||||
const onSetEditingRef = useRef(onSetEditing);
|
const onSetEditingRef = useRef(onSetEditing);
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { Text } from "@methanium/ui/markdown";
|
import { Text } from "@methanium/ui/markdown";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { SelectedUser } from "@tensamin/user/context";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { Forward, X } from "lucide-react";
|
import { Forward, X } from "lucide-react";
|
||||||
|
|
||||||
function ReplyUser({ user }: { user: User }) {
|
type ReplyUserData = SelectedUser<readonly ["Avatar", "Display"]>;
|
||||||
|
|
||||||
|
function ReplyUser({ user }: { user: ReplyUserData }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<Avatar className="h-5 w-5 shrink-0">
|
<Avatar className="h-5 w-5 shrink-0">
|
||||||
|
|
@ -40,7 +42,7 @@ export default function ReplyBox({
|
||||||
content?: string;
|
content?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
user?: User;
|
user?: ReplyUserData;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -71,6 +73,7 @@ export default function ReplyBox({
|
||||||
) : userId ? (
|
) : userId ? (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
fields={["Avatar", "Display"]}
|
||||||
loading={<Skeleton className="h-5 w-5 rounded-full" />}
|
loading={<Skeleton className="h-5 w-5 rounded-full" />}
|
||||||
component={(resolvedUser) => <ReplyUser user={resolvedUser} />}
|
component={(resolvedUser) => <ReplyUser user={resolvedUser} />}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
secretId,
|
secretId,
|
||||||
version: CHAT_SECRET_VERSION,
|
version: CHAT_SECRET_VERSION,
|
||||||
});
|
});
|
||||||
const peerUser = await getUser(userIdValue);
|
const peerUser = await getUser(userIdValue, ["PublicKey"]);
|
||||||
const peerWrapped = await wrapChatSecret({
|
const peerWrapped = await wrapChatSecret({
|
||||||
chatSecret: rawSecret,
|
chatSecret: rawSecret,
|
||||||
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
|
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,7 @@ export default function Screen() {
|
||||||
<Wrapper
|
<Wrapper
|
||||||
key={getMessageRenderKey(message)}
|
key={getMessageRenderKey(message)}
|
||||||
userId={message.SenderId}
|
userId={message.SenderId}
|
||||||
|
fields={["UserId", "Avatar", "Display"]}
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@
|
||||||
- Drop any unique reactions above 10
|
- Drop any unique reactions above 10
|
||||||
- Reply jumping
|
- Reply jumping
|
||||||
- Add emoji picker
|
- Add emoji picker
|
||||||
|
- Improve arrow-up hotkey
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,11 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await get(data.SenderId);
|
const user = await get(data.SenderId, [
|
||||||
|
"UserId",
|
||||||
|
"Display",
|
||||||
|
"Avatar",
|
||||||
|
]);
|
||||||
|
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
if (!appFocused) return;
|
if (!appFocused) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1 @@
|
||||||
- Tauri notifications
|
|
||||||
- Mobile notifications channel setup
|
|
||||||
- Add notification symbol / outline to user modal
|
- Add notification symbol / outline to user modal
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,25 @@ import {
|
||||||
Input,
|
Input,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
import {
|
||||||
|
useUser,
|
||||||
|
useUserFields,
|
||||||
|
type SelectedUser,
|
||||||
|
} from "@tensamin/user/context";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const PROFILE_FIELDS = [
|
||||||
|
"Avatar",
|
||||||
|
"Display",
|
||||||
|
"Username",
|
||||||
|
"About",
|
||||||
|
"UserId",
|
||||||
|
] as const;
|
||||||
|
type ProfileDraft = Partial<
|
||||||
|
Omit<SelectedUser<typeof PROFILE_FIELDS>, "UserId">
|
||||||
|
>;
|
||||||
|
|
||||||
async function prepImage(
|
async function prepImage(
|
||||||
file: File,
|
file: File,
|
||||||
size = 300,
|
size = 300,
|
||||||
|
|
@ -44,12 +59,13 @@ async function prepImage(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { get, update } = useUser();
|
const { updateProfile } = useUser();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send } = useMTP();
|
const { send } = useMTP();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [userId, setUserId] = useState<number | null>(null);
|
||||||
const [draftUser, setDraftUser] = useState<Partial<User>>({});
|
const { data: currentUser } = useUserFields(userId, PROFILE_FIELDS);
|
||||||
|
const [draftUser, setDraftUser] = useState<ProfileDraft>({});
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
||||||
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -57,18 +73,23 @@ export default function Page() {
|
||||||
const effectiveAvatar =
|
const effectiveAvatar =
|
||||||
draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
|
draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
|
||||||
const updateDraftUser = (
|
const updateDraftUser = (
|
||||||
updater: (previous: Partial<User>) => Partial<User>,
|
updater: (previous: ProfileDraft) => ProfileDraft,
|
||||||
) => {
|
) => {
|
||||||
setSaveSucceeded(false);
|
setSaveSucceeded(false);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
setDraftUser(updater);
|
setDraftUser(updater);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => setCurrentUser(await get(await load("user_id"))))();
|
void load("user_id").then(setUserId);
|
||||||
}, [get, load]);
|
}, [load]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || draftInitializedRef.current) return;
|
if (!currentUser || draftInitializedRef.current) return;
|
||||||
setDraftUser(currentUser);
|
setDraftUser({
|
||||||
|
Avatar: currentUser.Avatar,
|
||||||
|
Display: currentUser.Display,
|
||||||
|
Username: currentUser.Username,
|
||||||
|
About: currentUser.About,
|
||||||
|
});
|
||||||
draftInitializedRef.current = true;
|
draftInitializedRef.current = true;
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
async function handleAvatarUpload(file: File) {
|
async function handleAvatarUpload(file: File) {
|
||||||
|
|
@ -127,7 +148,7 @@ export default function Page() {
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateDraftUser((previous) => ({
|
updateDraftUser((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
display: event.target.value,
|
Display: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="Display Name"
|
placeholder="Display Name"
|
||||||
|
|
@ -138,7 +159,7 @@ export default function Page() {
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateDraftUser((previous) => ({
|
updateDraftUser((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
username: event.target.value,
|
Username: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
|
|
@ -150,7 +171,7 @@ export default function Page() {
|
||||||
fontSize=".875rem"
|
fontSize=".875rem"
|
||||||
placeholder="About Me"
|
placeholder="About Me"
|
||||||
setValue={(value) =>
|
setValue={(value) =>
|
||||||
updateDraftUser((previous) => ({ ...previous, about: value }))
|
updateDraftUser((previous) => ({ ...previous, About: value }))
|
||||||
}
|
}
|
||||||
value={draftUser.About || ""}
|
value={draftUser.About || ""}
|
||||||
/>
|
/>
|
||||||
|
|
@ -180,13 +201,7 @@ export default function Page() {
|
||||||
if (response.type.startsWith("Error")) {
|
if (response.type.startsWith("Error")) {
|
||||||
throw new Error(response.type);
|
throw new Error(response.type);
|
||||||
}
|
}
|
||||||
const updatedUser = mtp.GetUserData.response.parse({
|
await updateProfile(currentUser.UserId, validation.data);
|
||||||
...currentUser,
|
|
||||||
...draftUser,
|
|
||||||
});
|
|
||||||
await update(updatedUser);
|
|
||||||
setCurrentUser(updatedUser);
|
|
||||||
setDraftUser(updatedUser);
|
|
||||||
setSaveSucceeded(true);
|
setSaveSucceeded(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -260,20 +260,13 @@ export const mtp = {
|
||||||
UserState: clientUserStateSchema,
|
UserState: clientUserStateSchema,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
SetUserState: {
|
|
||||||
request: z.object({
|
|
||||||
UserState: userPresencePreferenceSchema,
|
|
||||||
}),
|
|
||||||
response: z.object({
|
|
||||||
UserState: userPresencePreferenceSchema,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
ChangeUserData: {
|
ChangeUserData: {
|
||||||
request: z
|
request: z
|
||||||
.object({
|
.object({
|
||||||
About: userFields.About,
|
About: userFields.About,
|
||||||
Avatar: userFields.Avatar,
|
Avatar: userFields.Avatar,
|
||||||
Display: userFields.Display,
|
Display: userFields.Display,
|
||||||
|
OnlineStatus: userPresencePreferenceSchema,
|
||||||
PublicKey: userFields.PublicKey,
|
PublicKey: userFields.PublicKey,
|
||||||
Status: userFields.Status,
|
Status: userFields.Status,
|
||||||
Username: userFields.Username,
|
Username: userFields.Username,
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
- Add `notifications: number` to contacts
|
- Add `notifications: number` to contacts
|
||||||
- Store private key in a device bound session
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
|
"test": "vitest run",
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
|
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
|
||||||
|
|
||||||
|
|
@ -19,9 +21,21 @@ import type z from "zod";
|
||||||
import { createCache } from "@tensamin/cache";
|
import { createCache } from "@tensamin/cache";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
|
import { getChangedUserFields, selectUserFields } from "./selection";
|
||||||
|
|
||||||
|
export { getChangedUserFields, selectUserFields } from "./selection";
|
||||||
|
|
||||||
export type User = z.infer<typeof schemas.GetUserData.response>;
|
export type User = z.infer<typeof schemas.GetUserData.response>;
|
||||||
type ClientUserState = z.infer<typeof clientUserStateSchema>;
|
type ClientUserState = z.infer<typeof clientUserStateSchema>;
|
||||||
|
export type UserField = keyof User;
|
||||||
|
export type UserFields = readonly [UserField, ...UserField[]];
|
||||||
|
export type SelectedUser<Fields extends readonly UserField[]> = Readonly<
|
||||||
|
Pick<User, Fields[number]>
|
||||||
|
>;
|
||||||
|
export type UserProfilePatch = Omit<
|
||||||
|
z.infer<typeof schemas.ChangeUserData.request>,
|
||||||
|
"OnlineStatus"
|
||||||
|
>;
|
||||||
|
|
||||||
export function mergeTransientPresence<T extends { UserId: number }>(
|
export function mergeTransientPresence<T extends { UserId: number }>(
|
||||||
user: T,
|
user: T,
|
||||||
|
|
@ -34,8 +48,22 @@ export function mergeTransientPresence<T extends { UserId: number }>(
|
||||||
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
||||||
|
|
||||||
interface contextValue {
|
interface contextValue {
|
||||||
get(userId: number): Promise<User>;
|
get<const Fields extends UserFields>(
|
||||||
update(user: User): Promise<void>;
|
userId: number,
|
||||||
|
fields: Fields,
|
||||||
|
): Promise<SelectedUser<Fields>>;
|
||||||
|
peek<const Fields extends UserFields>(
|
||||||
|
userId: number,
|
||||||
|
fields: Fields,
|
||||||
|
): SelectedUser<Fields> | undefined;
|
||||||
|
subscribe(
|
||||||
|
userId: number,
|
||||||
|
fields: readonly UserField[],
|
||||||
|
listener: () => void,
|
||||||
|
): () => void;
|
||||||
|
getVersion(userId: number, fields: readonly UserField[]): string;
|
||||||
|
updateProfile(userId: number, patch: UserProfilePatch): Promise<void>;
|
||||||
|
updateState(userId: number, state: ClientUserState): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserContext = createContext<contextValue | undefined>(undefined);
|
const UserContext = createContext<contextValue | undefined>(undefined);
|
||||||
|
|
@ -48,16 +76,24 @@ const UserContext = createContext<contextValue | undefined>(undefined);
|
||||||
export default function UserProvider(props: { children: ReactNode }) {
|
export default function UserProvider(props: { children: ReactNode }) {
|
||||||
const storageRef = useRef<Record<number, User>>({});
|
const storageRef = useRef<Record<number, User>>({});
|
||||||
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
|
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
|
||||||
|
const profileUpdateQueuesRef = useRef(new Map<number, Promise<void>>());
|
||||||
|
const profileGenerationsRef = useRef(new Map<number, number>());
|
||||||
const checkedAtRef = useRef<Record<number, number>>({});
|
const checkedAtRef = useRef<Record<number, number>>({});
|
||||||
const presenceRef = useRef(new Map<number, ClientUserState>());
|
const presenceRef = useRef(new Map<number, ClientUserState>());
|
||||||
const durableProfileRef = useRef<Record<number, User>>({});
|
const durableProfileRef = useRef<Record<number, User>>({});
|
||||||
|
const listenersRef = useRef(
|
||||||
|
new Map<
|
||||||
|
number,
|
||||||
|
Set<{ fields: ReadonlySet<UserField>; listener: () => void }>
|
||||||
|
>(),
|
||||||
|
);
|
||||||
|
const revisionsRef = useRef(new Map<number, Map<UserField, number>>());
|
||||||
|
|
||||||
const { send, subscribePush } = useMTP();
|
const { send, subscribePush } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { contacts } = useSession();
|
const { contacts } = useSession();
|
||||||
const [accountId, setAccountId] = useState<number | null>(null);
|
const [accountId, setAccountId] = useState<number | null>(null);
|
||||||
const [cacheVersion, setCacheVersion] = useState(0);
|
const accountIdRef = useRef<number | null>(null);
|
||||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
|
||||||
const contactsRef = useRef(contacts);
|
const contactsRef = useRef(contacts);
|
||||||
const initialStatesRef = useRef(
|
const initialStatesRef = useRef(
|
||||||
new Map<number, z.infer<typeof publicUserStateSchema>>(),
|
new Map<number, z.infer<typeof publicUserStateSchema>>(),
|
||||||
|
|
@ -71,40 +107,78 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
return mergeTransientPresence(user, presenceRef.current);
|
return mergeTransientPresence(user, presenceRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const publishUser = useCallback((userId: number, user?: User) => {
|
||||||
|
const previous = storageRef.current[userId];
|
||||||
|
if (user) storageRef.current[userId] = user;
|
||||||
|
else delete storageRef.current[userId];
|
||||||
|
|
||||||
|
const changedFields = getChangedUserFields(previous, user);
|
||||||
|
if (changedFields.length === 0) return;
|
||||||
|
|
||||||
|
let revisions = revisionsRef.current.get(userId);
|
||||||
|
if (!revisions) {
|
||||||
|
revisions = new Map();
|
||||||
|
revisionsRef.current.set(userId, revisions);
|
||||||
|
}
|
||||||
|
for (const field of changedFields) {
|
||||||
|
revisions.set(field, (revisions.get(field) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = new Set<UserField>(changedFields);
|
||||||
|
for (const entry of listenersRef.current.get(userId) ?? []) {
|
||||||
|
if ([...entry.fields].some((field) => changed.has(field))) {
|
||||||
|
entry.listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installProfile = useCallback(
|
||||||
|
(profile: User) => {
|
||||||
|
durableProfileRef.current[profile.UserId] = profile;
|
||||||
|
publishUser(profile.UserId, mergeUserPresence(profile));
|
||||||
|
},
|
||||||
|
[mergeUserPresence, publishUser],
|
||||||
|
);
|
||||||
|
|
||||||
const applyUserState = useCallback(
|
const applyUserState = useCallback(
|
||||||
(userId: number, state: ClientUserState, privateState = false) => {
|
(userId: number, state: ClientUserState, privateState = false) => {
|
||||||
|
const currentAccountId = accountIdRef.current;
|
||||||
const known =
|
const known =
|
||||||
userId === accountId ||
|
userId === currentAccountId ||
|
||||||
|
durableProfileRef.current[userId] !== undefined ||
|
||||||
contactsRef.current.some((contact) => contact.UserId === userId);
|
contactsRef.current.some((contact) => contact.UserId === userId);
|
||||||
if (!known) return false;
|
if (!known) return false;
|
||||||
|
|
||||||
if (state === "user_invisible" && userId !== accountId) return false;
|
if (state === "user_invisible" && userId !== currentAccountId)
|
||||||
if (userId === accountId && !privateState) return false;
|
return false;
|
||||||
|
if (userId === currentAccountId && !privateState) return false;
|
||||||
|
if (presenceRef.current.get(userId) === state) return true;
|
||||||
|
|
||||||
presenceRef.current.set(userId, state);
|
presenceRef.current.set(userId, state);
|
||||||
const current = storageRef.current[userId];
|
const profile = durableProfileRef.current[userId];
|
||||||
if (current) storageRef.current[userId] = mergeUserPresence(current);
|
if (profile) publishUser(userId, mergeUserPresence(profile));
|
||||||
setCacheVersion((version) => version + 1);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[accountId, mergeUserPresence],
|
[mergeUserPresence, publishUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removePresence = useCallback((userId: number) => {
|
const removePresence = useCallback(
|
||||||
presenceRef.current.delete(userId);
|
(userId: number) => {
|
||||||
initialStatesRef.current.delete(userId);
|
const removed = presenceRef.current.delete(userId);
|
||||||
delete checkedAtRef.current[userId];
|
initialStatesRef.current.delete(userId);
|
||||||
setCacheVersion((version) => version + 1);
|
delete checkedAtRef.current[userId];
|
||||||
}, []);
|
if (!removed) return;
|
||||||
|
const profile = durableProfileRef.current[userId];
|
||||||
|
if (profile) publishUser(userId, profile);
|
||||||
|
},
|
||||||
|
[publishUser],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStatePush = useCallback(
|
const handleStatePush = useCallback(
|
||||||
async (message: ProtocolMessage) => {
|
async (message: ProtocolMessage) => {
|
||||||
if (!accountId || !sessionId) return;
|
if (!accountId) return;
|
||||||
const data = message.data as Record<string, unknown>;
|
const data = message.data as Record<string, unknown>;
|
||||||
if (message.type === "GetStates") {
|
if (message.type === "GetStates") {
|
||||||
if (Number(data.SessionId) !== sessionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Array.isArray(data.MissingUserIds)) {
|
if (Array.isArray(data.MissingUserIds)) {
|
||||||
for (const userId of data.MissingUserIds) {
|
for (const userId of data.MissingUserIds) {
|
||||||
if (typeof userId === "number") removePresence(userId);
|
if (typeof userId === "number") removePresence(userId);
|
||||||
|
|
@ -126,36 +200,34 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type !== "ClientChanged") return;
|
if (message.type !== "ClientChanged") return;
|
||||||
if (Number(data.SessionId) !== sessionId) return;
|
|
||||||
const parsed = schemas.ClientChanged.response.safeParse(data);
|
const parsed = schemas.ClientChanged.response.safeParse(data);
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
|
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
|
||||||
},
|
},
|
||||||
[accountId, applyUserState, removePresence, sessionId],
|
[accountId, applyUserState, removePresence],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then((accountId) => {
|
void load("user_id").then((accountId) => {
|
||||||
|
accountIdRef.current = accountId;
|
||||||
setAccountId(accountId);
|
setAccountId(accountId);
|
||||||
});
|
});
|
||||||
void load("session_id").then((value) => {
|
|
||||||
setSessionId(value);
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
const getAccountId = useCallback(async () => {
|
||||||
if (!accountId || !sessionId) return;
|
if (accountIdRef.current !== null) return accountIdRef.current;
|
||||||
return subscribePush(handleStatePush);
|
const value = await load("user_id");
|
||||||
}, [accountId, handleStatePush, sessionId, subscribePush]);
|
accountIdRef.current = value;
|
||||||
|
return value;
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
/**
|
useEffect(() => {
|
||||||
* Executes get.
|
if (!accountId) return;
|
||||||
* @param userId Parameter userId.
|
return subscribePush(handleStatePush);
|
||||||
* @returns Promise<User>.
|
}, [accountId, handleStatePush, subscribePush]);
|
||||||
*/
|
|
||||||
const get = useCallback(
|
const loadUser = useCallback(
|
||||||
async (userId: number): Promise<User> => {
|
async (userId: number): Promise<User> => {
|
||||||
void cacheVersion;
|
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
throw new Error("userId is required");
|
throw new Error("userId is required");
|
||||||
}
|
}
|
||||||
|
|
@ -166,39 +238,45 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const cache = createCache(String(accountId ?? userId));
|
const cache = createCache(String(await getAccountId()));
|
||||||
const cachedValue =
|
const cachedValue =
|
||||||
storageRef.current[userId] ?? (await cache.profiles.get(userId));
|
durableProfileRef.current[userId] ??
|
||||||
|
(await cache.profiles.get(userId));
|
||||||
const cachedResult =
|
const cachedResult =
|
||||||
schemas.GetUserData.response.safeParse(cachedValue);
|
schemas.GetUserData.response.safeParse(cachedValue);
|
||||||
const cached = cachedResult.success ? cachedResult.data : undefined;
|
const cached = cachedResult.success ? cachedResult.data : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
durableProfileRef.current[userId] = cached;
|
installProfile(cached);
|
||||||
const merged = mergeUserPresence(cached);
|
|
||||||
storageRef.current[userId] = merged;
|
|
||||||
const checkedAt = checkedAtRef.current[userId];
|
const checkedAt = checkedAtRef.current[userId];
|
||||||
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
|
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
return merged;
|
return storageRef.current[userId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const profileGeneration =
|
||||||
|
profileGenerationsRef.current.get(userId) ?? 0;
|
||||||
const userData = await send("GetUserData", { UserId: userId });
|
const userData = await send("GetUserData", { UserId: userId });
|
||||||
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
||||||
delete storageRef.current[userId];
|
|
||||||
delete durableProfileRef.current[userId];
|
delete durableProfileRef.current[userId];
|
||||||
delete checkedAtRef.current[userId];
|
delete checkedAtRef.current[userId];
|
||||||
|
publishUser(userId);
|
||||||
await cache.profiles.delete(userId);
|
await cache.profiles.delete(userId);
|
||||||
throw new Error("GetUserData failed: user not found");
|
throw new Error("GetUserData failed: user not found");
|
||||||
}
|
}
|
||||||
if (userData.type.startsWith("Error")) {
|
if (userData.type.startsWith("Error")) {
|
||||||
throw new Error(`GetUserData failed: ${userData.type}`);
|
throw new Error(`GetUserData failed: ${userData.type}`);
|
||||||
}
|
}
|
||||||
const user = mergeUserPresence(userData.data);
|
if (
|
||||||
durableProfileRef.current[userId] = userData.data;
|
profileGeneration !==
|
||||||
storageRef.current[userId] = user;
|
(profileGenerationsRef.current.get(userId) ?? 0) &&
|
||||||
|
storageRef.current[userId]
|
||||||
|
) {
|
||||||
|
return storageRef.current[userId];
|
||||||
|
}
|
||||||
|
installProfile(userData.data);
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
return user;
|
return storageRef.current[userId];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cached && storageRef.current[userId]) {
|
if (cached && storageRef.current[userId]) {
|
||||||
checkedAtRef.current[userId] = Date.now();
|
checkedAtRef.current[userId] = Date.now();
|
||||||
|
|
@ -216,26 +294,91 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
delete pendingRef.current[userId];
|
delete pendingRef.current[userId];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[accountId, cacheVersion, mergeUserPresence, send],
|
[getAccountId, installProfile, publishUser, send],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const get = useCallback(
|
||||||
async (user: User) => {
|
async <const Fields extends UserFields>(
|
||||||
const durableProfile = {
|
userId: number,
|
||||||
...user,
|
fields: Fields,
|
||||||
OnlineStatus:
|
): Promise<SelectedUser<Fields>> => {
|
||||||
durableProfileRef.current[user.UserId]?.OnlineStatus ??
|
return selectUserFields(await loadUser(userId), fields);
|
||||||
user.OnlineStatus,
|
|
||||||
} as User;
|
|
||||||
await createCache(String(accountId ?? user.UserId)).profiles.put(
|
|
||||||
durableProfile,
|
|
||||||
);
|
|
||||||
durableProfileRef.current[user.UserId] = durableProfile;
|
|
||||||
storageRef.current[user.UserId] = mergeUserPresence(durableProfile);
|
|
||||||
checkedAtRef.current[user.UserId] = Date.now();
|
|
||||||
setCacheVersion((version) => version + 1);
|
|
||||||
},
|
},
|
||||||
[accountId, mergeUserPresence],
|
[loadUser],
|
||||||
|
);
|
||||||
|
|
||||||
|
const peek = useCallback(
|
||||||
|
<const Fields extends UserFields>(userId: number, fields: Fields) => {
|
||||||
|
const user = storageRef.current[userId];
|
||||||
|
return user ? selectUserFields(user, fields) : undefined;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(userId: number, fields: readonly UserField[], listener: () => void) => {
|
||||||
|
let listeners = listenersRef.current.get(userId);
|
||||||
|
if (!listeners) {
|
||||||
|
listeners = new Set();
|
||||||
|
listenersRef.current.set(userId, listeners);
|
||||||
|
}
|
||||||
|
const entry = { fields: new Set(fields), listener };
|
||||||
|
listeners.add(entry);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(entry);
|
||||||
|
if (listeners.size === 0) listenersRef.current.delete(userId);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getVersion = useCallback(
|
||||||
|
(userId: number, fields: readonly UserField[]) => {
|
||||||
|
const revisions = revisionsRef.current.get(userId);
|
||||||
|
return fields
|
||||||
|
.map((field) => `${field}:${revisions?.get(field) ?? 0}`)
|
||||||
|
.join("|");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateProfile = useCallback(
|
||||||
|
async (userId: number, patch: UserProfilePatch) => {
|
||||||
|
const previousUpdate = profileUpdateQueuesRef.current.get(userId);
|
||||||
|
const update = (
|
||||||
|
previousUpdate?.catch(() => undefined) ?? Promise.resolve()
|
||||||
|
).then(async () => {
|
||||||
|
const current = durableProfileRef.current[userId];
|
||||||
|
if (!current) throw new Error(`User ${userId} is not loaded`);
|
||||||
|
profileGenerationsRef.current.set(
|
||||||
|
userId,
|
||||||
|
(profileGenerationsRef.current.get(userId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
const profile = schemas.GetUserData.response.parse({
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
installProfile(profile);
|
||||||
|
checkedAtRef.current[userId] = Date.now();
|
||||||
|
await createCache(String(await getAccountId())).profiles.put(profile);
|
||||||
|
});
|
||||||
|
profileUpdateQueuesRef.current.set(userId, update);
|
||||||
|
try {
|
||||||
|
await update;
|
||||||
|
} finally {
|
||||||
|
if (profileUpdateQueuesRef.current.get(userId) === update) {
|
||||||
|
profileUpdateQueuesRef.current.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getAccountId, installProfile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateState = useCallback(
|
||||||
|
(userId: number, state: ClientUserState) => {
|
||||||
|
applyUserState(userId, state, true);
|
||||||
|
},
|
||||||
|
[applyUserState],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -247,7 +390,7 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
].filter((userId, index, all) => all.indexOf(userId) === index);
|
].filter((userId, index, all) => all.indexOf(userId) === index);
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
await get(userId);
|
await loadUser(userId);
|
||||||
const state = initialStatesRef.current.get(userId);
|
const state = initialStatesRef.current.get(userId);
|
||||||
if (state && applyUserState(userId, state)) {
|
if (state && applyUserState(userId, state)) {
|
||||||
initialStatesRef.current.delete(userId);
|
initialStatesRef.current.delete(userId);
|
||||||
|
|
@ -257,12 +400,22 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [accountId, applyUserState, contacts, get]);
|
}, [accountId, applyUserState, contacts, loadUser]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
get,
|
||||||
|
peek,
|
||||||
|
subscribe,
|
||||||
|
getVersion,
|
||||||
|
updateProfile,
|
||||||
|
updateState,
|
||||||
|
}),
|
||||||
|
[get, getVersion, peek, subscribe, updateProfile, updateState],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={{ get, update }}>
|
<UserContext.Provider value={value}>{props.children}</UserContext.Provider>
|
||||||
{props.children}
|
|
||||||
</UserContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,3 +431,63 @@ export function useUser(): contextValue {
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserLoadState<Fields extends readonly UserField[]> =
|
||||||
|
| { data: undefined; error: undefined; loading: true }
|
||||||
|
| { data: undefined; error: unknown; loading: false }
|
||||||
|
| { data: SelectedUser<Fields>; error: undefined; loading: false };
|
||||||
|
|
||||||
|
export function useUserFields<const Fields extends UserFields>(
|
||||||
|
userId: number | null,
|
||||||
|
fields: Fields,
|
||||||
|
): UserLoadState<Fields> {
|
||||||
|
const { get, getVersion, peek, subscribe } = useUser();
|
||||||
|
const fieldKey = fields.join("|");
|
||||||
|
const selectedFields = useMemo(
|
||||||
|
() => fieldKey.split("|") as unknown as Fields,
|
||||||
|
[fieldKey],
|
||||||
|
);
|
||||||
|
const requestKey = `${userId ?? "unresolved"}:${fieldKey}`;
|
||||||
|
const [failure, setFailure] = useState<{
|
||||||
|
error: unknown;
|
||||||
|
requestKey: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const version = useSyncExternalStore(
|
||||||
|
useCallback(
|
||||||
|
(listener) =>
|
||||||
|
userId === null
|
||||||
|
? () => undefined
|
||||||
|
: subscribe(userId, selectedFields, listener),
|
||||||
|
[selectedFields, subscribe, userId],
|
||||||
|
),
|
||||||
|
useCallback(
|
||||||
|
() =>
|
||||||
|
userId === null ? "unresolved" : getVersion(userId, selectedFields),
|
||||||
|
[getVersion, selectedFields, userId],
|
||||||
|
),
|
||||||
|
() => "server",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId === null) return;
|
||||||
|
let active = true;
|
||||||
|
void get(userId, selectedFields).catch((nextError: unknown) => {
|
||||||
|
if (active) setFailure({ error: nextError, requestKey });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [get, requestKey, selectedFields, userId]);
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
void version;
|
||||||
|
return userId === null ? undefined : peek(userId, selectedFields);
|
||||||
|
}, [peek, selectedFields, userId, version]);
|
||||||
|
|
||||||
|
if (data) return { data, error: undefined, loading: false };
|
||||||
|
if (failure?.requestKey === requestKey) {
|
||||||
|
return { data: undefined, error: failure.error, loading: false };
|
||||||
|
}
|
||||||
|
return { data: undefined, error: undefined, loading: true };
|
||||||
|
}
|
||||||
|
|
|
||||||
28
packages/user/src/selection.test.ts
Normal file
28
packages/user/src/selection.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { User } from "./context";
|
||||||
|
import { getChangedUserFields, selectUserFields } from "./selection";
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
Avatar: undefined,
|
||||||
|
Display: "Alice",
|
||||||
|
IotaId: 1,
|
||||||
|
OmikronConnections: [],
|
||||||
|
OnlineStatus: "user_online",
|
||||||
|
PublicKey: "AA==",
|
||||||
|
SubEnd: 0,
|
||||||
|
SubLevel: 0,
|
||||||
|
UserId: 1,
|
||||||
|
Username: "alice",
|
||||||
|
} satisfies User;
|
||||||
|
|
||||||
|
describe("presence selection", () => {
|
||||||
|
it("notifies OnlineStatus selections when presence changes", () => {
|
||||||
|
const next = { ...user, OnlineStatus: "user_dnd" as const };
|
||||||
|
|
||||||
|
expect(getChangedUserFields(user, next)).toEqual(["OnlineStatus"]);
|
||||||
|
expect(selectUserFields(next, ["OnlineStatus"])).toEqual({
|
||||||
|
OnlineStatus: "user_dnd",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/user/src/selection.ts
Normal file
40
packages/user/src/selection.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { User, UserField, UserFields, SelectedUser } from "./context";
|
||||||
|
|
||||||
|
const USER_FIELD_MAP = {
|
||||||
|
About: true,
|
||||||
|
Avatar: true,
|
||||||
|
Display: true,
|
||||||
|
IotaId: true,
|
||||||
|
OmikronConnections: true,
|
||||||
|
OmikronId: true,
|
||||||
|
OnlineStatus: true,
|
||||||
|
PublicKey: true,
|
||||||
|
Status: true,
|
||||||
|
SubEnd: true,
|
||||||
|
SubLevel: true,
|
||||||
|
UserId: true,
|
||||||
|
Username: true,
|
||||||
|
} satisfies Record<UserField, true>;
|
||||||
|
|
||||||
|
const USER_FIELDS = Object.keys(USER_FIELD_MAP) as UserField[];
|
||||||
|
|
||||||
|
export function selectUserFields<const Fields extends UserFields>(
|
||||||
|
user: User,
|
||||||
|
fields: Fields,
|
||||||
|
): SelectedUser<Fields> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
fields.map((field) => [field, user[field]]),
|
||||||
|
) as SelectedUser<Fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChangedUserFields(
|
||||||
|
previous: User | undefined,
|
||||||
|
next: User | undefined,
|
||||||
|
): UserField[] {
|
||||||
|
if ((previous === undefined) !== (next === undefined)) {
|
||||||
|
return [...USER_FIELDS];
|
||||||
|
}
|
||||||
|
return USER_FIELDS.filter(
|
||||||
|
(field) => !Object.is(previous?.[field], next?.[field]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,47 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useUser, type User } from "./context";
|
import { type SelectedUser, type UserFields, useUserFields } from "./context";
|
||||||
|
|
||||||
import { failedUser } from "@tensamin/shared/data";
|
import { failedUser } from "@tensamin/shared/data";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
// Wrapper function to pass user data to some component
|
// Wrapper function to pass user data to some component
|
||||||
export default function Wrapper(props: {
|
export default function Wrapper<const Fields extends UserFields>(props: {
|
||||||
userId: number | "own";
|
userId: number | "own";
|
||||||
|
fields: Fields;
|
||||||
loading: React.ReactNode;
|
loading: React.ReactNode;
|
||||||
component: (user: User) => React.ReactNode;
|
component: (user: SelectedUser<Fields>) => React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { get } = useUser();
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [ownUserId, setOwnUserId] = useState<number | null>(null);
|
||||||
|
const [ownUserError, setOwnUserError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.userId == null) return;
|
if (props.userId !== "own") return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
|
void load("user_id").then(
|
||||||
void (async () => {
|
(value) => {
|
||||||
try {
|
|
||||||
const userId =
|
|
||||||
props.userId === "own" ? await load("user_id") : props.userId;
|
|
||||||
const value = await get(userId);
|
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setUser(value);
|
setOwnUserId(value);
|
||||||
|
setOwnUserError(false);
|
||||||
}
|
}
|
||||||
} catch {
|
},
|
||||||
if (active) {
|
() => {
|
||||||
setUser(failedUser);
|
if (active) setOwnUserError(true);
|
||||||
}
|
},
|
||||||
}
|
);
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [load, get, props.userId]);
|
}, [load, props.userId]);
|
||||||
|
|
||||||
if (props.userId == null) {
|
const userId = props.userId === "own" ? ownUserId : props.userId;
|
||||||
return <>{props.component(failedUser)}</>;
|
const result = useUserFields(userId, props.fields);
|
||||||
|
if (
|
||||||
|
(props.userId === "own" && ownUserError) ||
|
||||||
|
(!result.loading && !result.data)
|
||||||
|
) {
|
||||||
|
return <>{props.component(failedUser as SelectedUser<Fields>)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{user ? props.component(user) : props.loading}</>;
|
return <>{result.data ? props.component(result.data) : props.loading}</>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue