Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b57bc0683c | |||
| 73ff854f0f | |||
|
|
abc0cceb56 | ||
| 67756255db | |||
| 257ef320d8 | |||
| 4a53d5c6dc | |||
|
|
089e6c5b28 | ||
| 00598348ab | |||
|
|
0c06516984 | ||
| 8f4f356746 |
31 changed files with 793 additions and 2337 deletions
|
|
@ -18,11 +18,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
"dev:mobile:raw": "tauri android dev",
|
||||||
"start-adb:mobile:raw": "adb devices",
|
|
||||||
"build:mobile:raw": "tauri android build",
|
"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",
|
"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",
|
"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",
|
"gen-icons": "tauri icon ./logo.json && bun scripts/sync-electron-icons.ts",
|
||||||
"format": "bunx prettier --write .",
|
"format": "bunx prettier --write .",
|
||||||
|
|
@ -32,7 +30,6 @@
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
"@tauri-apps/plugin-barcode-scanner": "~2",
|
||||||
"@tauri-apps/plugin-deep-link": "~2",
|
"@tauri-apps/plugin-deep-link": "~2",
|
||||||
"@tauri-apps/plugin-log": "~2",
|
|
||||||
"@tauri-apps/plugin-notification": "~2",
|
"@tauri-apps/plugin-notification": "~2",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
|
|
|
||||||
329
apps/tauri/src-tauri/Cargo.lock
generated
329
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -8,17 +8,6 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|
@ -43,23 +32,6 @@ dependencies = [
|
||||||
"alloc-no-stdlib",
|
"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]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -75,12 +47,6 @@ version = "1.0.102"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arrayvec"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -360,18 +326,6 @@ dependencies = [
|
||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -412,30 +366,6 @@ dependencies = [
|
||||||
"piper",
|
"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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.3"
|
version = "8.0.3"
|
||||||
|
|
@ -472,40 +402,6 @@ version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
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]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.0"
|
version = "1.25.0"
|
||||||
|
|
@ -1173,16 +1069,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -1246,15 +1132,6 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fern"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
|
|
@ -1341,12 +1218,6 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "funty"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1733,9 +1604,6 @@ name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
|
|
@ -2318,9 +2186,6 @@ name = "log"
|
||||||
version = "0.4.32"
|
version = "0.4.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
dependencies = [
|
|
||||||
"value-bag",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
|
|
@ -2530,15 +2395,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|
@ -3130,26 +2986,6 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.5"
|
version = "0.37.5"
|
||||||
|
|
@ -3245,12 +3081,6 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "radium"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -3371,9 +3201,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.4"
|
version = "1.12.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -3394,18 +3224,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-syntax"
|
name = "regex-syntax"
|
||||||
version = "0.8.11"
|
version = "0.8.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rend"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
|
|
||||||
dependencies = [
|
|
||||||
"bytecheck",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
|
|
@ -3455,35 +3276,6 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rust-ini"
|
name = "rust-ini"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
|
|
@ -3494,23 +3286,6 @@ dependencies = [
|
||||||
"ordered-multimap",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
|
|
@ -3694,12 +3469,6 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "seahash"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|
@ -3955,12 +3724,6 @@ version = "0.3.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simdutf8"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|
@ -4115,7 +3878,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -4214,12 +3976,6 @@ dependencies = [
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tap"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
|
|
@ -4419,28 +4175,6 @@ dependencies = [
|
||||||
"windows-result 0.3.4",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-notification"
|
name = "tauri-plugin-notification"
|
||||||
version = "2.3.3"
|
version = "2.3.3"
|
||||||
|
|
@ -4660,11 +4394,10 @@ dependencies = [
|
||||||
"tauri-plugin-app-events",
|
"tauri-plugin-app-events",
|
||||||
"tauri-plugin-barcode-scanner",
|
"tauri-plugin-barcode-scanner",
|
||||||
"tauri-plugin-deep-link",
|
"tauri-plugin-deep-link",
|
||||||
"tauri-plugin-log",
|
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"ttp-core",
|
"ttp-core",
|
||||||
"ttp-tauri",
|
"ttp-native",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4715,9 +4448,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"itoa",
|
"itoa",
|
||||||
"libc",
|
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"num_threads",
|
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|
@ -5042,7 +4773,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ttp-core"
|
name = "ttp-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://git.methanium.net/tensamin/ttp.git#0ccc2b808c1f8ff38ede906491ef703a51bd7786"
|
source = "git+https://git.methanium.net/tensamin/ttp.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
@ -5055,7 +4786,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ttp-native"
|
name = "ttp-native"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://git.methanium.net/tensamin/ttp.git#0ccc2b808c1f8ff38ede906491ef703a51bd7786"
|
source = "git+https://git.methanium.net/tensamin/ttp.git#23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|
@ -5063,25 +4794,9 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"ttp-core",
|
"ttp-core",
|
||||||
"webpki-roots",
|
|
||||||
"wtransport",
|
"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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|
@ -5207,12 +4922,6 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8-width"
|
|
||||||
version = "0.1.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -5231,12 +4940,6 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "value-bag"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -5490,15 +5193,6 @@ dependencies = [
|
||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|
@ -6194,15 +5888,6 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wyz"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
|
||||||
dependencies = [
|
|
||||||
"tap",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ serde_json = "1"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
ttp-core = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-core" }
|
ttp-core = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-core" }
|
||||||
ttp-tauri = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-tauri" }
|
ttp-native = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-native" }
|
||||||
tauri-plugin-log = "2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
||||||
version = "2"
|
version = "2"
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,6 @@
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"deep-link: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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
"barcode-scanner:default",
|
"barcode-scanner:default",
|
||||||
"barcode-scanner:allow-scan",
|
"barcode-scanner:allow-scan",
|
||||||
"barcode-scanner:allow-cancel",
|
"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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +40,6 @@ android {
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
applicationIdSuffix = ".dev"
|
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
isJniDebuggable = true
|
isJniDebuggable = true
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default().plugin(tauri_plugin_notification::init());
|
||||||
.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
|
let builder = builder
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use lib::log;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
mobile_lib::run();
|
mobile_lib::run()
|
||||||
log("Test test 123")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"android": {
|
|
||||||
"debugApplicationIdSuffix": ".dev"
|
|
||||||
},
|
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": [],
|
"targets": [],
|
||||||
"icon": [
|
"icon": [
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
|
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/public-sans": "^5.2.7",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.169.1",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import {
|
import { Label } from "@tensamin/ui";
|
||||||
Button,
|
import { Switch as UISwitch } from "@tensamin/ui";
|
||||||
Checkbox,
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Switch as UISwitch,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
import type { Storage } from "@tensamin/shared/data";
|
||||||
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
|
|
@ -19,13 +10,6 @@ type BooleanStorageKey = {
|
||||||
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
||||||
}[keyof Storage];
|
}[keyof Storage];
|
||||||
|
|
||||||
type ListStorageKey = {
|
|
||||||
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
|
|
||||||
}[keyof Storage];
|
|
||||||
|
|
||||||
type ListStorageItem<K extends ListStorageKey> =
|
|
||||||
Storage[K] extends Array<infer Item> ? Item : never;
|
|
||||||
|
|
||||||
export function Switch({
|
export function Switch({
|
||||||
label,
|
label,
|
||||||
id,
|
id,
|
||||||
|
|
@ -54,125 +38,3 @@ export function Switch({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function List<K extends ListStorageKey>({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
label: React.ReactNode;
|
|
||||||
id: K;
|
|
||||||
}) {
|
|
||||||
const { save, load } = useStorage();
|
|
||||||
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(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<K> => {
|
|
||||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
|
||||||
|
|
||||||
if (typeof referenceItem === "number") {
|
|
||||||
return Number(value) as ListStorageItem<K>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value as ListStorageItem<K>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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<number>) => {
|
|
||||||
const nextItems = items.filter(
|
|
||||||
(_, index) => !indexes.has(index),
|
|
||||||
) as Storage[K];
|
|
||||||
|
|
||||||
setItems(nextItems);
|
|
||||||
setSelectedItems(new Set());
|
|
||||||
save(id, nextItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
<Label>{label}</Label>
|
|
||||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Input
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") addItem();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button onClick={addItem}>Add item</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const labelId = `${String(id)}-${index}`;
|
|
||||||
const selected = selectedItems.has(index);
|
|
||||||
const deletingSelectedItems = selectedItems.size > 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu key={`${String(item)}-${index}`}>
|
|
||||||
<ContextMenuTrigger
|
|
||||||
render={
|
|
||||||
<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
|
||||||
<Checkbox
|
|
||||||
id={labelId}
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setSelectedItems((previous) => {
|
|
||||||
const nextSelected = new Set(previous);
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
nextSelected.add(index);
|
|
||||||
} else {
|
|
||||||
nextSelected.delete(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextSelected;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={labelId}>{String(item)}</Label>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() =>
|
|
||||||
deleteItems(
|
|
||||||
deletingSelectedItems
|
|
||||||
? selectedItems
|
|
||||||
: new Set([index]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tailwind-scrollbar-hide/v4";
|
@import "tailwind-scrollbar-hide/v4";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "@fontsource-variable/public-sans";
|
@import "@fontsource-variable/inter";
|
||||||
|
|
||||||
@source "./**/*.{ts,tsx}";
|
@source "./**/*.{ts,tsx}";
|
||||||
@source "../../../packages/**/src/**/*.{ts,tsx}";
|
@source "../../../packages/**/src/**/*.{ts,tsx}";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Public Sans Variable", sans-serif;
|
--font-sans: "Inter Variable", sans-serif;
|
||||||
|
|
||||||
--animate-wiggle: wiggle 0.5s ease-in-out infinite;
|
--animate-wiggle: wiggle 0.5s ease-in-out infinite;
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ body,
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Public Sans Variable", sans-serif;
|
font-family: "Inter Variable", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@ import NotificationsProvider from "@tensamin/notifications/context";
|
||||||
|
|
||||||
import TAuthWrapper from "@tensamin/tauth/context";
|
import TAuthWrapper from "@tensamin/tauth/context";
|
||||||
|
|
||||||
import { ThemeProvider, useTheme } from "@tensamin/ui";
|
import { ThemeProvider } from "@tensamin/ui";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import Storage from "@tensamin/storage/context";
|
import Storage from "@tensamin/storage/context";
|
||||||
import Session from "@tensamin/storage/session";
|
import Session from "@tensamin/storage/session";
|
||||||
|
|
@ -106,111 +106,12 @@ function LoginWrapper({ children }: { children: ReactNode }) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeStorageBridge() {
|
|
||||||
const { load, save } = useStorage();
|
|
||||||
const {
|
|
||||||
themeColor,
|
|
||||||
setThemeColor,
|
|
||||||
themePalette,
|
|
||||||
setThemePalette,
|
|
||||||
themePrimaryColor,
|
|
||||||
setThemePrimaryColor,
|
|
||||||
themePolarity,
|
|
||||||
setThemePolarity,
|
|
||||||
themeTint,
|
|
||||||
setThemeTint,
|
|
||||||
} = useTheme();
|
|
||||||
const loadedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
load("theme_color"),
|
|
||||||
load("theme_palette"),
|
|
||||||
load("theme_primary_color"),
|
|
||||||
load("theme_polarity"),
|
|
||||||
load("theme_tint"),
|
|
||||||
]).then(([color, palette, primaryColor, polarity, tint]) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemeColor(color);
|
|
||||||
setThemePalette(palette);
|
|
||||||
setThemePrimaryColor(primaryColor);
|
|
||||||
setThemePolarity(polarity);
|
|
||||||
setThemeTint(tint);
|
|
||||||
loadedRef.current = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
load,
|
|
||||||
setThemeColor,
|
|
||||||
setThemePalette,
|
|
||||||
setThemePolarity,
|
|
||||||
setThemePrimaryColor,
|
|
||||||
setThemeTint,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("theme_color", themeColor);
|
|
||||||
}, [save, themeColor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("theme_palette", themePalette);
|
|
||||||
}, [save, themePalette]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("theme_primary_color", themePrimaryColor);
|
|
||||||
}, [save, themePrimaryColor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("theme_polarity", themePolarity);
|
|
||||||
}, [save, themePolarity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("theme_tint", themeTint);
|
|
||||||
}, [save, themeTint]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootShell() {
|
function RootShell() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeeplinkContext>
|
<DeeplinkContext>
|
||||||
<ThemeProvider
|
<ThemeProvider>
|
||||||
storageKey={null}
|
|
||||||
colorStorageKey={null}
|
|
||||||
paletteStorageKey={null}
|
|
||||||
primaryColorStorageKey={null}
|
|
||||||
tintStorageKey={null}
|
|
||||||
>
|
|
||||||
<div className="w-screen h-dvh overflow-hidden">
|
<div className="w-screen h-dvh overflow-hidden">
|
||||||
<Toaster
|
<Toaster
|
||||||
position={isMobile ? "top-center" : "bottom-right"}
|
position={isMobile ? "top-center" : "bottom-right"}
|
||||||
|
|
@ -224,7 +125,6 @@ function RootShell() {
|
||||||
/>
|
/>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Storage>
|
<Storage>
|
||||||
<ThemeStorageBridge />
|
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
<LegalWrapper>
|
<LegalWrapper>
|
||||||
<Crypto>
|
<Crypto>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { List, Switch } from "@/features/settings/components";
|
import { Switch } from "@/features/settings/components";
|
||||||
import { Kbd } from "@tensamin/ui";
|
import { Kbd } from "@tensamin/ui";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|
@ -25,11 +25,6 @@ export default function Page() {
|
||||||
label="Sidebar message preview"
|
label="Sidebar message preview"
|
||||||
id="settings.show_start_of_last_message_in_sidebar"
|
id="settings.show_start_of_last_message_in_sidebar"
|
||||||
/>
|
/>
|
||||||
<p className="text-destructive pt-6">
|
|
||||||
Trusted embed domains can get your IP-Address! Only add domains if you
|
|
||||||
really trust them!
|
|
||||||
</p>
|
|
||||||
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { StylePicker } from "@tensamin/ui";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <StylePicker />;
|
|
||||||
}
|
|
||||||
|
|
@ -96,9 +96,6 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
||||||
optimizeDeps: {
|
|
||||||
exclude: ["@tensamin/ttp-core"],
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||||
|
|
|
||||||
26
flake.nix
26
flake.nix
|
|
@ -6,14 +6,15 @@
|
||||||
outputs = {nixpkgs, ...}: let
|
outputs = {nixpkgs, ...}: let
|
||||||
systems = ["x86_64-linux"];
|
systems = ["x86_64-linux"];
|
||||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||||
version = "0.0.9";
|
version = "0.0.8";
|
||||||
x86_64DebHash = "sha256-8rVb12SGGSac6l1NVPGyRLsAKAGDAOJVUwG8l+hoRCU=";
|
x86_64DebHash = "sha256-xC8YHVk+JpeMvXHB1dZihYLFyzFDXne4bkV6TZiOFrE=";
|
||||||
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
|
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
|
||||||
in {
|
in {
|
||||||
packages = forAllSystems (system: let
|
packages = forAllSystems (system: let
|
||||||
pkgs = import nixpkgs {inherit system;};
|
pkgs = import nixpkgs {inherit system;};
|
||||||
debArtifact = "Tensamin-${version}-linux-amd64.deb";
|
debArtifact = "Tensamin-${version}-linux-amd64.deb";
|
||||||
|
localDebPath = builtins.getEnv "TENSAMIN_DEB";
|
||||||
|
debPath = /. + localDebPath;
|
||||||
electronRuntimeLibs = with pkgs; [
|
electronRuntimeLibs = with pkgs; [
|
||||||
alsa-lib
|
alsa-lib
|
||||||
at-spi2-atk
|
at-spi2-atk
|
||||||
|
|
@ -54,8 +55,9 @@
|
||||||
packageDeb = src:
|
packageDeb = src:
|
||||||
pkgs.stdenv.mkDerivation {
|
pkgs.stdenv.mkDerivation {
|
||||||
pname = "tensamin";
|
pname = "tensamin";
|
||||||
inherit version src;
|
inherit version;
|
||||||
|
|
||||||
|
inherit src;
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
dpkg
|
dpkg
|
||||||
|
|
@ -68,21 +70,21 @@
|
||||||
|
|
||||||
unpackPhase = ''
|
unpackPhase = ''
|
||||||
runHook preUnpack
|
runHook preUnpack
|
||||||
dpkg-deb -x "$src" .
|
dpkg-deb -x $src .
|
||||||
runHook postUnpack
|
runHook postUnpack
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
mkdir -p "$out"
|
mkdir -p $out
|
||||||
cp -r opt "$out/"
|
cp -r opt $out/
|
||||||
cp -r usr/* "$out/"
|
cp -r usr/* $out/
|
||||||
|
|
||||||
mkdir -p "$out/bin"
|
mkdir -p $out/bin
|
||||||
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin"
|
makeWrapper $out/opt/Tensamin/tensamin $out/bin/tensamin
|
||||||
|
|
||||||
substituteInPlace "$out/share/applications/tensamin.desktop" \
|
substituteInPlace $out/share/applications/tensamin.desktop \
|
||||||
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
|
|
@ -94,6 +96,8 @@
|
||||||
});
|
});
|
||||||
in {
|
in {
|
||||||
default = defaultPackage;
|
default = defaultPackage;
|
||||||
|
|
||||||
|
localPathForDev = if localDebPath == "" then defaultPackage else packageDeb debPath;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "tensamin",
|
"name": "tensamin",
|
||||||
"version": "0.0.10",
|
"version": "0.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
"preview:web": "cd apps/web && bun run preview",
|
"preview:web": "cd apps/web && bun run preview",
|
||||||
"dev:mobile": "cd apps/tauri && bun dev:mobile",
|
"dev:mobile": "cd apps/tauri && bun dev:mobile",
|
||||||
"build:mobile": "cd apps/tauri && bun run build: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",
|
"dev:desktop": "cd apps/electron && bun run dev",
|
||||||
"build:desktop": "cd apps/electron && bun run package",
|
"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",
|
"run:desktop": "TENSAMIN_DEB=\"$PWD/apps/electron/release/Tensamin-$(node -p \\\"require('./package.json').version\\\")-linux-amd64.deb\" nix run .#localPathForDev --impure",
|
||||||
|
|
@ -44,8 +43,8 @@
|
||||||
"typescript-eslint": "^8.59.1"
|
"typescript-eslint": "^8.59.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/archive/0.0.37.tar.gz",
|
"@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.24.tar.gz"
|
"@tensamin/ttp-core": "https://git.methanium.net/tensamin/ttp/archive/0.0.20.tar.gz"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensamin/ttp-core": "*",
|
"@tensamin/ttp-core": "*",
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,16 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/pacer": "^0.21.1",
|
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tanstack/react-router": "^1.0.0",
|
"@tanstack/react-router": "^1.0.0",
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/ttp": "workspace:*",
|
||||||
|
"@tensamin/storage": "workspace:*",
|
||||||
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
|
||||||
"@tensamin/ttp": "workspace:*",
|
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,638 +0,0 @@
|
||||||
import { Debouncer } from "@tanstack/pacer";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import { Loader2, Search } from "lucide-react";
|
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
|
||||||
import React, {
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
startTransition,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
const klipyApiKey =
|
|
||||||
"KzyghnvdlaHjJuDycVx6rLIEu5eaacPyFERieoI7L8UWLt2rnJloL45UL5MVc9IG";
|
|
||||||
const klipyBaseUrl = "https://api.klipy.com/api/v1";
|
|
||||||
const pageSize = 24;
|
|
||||||
const minColumnWidth = 200;
|
|
||||||
|
|
||||||
function getColumnCount(width: number, itemCount: number) {
|
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
Math.min(itemCount || 1, Math.floor(width / minColumnWidth)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type KlipyKind = "gif" | "meme";
|
|
||||||
|
|
||||||
type KlipyMediaFile = {
|
|
||||||
url?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KlipyMediaFormats = Record<string, KlipyMediaFile | undefined>;
|
|
||||||
|
|
||||||
type KlipyItem = {
|
|
||||||
id: number | string;
|
|
||||||
title?: string;
|
|
||||||
file?: Record<string, KlipyMediaFormats | undefined>;
|
|
||||||
blur_preview?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KlipyPage = {
|
|
||||||
items: KlipyItem[];
|
|
||||||
currentPage: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type KlipyResponse = {
|
|
||||||
data?: {
|
|
||||||
data?: KlipyItem[];
|
|
||||||
current_page?: number;
|
|
||||||
has_next?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type PickerMedia = {
|
|
||||||
key: React.Key;
|
|
||||||
url: string;
|
|
||||||
alt: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function distributeByHeight<T>(
|
|
||||||
items: T[],
|
|
||||||
columnCount: number,
|
|
||||||
getAspectRatio: (item: T) => number,
|
|
||||||
) {
|
|
||||||
const columns = Array.from({ length: columnCount }, () => [] as T[]);
|
|
||||||
const columnHeights = Array.from({ length: columnCount }, () => 0);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const shortestColumnIndex = columnHeights.indexOf(
|
|
||||||
Math.min(...columnHeights),
|
|
||||||
);
|
|
||||||
|
|
||||||
columns[shortestColumnIndex].push(item);
|
|
||||||
columnHeights[shortestColumnIndex] += getAspectRatio(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getKlipyPath(kind: KlipyKind) {
|
|
||||||
const resource = kind === "gif" ? "gifs" : "static-memes";
|
|
||||||
|
|
||||||
return `${resource}/search`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchKlipyPage({
|
|
||||||
kind,
|
|
||||||
page,
|
|
||||||
search,
|
|
||||||
}: {
|
|
||||||
kind: KlipyKind;
|
|
||||||
page: number;
|
|
||||||
search: string;
|
|
||||||
}): Promise<KlipyPage> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
per_page: String(pageSize),
|
|
||||||
content_filter: "medium",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (search !== "") {
|
|
||||||
params.set("q", search);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "gif") {
|
|
||||||
params.set("format_filter", "gif,webp");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${klipyBaseUrl}/${klipyApiKey}/${getKlipyPath(kind)}?${params}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Klipy request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json()) as KlipyResponse;
|
|
||||||
const data = body.data;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: data?.data ?? [],
|
|
||||||
currentPage: data?.current_page ?? page,
|
|
||||||
hasNext: data?.has_next ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFormat(item: KlipyItem, size: string, format: string) {
|
|
||||||
return item.file?.[size]?.[format];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickDisplayMedia(kind: KlipyKind, item: KlipyItem) {
|
|
||||||
if (kind === "gif") {
|
|
||||||
return (
|
|
||||||
getFormat(item, "sm", "webp") ??
|
|
||||||
getFormat(item, "md", "webp") ??
|
|
||||||
getFormat(item, "sm", "gif") ??
|
|
||||||
getFormat(item, "md", "gif") ??
|
|
||||||
getFormat(item, "hd", "webp") ??
|
|
||||||
getFormat(item, "hd", "gif")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
getFormat(item, "sm", "webp") ??
|
|
||||||
getFormat(item, "md", "webp") ??
|
|
||||||
getFormat(item, "sm", "png") ??
|
|
||||||
getFormat(item, "md", "png") ??
|
|
||||||
getFormat(item, "hd", "webp") ??
|
|
||||||
getFormat(item, "hd", "png")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSelectionUrl(kind: KlipyKind, item: KlipyItem) {
|
|
||||||
if (kind === "gif") {
|
|
||||||
return (
|
|
||||||
getFormat(item, "md", "gif")?.url ??
|
|
||||||
getFormat(item, "sm", "gif")?.url ??
|
|
||||||
getFormat(item, "hd", "gif")?.url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
getFormat(item, "md", "png")?.url ??
|
|
||||||
getFormat(item, "md", "webp")?.url ??
|
|
||||||
getFormat(item, "sm", "png")?.url ??
|
|
||||||
getFormat(item, "sm", "webp")?.url ??
|
|
||||||
getFormat(item, "hd", "png")?.url ??
|
|
||||||
getFormat(item, "hd", "webp")?.url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useMeasuredWidth() {
|
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [scrollWidth, setScrollWidth] = useState(0);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const element = scrollRef.current;
|
|
||||||
|
|
||||||
if (!element || typeof ResizeObserver === "undefined") return;
|
|
||||||
|
|
||||||
const updateWidth = () => setScrollWidth(element.clientWidth);
|
|
||||||
const observer = new ResizeObserver(updateWidth);
|
|
||||||
|
|
||||||
updateWidth();
|
|
||||||
observer.observe(element);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return [scrollRef, scrollWidth] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useImageSizes(urls: string[]) {
|
|
||||||
const [sizes, setSizes] = useState<
|
|
||||||
Record<string, { width: number; height: number }>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
for (const url of urls) {
|
|
||||||
if (sizes[url]) continue;
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
|
|
||||||
image.onload = () => {
|
|
||||||
if (!active) return;
|
|
||||||
|
|
||||||
setSizes((current) => ({
|
|
||||||
...current,
|
|
||||||
[url]: {
|
|
||||||
width: image.naturalWidth,
|
|
||||||
height: image.naturalHeight,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
image.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [sizes, urls]);
|
|
||||||
|
|
||||||
return sizes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaGrid({
|
|
||||||
hasNextPage = false,
|
|
||||||
isFetchingNextPage = false,
|
|
||||||
items,
|
|
||||||
onLoadMore,
|
|
||||||
onSelect,
|
|
||||||
resizeWidth,
|
|
||||||
}: {
|
|
||||||
hasNextPage?: boolean;
|
|
||||||
isFetchingNextPage?: boolean;
|
|
||||||
items: PickerMedia[];
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
resizeWidth?: number;
|
|
||||||
}) {
|
|
||||||
const [scrollRef, measuredWidth] = useMeasuredWidth();
|
|
||||||
const columnWidth = resizeWidth ?? measuredWidth;
|
|
||||||
const columnCount = getColumnCount(columnWidth, items.length);
|
|
||||||
const columns = distributeByHeight(items, columnCount, (item) => {
|
|
||||||
if (!item.width || !item.height) return 1;
|
|
||||||
|
|
||||||
return item.height / item.width;
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
|
|
||||||
const element = event.currentTarget;
|
|
||||||
const distanceFromBottom =
|
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
|
|
||||||
if (
|
|
||||||
distanceFromBottom > 240 ||
|
|
||||||
!hasNextPage ||
|
|
||||||
isFetchingNextPage ||
|
|
||||||
!onLoadMore
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const element = scrollRef.current;
|
|
||||||
|
|
||||||
if (!element || !hasNextPage || isFetchingNextPage || !onLoadMore) return;
|
|
||||||
|
|
||||||
const distanceFromBottom =
|
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
|
|
||||||
if (distanceFromBottom <= 240) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
columnCount,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
items.length,
|
|
||||||
onLoadMore,
|
|
||||||
scrollRef,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="min-h-0 w-full flex-1 overflow-y-auto pr-1"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-start gap-2">
|
|
||||||
{columns.map((column, columnIndex) => (
|
|
||||||
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
|
|
||||||
{column.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
className="group overflow-hidden rounded-lg text-left transition hover:border-foreground/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
onClick={() => onSelect(item.url)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt={item.alt}
|
|
||||||
className="block h-auto w-full object-contain"
|
|
||||||
height={item.height}
|
|
||||||
loading="lazy"
|
|
||||||
src={item.url}
|
|
||||||
width={item.width}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{hasNextPage ? (
|
|
||||||
<div className="flex h-20 items-center justify-center text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
||||||
Loading more...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KlipyPanel({
|
|
||||||
kind,
|
|
||||||
onSelect,
|
|
||||||
resizeWidth,
|
|
||||||
searchString,
|
|
||||||
}: {
|
|
||||||
kind: KlipyKind;
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
resizeWidth?: number;
|
|
||||||
searchString: string;
|
|
||||||
}) {
|
|
||||||
const query = useInfiniteQuery({
|
|
||||||
queryKey: ["klipy", kind, searchString],
|
|
||||||
initialPageParam: 1,
|
|
||||||
enabled: searchString !== "",
|
|
||||||
queryFn: ({ pageParam }) =>
|
|
||||||
fetchKlipyPage({
|
|
||||||
kind,
|
|
||||||
page: Number(pageParam),
|
|
||||||
search: searchString,
|
|
||||||
}),
|
|
||||||
getNextPageParam: (lastPage) =>
|
|
||||||
lastPage.hasNext ? lastPage.currentPage + 1 : undefined,
|
|
||||||
});
|
|
||||||
const items: PickerMedia[] =
|
|
||||||
query.data?.pages.flatMap((page) =>
|
|
||||||
page.items.flatMap((item) => {
|
|
||||||
const displayMedia = pickDisplayMedia(kind, item);
|
|
||||||
const selectionUrl = pickSelectionUrl(kind, item);
|
|
||||||
|
|
||||||
if (!displayMedia?.url || !selectionUrl) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: item.id,
|
|
||||||
url: selectionUrl,
|
|
||||||
alt: item.title ?? "Klipy result",
|
|
||||||
width: displayMedia.width,
|
|
||||||
height: displayMedia.height,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
if (searchString === "") {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-5 text-center text-muted-foreground">
|
|
||||||
<Search className="size-12" />
|
|
||||||
<p className="w-50 text-lg text-foreground">
|
|
||||||
{kind === "gif" && 'Try searching for "Funny cat"'}
|
|
||||||
{kind === "meme" &&
|
|
||||||
'Try searching for "Spiderman pointing at Spiderman"'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.isError) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
|
||||||
<p>Failed to load {kind === "gif" ? "GIFs" : "memes"}.</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => void query.refetch()}
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
|
||||||
No {kind === "gif" ? "GIFs" : "memes"} found.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MediaGrid
|
|
||||||
hasNextPage={query.hasNextPage}
|
|
||||||
isFetchingNextPage={query.isFetchingNextPage}
|
|
||||||
items={items}
|
|
||||||
onLoadMore={() => void query.fetchNextPage()}
|
|
||||||
onSelect={onSelect}
|
|
||||||
resizeWidth={resizeWidth}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SavedPanel({
|
|
||||||
onSelect,
|
|
||||||
onSavedMediaChange,
|
|
||||||
resizeWidth,
|
|
||||||
urls,
|
|
||||||
}: {
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
onSavedMediaChange: (savedMedia: string[]) => void;
|
|
||||||
resizeWidth?: number;
|
|
||||||
urls: string[];
|
|
||||||
}) {
|
|
||||||
const [scrollRef, measuredWidth] = useMeasuredWidth();
|
|
||||||
const imageSizes = useImageSizes(urls);
|
|
||||||
const columnWidth = resizeWidth ?? measuredWidth;
|
|
||||||
const columnCount = getColumnCount(columnWidth, urls.length);
|
|
||||||
const columns = distributeByHeight(urls, columnCount, (url) => {
|
|
||||||
const size = imageSizes[url];
|
|
||||||
|
|
||||||
if (!size?.width || !size.height) return 1;
|
|
||||||
|
|
||||||
return size.height / size.width;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (urls.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
|
||||||
Saved media will appear here.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={scrollRef} className="min-h-0 w-full flex-1 overflow-y-auto pr-1">
|
|
||||||
<div className="flex w-full items-start gap-2">
|
|
||||||
{columns.map((column, columnIndex) => (
|
|
||||||
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
|
|
||||||
{column.map((url) => {
|
|
||||||
const size = imageSizes[url];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={url}
|
|
||||||
className="group relative overflow-hidden rounded-lg transition hover:border-foreground/30"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
onClick={() => onSelect(url)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt="Saved media"
|
|
||||||
className="block h-auto w-full object-contain"
|
|
||||||
height={size?.height}
|
|
||||||
loading="lazy"
|
|
||||||
src={url}
|
|
||||||
width={size?.width}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<MediaSaveButton
|
|
||||||
ariaLabel="Unsave media"
|
|
||||||
defaultSaved
|
|
||||||
onSavedMediaChange={onSavedMediaChange}
|
|
||||||
url={url}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GifPicker({
|
|
||||||
onSelect,
|
|
||||||
resizeHeight,
|
|
||||||
resizeWidth,
|
|
||||||
}: {
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
resizeHeight?: number;
|
|
||||||
resizeWidth?: number;
|
|
||||||
}) {
|
|
||||||
const { load, save } = useStorage();
|
|
||||||
const [tab, setTab] = useState<KlipyKind | "saved">("gif");
|
|
||||||
const [searchString, setSearchString] = useState("");
|
|
||||||
const [debouncedSearchString, setDebouncedSearchString] = useState("");
|
|
||||||
const [savedMedia, setSavedMedia] = useState<string[]>([]);
|
|
||||||
const didLoadLastTabRef = useRef(false);
|
|
||||||
const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (searchDebouncerRef.current === null) {
|
|
||||||
searchDebouncerRef.current = new Debouncer(
|
|
||||||
(value) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setDebouncedSearchString(value.trim());
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ wait: 300 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
searchDebouncerRef.current?.cancel();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab !== "saved") return;
|
|
||||||
|
|
||||||
void load("chat_picker_saved_media").then(setSavedMedia);
|
|
||||||
}, [load, tab]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load("chat_picker_last_tab").then((savedTab) => {
|
|
||||||
setTab(savedTab);
|
|
||||||
didLoadLastTabRef.current = true;
|
|
||||||
});
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!didLoadLastTabRef.current) return;
|
|
||||||
|
|
||||||
void save("chat_picker_last_tab", tab);
|
|
||||||
}, [save, tab]);
|
|
||||||
|
|
||||||
function handleTabChange(nextTab: string) {
|
|
||||||
if (nextTab !== "gif" && nextTab !== "meme" && nextTab !== "saved") return;
|
|
||||||
|
|
||||||
setTab(nextTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const nextValue = event.target.value;
|
|
||||||
|
|
||||||
setSearchString(nextValue);
|
|
||||||
searchDebouncerRef.current?.maybeExecute(nextValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
className="h-full min-h-0 w-full overflow-hidden"
|
|
||||||
style={resizeHeight ? { height: `${resizeHeight}px` } : undefined}
|
|
||||||
value={tab}
|
|
||||||
onValueChange={handleTabChange}
|
|
||||||
>
|
|
||||||
<TabsList className="w-full">
|
|
||||||
<TabsTrigger value="gif">GIFs</TabsTrigger>
|
|
||||||
<TabsTrigger value="meme">Memes</TabsTrigger>
|
|
||||||
<TabsTrigger value="saved">Saved</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="gif" className="flex min-h-0 w-full flex-col gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Search Klipy..."
|
|
||||||
value={searchString}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
/>
|
|
||||||
<KlipyPanel
|
|
||||||
kind="gif"
|
|
||||||
onSelect={onSelect}
|
|
||||||
resizeWidth={resizeWidth}
|
|
||||||
searchString={debouncedSearchString}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="meme" className="flex min-h-0 w-full flex-col gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Search Klipy..."
|
|
||||||
value={searchString}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
/>
|
|
||||||
<KlipyPanel
|
|
||||||
kind="meme"
|
|
||||||
onSelect={onSelect}
|
|
||||||
resizeWidth={resizeWidth}
|
|
||||||
searchString={debouncedSearchString}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="saved"
|
|
||||||
className="flex min-h-0 w-full flex-col overflow-hidden"
|
|
||||||
>
|
|
||||||
<SavedPanel
|
|
||||||
onSelect={onSelect}
|
|
||||||
onSavedMediaChange={setSavedMedia}
|
|
||||||
resizeWidth={resizeWidth}
|
|
||||||
urls={savedMedia}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import Input from "@tensamin/markdown/input";
|
import Input from "@tensamin/markdown/input";
|
||||||
import {
|
import { Card, CardHeader } from "@tensamin/ui";
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
|
|
||||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
import { Plus, Laugh, Clapperboard } from "lucide-react";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import { useTTP } from "@tensamin/ttp";
|
import { useTTP } from "@tensamin/ttp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
import { cn, useIsMobile } from "@tensamin/ui";
|
||||||
import { encryptText } from "@tensamin/crypto/worker";
|
import { encryptText } from "@tensamin/crypto/worker";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
|
||||||
|
|
||||||
export default function InputComponent({
|
export default function InputComponent({
|
||||||
value,
|
value,
|
||||||
|
|
@ -30,14 +23,8 @@ export default function InputComponent({
|
||||||
|
|
||||||
const { send } = useTTP();
|
const { send } = useTTP();
|
||||||
const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat();
|
const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat();
|
||||||
const { load, save } = useStorage();
|
const { load } = useStorage();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
const gifPopoverRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [gifPopoverOpen, setGifPopoverOpen] = React.useState(false);
|
|
||||||
const [gifPopoverSize, setGifPopoverSize] = React.useState<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
void load("settings.reverse_enter_behavior").then((shouldInvert) => {
|
||||||
|
|
@ -45,19 +32,16 @@ export default function InputComponent({
|
||||||
});
|
});
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
/**
|
||||||
void load("chat_picker_size").then((size) => {
|
* Executes handleSubmit.
|
||||||
if (size) {
|
* @param none This function has no parameters.
|
||||||
setGifPopoverSize(size);
|
* @returns unknown.
|
||||||
}
|
*/
|
||||||
});
|
async function handleSubmit() {
|
||||||
}, [load]);
|
if (value.trim() === "") return;
|
||||||
|
|
||||||
async function handleSubmit(content = value, preserveContent = false) {
|
|
||||||
if (content.trim() === "") return;
|
|
||||||
|
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
const currentValue = content;
|
const currentValue = value;
|
||||||
|
|
||||||
if (!Number.isSafeInteger(userId) || userId <= 0) {
|
if (!Number.isSafeInteger(userId) || userId <= 0) {
|
||||||
toast("error", "No conversation selected");
|
toast("error", "No conversation selected");
|
||||||
|
|
@ -98,70 +82,17 @@ export default function InputComponent({
|
||||||
|
|
||||||
moveUserIdToTop(userId);
|
moveUserIdToTop(userId);
|
||||||
|
|
||||||
if (!preserveContent) {
|
|
||||||
setValue("");
|
setValue("");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
function handleGifPopoverResizeStart(
|
|
||||||
event: React.PointerEvent<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const popover = gifPopoverRef.current;
|
|
||||||
|
|
||||||
if (!popover) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const rect = popover.getBoundingClientRect();
|
|
||||||
const startX = event.clientX;
|
|
||||||
const startY = event.clientY;
|
|
||||||
const startWidth = rect.width;
|
|
||||||
const startHeight = rect.height;
|
|
||||||
const minSize = 40;
|
|
||||||
const maxSize = 720;
|
|
||||||
|
|
||||||
function clampSize(size: number) {
|
|
||||||
return Math.min(Math.max(size, minSize), maxSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerMove(moveEvent: PointerEvent) {
|
|
||||||
const nextSize = {
|
|
||||||
width: clampSize(startWidth + startX - moveEvent.clientX),
|
|
||||||
height: clampSize(startHeight + startY - moveEvent.clientY),
|
|
||||||
};
|
|
||||||
|
|
||||||
setGifPopoverSize(nextSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp() {
|
|
||||||
const popover = gifPopoverRef.current;
|
|
||||||
|
|
||||||
if (popover) {
|
|
||||||
const rect = popover.getBoundingClientRect();
|
|
||||||
|
|
||||||
void save("chat_picker_size", {
|
|
||||||
width: clampSize(rect.width),
|
|
||||||
height: clampSize(rect.height),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
window.removeEventListener("pointerup", handlePointerUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
|
||||||
window.addEventListener("pointerup", handlePointerUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={inputBoxRef}
|
ref={inputBoxRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ring-0! rounded-none border-b-0 border-t border-x border-input/70 pt-0 pb-[env(safe-area-inset-bottom)] mx-2",
|
"rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)] mx-2",
|
||||||
isMobile ? "border-0 border-x-0! w-full mx-0 px-2" : "rounded-t-xl",
|
isMobile ? "border-0 border-t w-full mx-0 px-2" : "rounded-t-xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative p-0 flex flex-col">
|
<CardHeader className="relative p-0 flex flex-col">
|
||||||
|
|
@ -188,38 +119,9 @@ export default function InputComponent({
|
||||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||||
<Laugh size={20} />
|
<Laugh size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||||
<FileVideo size={20} />
|
<Clapperboard size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent
|
|
||||||
ref={gifPopoverRef}
|
|
||||||
className="relative min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
|
|
||||||
onTouchMoveCapture={(event) => event.stopPropagation()}
|
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
...gifPopoverSize,
|
|
||||||
maxHeight: gifPopoverSize?.height,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize"
|
|
||||||
onPointerDown={handleGifPopoverResizeStart}
|
|
||||||
/>
|
|
||||||
<GifPicker
|
|
||||||
resizeHeight={gifPopoverSize?.height}
|
|
||||||
resizeWidth={gifPopoverSize?.width}
|
|
||||||
onSelect={(url) => {
|
|
||||||
void handleSubmit(url, true);
|
|
||||||
setGifPopoverOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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";
|
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
|
||||||
|
|
||||||
export default function Media({ link }: { link: string }) {
|
|
||||||
const { load } = useStorage();
|
|
||||||
|
|
||||||
const [trustedDomains, setTrustedDomains] = useState<string[]>([]);
|
|
||||||
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) ? (
|
|
||||||
<div className="group relative inline-block">
|
|
||||||
{hidden ? (
|
|
||||||
<Text value={link} />
|
|
||||||
) : (
|
|
||||||
<MediaSaveButton className="top-2" url={link} />
|
|
||||||
)}
|
|
||||||
<img
|
|
||||||
src={link}
|
|
||||||
hidden={hidden}
|
|
||||||
onLoad={() => setHidden(false)}
|
|
||||||
className="max-h-70 max-w-70 py-1 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={<TriangleAlert className="size-4 stroke-yellow-400" />}
|
|
||||||
/>
|
|
||||||
<TooltipContent>
|
|
||||||
Link embeds can get your IP-Address! You can configure trusted domains
|
|
||||||
in the settings.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Text value={link} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { Button, cn } from "@tensamin/ui";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import { Save } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function MediaSaveButton({
|
|
||||||
ariaLabel,
|
|
||||||
className,
|
|
||||||
defaultSaved = false,
|
|
||||||
onSavedMediaChange,
|
|
||||||
url,
|
|
||||||
}: {
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
defaultSaved?: boolean;
|
|
||||||
onSavedMediaChange?: (savedMedia: string[]) => void;
|
|
||||||
url: string;
|
|
||||||
}) {
|
|
||||||
const { load, save } = useStorage();
|
|
||||||
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
|
|
||||||
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load("chat_picker_saved_media").then(setSavedMedia);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const currentSavedMedia =
|
|
||||||
savedMedia ?? (await load("chat_picker_saved_media"));
|
|
||||||
const nextSavedMedia = currentSavedMedia.includes(url)
|
|
||||||
? currentSavedMedia.filter((item) => item !== url)
|
|
||||||
: [url, ...currentSavedMedia];
|
|
||||||
|
|
||||||
setSavedMedia(nextSavedMedia);
|
|
||||||
onSavedMediaChange?.(nextSavedMedia);
|
|
||||||
void save("chat_picker_saved_media", nextSavedMedia);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute right-1 top-1 z-10 h-9 w-9 -translate-y-2 rounded-sm bg-black opacity-0 transition-all group-hover:translate-y-0 group-hover:opacity-100",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
|
|
||||||
className="h-full w-full rounded-sm border-0! p-0"
|
|
||||||
onClick={handleClick}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
<Save
|
|
||||||
className={cn(
|
|
||||||
"size-4.5 stroke-1.5!",
|
|
||||||
isSaved && "fill-(--primary) stroke-(--primary-foreground-alt)",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,6 @@ import * as React from "react";
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { User } from "@tensamin/user/context";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -14,54 +12,26 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
import { useChat } from "../context";
|
||||||
import MessageContextMenu from "./messageContextMenu";
|
import MessageContextMenu from "./messageContextMenu";
|
||||||
import Media from "./media";
|
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
grouped,
|
grouped,
|
||||||
message,
|
message,
|
||||||
user,
|
|
||||||
}: {
|
}: {
|
||||||
grouped: boolean;
|
grouped: boolean;
|
||||||
message: RawMessage & {
|
message: RawMessage & {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
};
|
};
|
||||||
user: User | null;
|
|
||||||
}) {
|
}) {
|
||||||
|
const { userId } = useChat();
|
||||||
const actuallyFailed = message.failed && message.message_state === "awaiting";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
// pt-3 is to get a gap between messages
|
// pt-3 is to get a gap between messages
|
||||||
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`}
|
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<MessageContextMenu
|
<MessageContextMenu
|
||||||
content={message.content}
|
content={message.content}
|
||||||
|
|
@ -76,7 +46,10 @@ function MessageComponent({
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{user ? (
|
<Wrapper
|
||||||
|
loading={"State not achievable"}
|
||||||
|
userId={message.sent_by_self ? "own" : userId}
|
||||||
|
component={(user) => (
|
||||||
<>
|
<>
|
||||||
{grouped ? (
|
{grouped ? (
|
||||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||||
|
|
@ -113,16 +86,11 @@ function MessageComponent({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isValidURL ? (
|
|
||||||
<Media link={message.content} />
|
|
||||||
) : (
|
|
||||||
<Text value={message.content} />
|
<Text value={message.content} />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
"Loading"
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</MessageContextMenu>
|
</MessageContextMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,8 +104,6 @@ export default React.memo(MessageComponent, (prev, next) => {
|
||||||
prev.message.height === next.message.height &&
|
prev.message.height === next.message.height &&
|
||||||
prev.message.sent_by_self === next.message.sent_by_self &&
|
prev.message.sent_by_self === next.message.sent_by_self &&
|
||||||
prev.message.message_state === next.message.message_state &&
|
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
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -266,11 +266,7 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
return updated ? next : prev;
|
return updated ? next : prev;
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryKey = [
|
const queryKey = ["chat-messages", String(userIdValue)] as const;
|
||||||
"chat-messages",
|
|
||||||
String(userIdValue),
|
|
||||||
currentSharedSecret.length > 0,
|
|
||||||
] as const;
|
|
||||||
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||||
queryKey,
|
queryKey,
|
||||||
(current) => {
|
(current) => {
|
||||||
|
|
@ -305,7 +301,7 @@ export default function Provider(props: { children: ReactNode }) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [currentSharedSecret, subscribePush, userIdValue]);
|
}, [subscribePush, userIdValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,56 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { failedUser } from "@tensamin/shared/data";
|
import { useChat } from "./context";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import { useUser, type User } from "@tensamin/user/context";
|
|
||||||
|
|
||||||
import InputComponent from "./components/input";
|
import InputComponent from "./components/input";
|
||||||
import Message from "./components/message";
|
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";
|
|
||||||
|
|
||||||
type MessageChunk = {
|
import { PAGE_SIZE } from "./values";
|
||||||
key: string;
|
import { useIsMobile } from "@tensamin/ui";
|
||||||
messages: Array<RawMessage | LiveMessage>;
|
import { Loader2 } from "lucide-react";
|
||||||
startIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEstimatedMessageHeight(message: { height?: number }) {
|
function getDistanceFromBottom(element: HTMLDivElement) {
|
||||||
return typeof message.height === "number" && Number.isFinite(message.height)
|
return element.scrollHeight - (element.scrollTop + element.clientHeight);
|
||||||
? Math.max(1, Math.ceil(message.height))
|
|
||||||
: FALLBACK_MESSAGE_HEIGHT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFetchPreviousPage({
|
const FALLBACK_MESSAGE_HEIGHT = 56;
|
||||||
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<RawMessage | LiveMessage>,
|
|
||||||
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.
|
* Renders the chat screen with virtualized history and live message updates.
|
||||||
* @returns Chat screen JSX.
|
* @returns Chat screen JSX.
|
||||||
*/
|
*/
|
||||||
export default function Screen() {
|
export default function Screen() {
|
||||||
const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } =
|
const {
|
||||||
useChat();
|
getMessages,
|
||||||
const { get: getUser } = useUser();
|
liveMessages,
|
||||||
const { load } = useStorage();
|
clearLiveMessages,
|
||||||
|
userId,
|
||||||
|
sharedSecret,
|
||||||
|
inputBoxRef,
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const topSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
const isMobile = useIsMobile();
|
||||||
const didInitialScrollRef = React.useRef(false);
|
const stickyBottomThreshold = isMobile ? 220 : 140;
|
||||||
const userScrolledUpRef = React.useRef(false);
|
|
||||||
const isAtBottomRef = React.useRef(true);
|
|
||||||
const smoothScrollFrameRef = React.useRef<number | null>(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<
|
const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] =
|
||||||
Record<string, User | undefined>
|
React.useState(false);
|
||||||
>({});
|
|
||||||
const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0);
|
const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0);
|
||||||
const [didInitialScroll, setDidInitialScroll] = React.useState(false);
|
const [prependAnchor, setPrependAnchor] = React.useState<{
|
||||||
const [viewportHeight, setViewportHeight] = React.useState(0);
|
totalSize: number;
|
||||||
const [value, setValue] = React.useState("");
|
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<number, number>
|
||||||
|
>({});
|
||||||
|
const measurementRefs = React.useRef(new Map<number, HTMLDivElement>());
|
||||||
|
const shouldRestoreBottomOnResizeRef = React.useRef(false);
|
||||||
|
const isAtBottomRef = React.useRef(true);
|
||||||
|
const shouldRestoreBottomOnFocusResizeRef = React.useRef(false);
|
||||||
|
|
||||||
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
||||||
const hasSharedSecret = sharedSecret.length > 0;
|
const hasSharedSecret = sharedSecret.length > 0;
|
||||||
|
|
@ -120,153 +68,80 @@ export default function Screen() {
|
||||||
return allPages.length * PAGE_SIZE;
|
return allPages.length * PAGE_SIZE;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
fetchNextPage: fetchMessagesNextPage,
|
|
||||||
hasNextPage: hasMessagesNextPage,
|
|
||||||
isFetchingNextPage: isFetchingMessagesNextPage,
|
|
||||||
} = messagesQuery;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
hasNextPageRef.current = hasMessagesNextPage;
|
|
||||||
isFetchingNextPageRef.current = isFetchingMessagesNextPage;
|
|
||||||
fetchNextPageRef.current = () => {
|
|
||||||
void fetchMessagesNextPage();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
fetchMessagesNextPage,
|
|
||||||
hasMessagesNextPage,
|
|
||||||
isFetchingMessagesNextPage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
clearLiveMessages();
|
|
||||||
didInitialScrollRef.current = false;
|
|
||||||
userScrolledUpRef.current = false;
|
|
||||||
isAtBottomRef.current = true;
|
|
||||||
setDidInitialScroll(false);
|
|
||||||
setLastLiveMessageCount(0);
|
|
||||||
}, [clearLiveMessages, userId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!hasValidChatUser) {
|
|
||||||
setMessageUsers({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
const loadMessageUser = async (
|
|
||||||
key: string,
|
|
||||||
resolveUserId: () => Promise<number>,
|
|
||||||
) => {
|
|
||||||
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 historicalMessages = React.useMemo(() => {
|
||||||
const pages = messagesQuery.data?.pages ?? [];
|
const pages = messagesQuery.data?.pages ?? [];
|
||||||
const seenSendTimes = new Set<number>();
|
return [...pages].reverse().flat();
|
||||||
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]);
|
}, [messagesQuery.data]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
clearLiveMessages();
|
||||||
|
setHasScrolledToBottomInitially(false);
|
||||||
|
setLastLiveMessageCount(0);
|
||||||
|
setPrependAnchor(null);
|
||||||
|
}, [userId, clearLiveMessages]);
|
||||||
|
|
||||||
const liveMessagesSnapshot = liveMessages();
|
const liveMessagesSnapshot = liveMessages();
|
||||||
|
|
||||||
const liveWithoutDuplicates = React.useMemo(() => {
|
const messages = React.useMemo(() => {
|
||||||
|
if (historicalMessages.length === 0) {
|
||||||
|
return liveMessagesSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
const historicalSendTimes = new Set(
|
const historicalSendTimes = new Set(
|
||||||
historicalMessages.map((message) => message.send_time),
|
historicalMessages.map((message) => message.send_time),
|
||||||
);
|
);
|
||||||
|
const liveWithoutDuplicates = liveMessagesSnapshot.filter(
|
||||||
return liveMessagesSnapshot.filter(
|
|
||||||
(message) => !historicalSendTimes.has(message.send_time),
|
(message) => !historicalSendTimes.has(message.send_time),
|
||||||
);
|
);
|
||||||
}, [historicalMessages, liveMessagesSnapshot]);
|
|
||||||
|
|
||||||
const messages = React.useMemo(() => {
|
|
||||||
return [...historicalMessages, ...liveWithoutDuplicates];
|
return [...historicalMessages, ...liveWithoutDuplicates];
|
||||||
}, [historicalMessages, liveWithoutDuplicates]);
|
}, [historicalMessages, liveMessagesSnapshot]);
|
||||||
|
|
||||||
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 =
|
const shouldShowConversationStart =
|
||||||
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
||||||
const virtualRowCount =
|
const virtualRowCount =
|
||||||
messageChunks.length + (shouldShowConversationStart ? 1 : 0);
|
messages.length + (shouldShowConversationStart ? 1 : 0);
|
||||||
|
|
||||||
const getItemKey = React.useCallback(
|
const getItemKey = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (shouldShowConversationStart && index === messageChunks.length) {
|
if (shouldShowConversationStart && index === 0) {
|
||||||
return "conversation-start";
|
return "conversation-start";
|
||||||
}
|
}
|
||||||
|
|
||||||
return messageChunks[index]?.key ?? index;
|
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
||||||
|
return messages[messageIndex]?.send_time ?? index;
|
||||||
},
|
},
|
||||||
[messageChunks, shouldShowConversationStart],
|
[shouldShowConversationStart, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const estimateSize = React.useCallback(
|
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(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
const isConversationStart =
|
if (shouldShowConversationStart && index === 0) {
|
||||||
shouldShowConversationStart && index === messageChunks.length;
|
|
||||||
if (isConversationStart) {
|
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
return FALLBACK_MESSAGE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunk = messageChunks[index];
|
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
||||||
|
const sendTime = messages[messageIndex]?.send_time;
|
||||||
|
|
||||||
if (!chunk) {
|
if (sendTime === undefined) {
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
return FALLBACK_MESSAGE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunk.messages.reduce(
|
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
|
||||||
(total, message) => total + getEstimatedMessageHeight(message),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[messageChunks, shouldShowConversationStart],
|
[measuredHeights, messages, shouldShowConversationStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
|
@ -274,12 +149,9 @@ export default function Screen() {
|
||||||
count: virtualRowCount,
|
count: virtualRowCount,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
getItemKey,
|
getItemKey,
|
||||||
estimateSize,
|
estimateSize: estimateMessageSize,
|
||||||
overscan: 2,
|
overscan: isMobile ? 10 : 6,
|
||||||
});
|
});
|
||||||
const totalSize = virtualizer.getTotalSize();
|
|
||||||
const contentHeight = Math.max(totalSize, viewportHeight);
|
|
||||||
const verticalOffset = Math.max(0, viewportHeight - totalSize);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const element = scrollRef.current;
|
const element = scrollRef.current;
|
||||||
|
|
@ -287,13 +159,14 @@ export default function Screen() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateViewportHeight = () => {
|
const updateDimensions = () => {
|
||||||
setViewportHeight(element.clientHeight);
|
setScrollWidth(element.clientWidth);
|
||||||
|
setScrollHeight(element.clientHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
updateViewportHeight();
|
updateDimensions();
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateViewportHeight);
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -302,185 +175,277 @@ export default function Screen() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (didInitialScrollRef.current || virtualRowCount === 0) {
|
const frame = requestAnimationFrame(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [inputHeight, measuredHeights, scrollWidth, virtualizer]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (typeof ResizeObserver === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
const measureHeights = () => {
|
||||||
if (scrollRef.current) {
|
setMeasuredHeights((prev) => {
|
||||||
scrollRef.current.scrollTop = 0;
|
let changed = false;
|
||||||
|
const next: Record<number, number> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [messages, scrollWidth]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreBottomAfterResize = () => {
|
||||||
|
shouldRestoreBottomOnResizeRef.current =
|
||||||
|
isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
didInitialScrollRef.current = true;
|
virtualizer.measure();
|
||||||
setDidInitialScroll(true);
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
shouldRestoreBottomOnFocusResizeRef.current = false;
|
||||||
|
shouldRestoreBottomOnResizeRef.current = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [virtualizer, virtualRowCount]);
|
};
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const count = liveMessagesSnapshot.length;
|
const count = liveMessagesSnapshot.length;
|
||||||
|
|
||||||
if (count > lastLiveMessageCount && isAtBottomRef.current) {
|
if (!scrollRef.current) {
|
||||||
requestAnimationFrame(() => {
|
return;
|
||||||
if (scrollRef.current) {
|
}
|
||||||
scrollRef.current.scrollTop = 0;
|
|
||||||
|
if (count > lastLiveMessageCount) {
|
||||||
|
const shouldStickToBottom =
|
||||||
|
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
|
||||||
|
|
||||||
|
if (shouldStickToBottom) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLastLiveMessageCount(count);
|
setLastLiveMessageCount(count);
|
||||||
}, [
|
}, [
|
||||||
lastLiveMessageCount,
|
lastLiveMessageCount,
|
||||||
liveMessagesSnapshot.length,
|
liveMessagesSnapshot.length,
|
||||||
virtualRowCount,
|
stickyBottomThreshold,
|
||||||
virtualizer,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (
|
|
||||||
viewportHeight === 0 ||
|
|
||||||
totalSize > viewportHeight ||
|
|
||||||
messagesQuery.isFetchingNextPage ||
|
|
||||||
!messagesQuery.hasNextPage
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void messagesQuery.fetchNextPage();
|
|
||||||
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (
|
if (
|
||||||
!didInitialScroll ||
|
!scrollRef.current ||
|
||||||
userScrolledUpRef.current ||
|
!prependAnchor ||
|
||||||
virtualRowCount === 0
|
messagesQuery.isFetchingNextPage
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
const delta = virtualizer.getTotalSize() - prependAnchor.totalSize;
|
||||||
|
scrollRef.current.scrollTop = prependAnchor.scrollTop + delta;
|
||||||
|
setPrependAnchor(null);
|
||||||
|
}, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers asynchronous scroll pagination without returning a promise to JSX.
|
||||||
|
* @returns Void.
|
||||||
|
*/
|
||||||
|
const handleContainerScroll = React.useCallback((): void => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = 0;
|
isAtBottomRef.current =
|
||||||
}
|
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
|
||||||
});
|
|
||||||
}, [didInitialScroll, virtualRowCount]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const root = scrollRef.current;
|
|
||||||
const sentinel = topSentinelRef.current;
|
|
||||||
|
|
||||||
if (!root || !sentinel || !didInitialScroll) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
void onScroll();
|
||||||
(entries) => {
|
}, [onScroll, stickyBottomThreshold]);
|
||||||
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);
|
const [value, setValue] = React.useState("");
|
||||||
|
const totalSize = virtualizer.getTotalSize();
|
||||||
return () => {
|
const verticalOffset = Math.max(0, scrollHeight - totalSize);
|
||||||
observer.disconnect();
|
const contentHeight = Math.max(totalSize, scrollHeight);
|
||||||
};
|
|
||||||
}, [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) {
|
if (!hasValidChatUser) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
||||||
|
|
@ -497,24 +462,31 @@ export default function Screen() {
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
style={{
|
style={{
|
||||||
overflowAnchor: "none",
|
overflowAnchor: "none",
|
||||||
paddingTop: "22px",
|
paddingBottom: "22px",
|
||||||
transform: "scaleY(-1)",
|
|
||||||
}}
|
}}
|
||||||
onScroll={handleContainerScroll}
|
onScroll={handleContainerScroll}
|
||||||
>
|
>
|
||||||
|
{messagesQuery.isPending && (
|
||||||
|
<div className="flex items-center justify-center pt-10">
|
||||||
|
<p className="text-foreground/45 flex gap-1 items-center justify-center">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messagesQuery.isFetchingNextPage && (
|
||||||
|
<div className="flex items-center justify-center pt-3 pb-1">
|
||||||
|
<Loader2 size={16} className="animate-spin text-foreground/45" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{ height: `${contentHeight}px` }}
|
style={{
|
||||||
|
height: `${contentHeight}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
ref={topSentinelRef}
|
|
||||||
className="absolute bottom-0 left-0 h-px w-full"
|
|
||||||
/>
|
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
if (
|
if (shouldShowConversationStart && virtualRow.index === 0) {
|
||||||
shouldShowConversationStart &&
|
|
||||||
virtualRow.index === messageChunks.length
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key="conversation-start"
|
key="conversation-start"
|
||||||
|
|
@ -525,10 +497,10 @@ export default function Screen() {
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full flex justify-start scale-y-[-1]">
|
<div className="w-full flex justify-start">
|
||||||
<div className="text-sm text-foreground/55 px-2.5">
|
<div className="text-sm text-foreground/55 px-2.5">
|
||||||
Conversation start
|
Conversation start
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -537,29 +509,14 @@ export default function Screen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunkIndex = virtualRow.index;
|
const messageIndex = shouldShowConversationStart
|
||||||
const chunk = messageChunks[chunkIndex];
|
? virtualRow.index - 1
|
||||||
if (!chunk) {
|
: virtualRow.index;
|
||||||
|
const message = messages[messageIndex];
|
||||||
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={chunk.key}
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
className="flex flex-col"
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col scale-y-[-1]">
|
|
||||||
{chunk.messages.map((message, chunkMessageIndex) => {
|
|
||||||
const messageIndex = chunk.startIndex + chunkMessageIndex;
|
|
||||||
const lastMessage = messages[messageIndex - 1];
|
const lastMessage = messages[messageIndex - 1];
|
||||||
const isGrouped =
|
const isGrouped =
|
||||||
lastMessage &&
|
lastMessage &&
|
||||||
|
|
@ -568,19 +525,19 @@ export default function Screen() {
|
||||||
Math.round(message.send_time / 10000);
|
Math.round(message.send_time / 10000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Message
|
<div
|
||||||
key={getMessageRenderKey(message)}
|
key={message.send_time}
|
||||||
grouped={isGrouped}
|
data-index={virtualRow.index}
|
||||||
message={message}
|
ref={virtualizer.measureElement}
|
||||||
user={
|
style={{
|
||||||
message.sent_by_self
|
position: "absolute",
|
||||||
? (messageUsers.own ?? null)
|
top: 0,
|
||||||
: (messageUsers.peer ?? null)
|
left: 0,
|
||||||
}
|
width: "100%",
|
||||||
/>
|
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
||||||
);
|
}}
|
||||||
})}
|
>
|
||||||
</div>
|
<Message grouped={isGrouped} message={message} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -589,6 +546,31 @@ export default function Screen() {
|
||||||
<div className="z-10 shrink-0">
|
<div className="z-10 shrink-0">
|
||||||
<InputComponent setValue={setValue} value={value} />
|
<InputComponent setValue={setValue} value={value} />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-0 top-0 -z-10 overflow-hidden opacity-0"
|
||||||
|
style={{ width: scrollWidth > 0 ? `${scrollWidth}px` : "100%" }}
|
||||||
|
>
|
||||||
|
{messages.map((message, messageIndex) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={`measure-${message.send_time}`}
|
||||||
|
ref={(element) => {
|
||||||
|
setMeasurementRef(message.send_time, element);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Message grouped={isGrouped} message={message} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,4 @@ export type LiveMessage = RawMessage & {
|
||||||
localId: string;
|
localId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PAGE_SIZE = 30;
|
export const PAGE_SIZE = 50;
|
||||||
export const FALLBACK_MESSAGE_HEIGHT = 28;
|
|
||||||
export const MESSAGES_PER_VIRTUAL_ROW = 30;
|
|
||||||
|
|
|
||||||
|
|
@ -47,26 +47,6 @@ export type Communities = z.infer<
|
||||||
>;
|
>;
|
||||||
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
export type Calls = z.infer<typeof ttp.challenge_response.response.shape.calls>;
|
||||||
|
|
||||||
type Base16Palette = Record<
|
|
||||||
| "base00"
|
|
||||||
| "base01"
|
|
||||||
| "base02"
|
|
||||||
| "base03"
|
|
||||||
| "base04"
|
|
||||||
| "base05"
|
|
||||||
| "base06"
|
|
||||||
| "base07"
|
|
||||||
| "base08"
|
|
||||||
| "base09"
|
|
||||||
| "base0A"
|
|
||||||
| "base0B"
|
|
||||||
| "base0C"
|
|
||||||
| "base0D"
|
|
||||||
| "base0E"
|
|
||||||
| "base0F",
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
|
|
||||||
// TTP
|
// TTP
|
||||||
const user = z.object({
|
const user = z.object({
|
||||||
about: z.string().max(255).optional(),
|
about: z.string().max(255).optional(),
|
||||||
|
|
@ -277,21 +257,8 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
cached_contacts: Contacts;
|
cached_contacts: Contacts;
|
||||||
cached_communities: Communities;
|
cached_communities: Communities;
|
||||||
ttp_url: string;
|
ttp_url: string;
|
||||||
ttp_server_cert: string;
|
|
||||||
call_mute_range_start: number;
|
call_mute_range_start: number;
|
||||||
call_mute_range_end: number;
|
call_mute_range_end: number;
|
||||||
theme_color: string;
|
|
||||||
theme_palette: Base16Palette | null;
|
|
||||||
theme_primary_color: string;
|
|
||||||
theme_polarity: "dark" | "light" | "system";
|
|
||||||
theme_tint: "soft" | "hard" | "extreme";
|
|
||||||
chat_trusted_domains: string[];
|
|
||||||
chat_picker_saved_media: string[];
|
|
||||||
chat_picker_last_tab: "gif" | "meme" | "saved";
|
|
||||||
chat_picker_size: {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageDefaults: Storage = {
|
export const storageDefaults: Storage = {
|
||||||
|
|
@ -325,41 +292,8 @@ export const storageDefaults: Storage = {
|
||||||
cached_contacts: [],
|
cached_contacts: [],
|
||||||
cached_communities: [],
|
cached_communities: [],
|
||||||
ttp_url: "https://tensamin.net:959",
|
ttp_url: "https://tensamin.net:959",
|
||||||
ttp_server_cert: "",
|
|
||||||
call_mute_range_start: -55,
|
call_mute_range_start: -55,
|
||||||
call_mute_range_end: -45,
|
call_mute_range_end: -45,
|
||||||
theme_color: "",
|
|
||||||
theme_palette: null,
|
|
||||||
theme_primary_color: "",
|
|
||||||
theme_polarity: "system",
|
|
||||||
theme_tint: "soft",
|
|
||||||
chat_trusted_domains: [
|
|
||||||
// Other platforms
|
|
||||||
"cdn.discordapp.com",
|
|
||||||
|
|
||||||
// Tenor
|
|
||||||
"tenor.com",
|
|
||||||
"c.tenor.com",
|
|
||||||
|
|
||||||
// Klipy
|
|
||||||
"static.klipy.com",
|
|
||||||
"static1.klipy.com",
|
|
||||||
"static2.klipy.com",
|
|
||||||
|
|
||||||
// Giphy
|
|
||||||
"giphy.com",
|
|
||||||
"www.giphy.com",
|
|
||||||
"media.giphy.com",
|
|
||||||
"i.giphy.com",
|
|
||||||
"media0.giphy.com",
|
|
||||||
"media1.giphy.com",
|
|
||||||
"media2.giphy.com",
|
|
||||||
"media3.giphy.com",
|
|
||||||
"media4.giphy.com",
|
|
||||||
],
|
|
||||||
chat_picker_saved_media: [],
|
|
||||||
chat_picker_last_tab: "gif",
|
|
||||||
chat_picker_size: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ const settings = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
application: {
|
application: {
|
||||||
theme: {},
|
|
||||||
licenses: {},
|
licenses: {},
|
||||||
},
|
},
|
||||||
} as const satisfies SettingsSchema;
|
} as const satisfies SettingsSchema;
|
||||||
|
|
|
||||||
|
|
@ -195,14 +195,10 @@ export function Provider(props: {
|
||||||
|
|
||||||
// Load ttp url
|
// Load ttp url
|
||||||
const [ttpUrl, setTtpUrl] = useState<string | null>(null);
|
const [ttpUrl, setTtpUrl] = useState<string | null>(null);
|
||||||
const [ttpServerCert, setTtpServerCert] = useState<string>("");
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load("ttp_url").then((url) => {
|
load("ttp_url").then((url) => {
|
||||||
setTtpUrl(url);
|
setTtpUrl(url);
|
||||||
});
|
});
|
||||||
load("ttp_server_cert").then((cert) => {
|
|
||||||
setTtpServerCert(cert.trim());
|
|
||||||
});
|
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -305,6 +301,7 @@ export function Provider(props: {
|
||||||
let reconnectScheduled = false;
|
let reconnectScheduled = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let resumeListenerRegistered = false;
|
let resumeListenerRegistered = false;
|
||||||
|
let currentReadyState: number = READY_STATE.CLOSED;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears any scheduled reconnect timeout and resets scheduling flags.
|
* Clears any scheduled reconnect timeout and resets scheduling flags.
|
||||||
|
|
@ -376,8 +373,8 @@ export function Provider(props: {
|
||||||
const transportClient = !props.blockConnection
|
const transportClient = !props.blockConnection
|
||||||
? createTransportClient(schemas, {
|
? createTransportClient(schemas, {
|
||||||
url: ttpUrl,
|
url: ttpUrl,
|
||||||
serverCert: ttpServerCert || undefined,
|
|
||||||
onReadyStateChange: (state) => {
|
onReadyStateChange: (state) => {
|
||||||
|
currentReadyState = state;
|
||||||
setReadyState(state);
|
setReadyState(state);
|
||||||
|
|
||||||
if (state === READY_STATE.OPEN) {
|
if (state === READY_STATE.OPEN) {
|
||||||
|
|
@ -450,6 +447,13 @@ export function Provider(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentReadyState === READY_STATE.OPEN ||
|
||||||
|
currentReadyState === READY_STATE.CONNECTING
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
clearReconnectResetTimer();
|
clearReconnectResetTimer();
|
||||||
attempts = 0;
|
attempts = 0;
|
||||||
|
|
@ -483,13 +487,14 @@ export function Provider(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
void transportClient?.close("context-dispose");
|
void transportClient?.close("context-dispose");
|
||||||
|
currentReadyState = READY_STATE.CLOSED;
|
||||||
setReadyState(READY_STATE.CLOSED);
|
setReadyState(READY_STATE.CLOSED);
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
setIdentified(false);
|
setIdentified(false);
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
identificationStartedRef.current = false;
|
identificationStartedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [props.blockConnection, ttpServerCert, ttpUrl]);
|
}, [props.blockConnection, ttpUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue