diff --git a/apps/tauri/package.json b/apps/tauri/package.json index 80b8b3a..b5d89ec 100644 --- a/apps/tauri/package.json +++ b/apps/tauri/package.json @@ -18,9 +18,11 @@ } }, "scripts": { - "dev:mobile:raw": "tauri android dev", + "dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}", + "start-adb:mobile:raw": "adb devices", "build:mobile:raw": "tauri android build", "dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi", + "start-adb:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun start-adb:mobile:raw; else bun start-adb:mobile:raw; fi", "build:mobile": "bun run render-version.ts && if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi && bun run render-version.ts --unrender", "gen-icons": "tauri icon ./logo.json && bun scripts/sync-electron-icons.ts", "format": "bunx prettier --write .", @@ -30,6 +32,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "~2", "@tauri-apps/plugin-deep-link": "~2", + "@tauri-apps/plugin-log": "~2", "@tauri-apps/plugin-notification": "~2", "@tensamin/shared": "workspace:*", "@tensamin/ui": "*", diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 386b3fc..37a6827 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +43,23 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -47,6 +75,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "asn1-rs" version = "0.7.2" @@ -326,6 +360,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -366,6 +412,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "brotli" version = "8.0.3" @@ -402,6 +472,40 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -1069,6 +1173,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1132,6 +1246,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1218,6 +1341,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1604,6 +1733,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -2186,6 +2318,9 @@ name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +dependencies = [ + "value-bag", +] [[package]] name = "lru-slab" @@ -2395,6 +2530,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2986,6 +3130,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -3081,6 +3245,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.6" @@ -3201,9 +3371,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3224,9 +3394,18 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] [[package]] name = "reqwest" @@ -3276,6 +3455,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -3286,6 +3494,23 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3469,6 +3694,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "3.7.0" @@ -3724,6 +3955,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3878,6 +4115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -3976,6 +4214,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4175,6 +4419,28 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -4394,10 +4660,11 @@ dependencies = [ "tauri-plugin-app-events", "tauri-plugin-barcode-scanner", "tauri-plugin-deep-link", + "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", "ttp-core", - "ttp-native", + "ttp-tauri", ] [[package]] @@ -4448,7 +4715,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4773,7 +5042,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp-core" version = "0.1.0" -source = "git+https://git.methanium.net/tensamin/ttp.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" +source = "git+https://git.methanium.net/tensamin/ttp.git#0ccc2b808c1f8ff38ede906491ef703a51bd7786" dependencies = [ "base64 0.22.1", "byteorder", @@ -4786,7 +5055,7 @@ dependencies = [ [[package]] name = "ttp-native" version = "0.1.0" -source = "git+https://git.methanium.net/tensamin/ttp.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2" +source = "git+https://git.methanium.net/tensamin/ttp.git#0ccc2b808c1f8ff38ede906491ef703a51bd7786" dependencies = [ "quinn", "rustls", @@ -4794,9 +5063,25 @@ dependencies = [ "thiserror 2.0.18", "tokio", "ttp-core", + "webpki-roots", "wtransport", ] +[[package]] +name = "ttp-tauri" +version = "0.1.0" +source = "git+https://git.methanium.net/tensamin/ttp.git#0ccc2b808c1f8ff38ede906491ef703a51bd7786" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "ttp-core", + "ttp-native", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4922,6 +5207,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4940,6 +5231,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "version-compare" version = "0.2.1" @@ -5193,6 +5490,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5888,6 +6194,15 @@ dependencies = [ "url", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index ba011c1..556bb8b 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -24,7 +24,8 @@ serde_json = "1" tauri-plugin-deep-link = "2" tauri-plugin-notification = "2" ttp-core = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-core" } -ttp-native = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-native" } +ttp-tauri = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-tauri" } +tauri-plugin-log = "2" [target.'cfg(target_os = "android")'.dependencies.tauri] version = "2" diff --git a/apps/tauri/src-tauri/capabilities/default.json b/apps/tauri/src-tauri/capabilities/default.json index 02d2627..352eda0 100644 --- a/apps/tauri/src-tauri/capabilities/default.json +++ b/apps/tauri/src-tauri/capabilities/default.json @@ -15,6 +15,11 @@ "core:window:allow-minimize", "core:event:default", "deep-link:default", - "notification:default" + "notification:default", + "log:default", + "ttp-tauri:allow-connect", + "ttp-tauri:allow-send", + "ttp-tauri:allow-close", + "ttp-tauri:allow-ready-state" ] -} \ No newline at end of file +} diff --git a/apps/tauri/src-tauri/capabilities/mobile.json b/apps/tauri/src-tauri/capabilities/mobile.json index ceee8f7..d9914a6 100644 --- a/apps/tauri/src-tauri/capabilities/mobile.json +++ b/apps/tauri/src-tauri/capabilities/mobile.json @@ -13,6 +13,11 @@ "barcode-scanner:default", "barcode-scanner:allow-scan", "barcode-scanner:allow-cancel", - "notification:default" + "notification:default", + "log:default", + "ttp-tauri:allow-connect", + "ttp-tauri:allow-send", + "ttp-tauri:allow-close", + "ttp-tauri:allow-ready-state" ] -} \ No newline at end of file +} diff --git a/apps/tauri/src-tauri/gen/android/app/build.gradle.kts b/apps/tauri/src-tauri/gen/android/app/build.gradle.kts index f13d1b3..3775319 100644 --- a/apps/tauri/src-tauri/gen/android/app/build.gradle.kts +++ b/apps/tauri/src-tauri/gen/android/app/build.gradle.kts @@ -40,6 +40,7 @@ android { } buildTypes { getByName("debug") { + applicationIdSuffix = ".dev" manifestPlaceholders["usesCleartextTraffic"] = "true" isDebuggable = true isJniDebuggable = true diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index f01b2ad..cfa496a 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -1,6 +1,9 @@ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default().plugin(tauri_plugin_notification::init()); + let builder = tauri::Builder::default() + .plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build()) + .plugin(tauri_plugin_notification::init()) + .plugin(ttp_tauri::init()); let builder = builder .plugin(tauri_plugin_deep_link::init()) diff --git a/apps/tauri/src-tauri/src/main.rs b/apps/tauri/src-tauri/src/main.rs index a9346de..e78a26a 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -1,6 +1,9 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use lib::log; + fn main() { - mobile_lib::run() + mobile_lib::run(); + log("Test test 123") } diff --git a/apps/tauri/src-tauri/tauri.conf.json b/apps/tauri/src-tauri/tauri.conf.json index 15e46c0..17effab 100644 --- a/apps/tauri/src-tauri/tauri.conf.json +++ b/apps/tauri/src-tauri/tauri.conf.json @@ -25,6 +25,9 @@ } }, "bundle": { + "android": { + "debugApplicationIdSuffix": ".dev" + }, "active": true, "targets": [], "icon": [ diff --git a/apps/web/package.json b/apps/web/package.json index 04d731f..5cbfb60 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,7 @@ "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." }, "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/public-sans": "^5.2.7", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-router": "^1.169.1", "@tanstack/react-virtual": "^3.13.24", diff --git a/apps/web/src/features/settings/components.tsx b/apps/web/src/features/settings/components.tsx index b9f2967..491b839 100644 --- a/apps/web/src/features/settings/components.tsx +++ b/apps/web/src/features/settings/components.tsx @@ -1,8 +1,17 @@ -import { Label } from "@tensamin/ui"; -import { Switch as UISwitch } from "@tensamin/ui"; +import { + Button, + Checkbox, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + Input, + Label, + Switch as UISwitch, +} from "@tensamin/ui"; import { useEffect, useState } from "react"; -import type { Storage } from "@tensamin/shared/data"; +import { storageDefaults, type Storage } from "@tensamin/shared/data"; import { settingsStorageDefaults } from "@tensamin/shared/settings"; import { useStorage } from "@tensamin/storage/context"; @@ -10,6 +19,13 @@ type BooleanStorageKey = { [K in keyof Storage]: Storage[K] extends boolean ? K : never; }[keyof Storage]; +type ListStorageKey = { + [K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never; +}[keyof Storage]; + +type ListStorageItem = + Storage[K] extends Array ? Item : never; + export function Switch({ label, id, @@ -38,3 +54,125 @@ export function Switch({ ); } + +export function List({ + label, + id, +}: { + label: React.ReactNode; + id: K; +}) { + const { save, load } = useStorage(); + const [items, setItems] = useState(storageDefaults[id]); + const [inputValue, setInputValue] = useState(""); + const [selectedItems, setSelectedItems] = useState>(new Set()); + + useEffect(() => { + load(id).then((value) => setItems(value)); + }, [id, load]); + + const persistItems = (nextItems: Storage[K]) => { + setItems(nextItems); + save(id, nextItems); + }; + + const toStorageItem = (value: string): ListStorageItem => { + const referenceItem = items[0] ?? storageDefaults[id][0]; + + if (typeof referenceItem === "number") { + return Number(value) as ListStorageItem; + } + + return value as ListStorageItem; + }; + + const addItem = () => { + const trimmedValue = inputValue.trim(); + if (!trimmedValue) return; + + const nextItem = toStorageItem(trimmedValue); + if (typeof nextItem === "number" && Number.isNaN(nextItem)) return; + + persistItems([...items, nextItem] as Storage[K]); + setInputValue(""); + }; + + const deleteItems = (indexes: Set) => { + const nextItems = items.filter( + (_, index) => !indexes.has(index), + ) as Storage[K]; + + setItems(nextItems); + setSelectedItems(new Set()); + save(id, nextItems); + }; + + return ( +
+ +
+
+ setInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addItem(); + }} + /> + +
+
+ {items.map((item, index) => { + const labelId = `${String(id)}-${index}`; + const selected = selectedItems.has(index); + const deletingSelectedItems = selectedItems.size > 1; + + return ( + + + { + setSelectedItems((previous) => { + const nextSelected = new Set(previous); + + if (checked) { + nextSelected.add(index); + } else { + nextSelected.delete(index); + } + + return nextSelected; + }); + }} + /> + +
+
+ } + /> + + + deleteItems( + deletingSelectedItems + ? selectedItems + : new Set([index]), + ) + } + > + {deletingSelectedItems ? "Delete Selected" : "Delete"} + + +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 925d401..f7dcb91 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,13 +1,13 @@ @import "tailwindcss"; @import "tailwind-scrollbar-hide/v4"; @import "tw-animate-css"; -@import "@fontsource-variable/inter"; +@import "@fontsource-variable/public-sans"; @source "./**/*.{ts,tsx}"; @source "../../../packages/**/src/**/*.{ts,tsx}"; @theme { - --font-sans: "Inter Variable", sans-serif; + --font-sans: "Public Sans Variable", sans-serif; --animate-wiggle: wiggle 0.5s ease-in-out infinite; @@ -31,7 +31,7 @@ body, } body { - font-family: "Inter Variable", sans-serif; + font-family: "Public Sans Variable", sans-serif; } #root { diff --git a/apps/web/src/routes/settings/chat.tsx b/apps/web/src/routes/settings/chat.tsx index f67a2c6..9f070df 100644 --- a/apps/web/src/routes/settings/chat.tsx +++ b/apps/web/src/routes/settings/chat.tsx @@ -1,4 +1,4 @@ -import { Switch } from "@/features/settings/components"; +import { List, Switch } from "@/features/settings/components"; import { Kbd } from "@tensamin/ui"; export default function Page() { @@ -25,6 +25,7 @@ export default function Page() { label="Sidebar message preview" id="settings.show_start_of_last_message_in_sidebar" /> + ); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f9d51eb..71fb4ac 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -96,6 +96,9 @@ export default defineConfig({ }, }, envPrefix: ["VITE_", "TAURI_ENV_*"], + optimizeDeps: { + exclude: ["@tensamin/ttp-core"], + }, build: { minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false, sourcemap: !!process.env.TAURI_ENV_DEBUG, diff --git a/bun.lock b/bun.lock index dc7cf4b..5e2b865 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "~2", "@tauri-apps/plugin-deep-link": "~2", + "@tauri-apps/plugin-log": "~2", "@tauri-apps/plugin-notification": "~2", "@tensamin/shared": "workspace:*", "@tensamin/ui": "*", @@ -58,7 +59,7 @@ "name": "@tensamin/web", "version": "0.0.0", "dependencies": { - "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/public-sans": "^5.2.7", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-router": "^1.169.1", "@tanstack/react-virtual": "^3.13.24", @@ -255,7 +256,7 @@ }, }, "overrides": { - "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz", + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.24.tar.gz", "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", }, "packages": { @@ -471,6 +472,8 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + "@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -719,6 +722,8 @@ "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.9", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA=="], + "@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.8.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw=="], + "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], "@tensamin/call": ["@tensamin/call@workspace:packages/call"], @@ -743,7 +748,7 @@ "@tensamin/ttp": ["@tensamin/ttp@workspace:packages/ttp"], - "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-08pYuWTivuDWNiBoSSY2P8rIZz4FvuvLQZMjaQnf7KE/Pz9bGO69XHFcCHQXIz1SWsT2TyjochT0sPEGAta15g=="], + "@tensamin/ttp-core": ["@tensamin/ttp-core@https://git.methanium.net/tensamin/ttp/archive/0.0.24.tar.gz", { "dependencies": { "@eslint/js": "^10.0.1", "@tauri-apps/api": "^2.9.0", "@typescript-eslint/parser": "^8.59.1", "@webtransport-bun/webtransport": "^0.3.0", "globals": "^17.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", "zod": "^4.4.1" } }, "sha512-Db94CDOTYvzJSQDZGDgziUXRvXx9PiEfYDQt6sYe8Nyg+KD/aiKnyQx7NeEiG03TfYE3ogNxXOsrcsip4di8+g=="], "@tensamin/ui": ["@tensamin/ui@https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", { "dependencies": { "@base-ui/react": "^1.3.0", "@fontsource-variable/inter": "^5.2.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "react-day-picker": "^9.14.0", "react-resizable-panels": "^4.10.0", "recharts": "3.8.0", "shadcn": "^3.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.3.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-5zql8LtBKVn7WuQDCIdLbthKvLEyxjWShAceXCYbk+kwO41VVILhS1EWuDvjgvv2BBdyuxoVWvdblPIW4BcdSQ=="], diff --git a/flake.nix b/flake.nix index c1ae625..b70fd6b 100644 --- a/flake.nix +++ b/flake.nix @@ -6,8 +6,8 @@ outputs = {nixpkgs, ...}: let systems = ["x86_64-linux"]; forAllSystems = nixpkgs.lib.genAttrs systems; - version = "0.0.8"; - x86_64DebHash = "sha256-xC8YHVk+JpeMvXHB1dZihYLFyzFDXne4bkV6TZiOFrE="; + version = "0.0.9"; + x86_64DebHash = "sha256-8rVb12SGGSac6l1NVPGyRLsAKAGDAOJVUwG8l+hoRCU="; forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}"; in { packages = forAllSystems (system: let diff --git a/package.json b/package.json index 86c8924..37fe3a9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "preview:web": "cd apps/web && bun run preview", "dev:mobile": "cd apps/tauri && bun dev:mobile", "build:mobile": "cd apps/tauri && bun run build:mobile", + "start-adb:mobile": "cd apps/tauri && bun run start-adb:mobile", "dev:desktop": "cd apps/electron && bun run dev", "build:desktop": "cd apps/electron && bun run package", "run:desktop": "TENSAMIN_DEB=\"$PWD/apps/electron/release/Tensamin-$(node -p \\\"require('./package.json').version\\\")-linux-amd64.deb\" nix run .#localPathForDev --impure", @@ -44,7 +45,7 @@ }, "overrides": { "@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.36.tar.gz", - "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz" + "@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.24.tar.gz" }, "dependencies": { "@tensamin/ttp-core": "*", diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index cf74848..b0032d5 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -91,8 +91,8 @@ export default function InputComponent({ diff --git a/packages/chat/src/components/media.tsx b/packages/chat/src/components/media.tsx new file mode 100644 index 0000000..eb38b68 --- /dev/null +++ b/packages/chat/src/components/media.tsx @@ -0,0 +1,42 @@ +import Text from "@tensamin/markdown/text"; +import { useStorage } from "@tensamin/storage/context"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui"; +import { TriangleAlert } from "lucide-react"; +import { useState, useMemo, useEffect } from "react"; + +export default function Media({ link }: { link: string }) { + const { load } = useStorage(); + + const [trustedDomains, setTrustedDomains] = useState([]); + const [hidden, setHidden] = useState(true); + const hostname = useMemo(() => new URL(link).hostname, [link]); + + useEffect(() => { + load("chat_trusted_domains").then(setTrustedDomains); + }, [load]); + + return trustedDomains.includes(hostname) ? ( + <> + {hidden && } + setHidden(false)} + className="max-h-70 max-w-70 py-1 rounded-lg" + /> + + ) : ( +
+ + } + /> + + Link embeds can expose your ip address. You can configure trusted + domains in the settings. + + + +
+ ); +} diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 60afa75..51705ff 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -2,6 +2,8 @@ import * as React from "react"; import type { RawMessage } from "../values"; import Text from "@tensamin/markdown/text"; import { AlertTriangle } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { User } from "@tensamin/user/context"; import { Avatar, @@ -12,26 +14,54 @@ import { TooltipContent, TooltipTrigger, } from "@tensamin/ui"; -import Wrapper from "@tensamin/user/wrapper"; -import { useChat } from "../context"; import MessageContextMenu from "./messageContextMenu"; +import Media from "./media"; function MessageComponent({ grouped, message, + user, }: { grouped: boolean; message: RawMessage & { failed?: boolean; }; + user: User | null; }) { - const { userId } = useChat(); const actuallyFailed = message.failed && message.message_state === "awaiting"; + const [hasFadedIn, setHasFadedIn] = useState(false); + const opacityClass = !hasFadedIn + ? "opacity-0" + : actuallyFailed || message.message_state === "awaiting" + ? "opacity-50" + : "opacity-100"; + + const [isValidURL, setIsValidURL] = useState(false); + useEffect(() => { + const timeout = window.setTimeout(() => { + setHasFadedIn(true); + }, 100); + + return () => { + window.clearTimeout(timeout); + }; + }, []); + + useEffect(() => { + try { + if (message.content.split(" ").length > 1) throw new Error(); + + new URL(message.content); + setIsValidURL(true); + } catch { + setIsValidURL(false); + } + }, [message.content]); return (
- ( - <> - {grouped ? ( -

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

+ {user ? ( + <> + {grouped ? ( +

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+ ) : ( + + + + {user.display.slice(0, 2).toUpperCase()} + + + )} + {message.failed && message.message_state === "awaiting" && ( + + +

Failed to send message

+
+ } /> +
+ )} +
+ {!grouped && ( +
+

{user.display}

+

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+
+ )} + {isValidURL ? ( + ) : ( - - - - {user.display.slice(0, 2).toUpperCase()} - - - )} - {message.failed && message.message_state === "awaiting" && ( - - -

Failed to send message

-
- } /> -
- )} -
- {!grouped && ( -
-

{user.display}

-

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
- )} -
- - )} - /> + )} +
+ + ) : ( + "Loading" + )}
@@ -104,6 +136,8 @@ export default React.memo(MessageComponent, (prev, next) => { prev.message.height === next.message.height && prev.message.sent_by_self === next.message.sent_by_self && prev.message.message_state === next.message.message_state && - prev.message.failed === next.message.failed + prev.message.failed === next.message.failed && + prev.grouped === next.grouped && + prev.user === next.user ); }); diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 38ee454..09d65c3 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -266,7 +266,11 @@ export default function Provider(props: { children: ReactNode }) { return updated ? next : prev; }); - const queryKey = ["chat-messages", String(userIdValue)] as const; + const queryKey = [ + "chat-messages", + String(userIdValue), + currentSharedSecret.length > 0, + ] as const; queryClient.setQueryData>( queryKey, (current) => { @@ -301,7 +305,7 @@ export default function Provider(props: { children: ReactNode }) { }, ); }); - }, [subscribePush, userIdValue]); + }, [currentSharedSecret, subscribePush, userIdValue]); return ( diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 44e581b..f6bcff5 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -1,56 +1,108 @@ import * as React from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useChat } from "./context"; +import { failedUser } from "@tensamin/shared/data"; +import { useStorage } from "@tensamin/storage/context"; +import { useUser, type User } from "@tensamin/user/context"; import InputComponent from "./components/input"; import Message from "./components/message"; +import { useChat } from "./context"; +import { + PAGE_SIZE, + FALLBACK_MESSAGE_HEIGHT, + MESSAGES_PER_VIRTUAL_ROW, + type LiveMessage, + type RawMessage, +} from "./values"; -import { PAGE_SIZE } from "./values"; -import { useIsMobile } from "@tensamin/ui"; -import { Loader2 } from "lucide-react"; +type MessageChunk = { + key: string; + messages: Array; + startIndex: number; +}; -function getDistanceFromBottom(element: HTMLDivElement) { - return element.scrollHeight - (element.scrollTop + element.clientHeight); +function getEstimatedMessageHeight(message: { height?: number }) { + return typeof message.height === "number" && Number.isFinite(message.height) + ? Math.max(1, Math.ceil(message.height)) + : FALLBACK_MESSAGE_HEIGHT; } -const FALLBACK_MESSAGE_HEIGHT = 56; +function shouldFetchPreviousPage({ + entry, + hasNextPage, + isFetchingNextPage, + userScrolledUp, +}: { + entry: IntersectionObserverEntry | undefined; + hasNextPage: boolean; + isFetchingNextPage: boolean; + userScrolledUp: boolean; +}) { + return [ + entry?.isIntersecting === true, + userScrolledUp, + hasNextPage, + !isFetchingNextPage, + ].every(Boolean); +} + +function getMessageRenderKey(message: RawMessage | LiveMessage) { + return "localId" in message ? message.localId : String(message.send_time); +} + +function buildMessageChunks( + messages: Array, + keyPrefix: string, + startOffset = 0, +) { + const chunks: MessageChunk[] = []; + + for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { + const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); + const chunkMessages = messages.slice(start, end); + const firstMessage = chunkMessages[0]; + + if (firstMessage) { + chunks.push({ + key: `${keyPrefix}-${getMessageRenderKey(firstMessage)}`, + messages: chunkMessages, + startIndex: startOffset + start, + }); + } + } + + return chunks; +} /** * Renders the chat screen with virtualized history and live message updates. * @returns Chat screen JSX. */ export default function Screen() { - const { - getMessages, - liveMessages, - clearLiveMessages, - userId, - sharedSecret, - inputBoxRef, - } = useChat(); + const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } = + useChat(); + const { get: getUser } = useUser(); + const { load } = useStorage(); const scrollRef = React.useRef(null); - const isMobile = useIsMobile(); - const stickyBottomThreshold = isMobile ? 220 : 140; - - const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] = - React.useState(false); - const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0); - const [prependAnchor, setPrependAnchor] = React.useState<{ - totalSize: number; - scrollTop: number; - } | null>(null); - const [scrollWidth, setScrollWidth] = React.useState(0); - const [scrollHeight, setScrollHeight] = React.useState(0); - const [inputHeight, setInputHeight] = React.useState(0); - const [measuredHeights, setMeasuredHeights] = React.useState< - Record - >({}); - const measurementRefs = React.useRef(new Map()); - const shouldRestoreBottomOnResizeRef = React.useRef(false); + const topSentinelRef = React.useRef(null); + const didInitialScrollRef = React.useRef(false); + const userScrolledUpRef = React.useRef(false); const isAtBottomRef = React.useRef(true); - const shouldRestoreBottomOnFocusResizeRef = React.useRef(false); + const smoothScrollFrameRef = React.useRef(null); + const smoothScrollTargetRef = React.useRef(0); + const hasNextPageRef = React.useRef(false); + const isFetchingNextPageRef = React.useRef(false); + const fetchNextPageRef = React.useRef<(() => void) | null>(null); + + const [messageUsers, setMessageUsers] = React.useState< + Record + >({}); + const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0); + const [didInitialScroll, setDidInitialScroll] = React.useState(false); + const [viewportHeight, setViewportHeight] = React.useState(0); + const [value, setValue] = React.useState(""); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasSharedSecret = sharedSecret.length > 0; @@ -68,80 +120,153 @@ export default function Screen() { return allPages.length * PAGE_SIZE; }, }); + const { + fetchNextPage: fetchMessagesNextPage, + hasNextPage: hasMessagesNextPage, + isFetchingNextPage: isFetchingMessagesNextPage, + } = messagesQuery; - const historicalMessages = React.useMemo(() => { - const pages = messagesQuery.data?.pages ?? []; - return [...pages].reverse().flat(); - }, [messagesQuery.data]); + React.useEffect(() => { + hasNextPageRef.current = hasMessagesNextPage; + isFetchingNextPageRef.current = isFetchingMessagesNextPage; + fetchNextPageRef.current = () => { + void fetchMessagesNextPage(); + }; + }, [ + fetchMessagesNextPage, + hasMessagesNextPage, + isFetchingMessagesNextPage, + ]); React.useEffect(() => { clearLiveMessages(); - setHasScrolledToBottomInitially(false); + didInitialScrollRef.current = false; + userScrolledUpRef.current = false; + isAtBottomRef.current = true; + setDidInitialScroll(false); setLastLiveMessageCount(0); - setPrependAnchor(null); - }, [userId, clearLiveMessages]); + }, [clearLiveMessages, userId]); + + React.useEffect(() => { + if (!hasValidChatUser) { + setMessageUsers({}); + return; + } + + let active = true; + + const loadMessageUser = async ( + key: string, + resolveUserId: () => Promise, + ) => { + try { + const resolvedUserId = await resolveUserId(); + const value = await getUser(resolvedUserId); + + if (active) { + setMessageUsers((prev) => ({ ...prev, [key]: value })); + } + } catch { + if (active) { + setMessageUsers((prev) => ({ ...prev, [key]: failedUser })); + } + } + }; + + setMessageUsers({}); + void loadMessageUser("peer", async () => userId); + void loadMessageUser("own", async () => Number(await load("user_id"))); + + return () => { + active = false; + }; + }, [getUser, hasValidChatUser, load, userId]); + + const historicalMessages = React.useMemo(() => { + const pages = messagesQuery.data?.pages ?? []; + const seenSendTimes = new Set(); + const dedupedMessages: RawMessage[] = []; + + for (const message of [...pages].reverse().flat()) { + if (seenSendTimes.has(message.send_time)) { + continue; + } + + seenSendTimes.add(message.send_time); + dedupedMessages.push(message); + } + + return dedupedMessages; + }, [messagesQuery.data]); const liveMessagesSnapshot = liveMessages(); - const messages = React.useMemo(() => { - if (historicalMessages.length === 0) { - return liveMessagesSnapshot; - } - + const liveWithoutDuplicates = React.useMemo(() => { const historicalSendTimes = new Set( historicalMessages.map((message) => message.send_time), ); - const liveWithoutDuplicates = liveMessagesSnapshot.filter( + + return liveMessagesSnapshot.filter( (message) => !historicalSendTimes.has(message.send_time), ); - - return [...historicalMessages, ...liveWithoutDuplicates]; }, [historicalMessages, liveMessagesSnapshot]); + + const messages = React.useMemo(() => { + return [...historicalMessages, ...liveWithoutDuplicates]; + }, [historicalMessages, liveWithoutDuplicates]); + + const historicalMessageChunks = React.useMemo(() => { + return buildMessageChunks(historicalMessages, "history"); + }, [historicalMessages]); + + const liveMessageChunks = React.useMemo(() => { + return buildMessageChunks( + liveWithoutDuplicates, + "live", + historicalMessages.length, + ); + }, [historicalMessages.length, liveWithoutDuplicates]); + + const messageChunks = React.useMemo(() => { + return [...liveMessageChunks, ...historicalMessageChunks]; + }, [historicalMessageChunks, liveMessageChunks]); + const shouldShowConversationStart = !!messagesQuery.data && !messagesQuery.hasNextPage; const virtualRowCount = - messages.length + (shouldShowConversationStart ? 1 : 0); + messageChunks.length + (shouldShowConversationStart ? 1 : 0); const getItemKey = React.useCallback( (index: number) => { - if (shouldShowConversationStart && index === 0) { + if (shouldShowConversationStart && index === messageChunks.length) { return "conversation-start"; } - const messageIndex = shouldShowConversationStart ? index - 1 : index; - return messages[messageIndex]?.send_time ?? index; + return messageChunks[index]?.key ?? index; }, - [shouldShowConversationStart, messages], + [messageChunks, shouldShowConversationStart], ); - const setMeasurementRef = React.useCallback( - (sendTime: number, element: HTMLDivElement | null) => { - if (!element) { - measurementRefs.current.delete(sendTime); - return; - } - - measurementRefs.current.set(sendTime, element); - }, - [], - ); - - const estimateMessageSize = React.useCallback( + const estimateSize = React.useCallback( (index: number) => { - if (shouldShowConversationStart && index === 0) { + const isConversationStart = + shouldShowConversationStart && index === messageChunks.length; + if (isConversationStart) { return FALLBACK_MESSAGE_HEIGHT; } - const messageIndex = shouldShowConversationStart ? index - 1 : index; - const sendTime = messages[messageIndex]?.send_time; + const chunk = messageChunks[index]; - if (sendTime === undefined) { + if (!chunk) { return FALLBACK_MESSAGE_HEIGHT; } - return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT; + return chunk.messages.reduce( + (total, message) => total + getEstimatedMessageHeight(message), + 0, + ); }, - [measuredHeights, messages, shouldShowConversationStart], + [messageChunks, shouldShowConversationStart], ); // eslint-disable-next-line react-hooks/incompatible-library @@ -149,9 +274,12 @@ export default function Screen() { count: virtualRowCount, getScrollElement: () => scrollRef.current, getItemKey, - estimateSize: estimateMessageSize, - overscan: isMobile ? 10 : 6, + estimateSize, + overscan: 2, }); + const totalSize = virtualizer.getTotalSize(); + const contentHeight = Math.max(totalSize, viewportHeight); + const verticalOffset = Math.max(0, viewportHeight - totalSize); React.useLayoutEffect(() => { const element = scrollRef.current; @@ -159,14 +287,13 @@ export default function Screen() { return; } - const updateDimensions = () => { - setScrollWidth(element.clientWidth); - setScrollHeight(element.clientHeight); + const updateViewportHeight = () => { + setViewportHeight(element.clientHeight); }; - updateDimensions(); + updateViewportHeight(); - const observer = new ResizeObserver(updateDimensions); + const observer = new ResizeObserver(updateViewportHeight); observer.observe(element); return () => { @@ -175,277 +302,185 @@ export default function Screen() { }, []); React.useLayoutEffect(() => { - const frame = requestAnimationFrame(() => { - virtualizer.measure(); - }); - - return () => { - cancelAnimationFrame(frame); - }; - }, [inputHeight, measuredHeights, scrollWidth, virtualizer]); - - React.useLayoutEffect(() => { - if (typeof ResizeObserver === "undefined") { + if (didInitialScrollRef.current || virtualRowCount === 0) { return; } - const measureHeights = () => { - setMeasuredHeights((prev) => { - let changed = false; - const next: Record = {}; - - for (const message of messages) { - const element = measurementRefs.current.get(message.send_time); - const measuredHeight = element?.getBoundingClientRect().height; - const nextHeight = - typeof measuredHeight === "number" && - Number.isFinite(measuredHeight) - ? Math.ceil(measuredHeight) - : (prev[message.send_time] ?? FALLBACK_MESSAGE_HEIGHT); - - next[message.send_time] = nextHeight; - - if (prev[message.send_time] !== nextHeight) { - changed = true; - } - } - - if (!changed && Object.keys(prev).length === messages.length) { - return prev; - } - - return next; - }); - }; - - measureHeights(); - - const observer = new ResizeObserver(() => { - measureHeights(); - }); - - for (const message of messages) { - const element = measurementRefs.current.get(message.send_time); - if (element) { - observer.observe(element); + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; } - } - - return () => { - observer.disconnect(); - }; - }, [messages, scrollWidth]); - - React.useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const restoreBottomAfterResize = () => { - shouldRestoreBottomOnResizeRef.current = - isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current; requestAnimationFrame(() => { - virtualizer.measure(); - - requestAnimationFrame(() => { - if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) { - return; - } - - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - isAtBottomRef.current = true; - shouldRestoreBottomOnFocusResizeRef.current = false; - shouldRestoreBottomOnResizeRef.current = false; - }); + didInitialScrollRef.current = true; + setDidInitialScroll(true); }); - }; - - window.addEventListener("resize", restoreBottomAfterResize); - window.visualViewport?.addEventListener("resize", restoreBottomAfterResize); - - return () => { - window.removeEventListener("resize", restoreBottomAfterResize); - window.visualViewport?.removeEventListener( - "resize", - restoreBottomAfterResize, - ); - }; - }, [virtualizer]); - - React.useLayoutEffect(() => { - const element = inputBoxRef.current; - if (!element || typeof ResizeObserver === "undefined") { - return; - } - - const updateHeight = () => { - setInputHeight(element.getBoundingClientRect().height); - }; - - updateHeight(); - - const observer = new ResizeObserver(updateHeight); - observer.observe(element); - - return () => { - observer.disconnect(); - }; - }, [inputBoxRef]); - - React.useEffect(() => { - const element = inputBoxRef.current; - if (!element) { - return; - } - - const handleFocusIn = () => { - if (!scrollRef.current) { - return; - } - - shouldRestoreBottomOnFocusResizeRef.current = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; - }; - - const handleFocusOut = () => { - shouldRestoreBottomOnFocusResizeRef.current = false; - }; - - element.addEventListener("focusin", handleFocusIn); - element.addEventListener("focusout", handleFocusOut); - - return () => { - element.removeEventListener("focusin", handleFocusIn); - element.removeEventListener("focusout", handleFocusOut); - }; - }, [inputBoxRef, stickyBottomThreshold]); - - /** - * Loads the next page when the scroll container reaches the top. - * @returns Promise that resolves once pagination handling completes. - */ - const onScroll = React.useCallback(async () => { - if (!scrollRef.current) { - return; - } - - if (scrollRef.current.scrollTop > 96) { - return; - } - - if (messagesQuery.isFetchingNextPage || !messagesQuery.hasNextPage) { - return; - } - - setPrependAnchor({ - totalSize: virtualizer.getTotalSize(), - scrollTop: scrollRef.current.scrollTop, }); - - await messagesQuery.fetchNextPage(); - }, [messagesQuery, virtualizer]); - - React.useLayoutEffect(() => { - if ( - !scrollRef.current || - hasScrolledToBottomInitially || - virtualRowCount === 0 - ) { - return; - } - - let rafId: number; - let stableFrames = 0; - let lastScrollHeight = 0; - - const settleToBottom = () => { - if (!scrollRef.current || hasScrolledToBottomInitially) { - return; - } - - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - const currentScrollHeight = scrollRef.current.scrollHeight; - - if (currentScrollHeight === lastScrollHeight) { - stableFrames++; - } else { - stableFrames = 0; - lastScrollHeight = currentScrollHeight; - } - - if (stableFrames >= 2) { - isAtBottomRef.current = true; - setHasScrolledToBottomInitially(true); - } else { - rafId = requestAnimationFrame(settleToBottom); - } - }; - - settleToBottom(); - - return () => { - cancelAnimationFrame(rafId); - }; - }, [hasScrolledToBottomInitially, virtualRowCount]); + }, [virtualizer, virtualRowCount]); React.useLayoutEffect(() => { const count = liveMessagesSnapshot.length; - if (!scrollRef.current) { - return; - } - - if (count > lastLiveMessageCount) { - const shouldStickToBottom = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; - - if (shouldStickToBottom) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - isAtBottomRef.current = true; - } + if (count > lastLiveMessageCount && isAtBottomRef.current) { + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }); } setLastLiveMessageCount(count); }, [ lastLiveMessageCount, liveMessagesSnapshot.length, - stickyBottomThreshold, + virtualRowCount, + virtualizer, ]); - React.useLayoutEffect(() => { + React.useEffect(() => { if ( - !scrollRef.current || - !prependAnchor || - messagesQuery.isFetchingNextPage + viewportHeight === 0 || + totalSize > viewportHeight || + messagesQuery.isFetchingNextPage || + !messagesQuery.hasNextPage ) { return; } - const delta = virtualizer.getTotalSize() - prependAnchor.totalSize; - scrollRef.current.scrollTop = prependAnchor.scrollTop + delta; - setPrependAnchor(null); - }, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]); + void messagesQuery.fetchNextPage(); + }, [didInitialScroll, messagesQuery, totalSize, viewportHeight]); - /** - * Triggers asynchronous scroll pagination without returning a promise to JSX. - * @returns Void. - */ - const handleContainerScroll = React.useCallback((): void => { - if (scrollRef.current) { - isAtBottomRef.current = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; + React.useLayoutEffect(() => { + if ( + !didInitialScroll || + userScrolledUpRef.current || + virtualRowCount === 0 + ) { + return; } - void onScroll(); - }, [onScroll, stickyBottomThreshold]); + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }); + }, [didInitialScroll, virtualRowCount]); - const [value, setValue] = React.useState(""); - const totalSize = virtualizer.getTotalSize(); - const verticalOffset = Math.max(0, scrollHeight - totalSize); - const contentHeight = Math.max(totalSize, scrollHeight); + React.useEffect(() => { + const root = scrollRef.current; + const sentinel = topSentinelRef.current; + + if (!root || !sentinel || !didInitialScroll) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + shouldFetchPreviousPage({ + entry, + hasNextPage: messagesQuery.hasNextPage, + isFetchingNextPage: messagesQuery.isFetchingNextPage, + userScrolledUp: userScrolledUpRef.current, + }) + ) { + void messagesQuery.fetchNextPage(); + } + }, + { root, rootMargin: "240px 0px 0px 0px" }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [didInitialScroll, messagesQuery, virtualRowCount]); + + const handleContainerScroll = React.useCallback(() => { + if (!scrollRef.current) { + return; + } + + isAtBottomRef.current = scrollRef.current.scrollTop <= 140; + + if (didInitialScrollRef.current && scrollRef.current.scrollTop > 140) { + userScrolledUpRef.current = true; + } + }, []); + + React.useEffect(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + smoothScrollTargetRef.current = element.scrollTop; + + const animateScroll = () => { + const distance = smoothScrollTargetRef.current - element.scrollTop; + if (Math.abs(distance) < 0.5) { + element.scrollTop = smoothScrollTargetRef.current; + smoothScrollFrameRef.current = null; + handleContainerScroll(); + return; + } + + element.scrollTop += distance * 0.35; + handleContainerScroll(); + smoothScrollFrameRef.current = requestAnimationFrame(animateScroll); + }; + + const fetchNextPageNearTop = (scrollTop: number, maxScrollTop: number) => { + if (didInitialScrollRef.current && scrollTop > 140) { + userScrolledUpRef.current = true; + } + + if ( + scrollTop >= maxScrollTop - 240 && + userScrolledUpRef.current && + hasNextPageRef.current && + !isFetchingNextPageRef.current + ) { + fetchNextPageRef.current?.(); + } + }; + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (smoothScrollFrameRef.current === null) { + smoothScrollTargetRef.current = element.scrollTop; + } + + const maxScrollTop = Math.max( + 0, + element.scrollHeight - element.clientHeight, + ); + smoothScrollTargetRef.current = Math.min( + Math.max(0, smoothScrollTargetRef.current - event.deltaY), + maxScrollTop, + ); + fetchNextPageNearTop(smoothScrollTargetRef.current, maxScrollTop); + + if (smoothScrollFrameRef.current === null) { + smoothScrollFrameRef.current = requestAnimationFrame(animateScroll); + } + }; + + element.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + element.removeEventListener("wheel", handleWheel); + if (smoothScrollFrameRef.current !== null) { + cancelAnimationFrame(smoothScrollFrameRef.current); + smoothScrollFrameRef.current = null; + } + }; + }, [handleContainerScroll]); - // Render if (!hasValidChatUser) { return (
@@ -462,31 +497,24 @@ export default function Screen() { className="min-h-0 flex-1 overflow-y-auto" style={{ overflowAnchor: "none", - paddingBottom: "22px", + paddingTop: "22px", + transform: "scaleY(-1)", }} onScroll={handleContainerScroll} > - {messagesQuery.isPending && ( -
-

- - Loading... -

-
- )} - {messagesQuery.isFetchingNextPage && ( -
- -
- )}
+
{virtualizer.getVirtualItems().map((virtualRow) => { - if (shouldShowConversationStart && virtualRow.index === 0) { + if ( + shouldShowConversationStart && + virtualRow.index === messageChunks.length + ) { return (
-
+
Conversation start
@@ -509,35 +537,50 @@ export default function Screen() { ); } - const messageIndex = shouldShowConversationStart - ? virtualRow.index - 1 - : virtualRow.index; - const message = messages[messageIndex]; - if (!message) { + const chunkIndex = virtualRow.index; + const chunk = messageChunks[chunkIndex]; + if (!chunk) { return null; } - const lastMessage = messages[messageIndex - 1]; - const isGrouped = - lastMessage && - lastMessage.sent_by_self === message.sent_by_self && - Math.round(lastMessage.send_time / 10000) === - Math.round(message.send_time / 10000); - return (
- +
+ {chunk.messages.map((message, chunkMessageIndex) => { + const messageIndex = chunk.startIndex + chunkMessageIndex; + const lastMessage = messages[messageIndex - 1]; + const isGrouped = + lastMessage && + lastMessage.sent_by_self === message.sent_by_self && + Math.round(lastMessage.send_time / 10000) === + Math.round(message.send_time / 10000); + + return ( + + ); + })} +
); })} @@ -546,31 +589,6 @@ export default function Screen() {
-
); } diff --git a/packages/chat/src/values.ts b/packages/chat/src/values.ts index 880091c..8a97ef0 100644 --- a/packages/chat/src/values.ts +++ b/packages/chat/src/values.ts @@ -10,4 +10,6 @@ export type LiveMessage = RawMessage & { localId: string; }; -export const PAGE_SIZE = 50; +export const PAGE_SIZE = 30; +export const FALLBACK_MESSAGE_HEIGHT = 28; +export const MESSAGES_PER_VIRTUAL_ROW = 30; diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 2210192..ade2562 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -257,8 +257,10 @@ export interface Storage extends SettingsStorageDefaults { cached_contacts: Contacts; cached_communities: Communities; ttp_url: string; + ttp_server_cert: string; call_mute_range_start: number; call_mute_range_end: number; + chat_trusted_domains: string[]; } export const storageDefaults: Storage = { @@ -292,8 +294,10 @@ export const storageDefaults: Storage = { cached_contacts: [], cached_communities: [], ttp_url: "https://tensamin.net:959", + ttp_server_cert: "", call_mute_range_start: -55, call_mute_range_end: -45, + chat_trusted_domains: ["tenor.com", "c.tenor.com"], }; // User Status diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index d33313d..50eec69 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -195,10 +195,14 @@ export function Provider(props: { // Load ttp url const [ttpUrl, setTtpUrl] = useState(null); + const [ttpServerCert, setTtpServerCert] = useState(""); useEffect(() => { load("ttp_url").then((url) => { setTtpUrl(url); }); + load("ttp_server_cert").then((cert) => { + setTtpServerCert(cert.trim()); + }); }, [load]); /** @@ -301,7 +305,6 @@ export function Provider(props: { let reconnectScheduled = false; let disposed = false; let resumeListenerRegistered = false; - let currentReadyState: number = READY_STATE.CLOSED; /** * Clears any scheduled reconnect timeout and resets scheduling flags. @@ -373,8 +376,8 @@ export function Provider(props: { const transportClient = !props.blockConnection ? createTransportClient(schemas, { url: ttpUrl, + serverCert: ttpServerCert || undefined, onReadyStateChange: (state) => { - currentReadyState = state; setReadyState(state); if (state === READY_STATE.OPEN) { @@ -447,13 +450,6 @@ export function Provider(props: { return; } - if ( - currentReadyState === READY_STATE.OPEN || - currentReadyState === READY_STATE.CONNECTING - ) { - return; - } - clearReconnectTimer(); clearReconnectResetTimer(); attempts = 0; @@ -487,14 +483,13 @@ export function Provider(props: { } void transportClient?.close("context-dispose"); - currentReadyState = READY_STATE.CLOSED; setReadyState(READY_STATE.CLOSED); setConnected(false); setIdentified(false); setIdentifying(false); identificationStartedRef.current = false; }; - }, [props.blockConnection, ttpUrl]); + }, [props.blockConnection, ttpServerCert, ttpUrl]); useEffect(() => { if (!connected) {