Compare commits

..
Author SHA1 Message Date
595f7c3888 chore(deps): update mtp digest to b067614
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 14m18s
Dependency builds / Build desktop (pull_request) Failing after 15m12s
Dependency builds / Build mobile (pull_request) Failing after 15m12s
2026-08-10 14:00:29 +02:00
825be4eb33
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m17s
/ build-mobile (push) Failing after 10m56s
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-10 13:48:42 +02:00
1c90a0c859
feat(status): make the set-status menu work
feat(user): optimise get() function for react
qol(todos): update todos
2026-08-10 13:48:32 +02:00
6dee7e8dcc Merge pull request 'chore(deps): update rust crate reqwest to 0.13' (#72) from renovate/reqwest-0.x into dev
Some checks failed
/ build-web (push) Successful in 12m8s
/ build-mobile (push) Failing after 15m1s
/ build-desktop (linux) (push) Successful in 17m16s
/ release (push) Has been skipped
2026-08-10 13:00:41 +02:00
41258a8483 chore(deps): update rust crate reqwest to 0.13
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 8m47s
Dependency builds / Build desktop (pull_request) Failing after 8m21s
Dependency builds / Build mobile (pull_request) Failing after 13m21s
2026-08-10 13:00:39 +02:00
32 changed files with 766 additions and 368 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,66 +11,20 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@methanium/ui"; } from "@methanium/ui";
export default function TopBar() { const USER_FIELDS = ["Avatar", "Display"] as const;
const { get } = useUser();
const { load } = useStorage();
const room = getRoom();
const screenRef = useCall((state) => state.screenRef);
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
useEffect(() => { function ParticipantAvatar({
setPortalContainer(screenRef?.current ?? undefined); userId,
}, [screenRef]); portalContainer,
}: {
const userIds = Array.from( userId: number;
room.remoteParticipants.values(), portalContainer?: HTMLElement;
(participant) => { }) {
const participantId = Number(participant.identity); const { data: user } = useUserFields(userId, USER_FIELDS);
return Number.isInteger(participantId) && participantId > 0 if (!user) return null;
? participantId
: null;
},
).filter((participantId): participantId is number => participantId != null);
const userIdsKey = userIds.join(",");
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
let active = true;
const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number);
void Promise.all(ids.map((id) => get(id)))
.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="-ml-4">
<div className="flex gap-1 m-3 ml-7">
{users.map((user) => (
<div key={user.UserId} className="-ml-4">
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
@ -90,6 +44,45 @@ export default function TopBar() {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
);
}
export default function TopBar() {
const { load } = useStorage();
const room = getRoom();
const screenRef = useCall((state) => state.screenRef);
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
useEffect(() => {
setPortalContainer(screenRef?.current ?? undefined);
}, [screenRef]);
const userIds = Array.from(
room.remoteParticipants.values(),
(participant) => {
const participantId = Number(participant.identity);
return Number.isInteger(participantId) && participantId > 0
? participantId
: null;
},
).filter((participantId): participantId is number => participantId != null);
const [ownId, setOwnId] = useState<number>();
useEffect(() => {
void load("user_id").then(setOwnId);
}, [load]);
const participantIds = ownId === undefined ? userIds : [ownId, ...userIds];
return (
<div className="w-full flex justify-between h-12">
<div className="flex gap-1 m-3 ml-7">
{participantIds.map((userId) => (
<ParticipantAvatar
key={userId}
userId={userId}
portalContainer={portalContainer}
/>
))} ))}
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => (
<> <>

View file

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

View file

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

View file

@ -1,3 +1 @@
- Tauri notifications
- Mobile notifications channel setup
- Add notification symbol / outline to user modal - Add notification symbol / outline to user modal

View file

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

View file

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

View file

@ -1,2 +1 @@
- Add `notifications: number` to contacts - Add `notifications: number` to contacts
- Store private key in a device bound session

View file

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

View file

@ -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) => {
const removed = presenceRef.current.delete(userId);
initialStatesRef.current.delete(userId); initialStatesRef.current.delete(userId);
delete checkedAtRef.current[userId]; delete checkedAtRef.current[userId];
setCacheVersion((version) => version + 1); 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 };
}

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

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

View file

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