feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
951
fluxer/fluxer_app/crates/libfluxcore/Cargo.lock
generated
Normal file
951
fluxer/fluxer_app/crates/libfluxcore/Cargo.lock
generated
Normal file
@@ -0,0 +1,951 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "av-scenechange"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"anyhow",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"rayon",
|
||||
"thiserror",
|
||||
"v_frame",
|
||||
"y4m",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av1-grain"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avif-serialize"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||
dependencies = [
|
||||
"core2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "core2"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.0",
|
||||
"ravif",
|
||||
"rgb",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libfluxcore"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gif",
|
||||
"image",
|
||||
"png 0.17.16",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "loop9"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||
dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||
dependencies = [
|
||||
"profiling-procmacros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling-procmacros"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av-scenechange",
|
||||
"av1-grain",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"simd_helpers",
|
||||
"thiserror",
|
||||
"v_frame",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ravif"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fad02996bfc73da3e301efe90b1837be9ed8f4a462b6ed410aa35d00381de89f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "y4m"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
15
fluxer/fluxer_app/crates/libfluxcore/Cargo.toml
Normal file
15
fluxer/fluxer_app/crates/libfluxcore/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "libfluxcore"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
gif = "0.13"
|
||||
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp", "avif"] }
|
||||
png = "0.17"
|
||||
ruzstd = { version = "0.7", default-features = false, features = ["std"] }
|
||||
wasm-bindgen = { version = "0.2", features = ["std"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
159
fluxer/fluxer_app/crates/libfluxcore/src/animation.rs
Normal file
159
fluxer/fluxer_app/crates/libfluxcore/src/animation.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use gif::{ColorOutput, DecodeOptions};
|
||||
use std::io::Cursor;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
const PNG_SIGNATURE: [u8; 8] = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
|
||||
enum ImageFormat {
|
||||
Gif,
|
||||
Png,
|
||||
Webp,
|
||||
Avif,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn detect_format(input: &[u8]) -> ImageFormat {
|
||||
if input.len() >= 6 && &input[..6] == b"GIF89a" {
|
||||
return ImageFormat::Gif;
|
||||
}
|
||||
if input.len() >= 6 && &input[..6] == b"GIF87a" {
|
||||
return ImageFormat::Gif;
|
||||
}
|
||||
if input.len() >= PNG_SIGNATURE.len() && input[..PNG_SIGNATURE.len()] == PNG_SIGNATURE {
|
||||
return ImageFormat::Png;
|
||||
}
|
||||
if input.len() >= 12 && &input[..4] == b"RIFF" && &input[8..12] == b"WEBP" {
|
||||
return ImageFormat::Webp;
|
||||
}
|
||||
if is_avif_file(input) {
|
||||
return ImageFormat::Avif;
|
||||
}
|
||||
ImageFormat::Unknown
|
||||
}
|
||||
|
||||
fn is_animated_gif(input: &[u8]) -> bool {
|
||||
let mut options = DecodeOptions::new();
|
||||
options.set_color_output(ColorOutput::RGBA);
|
||||
let cursor = Cursor::new(input);
|
||||
let mut reader = match options.read_info(cursor) {
|
||||
Ok(reader) => reader,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut frame_count = 0;
|
||||
loop {
|
||||
match reader.read_next_frame() {
|
||||
Ok(Some(_frame)) => {
|
||||
frame_count += 1;
|
||||
if frame_count > 1 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn has_apng_actl(input: &[u8]) -> bool {
|
||||
if input.len() < PNG_SIGNATURE.len() || input[..PNG_SIGNATURE.len()] != PNG_SIGNATURE {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut offset = PNG_SIGNATURE.len();
|
||||
while offset + 12 <= input.len() {
|
||||
let length_bytes = &input[offset..offset + 4];
|
||||
let length = u32::from_be_bytes(length_bytes.try_into().unwrap()) as usize;
|
||||
let chunk_type = &input[offset + 4..offset + 8];
|
||||
if chunk_type == b"acTL" {
|
||||
return true;
|
||||
}
|
||||
offset = offset
|
||||
.saturating_add(8)
|
||||
.saturating_add(length)
|
||||
.saturating_add(4);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn has_webp_anim(input: &[u8]) -> bool {
|
||||
if input.len() < 12 || &input[..4] != b"RIFF" || &input[8..12] != b"WEBP" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut offset = 12;
|
||||
while offset + 8 <= input.len() {
|
||||
let chunk_id = &input[offset..offset + 4];
|
||||
let size_bytes = &input[offset + 4..offset + 8];
|
||||
let size = u32::from_le_bytes(size_bytes.try_into().unwrap()) as usize;
|
||||
|
||||
if chunk_id == b"ANIM" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let advance = 8 + size + (size % 2);
|
||||
offset = offset.saturating_add(advance);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_avif_file(input: &[u8]) -> bool {
|
||||
if input.len() < 12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let box_type = &input[4..8];
|
||||
|
||||
if box_type != b"ftyp" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let brand = &input[8..12];
|
||||
brand == b"avif" || brand == b"avis"
|
||||
}
|
||||
|
||||
fn has_avif_anim(input: &[u8]) -> bool {
|
||||
if !is_avif_file(input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if input.len() < 12 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let brand = &input[8..12];
|
||||
brand == b"avis"
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn is_animated_image(input: &[u8]) -> bool {
|
||||
match detect_format(input) {
|
||||
ImageFormat::Gif => is_animated_gif(input),
|
||||
ImageFormat::Png => has_apng_actl(input),
|
||||
ImageFormat::Webp => has_webp_anim(input),
|
||||
ImageFormat::Avif => has_avif_anim(input),
|
||||
ImageFormat::Unknown => false,
|
||||
}
|
||||
}
|
||||
411
fluxer/fluxer_app/crates/libfluxcore/src/apng.rs
Normal file
411
fluxer/fluxer_app/crates/libfluxcore/src/apng.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use png::{BlendOp, DisposeOp};
|
||||
use std::io::Cursor;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_and_rotate_apng(
|
||||
input: &[u8],
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
resize_width: Option<u32>,
|
||||
resize_height: Option<u32>,
|
||||
) -> Result<Box<[u8]>, JsValue> {
|
||||
process_apng(
|
||||
input,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_apng(
|
||||
input: &[u8],
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
resize_width: Option<u32>,
|
||||
resize_height: Option<u32>,
|
||||
) -> Result<Box<[u8]>, JsValue> {
|
||||
let cursor = Cursor::new(input);
|
||||
let mut decoder = png::Decoder::new(cursor);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
|
||||
let mut reader = decoder
|
||||
.read_info()
|
||||
.map_err(|e| JsValue::from_str(&format!("png read_info: {e}")))?;
|
||||
|
||||
let info = reader.info();
|
||||
let animation_control = info
|
||||
.animation_control()
|
||||
.ok_or_else(|| JsValue::from_str("Not an animated PNG"))?;
|
||||
|
||||
let screen_width = info.width;
|
||||
let screen_height = info.height;
|
||||
|
||||
let crop_x = x.min(screen_width);
|
||||
let crop_y = y.min(screen_height);
|
||||
let crop_w = width.min(screen_width - crop_x);
|
||||
let crop_h = height.min(screen_height - crop_y);
|
||||
|
||||
if crop_w == 0 || crop_h == 0 {
|
||||
return Err(JsValue::from_str("Crop area is empty"));
|
||||
}
|
||||
|
||||
let rotation = rotation_deg.rem_euclid(360);
|
||||
|
||||
let (base_w, base_h) = match rotation {
|
||||
90 | 270 => (crop_h, crop_w),
|
||||
_ => (crop_w, crop_h),
|
||||
};
|
||||
|
||||
let (target_w, target_h) = match (
|
||||
resize_width.filter(|w| *w > 0),
|
||||
resize_height.filter(|h| *h > 0),
|
||||
) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
_ => (base_w, base_h),
|
||||
};
|
||||
|
||||
if target_w == 0 || target_h == 0 {
|
||||
return Err(JsValue::from_str("Target dimensions are empty"));
|
||||
}
|
||||
|
||||
if crop_x == 0
|
||||
&& crop_y == 0
|
||||
&& crop_w == screen_width
|
||||
&& crop_h == screen_height
|
||||
&& rotation == 0
|
||||
&& target_w == screen_width
|
||||
&& target_h == screen_height
|
||||
{
|
||||
return Ok(input.to_vec().into_boxed_slice());
|
||||
}
|
||||
|
||||
let mut output = Cursor::new(Vec::new());
|
||||
let mut encoder = png::Encoder::new(&mut output, target_w, target_h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.set_animated(animation_control.num_frames, animation_control.num_plays)
|
||||
.map_err(|e| JsValue::from_str(&format!("png set_animated: {e}")))?;
|
||||
encoder.validate_sequence(true);
|
||||
let mut writer = encoder
|
||||
.write_header()
|
||||
.map_err(|e| JsValue::from_str(&format!("png write_header: {e}")))?;
|
||||
|
||||
let mut canvas = vec![0u8; (screen_width * screen_height * 4) as usize];
|
||||
let mut previous_canvas: Option<Vec<u8>> = None;
|
||||
|
||||
let mut processed_any = false;
|
||||
const MAX_TOTAL_PIXELS: u64 = 200_000_000;
|
||||
let mut processed_pixels: u64 = 0;
|
||||
|
||||
let mut frame_buffer = vec![0u8; (screen_width * screen_height * 4) as usize];
|
||||
|
||||
while let Ok(frame_info) = reader.next_frame_info() {
|
||||
processed_any = true;
|
||||
|
||||
let dispose_op = frame_info.dispose_op;
|
||||
let blend_op = frame_info.blend_op;
|
||||
let delay_num = frame_info.delay_num;
|
||||
let delay_den = frame_info.delay_den;
|
||||
let fx = frame_info.x_offset as usize;
|
||||
let fy = frame_info.y_offset as usize;
|
||||
let fw = frame_info.width as usize;
|
||||
let fh = frame_info.height as usize;
|
||||
let rect_x = frame_info.x_offset;
|
||||
let rect_y = frame_info.y_offset;
|
||||
let rect_w = frame_info.width;
|
||||
let rect_h = frame_info.height;
|
||||
|
||||
if dispose_op == DisposeOp::Previous {
|
||||
previous_canvas = Some(canvas.clone());
|
||||
}
|
||||
|
||||
reader
|
||||
.next_frame(&mut frame_buffer)
|
||||
.map_err(|e| JsValue::from_str(&format!("png next_frame: {e}")))?;
|
||||
|
||||
if blend_op == BlendOp::Source {
|
||||
draw_frame_on_canvas_source(&mut canvas, screen_width, fx, fy, fw, fh, &frame_buffer);
|
||||
} else {
|
||||
draw_frame_on_canvas_over(&mut canvas, screen_width, fx, fy, fw, fh, &frame_buffer);
|
||||
}
|
||||
|
||||
let (cw, ch) = (crop_w as usize, crop_h as usize);
|
||||
let cropped = crop_rgba(
|
||||
&canvas,
|
||||
screen_width as usize,
|
||||
screen_height as usize,
|
||||
crop_x as usize,
|
||||
crop_y as usize,
|
||||
cw,
|
||||
ch,
|
||||
)?;
|
||||
|
||||
let (rotated, rw, rh) = match rotation {
|
||||
90 => rotate_rgba_90(&cropped, cw, ch),
|
||||
180 => rotate_rgba_180(&cropped, cw, ch),
|
||||
270 => rotate_rgba_270(&cropped, cw, ch),
|
||||
_ => (cropped, cw, ch),
|
||||
};
|
||||
|
||||
let (final_rgba, _fw, _fh) = if target_w as usize != rw || target_h as usize != rh {
|
||||
let resized =
|
||||
resize_rgba_nearest(&rotated, rw, rh, target_w as usize, target_h as usize);
|
||||
(resized, target_w as usize, target_h as usize)
|
||||
} else {
|
||||
(rotated, rw, rh)
|
||||
};
|
||||
|
||||
processed_pixels += (final_rgba.len() / 4) as u64;
|
||||
if processed_pixels > MAX_TOTAL_PIXELS {
|
||||
return Err(JsValue::from_str(
|
||||
"Animated PNG is too large to crop. Try reducing its dimensions or number of frames.",
|
||||
));
|
||||
}
|
||||
|
||||
writer
|
||||
.set_frame_delay(delay_num, delay_den)
|
||||
.map_err(|e| JsValue::from_str(&format!("png set_frame_delay: {e}")))?;
|
||||
|
||||
writer
|
||||
.set_dispose_op(dispose_op)
|
||||
.map_err(|e| JsValue::from_str(&format!("png set_dispose_op: {e}")))?;
|
||||
writer
|
||||
.set_blend_op(blend_op)
|
||||
.map_err(|e| JsValue::from_str(&format!("png set_blend_op: {e}")))?;
|
||||
|
||||
writer
|
||||
.write_image_data(&final_rgba)
|
||||
.map_err(|e| JsValue::from_str(&format!("png write_image_data: {e}")))?;
|
||||
|
||||
match dispose_op {
|
||||
DisposeOp::Background => {
|
||||
clear_rect(&mut canvas, screen_width, rect_x, rect_y, rect_w, rect_h);
|
||||
}
|
||||
DisposeOp::Previous => {
|
||||
if let Some(prev) = previous_canvas.take() {
|
||||
canvas = prev;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !processed_any {
|
||||
return Err(JsValue::from_str("APNG has no frames"));
|
||||
}
|
||||
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|e| JsValue::from_str(&format!("png finish: {e}")))?;
|
||||
|
||||
Ok(output.into_inner().into_boxed_slice())
|
||||
}
|
||||
|
||||
fn draw_frame_on_canvas_source(
|
||||
canvas: &mut [u8],
|
||||
canvas_width: u32,
|
||||
fx: usize,
|
||||
fy: usize,
|
||||
fw: usize,
|
||||
fh: usize,
|
||||
buffer: &[u8],
|
||||
) {
|
||||
let cw = canvas_width as usize;
|
||||
|
||||
for row in 0..fh {
|
||||
let canvas_y = fy + row;
|
||||
let canvas_offset = (canvas_y * cw + fx) * 4;
|
||||
let frame_offset = row * fw * 4;
|
||||
|
||||
if canvas_offset + fw * 4 <= canvas.len() && frame_offset + fw * 4 <= buffer.len() {
|
||||
canvas[canvas_offset..canvas_offset + fw * 4]
|
||||
.copy_from_slice(&buffer[frame_offset..frame_offset + fw * 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_frame_on_canvas_over(
|
||||
canvas: &mut [u8],
|
||||
canvas_width: u32,
|
||||
fx: usize,
|
||||
fy: usize,
|
||||
fw: usize,
|
||||
fh: usize,
|
||||
buffer: &[u8],
|
||||
) {
|
||||
let cw = canvas_width as usize;
|
||||
|
||||
for row in 0..fh {
|
||||
let canvas_y = fy + row;
|
||||
let canvas_offset = (canvas_y * cw + fx) * 4;
|
||||
let frame_offset = row * fw * 4;
|
||||
|
||||
if canvas_offset + fw * 4 <= canvas.len() && frame_offset + fw * 4 <= buffer.len() {
|
||||
let frame_row = &buffer[frame_offset..frame_offset + fw * 4];
|
||||
let canvas_row = &mut canvas[canvas_offset..canvas_offset + fw * 4];
|
||||
|
||||
for i in 0..fw {
|
||||
let pixel_idx = i * 4;
|
||||
let alpha = frame_row[pixel_idx + 3];
|
||||
if alpha > 0 {
|
||||
canvas_row[pixel_idx] = frame_row[pixel_idx];
|
||||
canvas_row[pixel_idx + 1] = frame_row[pixel_idx + 1];
|
||||
canvas_row[pixel_idx + 2] = frame_row[pixel_idx + 2];
|
||||
canvas_row[pixel_idx + 3] = frame_row[pixel_idx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_rect(canvas: &mut [u8], canvas_width: u32, x: u32, y: u32, w: u32, h: u32) {
|
||||
let cw = canvas_width as usize;
|
||||
let x = x as usize;
|
||||
let y = y as usize;
|
||||
let w = w as usize;
|
||||
let h = h as usize;
|
||||
|
||||
for row in 0..h {
|
||||
let canvas_y = y + row;
|
||||
let offset = (canvas_y * cw + x) * 4;
|
||||
if offset + w * 4 <= canvas.len() {
|
||||
for i in 0..w {
|
||||
let idx = offset + i * 4;
|
||||
canvas[idx] = 0;
|
||||
canvas[idx + 1] = 0;
|
||||
canvas[idx + 2] = 0;
|
||||
canvas[idx + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_rgba(
|
||||
src: &[u8],
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
if x + w > src_w || y + h > src_h {
|
||||
return Err(JsValue::from_str("Crop rect out of bounds"));
|
||||
}
|
||||
|
||||
let mut dst = vec![0u8; w * h * 4];
|
||||
|
||||
for row in 0..h {
|
||||
let src_y = y + row;
|
||||
let src_offset = (src_y * src_w + x) * 4;
|
||||
let dst_offset = row * w * 4;
|
||||
dst[dst_offset..dst_offset + w * 4].copy_from_slice(&src[src_offset..src_offset + w * 4]);
|
||||
}
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
fn rotate_rgba_90(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let dst_w = src_h;
|
||||
let dst_h = src_w;
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = src_h - 1 - y;
|
||||
let dst_y = x;
|
||||
let dst_idx = (dst_y * dst_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
(dst, dst_w, dst_h)
|
||||
}
|
||||
|
||||
fn rotate_rgba_180(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let mut dst = vec![0u8; src.len()];
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = src_w - 1 - x;
|
||||
let dst_y = src_h - 1 - y;
|
||||
let dst_idx = (dst_y * src_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
(dst, src_w, src_h)
|
||||
}
|
||||
|
||||
fn rotate_rgba_270(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let dst_w = src_h;
|
||||
let dst_h = src_w;
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = y;
|
||||
let dst_y = dst_h - 1 - x;
|
||||
let dst_idx = (dst_y * dst_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
(dst, dst_w, dst_h)
|
||||
}
|
||||
|
||||
fn resize_rgba_nearest(
|
||||
src: &[u8],
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
dst_w: usize,
|
||||
dst_h: usize,
|
||||
) -> Vec<u8> {
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for dy in 0..dst_h {
|
||||
let sy = dy * src_h / dst_h;
|
||||
for dx in 0..dst_w {
|
||||
let sx = dx * src_w / dst_w;
|
||||
let src_idx = (sy * src_w + sx) * 4;
|
||||
let dst_idx = (dy * dst_w + dx) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
dst
|
||||
}
|
||||
35
fluxer/fluxer_app/crates/libfluxcore/src/gateway.rs
Normal file
35
fluxer/fluxer_app/crates/libfluxcore/src/gateway.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use ruzstd::StreamingDecoder;
|
||||
use std::io::{Cursor, Read};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decompress_zstd_frame(input: &[u8]) -> Result<Box<[u8]>, JsValue> {
|
||||
let mut decoder = StreamingDecoder::new(Cursor::new(input))
|
||||
.map_err(|e| JsValue::from_str(&format!("zstd init error: {e}")))?;
|
||||
|
||||
let mut output = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut output)
|
||||
.map_err(|e| JsValue::from_str(&format!("zstd read error: {e}")))?;
|
||||
|
||||
Ok(output.into_boxed_slice())
|
||||
}
|
||||
570
fluxer/fluxer_app/crates/libfluxcore/src/gif.rs
Normal file
570
fluxer/fluxer_app/crates/libfluxcore/src/gif.rs
Normal file
@@ -0,0 +1,570 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use gif::{ColorOutput, DecodeOptions, DisposalMethod, Encoder as GifEncoder, Frame, Repeat};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
enum EncodeError {
|
||||
TooManyColors,
|
||||
Js(JsValue),
|
||||
}
|
||||
|
||||
impl From<JsValue> for EncodeError {
|
||||
fn from(value: JsValue) -> Self {
|
||||
Self::Js(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_and_rotate_gif(
|
||||
input: &[u8],
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
resize_width: Option<u32>,
|
||||
resize_height: Option<u32>,
|
||||
) -> Result<Box<[u8]>, JsValue> {
|
||||
match process_gif(
|
||||
input,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
EncoderMode::Palette,
|
||||
) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(EncodeError::TooManyColors) => process_gif(
|
||||
input,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation_deg,
|
||||
resize_width,
|
||||
resize_height,
|
||||
EncoderMode::Quantized,
|
||||
)
|
||||
.map_err(|err| match err {
|
||||
EncodeError::Js(js) => js,
|
||||
EncodeError::TooManyColors => {
|
||||
JsValue::from_str("GIF contains more than 256 unique colors")
|
||||
}
|
||||
}),
|
||||
Err(EncodeError::Js(js)) => Err(js),
|
||||
}
|
||||
}
|
||||
|
||||
enum EncoderMode {
|
||||
Palette,
|
||||
Quantized,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_gif(
|
||||
input: &[u8],
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
resize_width: Option<u32>,
|
||||
resize_height: Option<u32>,
|
||||
mode: EncoderMode,
|
||||
) -> Result<Box<[u8]>, EncodeError> {
|
||||
let mut decoder = create_decoder(input)?;
|
||||
|
||||
let screen_width = decoder.width() as u32;
|
||||
let screen_height = decoder.height() as u32;
|
||||
|
||||
let crop_x = x.min(screen_width);
|
||||
let crop_y = y.min(screen_height);
|
||||
let crop_w = width.min(screen_width - crop_x);
|
||||
let crop_h = height.min(screen_height - crop_y);
|
||||
|
||||
if crop_w == 0 || crop_h == 0 {
|
||||
return Err(EncodeError::Js(JsValue::from_str("Crop area is empty")));
|
||||
}
|
||||
|
||||
let rotation = rotation_deg.rem_euclid(360);
|
||||
|
||||
let (base_w, base_h) = match rotation {
|
||||
90 | 270 => (crop_h, crop_w),
|
||||
_ => (crop_w, crop_h),
|
||||
};
|
||||
|
||||
let (target_w, target_h) = match (
|
||||
resize_width.filter(|w| *w > 0),
|
||||
resize_height.filter(|h| *h > 0),
|
||||
) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
_ => (base_w, base_h),
|
||||
};
|
||||
|
||||
if target_w == 0 || target_h == 0 {
|
||||
return Err(EncodeError::Js(JsValue::from_str(
|
||||
"Target dimensions are empty",
|
||||
)));
|
||||
}
|
||||
|
||||
if crop_x == 0
|
||||
&& crop_y == 0
|
||||
&& crop_w == screen_width
|
||||
&& crop_h == screen_height
|
||||
&& rotation == 0
|
||||
&& target_w == screen_width
|
||||
&& target_h == screen_height
|
||||
{
|
||||
return Ok(input.to_vec().into_boxed_slice());
|
||||
}
|
||||
|
||||
let mut frame_encoder = FrameEncoder::new(mode, target_w as u16, target_h as u16)?;
|
||||
|
||||
let mut canvas = vec![0u8; (screen_width * screen_height * 4) as usize];
|
||||
let mut previous_canvas: Option<Vec<u8>> = None;
|
||||
|
||||
let mut processed_any = false;
|
||||
const MAX_TOTAL_PIXELS: u64 = 200_000_000;
|
||||
let mut processed_pixels: u64 = 0;
|
||||
|
||||
while let Some(frame) = decoder
|
||||
.read_next_frame()
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("gif read_next_frame: {e}"))))?
|
||||
{
|
||||
processed_any = true;
|
||||
|
||||
if frame.dispose == DisposalMethod::Previous {
|
||||
previous_canvas = Some(canvas.clone());
|
||||
}
|
||||
|
||||
draw_frame_on_canvas(
|
||||
&mut canvas,
|
||||
screen_width,
|
||||
frame.left,
|
||||
frame.top,
|
||||
frame.width,
|
||||
frame.height,
|
||||
frame.buffer.as_ref(),
|
||||
);
|
||||
|
||||
let (cw, ch) = (crop_w as usize, crop_h as usize);
|
||||
let cropped = crop_rgba(
|
||||
&canvas,
|
||||
screen_width as usize,
|
||||
screen_height as usize,
|
||||
crop_x as usize,
|
||||
crop_y as usize,
|
||||
cw,
|
||||
ch,
|
||||
)?;
|
||||
|
||||
let (rotated, rw, rh) = match rotation {
|
||||
90 => rotate_rgba_90(&cropped, cw, ch),
|
||||
180 => rotate_rgba_180(&cropped, cw, ch),
|
||||
270 => rotate_rgba_270(&cropped, cw, ch),
|
||||
_ => (cropped, cw, ch),
|
||||
};
|
||||
|
||||
let (final_rgba, _fw, _fh) = if target_w as usize != rw || target_h as usize != rh {
|
||||
let resized =
|
||||
resize_rgba_nearest(&rotated, rw, rh, target_w as usize, target_h as usize);
|
||||
(resized, target_w as usize, target_h as usize)
|
||||
} else {
|
||||
(rotated, rw, rh)
|
||||
};
|
||||
|
||||
processed_pixels += (final_rgba.len() / 4) as u64;
|
||||
if processed_pixels > MAX_TOTAL_PIXELS {
|
||||
return Err(EncodeError::Js(JsValue::from_str(
|
||||
"Animated GIF is too large to crop. Try reducing its dimensions or number of frames.",
|
||||
)));
|
||||
}
|
||||
|
||||
frame_encoder.write_frame(final_rgba, frame.delay)?;
|
||||
|
||||
match frame.dispose {
|
||||
DisposalMethod::Background => {
|
||||
clear_rect(
|
||||
&mut canvas,
|
||||
screen_width,
|
||||
frame.left,
|
||||
frame.top,
|
||||
frame.width,
|
||||
frame.height,
|
||||
);
|
||||
}
|
||||
DisposalMethod::Previous => {
|
||||
if let Some(prev) = previous_canvas.take() {
|
||||
canvas = prev;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !processed_any {
|
||||
return Err(EncodeError::Js(JsValue::from_str("GIF has no frames")));
|
||||
}
|
||||
|
||||
frame_encoder.finish()
|
||||
}
|
||||
|
||||
fn draw_frame_on_canvas(
|
||||
canvas: &mut [u8],
|
||||
canvas_width: u32,
|
||||
left: u16,
|
||||
top: u16,
|
||||
width: u16,
|
||||
height: u16,
|
||||
buffer: &[u8],
|
||||
) {
|
||||
let fw = width as usize;
|
||||
let fh = height as usize;
|
||||
let fx = left as usize;
|
||||
let fy = top as usize;
|
||||
let cw = canvas_width as usize;
|
||||
|
||||
for row in 0..fh {
|
||||
let canvas_y = fy + row;
|
||||
let canvas_offset = (canvas_y * cw + fx) * 4;
|
||||
let frame_offset = row * fw * 4;
|
||||
|
||||
let frame_row = &buffer[frame_offset..frame_offset + fw * 4];
|
||||
let canvas_row = &mut canvas[canvas_offset..canvas_offset + fw * 4];
|
||||
|
||||
for i in 0..fw {
|
||||
let pixel_idx = i * 4;
|
||||
let alpha = frame_row[pixel_idx + 3];
|
||||
if alpha > 0 {
|
||||
canvas_row[pixel_idx] = frame_row[pixel_idx];
|
||||
canvas_row[pixel_idx + 1] = frame_row[pixel_idx + 1];
|
||||
canvas_row[pixel_idx + 2] = frame_row[pixel_idx + 2];
|
||||
canvas_row[pixel_idx + 3] = frame_row[pixel_idx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_rect(canvas: &mut [u8], canvas_width: u32, x: u16, y: u16, w: u16, h: u16) {
|
||||
let cw = canvas_width as usize;
|
||||
let x = x as usize;
|
||||
let y = y as usize;
|
||||
let w = w as usize;
|
||||
let h = h as usize;
|
||||
|
||||
for row in 0..h {
|
||||
let canvas_y = y + row;
|
||||
let offset = (canvas_y * cw + x) * 4;
|
||||
for i in 0..w {
|
||||
let idx = offset + i * 4;
|
||||
canvas[idx] = 0;
|
||||
canvas[idx + 1] = 0;
|
||||
canvas[idx + 2] = 0;
|
||||
canvas[idx + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn crop_rgba(
|
||||
src: &[u8],
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
if x + w > src_w || y + h > src_h {
|
||||
return Err(JsValue::from_str("Crop rect out of bounds"));
|
||||
}
|
||||
|
||||
let mut dst = vec![0u8; w * h * 4];
|
||||
|
||||
for row in 0..h {
|
||||
let src_y = y + row;
|
||||
let src_offset = (src_y * src_w + x) * 4;
|
||||
let dst_offset = row * w * 4;
|
||||
dst[dst_offset..dst_offset + w * 4].copy_from_slice(&src[src_offset..src_offset + w * 4]);
|
||||
}
|
||||
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
fn rotate_rgba_90(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let dst_w = src_h;
|
||||
let dst_h = src_w;
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = src_h - 1 - y;
|
||||
let dst_y = x;
|
||||
let dst_idx = (dst_y * dst_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
(dst, dst_w, dst_h)
|
||||
}
|
||||
|
||||
fn rotate_rgba_180(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let mut dst = vec![0u8; src.len()];
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = src_w - 1 - x;
|
||||
let dst_y = src_h - 1 - y;
|
||||
let dst_idx = (dst_y * src_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
(dst, src_w, src_h)
|
||||
}
|
||||
|
||||
fn rotate_rgba_270(src: &[u8], src_w: usize, src_h: usize) -> (Vec<u8>, usize, usize) {
|
||||
let dst_w = src_h;
|
||||
let dst_h = src_w;
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for y in 0..src_h {
|
||||
for x in 0..src_w {
|
||||
let src_idx = (y * src_w + x) * 4;
|
||||
let dst_x = y;
|
||||
let dst_y = dst_h - 1 - x;
|
||||
let dst_idx = (dst_y * dst_w + dst_x) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
(dst, dst_w, dst_h)
|
||||
}
|
||||
|
||||
fn resize_rgba_nearest(
|
||||
src: &[u8],
|
||||
src_w: usize,
|
||||
src_h: usize,
|
||||
dst_w: usize,
|
||||
dst_h: usize,
|
||||
) -> Vec<u8> {
|
||||
let mut dst = vec![0u8; dst_w * dst_h * 4];
|
||||
|
||||
for dy in 0..dst_h {
|
||||
let sy = dy * src_h / dst_h;
|
||||
for dx in 0..dst_w {
|
||||
let sx = dx * src_w / dst_w;
|
||||
let src_idx = (sy * src_w + sx) * 4;
|
||||
let dst_idx = (dy * dst_w + dx) * 4;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
dst
|
||||
}
|
||||
|
||||
fn create_decoder(input: &[u8]) -> Result<gif::Decoder<Cursor<&[u8]>>, EncodeError> {
|
||||
let cursor = Cursor::new(input);
|
||||
let mut options = DecodeOptions::new();
|
||||
options.set_color_output(ColorOutput::RGBA);
|
||||
options
|
||||
.read_info(cursor)
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("gif read_info: {e}"))))
|
||||
}
|
||||
|
||||
enum FrameEncoder {
|
||||
Palette(PaletteFrameEncoder),
|
||||
Quantized(QuantizedFrameEncoder),
|
||||
}
|
||||
|
||||
impl FrameEncoder {
|
||||
fn new(mode: EncoderMode, width: u16, height: u16) -> Result<Self, EncodeError> {
|
||||
match mode {
|
||||
EncoderMode::Palette => PaletteFrameEncoder::new(width, height).map(Self::Palette),
|
||||
EncoderMode::Quantized => {
|
||||
QuantizedFrameEncoder::new(width, height).map(Self::Quantized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_frame(&mut self, rgba: Vec<u8>, delay: u16) -> Result<(), EncodeError> {
|
||||
match self {
|
||||
Self::Palette(enc) => enc.write_frame(rgba, delay),
|
||||
Self::Quantized(enc) => enc.write_frame(rgba, delay),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Box<[u8]>, EncodeError> {
|
||||
match self {
|
||||
Self::Palette(enc) => enc.finish(),
|
||||
Self::Quantized(enc) => enc.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PaletteFrameEncoder {
|
||||
encoder: GifEncoder<Cursor<Vec<u8>>>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl PaletteFrameEncoder {
|
||||
fn new(width: u16, height: u16) -> Result<Self, EncodeError> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut encoder = GifEncoder::new(cursor, width, height, &[])
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("GifEncoder::new: {e}"))))?;
|
||||
encoder
|
||||
.set_repeat(Repeat::Infinite)
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("set_repeat: {e}"))))?;
|
||||
Ok(Self {
|
||||
encoder,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_frame(&mut self, rgba: Vec<u8>, delay: u16) -> Result<(), EncodeError> {
|
||||
let PaletteFrameData {
|
||||
indices,
|
||||
palette,
|
||||
transparent_index,
|
||||
} = PaletteFrameData::from_rgba(&rgba)?;
|
||||
|
||||
let frame = Frame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
delay,
|
||||
buffer: Cow::Owned(indices),
|
||||
palette: Some(palette),
|
||||
transparent: transparent_index,
|
||||
..Frame::default()
|
||||
};
|
||||
self.encoder.write_frame(&frame).map_err(map_encoding_error)
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Box<[u8]>, EncodeError> {
|
||||
let cursor = self.encoder.into_inner().map_err(map_io_error)?;
|
||||
Ok(cursor.into_inner().into_boxed_slice())
|
||||
}
|
||||
}
|
||||
|
||||
struct PaletteFrameData {
|
||||
indices: Vec<u8>,
|
||||
palette: Vec<u8>,
|
||||
transparent_index: Option<u8>,
|
||||
}
|
||||
|
||||
impl PaletteFrameData {
|
||||
fn from_rgba(rgba: &[u8]) -> Result<Self, EncodeError> {
|
||||
let mut palette = Vec::with_capacity(256 * 3);
|
||||
let mut color_to_index = HashMap::with_capacity(256);
|
||||
let mut transparent_index = None;
|
||||
let mut indices = Vec::with_capacity(rgba.len() / 4);
|
||||
|
||||
for pixel in rgba.chunks_exact(4) {
|
||||
let idx = if pixel[3] == 0 {
|
||||
if let Some(idx) = transparent_index {
|
||||
idx
|
||||
} else {
|
||||
let next_index = palette.len() / 3;
|
||||
if next_index >= 256 {
|
||||
return Err(EncodeError::TooManyColors);
|
||||
}
|
||||
palette.extend_from_slice(&[0, 0, 0]);
|
||||
let idx = next_index as u8;
|
||||
transparent_index = Some(idx);
|
||||
idx
|
||||
}
|
||||
} else {
|
||||
let key = [pixel[0], pixel[1], pixel[2]];
|
||||
if let Some(&idx) = color_to_index.get(&key) {
|
||||
idx
|
||||
} else {
|
||||
let next_index = palette.len() / 3;
|
||||
if next_index >= 256 {
|
||||
return Err(EncodeError::TooManyColors);
|
||||
}
|
||||
palette.extend_from_slice(&key);
|
||||
let idx = next_index as u8;
|
||||
color_to_index.insert(key, idx);
|
||||
idx
|
||||
}
|
||||
};
|
||||
indices.push(idx);
|
||||
}
|
||||
|
||||
if palette.is_empty() {
|
||||
palette.extend_from_slice(&[0, 0, 0]);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
indices,
|
||||
palette,
|
||||
transparent_index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct QuantizedFrameEncoder {
|
||||
encoder: GifEncoder<Cursor<Vec<u8>>>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl QuantizedFrameEncoder {
|
||||
fn new(width: u16, height: u16) -> Result<Self, EncodeError> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut encoder = GifEncoder::new(cursor, width, height, &[])
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("GifEncoder::new: {e}"))))?;
|
||||
encoder
|
||||
.set_repeat(Repeat::Infinite)
|
||||
.map_err(|e| EncodeError::Js(JsValue::from_str(&format!("set_repeat: {e}"))))?;
|
||||
Ok(Self {
|
||||
encoder,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_frame(&mut self, mut rgba: Vec<u8>, delay: u16) -> Result<(), EncodeError> {
|
||||
let mut frame = Frame::from_rgba_speed(self.width, self.height, &mut rgba, 10);
|
||||
frame.delay = delay;
|
||||
self.encoder.write_frame(&frame).map_err(map_encoding_error)
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Box<[u8]>, EncodeError> {
|
||||
let cursor = self.encoder.into_inner().map_err(map_io_error)?;
|
||||
Ok(cursor.into_inner().into_boxed_slice())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_encoding_error(err: gif::EncodingError) -> EncodeError {
|
||||
EncodeError::Js(JsValue::from_str(&format!("gif encode: {err}")))
|
||||
}
|
||||
|
||||
fn map_io_error(err: std::io::Error) -> EncodeError {
|
||||
EncodeError::Js(JsValue::from_str(&format!("gif io: {err}")))
|
||||
}
|
||||
30
fluxer/fluxer_app/crates/libfluxcore/src/lib.rs
Normal file
30
fluxer/fluxer_app/crates/libfluxcore/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
pub mod animation;
|
||||
pub mod apng;
|
||||
pub mod gateway;
|
||||
pub mod gif;
|
||||
pub mod static_image;
|
||||
|
||||
pub use animation::is_animated_image;
|
||||
pub use apng::crop_and_rotate_apng;
|
||||
pub use gateway::decompress_zstd_frame;
|
||||
pub use gif::crop_and_rotate_gif;
|
||||
pub use static_image::crop_and_rotate_image;
|
||||
98
fluxer/fluxer_app/crates/libfluxcore/src/static_image.rs
Normal file
98
fluxer/fluxer_app/crates/libfluxcore/src/static_image.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImageView, ImageFormat, RgbaImage, imageops};
|
||||
use std::io::Cursor;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn normalize_rotation(rotation_deg: u32) -> u32 {
|
||||
rotation_deg % 360
|
||||
}
|
||||
|
||||
fn map_format_hint(hint: &str) -> Result<ImageFormat, JsValue> {
|
||||
match hint.trim().to_lowercase().as_str() {
|
||||
"png" | "apng" => Ok(ImageFormat::Png),
|
||||
"jpeg" | "jpg" => Ok(ImageFormat::Jpeg),
|
||||
"webp" => Ok(ImageFormat::WebP),
|
||||
"avif" => Ok(ImageFormat::Avif),
|
||||
"gif" => Ok(ImageFormat::Gif),
|
||||
_ => Err(JsValue::from_str("Unsupported static format")),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn crop_and_rotate_image(
|
||||
input: &[u8],
|
||||
format_hint: &str,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rotation_deg: u32,
|
||||
resize_width: Option<u32>,
|
||||
resize_height: Option<u32>,
|
||||
) -> Result<Box<[u8]>, JsValue> {
|
||||
let format = map_format_hint(format_hint)?;
|
||||
let dynamic_image = image::load_from_memory_with_format(input, format)
|
||||
.map_err(|err| JsValue::from_str(&format!("Failed to decode {format_hint}: {err}")))?;
|
||||
|
||||
let (img_w, img_h) = dynamic_image.dimensions();
|
||||
let crop_x = x.min(img_w);
|
||||
let crop_y = y.min(img_h);
|
||||
let crop_w = width.min(img_w.saturating_sub(crop_x));
|
||||
let crop_h = height.min(img_h.saturating_sub(crop_y));
|
||||
|
||||
if crop_w == 0 || crop_h == 0 {
|
||||
return Err(JsValue::from_str("Crop area is empty"));
|
||||
}
|
||||
|
||||
let cropped: RgbaImage = dynamic_image
|
||||
.crop_imm(crop_x, crop_y, crop_w, crop_h)
|
||||
.to_rgba8();
|
||||
|
||||
let rotated = match normalize_rotation(rotation_deg) {
|
||||
90 => imageops::rotate90(&cropped),
|
||||
180 => imageops::rotate180(&cropped),
|
||||
270 => imageops::rotate270(&cropped),
|
||||
_ => cropped.clone(),
|
||||
};
|
||||
|
||||
let target_w = resize_width.filter(|w| *w > 0).unwrap_or(rotated.width());
|
||||
let target_h = resize_height.filter(|h| *h > 0).unwrap_or(rotated.height());
|
||||
|
||||
if target_w == 0 || target_h == 0 {
|
||||
return Err(JsValue::from_str("Target dimensions are empty"));
|
||||
}
|
||||
|
||||
let final_buffer = if target_w == rotated.width() && target_h == rotated.height() {
|
||||
rotated
|
||||
} else {
|
||||
imageops::resize(&rotated, target_w, target_h, FilterType::Lanczos3)
|
||||
};
|
||||
|
||||
let final_frame = DynamicImage::ImageRgba8(final_buffer);
|
||||
let mut output = Cursor::new(Vec::new());
|
||||
final_frame
|
||||
.write_to(&mut output, format)
|
||||
.map_err(|err| JsValue::from_str(&format!("Failed to encode {format_hint}: {err}")))?;
|
||||
|
||||
Ok(output.into_inner().into_boxed_slice())
|
||||
}
|
||||
30
fluxer/fluxer_app/index.html
Normal file
30
fluxer/fluxer_app/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Fluxer</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, maximum-scale=1, user-scalable=no">
|
||||
<meta name="description" content="Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.">
|
||||
<link rel="preconnect" href="https://fluxerstatic.com">
|
||||
<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://fluxerstatic.com/web/favicon-32x32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="https://fluxerstatic.com/web/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Fluxer">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#4641D9">
|
||||
<meta name="msapplication-navbutton-color" content="#4641D9">
|
||||
<meta name="msapplication-TileColor" content="#4641D9">
|
||||
<meta name="msapplication-config" content="https://fluxerstatic.com/web/browserconfig.xml">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="display" content="standalone">
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>JavaScript is required to use this application.</noscript>
|
||||
</body>
|
||||
</html>
|
||||
67
fluxer/fluxer_app/lingui.config.js
Normal file
67
fluxer/fluxer_app/lingui.config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
locales: [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-ES',
|
||||
'es-419',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
],
|
||||
sourceLocale: 'en-US',
|
||||
catalogs: [
|
||||
{
|
||||
path: 'src/locales/{locale}/messages',
|
||||
include: ['src'],
|
||||
exclude: ['**/node_modules/**', '**/*.d.ts'],
|
||||
},
|
||||
],
|
||||
format: 'po',
|
||||
compileNamespace: 'es',
|
||||
};
|
||||
155
fluxer/fluxer_app/package.json
Normal file
155
fluxer/fluxer_app/package.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"name": "fluxer_app",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.",
|
||||
"homepage": "https://fluxer.app",
|
||||
"author": "Fluxer Contributors <developers@fluxer.app>",
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"**/*.css"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:masks && pnpm generate:css-types && tsgo --noEmit && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"dev": "pnpm tsx scripts/DevServer.tsx",
|
||||
"typecheck": "pnpm wasm:codegen && pnpm generate:masks && pnpm generate:css-types && tsgo --noEmit",
|
||||
"typecheck:only": "tsgo --noEmit",
|
||||
"generate:colors": "pnpm tsx scripts/GenerateColorSystem.tsx",
|
||||
"generate:css-types": "tcm src --pattern '**/*.module.css'",
|
||||
"generate:css-types:watch": "tcm src --pattern '**/*.module.css' --watch",
|
||||
"generate:emoji-sprites": "pnpm tsx scripts/GenerateEmojiSprites.tsx",
|
||||
"generate:masks": "pnpm tsx scripts/GenerateAvatarMasks.tsx",
|
||||
"i18n:auto": "node scripts/auto-i18n.mjs",
|
||||
"i18n:compile": "pnpm lingui:compile",
|
||||
"i18n:extract": "pnpm lingui:extract",
|
||||
"lingui:compile": "lingui compile --strict",
|
||||
"lingui:extract": "lingui extract --clean",
|
||||
"test": "pnpm i18n:compile && vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:debug": "vitest run --no-coverage --inspect-brk --threads=false",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest",
|
||||
"tsc:all": "pnpm wasm:codegen && pnpm tsgo -p tsconfig.json",
|
||||
"wasm:codegen": "cd crates/libfluxcore && wasm-pack build --target web --out-dir ../../pkgs/libfluxcore --release"
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome >= 47",
|
||||
"edge >= 12",
|
||||
"firefox >= 44",
|
||||
"safari >= 9",
|
||||
"ios >= 9",
|
||||
"last 2 versions",
|
||||
"not dead",
|
||||
"> 0.2%",
|
||||
"not op_mini all"
|
||||
],
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "catalog:",
|
||||
"@floating-ui/react-dom": "catalog:",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/date_utils": "workspace:*",
|
||||
"@fluxer/geo_utils": "workspace:*",
|
||||
"@fluxer/limits": "workspace:*",
|
||||
"@fluxer/list_utils": "workspace:*",
|
||||
"@fluxer/markdown_parser": "workspace:*",
|
||||
"@fluxer/number_utils": "workspace:*",
|
||||
"@fluxer/schema": "workspace:*",
|
||||
"@fluxer/snowflake": "workspace:*",
|
||||
"@fluxer/ui": "workspace:*",
|
||||
"@hcaptcha/react-hcaptcha": "catalog:",
|
||||
"@lingui/core": "catalog:",
|
||||
"@lingui/react": "catalog:",
|
||||
"@livekit/components-react": "catalog:",
|
||||
"@livekit/track-processors": "catalog:",
|
||||
"@marsidev/react-turnstile": "catalog:",
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"@radix-ui/react-checkbox": "catalog:",
|
||||
"@radix-ui/react-radio-group": "catalog:",
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
"@sentry/react": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"colorjs.io": "catalog:",
|
||||
"combokeys": "catalog:",
|
||||
"eventemitter3": "catalog:",
|
||||
"favico.js": "catalog:",
|
||||
"framer-motion": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"highlight.js": "catalog:",
|
||||
"katex": "catalog:",
|
||||
"livekit-client": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"lru-cache": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"match-sorter": "catalog:",
|
||||
"mobx": "catalog:",
|
||||
"mobx-persist-store": "catalog:",
|
||||
"mobx-react-lite": "catalog:",
|
||||
"motion": "catalog:",
|
||||
"qrcode": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-aria-components": "catalog:",
|
||||
"react-day-picker": "catalog:",
|
||||
"react-dnd": "catalog:",
|
||||
"react-dnd-accessible-backend": "catalog:",
|
||||
"react-dnd-html5-backend": "catalog:",
|
||||
"react-dnd-multi-backend": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-hotkeys-hook": "catalog:",
|
||||
"react-modal-sheet": "catalog:",
|
||||
"react-select": "catalog:",
|
||||
"react-zoom-pan-pinch": "catalog:",
|
||||
"rxjs": "catalog:",
|
||||
"thumbhash": "catalog:",
|
||||
"unique-names-generator": "catalog:",
|
||||
"urlpattern-polyfill": "catalog:",
|
||||
"valibot": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "catalog:",
|
||||
"@lingui/swc-plugin": "catalog:",
|
||||
"@rspack/cli": "catalog:",
|
||||
"@rspack/core": "catalog:",
|
||||
"@svgr/core": "catalog:",
|
||||
"@svgr/plugin-jsx": "catalog:",
|
||||
"@svgr/plugin-svgo": "catalog:",
|
||||
"@svgr/webpack": "catalog:",
|
||||
"@types/combokeys": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/qrcode": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"autoprefixer": "catalog:",
|
||||
"browserslist": "catalog:",
|
||||
"chokidar": "catalog:",
|
||||
"happy-dom": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"lightningcss": "catalog:",
|
||||
"node-addon-api": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"postcss-discard-comments": "catalog:",
|
||||
"postcss-loader": "catalog:",
|
||||
"postcss-modules": "catalog:",
|
||||
"postcss-preset-env": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typed-css-modules": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"wasm-pack": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
}
|
||||
14934
fluxer/fluxer_app/pnpm-lock.yaml
generated
Normal file
14934
fluxer/fluxer_app/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
fluxer/fluxer_app/postcss.config.js
Normal file
43
fluxer/fluxer_app/postcss.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import postcssDiscardComments from 'postcss-discard-comments';
|
||||
import postcssPresetEnv from 'postcss-preset-env';
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
postcssDiscardComments({
|
||||
removeAll: true,
|
||||
}),
|
||||
postcssPresetEnv({
|
||||
stage: 3,
|
||||
features: {
|
||||
'nesting-rules': true,
|
||||
'custom-properties': true,
|
||||
'custom-media-queries': true,
|
||||
},
|
||||
browsers: 'last 10 years, > 0.5%, not dead',
|
||||
}),
|
||||
autoprefixer({
|
||||
flexbox: 'no-2009',
|
||||
grid: 'no-autoplace',
|
||||
}),
|
||||
],
|
||||
};
|
||||
622
fluxer/fluxer_app/rspack.config.mjs
Normal file
622
fluxer/fluxer_app/rspack.config.mjs
Normal file
@@ -0,0 +1,622 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path, {dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {CopyRspackPlugin, DefinePlugin, HtmlRspackPlugin, SwcJsMinimizerRspackPlugin} from '@rspack/core';
|
||||
import {createPoFileRule, getLinguiSwcPluginConfig} from './scripts/build/rspack/lingui.mjs';
|
||||
import {staticFilesPlugin} from './scripts/build/rspack/static-files.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '.');
|
||||
const MONOREPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
|
||||
const CDN_ENDPOINT = 'https://fluxerstatic.com';
|
||||
|
||||
function resolveMode() {
|
||||
const modeIndex = process.argv.indexOf('--mode');
|
||||
if (modeIndex >= 0) {
|
||||
const modeValue = process.argv[modeIndex + 1];
|
||||
if (modeValue) {
|
||||
return modeValue;
|
||||
}
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
|
||||
const mode = resolveMode();
|
||||
const isProduction = mode === 'production';
|
||||
const isDevelopment = !isProduction;
|
||||
const devJsName = 'assets/[name].js';
|
||||
const devCssName = 'assets/[name].css';
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
const configPath = process.env.FLUXER_CONFIG;
|
||||
if (!configPath) {
|
||||
throw new Error('FLUXER_CONFIG must be set to a JSON config path.');
|
||||
}
|
||||
const resolvedConfigPath = path.isAbsolute(configPath) ? configPath : path.resolve(MONOREPO_ROOT, configPath);
|
||||
const content = fs.readFileSync(resolvedConfigPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error('Invalid JSON config: expected an object at root.');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getValue(source, path, fallback) {
|
||||
let current = source;
|
||||
for (const segment of path) {
|
||||
if (!isPlainObject(current)) {
|
||||
return fallback;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current ?? fallback;
|
||||
}
|
||||
|
||||
function asString(value, fallback = undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeOverride(value) {
|
||||
const trimmed = asString(value, '')?.trim() ?? '';
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function buildUrl(scheme, domain, port, path = '') {
|
||||
const isStandardPort =
|
||||
(scheme === 'http' && port === 80) ||
|
||||
(scheme === 'https' && port === 443) ||
|
||||
(scheme === 'ws' && port === 80) ||
|
||||
(scheme === 'wss' && port === 443);
|
||||
const portPart = port && !isStandardPort ? `:${port}` : '';
|
||||
return `${scheme}://${domain}${portPart}${path}`;
|
||||
}
|
||||
|
||||
function deriveDomain(endpointType, config) {
|
||||
switch (endpointType) {
|
||||
case 'staticCdn':
|
||||
return config.static_cdn_domain || config.base_domain;
|
||||
case 'invite':
|
||||
return config.invite_domain || config.base_domain;
|
||||
case 'gift':
|
||||
return config.gift_domain || config.base_domain;
|
||||
default:
|
||||
return config.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveEndpointsFromDomain(domainConfig, overrides) {
|
||||
const publicScheme = domainConfig.public_scheme ?? 'https';
|
||||
const publicPort = domainConfig.public_port ?? (publicScheme === 'https' ? 443 : 80);
|
||||
const gatewayScheme = publicScheme === 'https' ? 'wss' : 'ws';
|
||||
const derived = {
|
||||
api: buildUrl(publicScheme, deriveDomain('api', domainConfig), publicPort, '/api'),
|
||||
app: buildUrl(publicScheme, deriveDomain('app', domainConfig), publicPort),
|
||||
gateway: buildUrl(gatewayScheme, deriveDomain('gateway', domainConfig), publicPort, '/gateway'),
|
||||
media: buildUrl(publicScheme, deriveDomain('media', domainConfig), publicPort, '/media'),
|
||||
staticCdn: buildUrl(publicScheme, deriveDomain('staticCdn', domainConfig), publicPort, '/static'),
|
||||
admin: buildUrl(publicScheme, deriveDomain('admin', domainConfig), publicPort, '/admin'),
|
||||
marketing: buildUrl(publicScheme, deriveDomain('marketing', domainConfig), publicPort, '/marketing'),
|
||||
invite: buildUrl(publicScheme, deriveDomain('invite', domainConfig), publicPort, '/invite'),
|
||||
gift: buildUrl(publicScheme, deriveDomain('gift', domainConfig), publicPort, '/gift'),
|
||||
};
|
||||
const normalizedOverrides = {
|
||||
api: normalizeOverride(overrides?.api),
|
||||
app: normalizeOverride(overrides?.app),
|
||||
gateway: normalizeOverride(overrides?.gateway),
|
||||
media: normalizeOverride(overrides?.media),
|
||||
staticCdn: normalizeOverride(overrides?.static_cdn),
|
||||
admin: normalizeOverride(overrides?.admin),
|
||||
marketing: normalizeOverride(overrides?.marketing),
|
||||
invite: normalizeOverride(overrides?.invite),
|
||||
gift: normalizeOverride(overrides?.gift),
|
||||
};
|
||||
return {
|
||||
api: normalizedOverrides.api ?? derived.api,
|
||||
app: normalizedOverrides.app ?? derived.app,
|
||||
gateway: normalizedOverrides.gateway ?? derived.gateway,
|
||||
media: normalizedOverrides.media ?? derived.media,
|
||||
staticCdn: normalizedOverrides.staticCdn ?? derived.staticCdn,
|
||||
admin: normalizedOverrides.admin ?? derived.admin,
|
||||
marketing: normalizedOverrides.marketing ?? derived.marketing,
|
||||
invite: normalizedOverrides.invite ?? derived.invite,
|
||||
gift: normalizedOverrides.gift ?? derived.gift,
|
||||
};
|
||||
}
|
||||
|
||||
function stripApiSuffix(url) {
|
||||
if (!url) {
|
||||
return url;
|
||||
}
|
||||
return url.endsWith('/api') ? url.slice(0, -4) : url;
|
||||
}
|
||||
|
||||
function resolveAppPublic(config) {
|
||||
const appPublic = getValue(config, ['app_public'], {});
|
||||
const domain = getValue(config, ['domain'], {});
|
||||
const overrides = getValue(config, ['endpoint_overrides'], {});
|
||||
const endpoints = deriveEndpointsFromDomain(domain, overrides);
|
||||
const defaultBootstrapEndpoint = endpoints.api;
|
||||
const defaultPublicEndpoint = stripApiSuffix(endpoints.api);
|
||||
const sentryDsn = asString(appPublic.sentry_dsn);
|
||||
return {
|
||||
apiVersion: asString(appPublic.api_version, '1'),
|
||||
bootstrapApiEndpoint: asString(appPublic.bootstrap_api_endpoint, defaultBootstrapEndpoint),
|
||||
bootstrapApiPublicEndpoint: asString(appPublic.bootstrap_api_public_endpoint, defaultPublicEndpoint),
|
||||
relayDirectoryUrl: asString(appPublic.relay_directory_url),
|
||||
sentryDsn,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReleaseChannel() {
|
||||
const raw = asString(process.env.RELEASE_CHANNEL, 'nightly').toLowerCase();
|
||||
if (raw === 'stable' || raw === 'canary') {
|
||||
return raw;
|
||||
}
|
||||
return 'nightly';
|
||||
}
|
||||
|
||||
function resolveBuildMetadata() {
|
||||
const envBuildSha = asString(process.env.BUILD_SHA);
|
||||
let buildSha = envBuildSha ?? undefined;
|
||||
if (!buildSha) {
|
||||
try {
|
||||
buildSha = execSync('git rev-parse --short HEAD', {cwd: ROOT_DIR, stdio: ['ignore', 'pipe', 'ignore']})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
buildSha = 'dev';
|
||||
}
|
||||
}
|
||||
const buildNumber = asString(process.env.BUILD_NUMBER, '0');
|
||||
const buildTimestamp = asString(process.env.BUILD_TIMESTAMP, String(Math.floor(Date.now() / 1000)));
|
||||
const releaseChannel = resolveReleaseChannel();
|
||||
return {
|
||||
buildSha,
|
||||
buildNumber,
|
||||
buildTimestamp,
|
||||
releaseChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicEnvVar(values, name) {
|
||||
const value = values[name];
|
||||
return value === undefined ? 'undefined' : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const linguiSwcPlugin = getLinguiSwcPluginConfig();
|
||||
const config = readConfig();
|
||||
const appPublic = resolveAppPublic(config);
|
||||
const buildMetadata = resolveBuildMetadata();
|
||||
const publicValues = {
|
||||
PUBLIC_BUILD_SHA: buildMetadata.buildSha,
|
||||
PUBLIC_BUILD_NUMBER: buildMetadata.buildNumber,
|
||||
PUBLIC_BUILD_TIMESTAMP: buildMetadata.buildTimestamp,
|
||||
PUBLIC_RELEASE_CHANNEL: buildMetadata.releaseChannel,
|
||||
PUBLIC_SENTRY_DSN: appPublic.sentryDsn ?? null,
|
||||
PUBLIC_API_VERSION: appPublic.apiVersion,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: appPublic.bootstrapApiEndpoint,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: appPublic.bootstrapApiPublicEndpoint ?? appPublic.bootstrapApiEndpoint,
|
||||
PUBLIC_RELAY_DIRECTORY_URL: appPublic.relayDirectoryUrl ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
|
||||
entry: {
|
||||
main: path.join(SRC_DIR, 'index.tsx'),
|
||||
sw: path.join(SRC_DIR, 'service_worker', 'Worker.tsx'),
|
||||
},
|
||||
|
||||
output: {
|
||||
path: DIST_DIR,
|
||||
publicPath: isProduction ? `${CDN_ENDPOINT}/` : '/',
|
||||
workerPublicPath: '/',
|
||||
filename: (pathData) => {
|
||||
if (pathData.chunk?.name === 'sw') {
|
||||
return 'sw.js';
|
||||
}
|
||||
return isProduction ? 'assets/[contenthash:16].js' : devJsName;
|
||||
},
|
||||
chunkFilename: isProduction ? 'assets/[contenthash:16].js' : devJsName,
|
||||
cssFilename: isProduction ? 'assets/[contenthash:16].css' : devCssName,
|
||||
cssChunkFilename: isProduction ? 'assets/[contenthash:16].css' : devCssName,
|
||||
assetModuleFilename: isProduction ? 'assets/[contenthash:16][ext]' : 'assets/[name].[hash][ext]',
|
||||
webAssemblyModuleFilename: isProduction ? 'assets/[contenthash:16].wasm' : 'assets/[name].[hash].wasm',
|
||||
clean: true,
|
||||
},
|
||||
|
||||
devtool: 'source-map',
|
||||
|
||||
target: ['web', 'browserslist'],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': SRC_DIR,
|
||||
'@app': SRC_DIR,
|
||||
'@pkgs': PKGS_DIR,
|
||||
'@fluxer/constants/src': path.join(MONOREPO_ROOT, 'packages/constants/src'),
|
||||
'@fluxer/date_utils/src': path.join(MONOREPO_ROOT, 'packages/date_utils/src'),
|
||||
'@fluxer/geo_utils/src': path.join(MONOREPO_ROOT, 'packages/geo_utils/src'),
|
||||
'@fluxer/limits/src': path.join(MONOREPO_ROOT, 'packages/limits/src'),
|
||||
'@fluxer/list_utils/src': path.join(MONOREPO_ROOT, 'packages/list_utils/src'),
|
||||
'@fluxer/markdown_parser/src': path.join(MONOREPO_ROOT, 'packages/markdown_parser/src'),
|
||||
'@fluxer/number_utils/src': path.join(MONOREPO_ROOT, 'packages/number_utils/src'),
|
||||
'@fluxer/schema/src': path.join(MONOREPO_ROOT, 'packages/schema/src'),
|
||||
'@fluxer/snowflake/src': path.join(MONOREPO_ROOT, 'packages/snowflake/src'),
|
||||
'@fluxer/ui/src': path.join(MONOREPO_ROOT, 'packages/ui/src'),
|
||||
},
|
||||
extensions: [
|
||||
'.web.tsx',
|
||||
'.web.ts',
|
||||
'.web.jsx',
|
||||
'.web.js',
|
||||
'.tsx',
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.json',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.po',
|
||||
],
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(tsx|ts|jsx|js)$/,
|
||||
exclude: /node_modules/,
|
||||
type: 'javascript/auto',
|
||||
parser: {
|
||||
dynamicImport: false,
|
||||
},
|
||||
use: {
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx: true,
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
development: isDevelopment,
|
||||
refresh: false,
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
plugins: [linguiSwcPlugin],
|
||||
},
|
||||
target: 'es2015',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
createPoFileRule(),
|
||||
|
||||
{
|
||||
test: /\.module\.css$/,
|
||||
use: [{loader: 'postcss-loader'}],
|
||||
type: 'css/module',
|
||||
parser: {namedExports: false},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
exclude: /\.module\.css$/,
|
||||
use: [{loader: 'postcss-loader'}],
|
||||
type: 'css',
|
||||
},
|
||||
|
||||
{
|
||||
test: /\.svg$/,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
resourceQuery: /react/,
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {syntax: 'typescript', tsx: true},
|
||||
transform: {react: {runtime: 'automatic', development: isDevelopment}},
|
||||
target: 'es2015',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
babel: false,
|
||||
typescript: true,
|
||||
jsxRuntime: 'automatic',
|
||||
svgoConfig: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'preset-default',
|
||||
params: {overrides: {removeViewBox: false}},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
resourceQuery: {not: [/react/]},
|
||||
type: 'asset/resource',
|
||||
},
|
||||
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|mp3|wav|ogg|mp4|webm)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: isProduction ? 'assets/[contenthash:16][ext]' : 'assets/[name].[hash][ext]',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
generator: {
|
||||
'css/module': {
|
||||
localIdentName: '[name]__[local]___[hash:base64:6]',
|
||||
exportsConvention: 'camel-case-only',
|
||||
exportsOnly: false,
|
||||
},
|
||||
'css/auto': {
|
||||
localIdentName: '[name]__[local]___[hash:base64:6]',
|
||||
exportsConvention: 'camel-case-only',
|
||||
exportsOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new HtmlRspackPlugin({
|
||||
template: path.join(ROOT_DIR, 'index.html'),
|
||||
filename: 'index.html',
|
||||
inject: 'body',
|
||||
scriptLoading: 'module',
|
||||
excludeChunks: ['sw'],
|
||||
}),
|
||||
|
||||
new CopyRspackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: PUBLIC_DIR,
|
||||
to: DIST_DIR,
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
staticFilesPlugin({staticCdnEndpoint: CDN_ENDPOINT}),
|
||||
|
||||
new DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(mode),
|
||||
'import.meta.env.DEV': JSON.stringify(isDevelopment),
|
||||
'import.meta.env.PROD': JSON.stringify(isProduction),
|
||||
'import.meta.env.MODE': JSON.stringify(mode),
|
||||
'import.meta.env.PUBLIC_BUILD_SHA': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_SHA'),
|
||||
'import.meta.env.PUBLIC_BUILD_NUMBER': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_NUMBER'),
|
||||
'import.meta.env.PUBLIC_BUILD_TIMESTAMP': getPublicEnvVar(publicValues, 'PUBLIC_BUILD_TIMESTAMP'),
|
||||
'import.meta.env.PUBLIC_RELEASE_CHANNEL': getPublicEnvVar(publicValues, 'PUBLIC_RELEASE_CHANNEL'),
|
||||
'import.meta.env.PUBLIC_SENTRY_DSN': getPublicEnvVar(publicValues, 'PUBLIC_SENTRY_DSN'),
|
||||
'import.meta.env.PUBLIC_API_VERSION': getPublicEnvVar(publicValues, 'PUBLIC_API_VERSION'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT': getPublicEnvVar(publicValues, 'PUBLIC_BOOTSTRAP_API_ENDPOINT'),
|
||||
'import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT': getPublicEnvVar(
|
||||
publicValues,
|
||||
'PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT',
|
||||
),
|
||||
'import.meta.env.PUBLIC_RELAY_DIRECTORY_URL': getPublicEnvVar(publicValues, 'PUBLIC_RELAY_DIRECTORY_URL'),
|
||||
}),
|
||||
],
|
||||
|
||||
optimization: {
|
||||
splitChunks: isProduction
|
||||
? {
|
||||
chunks: (chunk) => chunk.name !== 'sw',
|
||||
maxInitialRequests: 50,
|
||||
cacheGroups: {
|
||||
icons: {
|
||||
test: /[\\/]node_modules[\\/]@phosphor-icons[\\/]/,
|
||||
name: 'icons',
|
||||
priority: 60,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
highlight: {
|
||||
test: /[\\/]node_modules[\\/]highlight\.js[\\/]/,
|
||||
name: 'highlight',
|
||||
priority: 55,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
livekit: {
|
||||
test: /[\\/]node_modules[\\/](livekit-client|@livekit)[\\/]/,
|
||||
name: 'livekit',
|
||||
priority: 50,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
katex: {
|
||||
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||
name: 'katex',
|
||||
priority: 48,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
animation: {
|
||||
test: /[\\/]node_modules[\\/](framer-motion|motion)[\\/]/,
|
||||
name: 'animation',
|
||||
priority: 45,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
mobx: {
|
||||
test: /[\\/]node_modules[\\/](mobx|mobx-react-lite|mobx-persist-store)[\\/]/,
|
||||
name: 'mobx',
|
||||
priority: 43,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
sentry: {
|
||||
test: /[\\/]node_modules[\\/]@sentry[\\/]/,
|
||||
name: 'sentry',
|
||||
priority: 41,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
reactAria: {
|
||||
test: /[\\/]node_modules[\\/]react-aria-components[\\/]/,
|
||||
name: 'react-aria',
|
||||
priority: 40,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
validation: {
|
||||
test: /[\\/]node_modules[\\/](valibot)[\\/]/,
|
||||
name: 'validation',
|
||||
priority: 38,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
datetime: {
|
||||
test: /[\\/]node_modules[\\/]luxon[\\/]/,
|
||||
name: 'datetime',
|
||||
priority: 37,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
observable: {
|
||||
test: /[\\/]node_modules[\\/]rxjs[\\/]/,
|
||||
name: 'observable',
|
||||
priority: 36,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
unicode: {
|
||||
test: /[\\/]node_modules[\\/](idna-uts46-hx|emoji-regex)[\\/]/,
|
||||
name: 'unicode',
|
||||
priority: 35,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
dnd: {
|
||||
test: /[\\/]node_modules[\\/](@dnd-kit|react-dnd)[\\/]/,
|
||||
name: 'dnd',
|
||||
priority: 33,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
radix: {
|
||||
test: /[\\/]node_modules[\\/]@radix-ui[\\/]/,
|
||||
name: 'radix',
|
||||
priority: 31,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
ui: {
|
||||
test: /[\\/]node_modules[\\/](react-select|react-hook-form|react-modal-sheet|react-zoom-pan-pinch|@floating-ui)[\\/]/,
|
||||
name: 'ui',
|
||||
priority: 30,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
utils: {
|
||||
test: /[\\/]node_modules[\\/](lodash|clsx|qrcode|thumbhash|bowser|match-sorter)[\\/]/,
|
||||
name: 'utils',
|
||||
priority: 28,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
networking: {
|
||||
test: /[\\/]node_modules[\\/](ws)[\\/]/,
|
||||
name: 'networking',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
react: {
|
||||
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
|
||||
name: 'react',
|
||||
priority: 25,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendor',
|
||||
priority: 10,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
: false,
|
||||
runtimeChunk: false,
|
||||
chunkSplit: false,
|
||||
moduleIds: 'named',
|
||||
chunkIds: 'named',
|
||||
minimize: isProduction,
|
||||
minimizer: [
|
||||
new SwcJsMinimizerRspackPlugin({
|
||||
compress: true,
|
||||
mangle: true,
|
||||
format: {comments: false},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
devServer: {
|
||||
port: Number(process.env.FLUXER_APP_DEV_PORT) || 49427,
|
||||
hot: false,
|
||||
liveReload: false,
|
||||
client: false,
|
||||
webSocketServer: false,
|
||||
historyApiFallback: true,
|
||||
allowedHosts: 'all',
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
static: {
|
||||
directory: DIST_DIR,
|
||||
watch: false,
|
||||
},
|
||||
},
|
||||
|
||||
experiments: {css: true},
|
||||
};
|
||||
};
|
||||
2
fluxer/fluxer_app/rust-toolchain.toml
Normal file
2
fluxer/fluxer_app/rust-toolchain.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "1.93.0"
|
||||
364
fluxer/fluxer_app/scripts/DevServer.tsx
Normal file
364
fluxer/fluxer_app/scripts/DevServer.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChildProcess, spawn} from 'node:child_process';
|
||||
import type {Dirent} from 'node:fs';
|
||||
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const metadataFile = path.join(projectRoot, '.devserver-cache.json');
|
||||
const binDir = path.join(projectRoot, 'node_modules', '.bin');
|
||||
const rspackBin = path.join(binDir, 'rspack');
|
||||
const tcmBin = path.join(binDir, 'tcm');
|
||||
const DEFAULT_SKIP_DIRS = new Set(['.git', 'node_modules', '.turbo', 'dist', 'target', 'pkg', 'pkgs']);
|
||||
let metadataCache: Metadata | null = null;
|
||||
|
||||
interface StepMetadata {
|
||||
lastRun: number;
|
||||
inputs: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
[key: string]: StepMetadata;
|
||||
}
|
||||
|
||||
type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui';
|
||||
|
||||
async function loadMetadata(): Promise<void> {
|
||||
if (metadataCache !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(metadataFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
metadataCache = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Metadata;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
metadataCache = {};
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to read dev server metadata cache, falling back to full rebuild:', error);
|
||||
metadataCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetadata(): Promise<void> {
|
||||
if (!metadataCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(metadataFile), {recursive: true});
|
||||
await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function haveInputsChanged(prev: Record<string, number>, next: Record<string, number>): boolean {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of nextKeys) {
|
||||
if (!Object.hasOwn(prev, key) || prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldRunStep(stepName: StepKey, inputs: Record<string, number>): boolean {
|
||||
if (!metadataCache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = metadataCache[stepName];
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return haveInputsChanged(entry.inputs, inputs);
|
||||
}
|
||||
|
||||
async function collectFileStats(paths: ReadonlyArray<string>): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const relPath of paths) {
|
||||
const absolutePath = path.join(projectRoot, relPath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`Expected ${relPath} to be a file when collecting dev server cache inputs.`);
|
||||
}
|
||||
result[relPath] = fileStat.mtimeMs;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectDirectoryStats(
|
||||
rootRel: string,
|
||||
predicate: (relPath: string) => boolean,
|
||||
): Promise<Record<string, number>> {
|
||||
const accumulator: Record<string, number> = {};
|
||||
|
||||
async function walk(relPath: string): Promise<void> {
|
||||
const absoluteDir = path.join(projectRoot, relPath);
|
||||
let entries: Array<Dirent>;
|
||||
try {
|
||||
entries = await readdir(absoluteDir, {withFileTypes: true});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (DEFAULT_SKIP_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(path.join(relPath, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileRel = path.join(relPath, entry.name);
|
||||
if (!predicate(fileRel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileStat = await stat(path.join(projectRoot, fileRel));
|
||||
accumulator[fileRel] = fileStat.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootRel);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
async function runCachedStep(
|
||||
stepName: StepKey,
|
||||
gatherInputs: () => Promise<Record<string, number>>,
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
): Promise<void> {
|
||||
const inputs = await gatherInputs();
|
||||
if (!shouldRunStep(stepName, inputs)) {
|
||||
console.log(`Skipping ${command} ${args.join(' ')} (no changes detected)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand(command, args);
|
||||
|
||||
metadataCache ??= {};
|
||||
metadataCache[stepName] = {lastRun: Date.now(), inputs};
|
||||
await saveMetadata();
|
||||
}
|
||||
|
||||
async function gatherWasmInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true);
|
||||
}
|
||||
|
||||
async function gatherColorInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateColorSystem.tsx']);
|
||||
}
|
||||
|
||||
async function gatherMaskInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']);
|
||||
}
|
||||
|
||||
async function gatherCssModuleInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css'));
|
||||
}
|
||||
|
||||
async function gatherLinguiInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('src', 'locales'), (relPath) => relPath.endsWith('.po'));
|
||||
}
|
||||
|
||||
let currentChild: ChildProcess | null = null;
|
||||
let cssTypeWatcher: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdownSignals: ReadonlyArray<NodeJS.Signals> = ['SIGINT', 'SIGTERM'];
|
||||
|
||||
function handleShutdown(signal: NodeJS.Signals): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
console.log(`\nReceived ${signal}, shutting down fluxer app dev server...`);
|
||||
currentChild?.kill('SIGTERM');
|
||||
cssTypeWatcher?.kill('SIGTERM');
|
||||
}
|
||||
|
||||
shutdownSignals.forEach((signal) => {
|
||||
process.on(signal, () => handleShutdown(signal));
|
||||
});
|
||||
|
||||
function runCommand(command: string, args: ReadonlyArray<string>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`${command} ${args.join(' ')} terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && code !== 0) {
|
||||
reject(new Error(`${command} ${args.join(' ')} exited with status ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanDist(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
await rm(distPath, {recursive: true, force: true});
|
||||
}
|
||||
|
||||
function startCssTypeWatcher(): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(tcmBin, ['src', '--pattern', '**/*.module.css', '--watch', '--silent'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
cssTypeWatcher = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
if (!shuttingDown) {
|
||||
console.error('CSS type watcher error:', error);
|
||||
}
|
||||
cssTypeWatcher = null;
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
cssTypeWatcher = null;
|
||||
if (!shuttingDown && code !== 0) {
|
||||
console.error(`CSS type watcher exited unexpectedly (code: ${code}, signal: ${signal})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runRspack(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(rspackBin, ['serve', '--mode', 'development'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`rspack serve terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadMetadata();
|
||||
|
||||
try {
|
||||
await runCachedStep('wasm', gatherWasmInputs, 'pnpm', ['wasm:codegen']);
|
||||
await runCachedStep('colors', gatherColorInputs, 'pnpm', ['generate:colors']);
|
||||
await runCachedStep('masks', gatherMaskInputs, 'pnpm', ['generate:masks']);
|
||||
await runCachedStep('cssTypes', gatherCssModuleInputs, 'pnpm', ['generate:css-types']);
|
||||
await runCachedStep('lingui', gatherLinguiInputs, 'pnpm', ['lingui:compile']);
|
||||
await cleanDist();
|
||||
|
||||
startCssTypeWatcher();
|
||||
|
||||
const rspackExitCode = await runRspack();
|
||||
|
||||
if (!shuttingDown && rspackExitCode !== 0) {
|
||||
process.exit(rspackExitCode);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shuttingDown) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
584
fluxer/fluxer_app/scripts/GenerateAvatarMasks.tsx
Normal file
584
fluxer/fluxer_app/scripts/GenerateAvatarMasks.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '@app/components/uikit/TypingConstants';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 44 | 48 | 56 | 80 | 120;
|
||||
|
||||
interface StatusConfig {
|
||||
statusSize: number;
|
||||
cutoutRadius: number;
|
||||
cutoutCenter: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
|
||||
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
|
||||
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
|
||||
40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34},
|
||||
44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38},
|
||||
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
|
||||
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
|
||||
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
|
||||
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
|
||||
};
|
||||
|
||||
const DESIGN_RULES = {
|
||||
mobileAspectRatio: 0.75,
|
||||
mobileCornerRadius: 0.12,
|
||||
mobileScreenWidth: 0.72,
|
||||
mobileScreenHeight: 0.7,
|
||||
mobileScreenY: 0.06,
|
||||
mobileWheelRadius: 0.13,
|
||||
mobileWheelY: 0.83,
|
||||
|
||||
mobilePhoneExtraHeight: 2,
|
||||
mobileDisplayExtraHeight: 2,
|
||||
mobileDisplayExtraWidthPerSide: 2,
|
||||
|
||||
idle: {
|
||||
cutoutRadiusRatio: 0.7,
|
||||
cutoutOffsetRatio: 0.35,
|
||||
},
|
||||
dnd: {
|
||||
barWidthRatio: 1.3,
|
||||
barHeightRatio: 0.4,
|
||||
minBarHeight: 2,
|
||||
},
|
||||
offline: {
|
||||
innerRingRatio: 0.6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
|
||||
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
|
||||
const MOBILE_SCREEN_X_OFFSET_PX = 0;
|
||||
const MOBILE_SCREEN_Y_OFFSET_PX = 3;
|
||||
|
||||
function getStatusConfig(avatarSize: number): StatusConfig {
|
||||
if (STATUS_CONFIG[avatarSize]) {
|
||||
return STATUS_CONFIG[avatarSize];
|
||||
}
|
||||
const sizes = Object.keys(STATUS_CONFIG)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const closest = sizes.reduce((prev, curr) =>
|
||||
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
|
||||
);
|
||||
return STATUS_CONFIG[closest];
|
||||
}
|
||||
|
||||
interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
|
||||
interface MobileStatusGeometry extends StatusGeometry {
|
||||
phoneWidth: number;
|
||||
phoneHeight: number;
|
||||
phoneX: number;
|
||||
phoneY: number;
|
||||
phoneRx: number;
|
||||
bezelHeight: number;
|
||||
}
|
||||
|
||||
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
|
||||
const config = getStatusConfig(avatarSize);
|
||||
|
||||
const statusSize = config.statusSize;
|
||||
const cutoutCenter = config.cutoutCenter;
|
||||
const cutoutRadius = config.cutoutRadius;
|
||||
|
||||
const innerRadius = statusSize / 2;
|
||||
const outerRadius = cutoutRadius;
|
||||
const borderWidth = cutoutRadius - innerRadius;
|
||||
|
||||
const baseGeometry = {
|
||||
size: statusSize,
|
||||
cx: cutoutCenter,
|
||||
cy: cutoutCenter,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
borderWidth,
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
return baseGeometry;
|
||||
}
|
||||
|
||||
const phoneWidth = statusSize;
|
||||
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
|
||||
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
|
||||
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
|
||||
|
||||
const phoneX = cutoutCenter - phoneWidth / 2;
|
||||
const phoneY = cutoutCenter - phoneHeight / 2;
|
||||
|
||||
return {
|
||||
...baseGeometry,
|
||||
phoneWidth,
|
||||
phoneHeight,
|
||||
phoneX,
|
||||
phoneY,
|
||||
phoneRx,
|
||||
bezelHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAvatarMaskDefault(size: number): string {
|
||||
const r = size / 2;
|
||||
return `<circle fill="white" cx="${r}" cy="${r}" r="${r}" />`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusRound(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidth =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeight =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenY =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
|
||||
|
||||
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
|
||||
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
|
||||
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
|
||||
<rect fill="black" x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}" rx="${screenRx}" ry="${screenRx}" />
|
||||
<circle fill="black" cx="${wheelCx}" cy="${wheelCy}" r="${wheelRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusOnline(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusIdle(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
|
||||
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
|
||||
const cutoutCx = status.cx - cutoutOffsetDistance;
|
||||
const cutoutCy = status.cy - cutoutOffsetDistance;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${cutoutCx}" cy="${cutoutCy}" r="${cutoutRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
|
||||
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
|
||||
const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight));
|
||||
const barX = status.cx - barWidth / 2;
|
||||
const barY = status.cy - barHeight / 2;
|
||||
const barRx = barHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<rect fill="black" x="${barX}" y="${barY}" width="${barWidth}" height="${barHeight}" rx="${barRx}" ry="${barRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusOffline(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${innerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusTyping(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const rx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
|
||||
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120];
|
||||
|
||||
let output = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
type AvatarSize = ${SIZES.join(' | ')};
|
||||
|
||||
interface MaskDefinition {
|
||||
viewBox: string;
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
interface MaskSet {
|
||||
avatarDefault: MaskDefinition;
|
||||
avatarStatusRound: MaskDefinition;
|
||||
avatarStatusTyping: MaskDefinition;
|
||||
statusOnline: MaskDefinition;
|
||||
statusOnlineMobile: MaskDefinition;
|
||||
statusIdle: MaskDefinition;
|
||||
statusIdleMobile: MaskDefinition;
|
||||
statusDnd: MaskDefinition;
|
||||
statusDndMobile: MaskDefinition;
|
||||
statusOffline: MaskDefinition;
|
||||
statusTyping: MaskDefinition;
|
||||
}
|
||||
|
||||
export const AVATAR_MASKS: Record<AvatarSize, MaskSet> = {
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
output += ` ${size}: {
|
||||
avatarDefault: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskDefault(size)},
|
||||
},
|
||||
avatarStatusRound: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusRound(size)},
|
||||
},
|
||||
avatarStatusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusTyping(size)},
|
||||
},
|
||||
statusOnline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, false)},
|
||||
},
|
||||
statusOnlineMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, true)},
|
||||
},
|
||||
statusIdle: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, false)},
|
||||
},
|
||||
statusIdleMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, true)},
|
||||
},
|
||||
statusDnd: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, false)},
|
||||
},
|
||||
statusDndMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, true)},
|
||||
},
|
||||
statusOffline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOffline(size)},
|
||||
},
|
||||
statusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusTyping(size)},
|
||||
},
|
||||
},
|
||||
`;
|
||||
}
|
||||
|
||||
output += `} as const;
|
||||
|
||||
export const SVGMasks = () => (
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
aria-hidden={true}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
top: '-1px',
|
||||
left: '-1px',
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
const status = calculateStatusGeometry(size, false);
|
||||
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
|
||||
const cx = status.cx / size;
|
||||
const cy = status.cy / size;
|
||||
const r = status.outerRadius / size;
|
||||
|
||||
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
|
||||
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
|
||||
const idleCutoutCx = cx - idleCutoutOffset;
|
||||
const idleCutoutCy = cy - idleCutoutOffset;
|
||||
|
||||
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
|
||||
const dndBarHeight =
|
||||
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
|
||||
const dndBarX = cx - dndBarWidth / 2;
|
||||
const dndBarY = cy - dndBarHeight / 2;
|
||||
const dndBarRx = dndBarHeight / 2;
|
||||
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
|
||||
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
|
||||
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingWidth = typingWidthPx / size;
|
||||
const typingHeight = status.size / size;
|
||||
const typingX = cx - typingWidth / 2 + typingBridgeShift;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
|
||||
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidthPx =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeightPx =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenYpx =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
|
||||
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
|
||||
|
||||
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
|
||||
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
|
||||
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
`;
|
||||
|
||||
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
|
||||
fs.writeFileSync(outputPath, output);
|
||||
|
||||
console.log(`Generated ${outputPath}`);
|
||||
|
||||
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
export interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
borderWidth: number;
|
||||
isMobile?: boolean;
|
||||
phoneWidth?: number;
|
||||
phoneHeight?: number;
|
||||
}
|
||||
|
||||
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, false);
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry {
|
||||
const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY;
|
||||
|
||||
if (map[avatarSize]) {
|
||||
return map[avatarSize];
|
||||
}
|
||||
|
||||
const closestSize = Object.keys(map)
|
||||
.map(Number)
|
||||
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
|
||||
|
||||
return map[closestSize];
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
|
||||
fs.writeFileSync(layoutPath, layoutOutput);
|
||||
680
fluxer/fluxer_app/scripts/GenerateColorSystem.tsx
Normal file
680
fluxer/fluxer_app/scripts/GenerateColorSystem.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative} from 'node:path';
|
||||
|
||||
interface ColorFamily {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
useSaturationFactor: boolean;
|
||||
}
|
||||
|
||||
interface ScaleStop {
|
||||
name: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface Scale {
|
||||
family: string;
|
||||
range: [number, number];
|
||||
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
||||
stops: Array<ScaleStop>;
|
||||
}
|
||||
|
||||
interface TokenDef {
|
||||
name?: string;
|
||||
scale?: string;
|
||||
value?: string;
|
||||
family?: string;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
lightness?: number;
|
||||
alpha?: number;
|
||||
useSaturationFactor?: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
families: Record<string, ColorFamily>;
|
||||
scales: Record<string, Scale>;
|
||||
tokens: {
|
||||
root: Array<TokenDef>;
|
||||
light: Array<TokenDef>;
|
||||
coal: Array<TokenDef>;
|
||||
};
|
||||
}
|
||||
|
||||
const CONFIG: Config = {
|
||||
families: {
|
||||
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
|
||||
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
|
||||
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
|
||||
link: {hue: 210, saturation: 100, useSaturationFactor: true},
|
||||
accentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
|
||||
statusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
|
||||
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
|
||||
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
|
||||
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
|
||||
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
|
||||
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
|
||||
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
|
||||
},
|
||||
|
||||
scales: {
|
||||
darkSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [5, 26],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-lighter', position: 0.22},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
coalSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [1, 12],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
darkText: {
|
||||
family: 'neutralDark',
|
||||
range: [52, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
{name: '--text-tertiary-muted', position: 0.2},
|
||||
{name: '--text-tertiary', position: 0.38},
|
||||
{name: '--text-primary-muted', position: 0.55},
|
||||
{name: '--text-chat-muted', position: 0.55},
|
||||
{name: '--text-secondary', position: 0.72},
|
||||
{name: '--text-chat', position: 0.82},
|
||||
{name: '--text-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightSurface: {
|
||||
family: 'neutralLight',
|
||||
range: [86, 98.5],
|
||||
curve: 'easeIn',
|
||||
stops: [
|
||||
{name: '--background-header-primary-hover', position: 0},
|
||||
{name: '--background-header-primary', position: 0.12},
|
||||
{name: '--background-header-secondary', position: 0.2},
|
||||
{name: '--guild-list-foreground', position: 0.35},
|
||||
{name: '--background-tertiary', position: 0.42},
|
||||
{name: '--background-channel-header', position: 0.5},
|
||||
{name: '--background-secondary-alt', position: 0.63},
|
||||
{name: '--background-secondary', position: 0.74},
|
||||
{name: '--background-secondary-lighter', position: 0.83},
|
||||
{name: '--background-textarea', position: 0.88},
|
||||
{name: '--background-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightText: {
|
||||
family: 'neutralLight',
|
||||
range: [15, 60],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--text-primary', position: 0},
|
||||
{name: '--text-chat', position: 0.08},
|
||||
{name: '--text-secondary', position: 0.28},
|
||||
{name: '--text-chat-muted', position: 0.45},
|
||||
{name: '--text-primary-muted', position: 0.45},
|
||||
{name: '--text-tertiary', position: 0.6},
|
||||
{name: '--text-tertiary-secondary', position: 0.75},
|
||||
{name: '--text-tertiary-muted', position: 0.85},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
tokens: {
|
||||
root: [
|
||||
{scale: 'darkSurface'},
|
||||
{scale: 'darkText'},
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
|
||||
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 22},
|
||||
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 24},
|
||||
{name: '--control-button-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60},
|
||||
{name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20},
|
||||
{name: '--brand-primary', family: 'brand', lightness: 55},
|
||||
{name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49},
|
||||
{name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84},
|
||||
{name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--status-online', family: 'statusOnline', lightness: 40},
|
||||
{name: '--status-idle', family: 'statusIdle', lightness: 50},
|
||||
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
|
||||
{name: '--status-offline', family: 'statusOffline', lightness: 65},
|
||||
{name: '--status-danger', family: 'statusDanger', lightness: 55},
|
||||
{name: '--status-warning', value: 'var(--status-idle)'},
|
||||
{name: '--text-warning', family: 'statusIdle', lightness: 55},
|
||||
{name: '--plutonium', value: 'var(--brand-primary)'},
|
||||
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
|
||||
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--plutonium-icon', family: 'brandIcon', lightness: 50},
|
||||
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--text-link', family: 'link', lightness: 70},
|
||||
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
|
||||
{name: '--text-code', family: 'textCode', lightness: 90},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
{name: '--markup-mention-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-mention-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 70, alpha: 0.3},
|
||||
{name: '--markup-jump-link-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 12%, transparent)'},
|
||||
{name: '--markup-jump-link-hover-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
|
||||
{name: '--markup-everyone-text', hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75},
|
||||
{
|
||||
name: '--markup-everyone-fill',
|
||||
value: 'color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)',
|
||||
},
|
||||
{
|
||||
name: '--markup-everyone-border',
|
||||
hue: 250,
|
||||
saturation: 80,
|
||||
useSaturationFactor: true,
|
||||
lightness: 75,
|
||||
alpha: 0.3,
|
||||
},
|
||||
{name: '--markup-here-text', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70},
|
||||
{
|
||||
name: '--markup-here-fill',
|
||||
value: 'color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)',
|
||||
},
|
||||
{name: '--markup-here-border', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3},
|
||||
{name: '--markup-interactive-hover-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-interactive-hover-fill', value: 'color-mix(in srgb, var(--text-link) 30%, transparent)'},
|
||||
{
|
||||
name: '--interactive-muted',
|
||||
value: `color-mix(
|
||||
in oklab,
|
||||
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)`,
|
||||
},
|
||||
{
|
||||
name: '--interactive-active',
|
||||
value: `color-mix(
|
||||
in oklab,
|
||||
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)`,
|
||||
},
|
||||
{name: '--button-primary-fill', hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44},
|
||||
{name: '--button-primary-active-fill', hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38},
|
||||
{name: '--button-primary-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-secondary-fill', hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false},
|
||||
{
|
||||
name: '--button-secondary-active-fill',
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 100,
|
||||
alpha: 0.15,
|
||||
useSaturationFactor: false,
|
||||
},
|
||||
{name: '--button-secondary-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-secondary-active-text', value: 'var(--button-secondary-text)'},
|
||||
{name: '--button-danger-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54},
|
||||
{name: '--button-danger-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45},
|
||||
{name: '--button-danger-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)'},
|
||||
{name: '--button-danger-outline-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48},
|
||||
{name: '--button-danger-outline-active-border', value: 'transparent'},
|
||||
{name: '--button-ghost-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 0},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.3)'},
|
||||
{name: '--button-outline-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.15)'},
|
||||
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.4)'},
|
||||
{name: '--theme-border', value: 'transparent'},
|
||||
{name: '--theme-border-width', value: '0px'},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralDark', lightness: 15, alpha: 0.8},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--border-color', family: 'neutralDark', lightness: 50, alpha: 0.2},
|
||||
{name: '--border-color-hover', family: 'neutralDark', lightness: 50, alpha: 0.3},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
|
||||
{name: '--accent-primary', value: 'var(--brand-primary)'},
|
||||
{name: '--accent-success', value: 'var(--status-online)'},
|
||||
{name: '--accent-warning', value: 'var(--status-idle)'},
|
||||
{name: '--accent-danger', value: 'var(--status-dnd)'},
|
||||
{name: '--accent-info', value: 'var(--text-link)'},
|
||||
{name: '--accent-purple', family: 'accentPurple', lightness: 65},
|
||||
{name: '--alert-note-color', family: 'link', lightness: 70},
|
||||
{name: '--alert-tip-color', family: 'statusOnline', lightness: 45},
|
||||
{name: '--alert-important-color', family: 'accentPurple', lightness: 65},
|
||||
{name: '--alert-warning-color', family: 'statusIdle', lightness: 55},
|
||||
{name: '--alert-caution-color', hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60},
|
||||
{name: '--shadow-sm', value: '0 1px 2px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-md', value: '0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-lg', value: '0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-xl', value: '0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--transition-fast', value: '100ms ease'},
|
||||
{name: '--transition-normal', value: '200ms ease'},
|
||||
{name: '--transition-slow', value: '300ms ease'},
|
||||
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.2)'},
|
||||
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.3)'},
|
||||
{name: '--scrollbar-thumb-bg', value: 'rgba(121, 122, 124, 0.4)'},
|
||||
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(121, 122, 124, 0.7)'},
|
||||
{name: '--scrollbar-track-bg', value: 'transparent'},
|
||||
{
|
||||
name: '--user-area-divider-color',
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
|
||||
},
|
||||
],
|
||||
|
||||
light: [
|
||||
{scale: 'lightSurface'},
|
||||
{scale: 'lightText'},
|
||||
{name: '--panel-control-bg', value: 'color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)'},
|
||||
{name: '--panel-control-border', family: 'neutralLight', saturation: 25, lightness: 45, alpha: 0.25},
|
||||
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', family: 'neutralLight', lightness: 50},
|
||||
{name: '--control-button-hover-bg', family: 'neutralLight', lightness: 88},
|
||||
{name: '--control-button-hover-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--control-button-active-bg', family: 'neutralLight', lightness: 85},
|
||||
{name: '--control-button-active-text', family: 'neutralLight', lightness: 15},
|
||||
{name: '--control-button-danger-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--control-button-danger-hover-bg', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95},
|
||||
{name: '--text-link', family: 'link', lightness: 45},
|
||||
{name: '--text-code', family: 'textCode', lightness: 45},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 45, alpha: 0.4},
|
||||
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 8%, transparent)'},
|
||||
{name: '--markup-everyone-text', hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45},
|
||||
{
|
||||
name: '--markup-everyone-fill',
|
||||
value: 'color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)',
|
||||
},
|
||||
{
|
||||
name: '--markup-everyone-border',
|
||||
hue: 250,
|
||||
saturation: 70,
|
||||
useSaturationFactor: true,
|
||||
lightness: 45,
|
||||
alpha: 0.4,
|
||||
},
|
||||
{name: '--markup-here-text', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40},
|
||||
{
|
||||
name: '--markup-here-fill',
|
||||
value: 'color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)',
|
||||
},
|
||||
{name: '--markup-here-border', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4},
|
||||
{name: '--status-online', family: 'statusOnline', saturation: 70, lightness: 40},
|
||||
{name: '--status-idle', family: 'statusIdle', saturation: 90, lightness: 45},
|
||||
{name: '--status-dnd', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--status-offline', family: 'statusOffline', hue: 210, saturation: 10, lightness: 55},
|
||||
{name: '--plutonium', value: 'var(--brand-primary)'},
|
||||
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
|
||||
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
|
||||
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
|
||||
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
|
||||
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralLight', saturation: 22, lightness: 90, alpha: 0.9},
|
||||
{name: '--bg-code-block', value: 'var(--background-primary)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--alert-note-color', family: 'link', lightness: 45},
|
||||
{name: '--alert-tip-color', hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35},
|
||||
{name: '--alert-important-color', family: 'accentPurple', lightness: 50},
|
||||
{name: '--alert-warning-color', family: 'statusIdle', saturation: 90, lightness: 45},
|
||||
{name: '--alert-caution-color', hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.15)'},
|
||||
{name: '--button-secondary-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{name: '--button-secondary-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.15},
|
||||
{name: '--button-secondary-text', family: 'neutralLight', lightness: 15},
|
||||
{name: '--button-secondary-active-text', family: 'neutralLight', lightness: 10},
|
||||
{name: '--button-ghost-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 10},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.3)'},
|
||||
{name: '--button-outline-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--button-outline-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{
|
||||
name: '--button-outline-active-border',
|
||||
value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.5)',
|
||||
},
|
||||
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)'},
|
||||
{name: '--button-danger-outline-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45},
|
||||
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--user-area-divider-color', family: 'neutralLight', lightness: 40, alpha: 0.2},
|
||||
],
|
||||
|
||||
coal: [
|
||||
{scale: 'coalSurface'},
|
||||
{name: '--background-secondary', value: 'var(--background-primary)'},
|
||||
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-primary) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
|
||||
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 12},
|
||||
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 14},
|
||||
{name: '--control-button-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--scrollbar-thumb-bg', value: 'rgba(160, 160, 160, 0.35)'},
|
||||
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(200, 200, 200, 0.55)'},
|
||||
{name: '--scrollbar-track-bg', value: 'rgba(0, 0, 0, 0.45)'},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', value: 'hsl(220, calc(13% * var(--saturation-factor)), 8%)'},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--button-secondary-fill', value: 'hsla(0, 0%, 100%, 0.04)'},
|
||||
{name: '--button-secondary-active-fill', value: 'hsla(0, 0%, 100%, 0.07)'},
|
||||
{name: '--button-secondary-text', value: 'var(--text-primary)'},
|
||||
{name: '--button-secondary-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.08)'},
|
||||
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.12)'},
|
||||
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.16)'},
|
||||
{
|
||||
name: '--user-area-divider-color',
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface OutputToken {
|
||||
type: 'tone' | 'literal';
|
||||
name: string;
|
||||
family?: string;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
lightness?: number;
|
||||
alpha?: number;
|
||||
useSaturationFactor?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function applyCurve(curve: Scale['curve'], t: number): number {
|
||||
switch (curve) {
|
||||
case 'easeIn':
|
||||
return t * t;
|
||||
case 'easeOut':
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
case 'easeInOut':
|
||||
if (t < 0.5) {
|
||||
return 2 * t * t;
|
||||
}
|
||||
return 1 - 2 * (1 - t) * (1 - t);
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function buildScaleTokens(scale: Scale): Array<OutputToken> {
|
||||
const lastIndex = Math.max(scale.stops.length - 1, 1);
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (let i = 0; i < scale.stops.length; i++) {
|
||||
const stop = scale.stops[i];
|
||||
let pos: number;
|
||||
|
||||
if (stop.position !== undefined) {
|
||||
pos = clamp01(stop.position);
|
||||
} else {
|
||||
pos = i / lastIndex;
|
||||
}
|
||||
|
||||
const eased = applyCurve(scale.curve, pos);
|
||||
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
|
||||
lightness = Math.round(lightness * 1000) / 1000;
|
||||
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: stop.name,
|
||||
family: scale.family,
|
||||
lightness,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (const def of defs) {
|
||||
if (def.scale) {
|
||||
const scale = scales[def.scale];
|
||||
if (!scale) {
|
||||
console.warn(`Warning: unknown scale "${def.scale}"`);
|
||||
continue;
|
||||
}
|
||||
tokens.push(...buildScaleTokens(scale));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.value !== undefined) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
name: def.name!,
|
||||
value: def.value.trim(),
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: def.name!,
|
||||
family: def.family,
|
||||
hue: def.hue,
|
||||
saturation: def.saturation,
|
||||
lightness: def.lightness,
|
||||
alpha: def.alpha,
|
||||
useSaturationFactor: def.useSaturationFactor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value === Math.floor(value)) {
|
||||
return String(Math.floor(value));
|
||||
}
|
||||
let s = value.toFixed(2);
|
||||
s = s.replace(/\.?0+$/, '');
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatTone(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
const family = token.family ? families[token.family] : undefined;
|
||||
|
||||
let hue = 0;
|
||||
let saturation = 0;
|
||||
let lightness = 0;
|
||||
let useFactor = false;
|
||||
|
||||
if (token.hue !== undefined) {
|
||||
hue = token.hue;
|
||||
} else if (family) {
|
||||
hue = family.hue;
|
||||
}
|
||||
|
||||
if (token.saturation !== undefined) {
|
||||
saturation = token.saturation;
|
||||
} else if (family) {
|
||||
saturation = family.saturation;
|
||||
}
|
||||
|
||||
if (token.lightness !== undefined) {
|
||||
lightness = token.lightness;
|
||||
}
|
||||
|
||||
if (token.useSaturationFactor !== undefined) {
|
||||
useFactor = token.useSaturationFactor;
|
||||
} else if (family) {
|
||||
useFactor = family.useSaturationFactor;
|
||||
}
|
||||
|
||||
let satStr: string;
|
||||
if (useFactor) {
|
||||
satStr = `calc(${formatNumber(saturation)}% * var(--saturation-factor))`;
|
||||
} else {
|
||||
satStr = `${formatNumber(saturation)}%`;
|
||||
}
|
||||
|
||||
if (token.alpha === undefined) {
|
||||
return `hsl(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%)`;
|
||||
}
|
||||
|
||||
return `hsla(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%, ${formatNumber(token.alpha)})`;
|
||||
}
|
||||
|
||||
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
if (token.type === 'tone') {
|
||||
return formatTone(token, families);
|
||||
}
|
||||
return token.value!.trim();
|
||||
}
|
||||
|
||||
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
|
||||
const lines: Array<string> = [];
|
||||
for (const token of tokens) {
|
||||
lines.push(`\t${token.name}: ${formatValue(token, families)};`);
|
||||
}
|
||||
return `${selector} {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
|
||||
function generateCSS(
|
||||
cfg: Config,
|
||||
rootTokens: Array<OutputToken>,
|
||||
lightTokens: Array<OutputToken>,
|
||||
coalTokens: Array<OutputToken>,
|
||||
): string {
|
||||
const header = `/*
|
||||
* This file is auto-generated by scripts/GenerateColorSystem.ts.
|
||||
* Do not edit directly — update the config in generate-color-system.ts instead.
|
||||
*/`;
|
||||
|
||||
const blocks = [
|
||||
renderBlock(':root', rootTokens, cfg.families),
|
||||
renderBlock('.theme-light', lightTokens, cfg.families),
|
||||
renderBlock('.theme-coal', coalTokens, cfg.families),
|
||||
];
|
||||
|
||||
return `${header}\n\n${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const rootTokens = expandTokens(CONFIG.tokens.root, CONFIG.scales);
|
||||
const lightTokens = expandTokens(CONFIG.tokens.light, CONFIG.scales);
|
||||
const coalTokens = expandTokens(CONFIG.tokens.coal, CONFIG.scales);
|
||||
|
||||
const cssPath = join(appDir, 'src', 'styles', 'generated', 'color-system.css');
|
||||
|
||||
mkdirSync(dirname(cssPath), {recursive: true});
|
||||
|
||||
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
|
||||
writeFileSync(cssPath, css);
|
||||
|
||||
const relCSS = relative(appDir, cssPath);
|
||||
console.log(`Wrote ${relCSS}`);
|
||||
}
|
||||
|
||||
main();
|
||||
315
fluxer/fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal file
315
fluxer/fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const EMOJI_SPRITES = {
|
||||
nonDiversityPerRow: 42,
|
||||
diversityPerRow: 10,
|
||||
pickerPerRow: 11,
|
||||
pickerCount: 50,
|
||||
} as const;
|
||||
|
||||
const EMOJI_SIZE = 32;
|
||||
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
|
||||
const SPRITE_SCALES = [1, 2] as const;
|
||||
|
||||
interface EmojiObject {
|
||||
surrogates: string;
|
||||
skins?: Array<{surrogates: string}>;
|
||||
}
|
||||
|
||||
interface EmojiEntry {
|
||||
surrogates: string;
|
||||
}
|
||||
|
||||
const svgCache = new Map<string, string | null>();
|
||||
|
||||
async function fetchTwemojiSVG(codepoint: string): Promise<string | null> {
|
||||
if (svgCache.has(codepoint)) {
|
||||
return svgCache.get(codepoint) ?? null;
|
||||
}
|
||||
|
||||
const url = `${TWEMOJI_CDN}/${codepoint}.svg`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Twemoji ${codepoint} returned ${response.status}`);
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
svgCache.set(codepoint, body);
|
||||
return body;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch Twemoji ${codepoint}:`, err);
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fixSVGSize(svg: string, size: number): string {
|
||||
return svg.replace(/<svg([^>]*)>/i, `<svg$1 width="${size}" height="${size}">`);
|
||||
}
|
||||
|
||||
async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buffer> {
|
||||
const fixed = fixSVGSize(svgContent, size);
|
||||
return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer();
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
h /= 360;
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
const hueToRgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
r = hueToRgb(p, q, h + 1 / 3);
|
||||
g = hueToRgb(p, q, h);
|
||||
b = hueToRgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round(Math.min(1, Math.max(0, r)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, g)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, b)) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
async function createPlaceholder(size: number): Promise<Buffer> {
|
||||
const h = Math.random() * 360;
|
||||
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
|
||||
|
||||
const radius = Math.floor(size * 0.4);
|
||||
const cx = Math.floor(size / 2);
|
||||
const cy = Math.floor(size / 2);
|
||||
|
||||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
|
||||
const codepoint = convertToCodePoints(surrogate);
|
||||
|
||||
const svg = await fetchTwemojiSVG(codepoint);
|
||||
if (svg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(svg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render SVG for ${codepoint}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (codepoint.includes('-200d-')) {
|
||||
const basePart = codepoint.split('-200d-')[0];
|
||||
const baseSvg = await fetchTwemojiSVG(basePart);
|
||||
if (baseSvg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(baseSvg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render base SVG for ${basePart}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
|
||||
return createPlaceholder(size);
|
||||
}
|
||||
|
||||
async function renderSpriteSheet(
|
||||
emojiEntries: Array<EmojiEntry>,
|
||||
perRow: number,
|
||||
fileNameBase: string,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
if (perRow <= 0) {
|
||||
throw new Error('perRow must be > 0');
|
||||
}
|
||||
|
||||
const rows = Math.ceil(emojiEntries.length / perRow);
|
||||
|
||||
for (const scale of SPRITE_SCALES) {
|
||||
const size = EMOJI_SIZE * scale;
|
||||
const dstW = perRow * size;
|
||||
const dstH = rows * size;
|
||||
|
||||
const compositeOps: Array<sharp.OverlayOptions> = [];
|
||||
|
||||
for (let i = 0; i < emojiEntries.length; i++) {
|
||||
const item = emojiEntries[i];
|
||||
const emojiBuffer = await loadEmojiImage(item.surrogates, size);
|
||||
|
||||
const row = Math.floor(i / perRow);
|
||||
const col = i % perRow;
|
||||
const x = col * size;
|
||||
const y = row * size;
|
||||
|
||||
compositeOps.push({
|
||||
input: emojiBuffer,
|
||||
left: x,
|
||||
top: y,
|
||||
});
|
||||
}
|
||||
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: dstW,
|
||||
height: dstH,
|
||||
channels: 4,
|
||||
background: {r: 0, g: 0, b: 0, alpha: 0},
|
||||
},
|
||||
})
|
||||
.composite(compositeOps)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const suffix = scale !== 1 ? `@${scale}x` : '';
|
||||
const outPath = join(outputDir, `${fileNameBase}${suffix}.png`);
|
||||
writeFileSync(outPath, sheet);
|
||||
console.log(`Wrote ${outPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMainSpriteSheet(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const base: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const obj of objs) {
|
||||
base.push({surrogates: obj.surrogates});
|
||||
}
|
||||
}
|
||||
await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir);
|
||||
}
|
||||
|
||||
async function generateDiversitySpriteSheets(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
for (let skinIndex = 0; skinIndex < skinTones.length; skinIndex++) {
|
||||
const skinTone = skinTones[skinIndex];
|
||||
const skinCodepoint = convertToCodePoints(skinTone);
|
||||
|
||||
const skinEntries: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const obj of objs) {
|
||||
if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) {
|
||||
skinEntries.push({surrogates: obj.skins[skinIndex].surrogates});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skinEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
|
||||
const basicEmojis = [
|
||||
'\u{1F600}',
|
||||
'\u{1F603}',
|
||||
'\u{1F604}',
|
||||
'\u{1F601}',
|
||||
'\u{1F606}',
|
||||
'\u{1F605}',
|
||||
'\u{1F602}',
|
||||
'\u{1F923}',
|
||||
'\u{1F60A}',
|
||||
'\u{1F607}',
|
||||
'\u{1F642}',
|
||||
'\u{1F609}',
|
||||
'\u{1F60C}',
|
||||
'\u{1F60D}',
|
||||
'\u{1F970}',
|
||||
'\u{1F618}',
|
||||
'\u{1F617}',
|
||||
'\u{1F619}',
|
||||
'\u{1F61A}',
|
||||
'\u{1F60B}',
|
||||
'\u{1F61B}',
|
||||
'\u{1F61D}',
|
||||
'\u{1F61C}',
|
||||
'\u{1F92A}',
|
||||
'\u{1F928}',
|
||||
'\u{1F9D0}',
|
||||
'\u{1F913}',
|
||||
'\u{1F60E}',
|
||||
'\u{1F973}',
|
||||
'\u{1F60F}',
|
||||
];
|
||||
|
||||
const entries: Array<EmojiEntry> = basicEmojis.map((e) => ({surrogates: e}));
|
||||
await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites');
|
||||
mkdirSync(outputDir, {recursive: true});
|
||||
|
||||
const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json');
|
||||
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
|
||||
console.log('Generating main sprite sheet...');
|
||||
await generateMainSpriteSheet(emojiData, outputDir);
|
||||
|
||||
console.log('Generating diversity sprite sheets...');
|
||||
await generateDiversitySpriteSheets(emojiData, outputDir);
|
||||
|
||||
console.log('Generating picker sprite sheet...');
|
||||
await generatePickerSpriteSheet(outputDir);
|
||||
|
||||
console.log('Emoji sprites generated successfully.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
86
fluxer/fluxer_app/scripts/auto-i18n.mjs
Normal file
86
fluxer/fluxer_app/scripts/auto-i18n.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {homedir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const envOverrides = loadEnvFromFiles(['FLUXER_AUTO_I18N', 'OPENROUTER_API_KEY']);
|
||||
const FLUXER_AUTO_I18N = process.env.FLUXER_AUTO_I18N ?? envOverrides.FLUXER_AUTO_I18N ?? '';
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? envOverrides.OPENROUTER_API_KEY ?? '';
|
||||
|
||||
const shouldRun = FLUXER_AUTO_I18N === '1' && Boolean(OPENROUTER_API_KEY);
|
||||
if (!shouldRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const childEnv = {...process.env, FLUXER_AUTO_I18N, OPENROUTER_API_KEY};
|
||||
|
||||
const scriptPath = new URL('./translate-i18n.mjs', import.meta.url).pathname;
|
||||
const result = spawnSync(process.execPath, [scriptPath], {stdio: 'inherit', env: childEnv});
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function loadEnvFromFiles(keys) {
|
||||
const homeDir = homedir();
|
||||
const targetKeys = new Set(keys);
|
||||
const env = Object.create(null);
|
||||
const candidates = ['.bash_profile', '.bashrc', '.profile'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const filePath = join(homeDir, candidate);
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseExportLine(line);
|
||||
if (!parsed || !targetKeys.has(parsed.key) || env[parsed.key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
env[parsed.key] = parsed.value;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseExportLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('export ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {key: match[1], value: stripQuotes(match[2])};
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
27
fluxer/fluxer_app/scripts/build-sw.mjs
Normal file
27
fluxer/fluxer_app/scripts/build-sw.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {buildServiceWorker} from './build/utils/ServiceWorker';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
buildServiceWorker(isProduction).catch((error) => {
|
||||
console.error('Service worker build failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
88
fluxer/fluxer_app/scripts/build/Config.tsx
Normal file
88
fluxer/fluxer_app/scripts/build/Config.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
|
||||
export const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
export const ASSETS_DIR = path.join(DIST_DIR, 'assets');
|
||||
export const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
export const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
|
||||
export const CDN_ENDPOINT = 'https://fluxerstatic.com';
|
||||
|
||||
export const DEV_PORT = 3000;
|
||||
|
||||
export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.json', '.mjs', '.cjs'];
|
||||
|
||||
export const LOCALES = [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-419',
|
||||
'es-ES',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
];
|
||||
|
||||
export const FILE_LOADERS: Record<string, 'file'> = {
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.ttf': 'file',
|
||||
'.eot': 'file',
|
||||
'.png': 'file',
|
||||
'.jpg': 'file',
|
||||
'.jpeg': 'file',
|
||||
'.gif': 'file',
|
||||
'.webp': 'file',
|
||||
'.ico': 'file',
|
||||
'.mp3': 'file',
|
||||
'.wav': 'file',
|
||||
'.ogg': 'file',
|
||||
'.mp4': 'file',
|
||||
'.webm': 'file',
|
||||
};
|
||||
65
fluxer/fluxer_app/scripts/build/rspack/externals.mjs
Normal file
65
fluxer/fluxer_app/scripts/build/rspack/externals.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const EXTERNAL_MODULES = [
|
||||
'@lingui/cli',
|
||||
'@lingui/conf',
|
||||
'cosmiconfig',
|
||||
'jiti',
|
||||
'node:*',
|
||||
'crypto',
|
||||
'path',
|
||||
'fs',
|
||||
'os',
|
||||
'vm',
|
||||
'perf_hooks',
|
||||
'util',
|
||||
'events',
|
||||
'stream',
|
||||
'buffer',
|
||||
'child_process',
|
||||
'cluster',
|
||||
'dgram',
|
||||
'dns',
|
||||
'http',
|
||||
'https',
|
||||
'module',
|
||||
'net',
|
||||
'repl',
|
||||
'tls',
|
||||
'url',
|
||||
'worker_threads',
|
||||
'readline',
|
||||
'zlib',
|
||||
'resolve',
|
||||
];
|
||||
|
||||
const EXTERNAL_PATTERNS = [/^node:.*/];
|
||||
|
||||
export class ExternalsPlugin {
|
||||
apply(compiler) {
|
||||
const existingExternals = compiler.options.externals || [];
|
||||
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
|
||||
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
|
||||
}
|
||||
}
|
||||
|
||||
export function externalsPlugin() {
|
||||
return new ExternalsPlugin();
|
||||
}
|
||||
48
fluxer/fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
48
fluxer/fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export function getLinguiSwcPluginConfig() {
|
||||
return [
|
||||
'@lingui/swc-plugin',
|
||||
{
|
||||
localeDir: 'src/locales/{locale}/messages',
|
||||
runtimeModules: {
|
||||
i18n: ['@lingui/core', 'i18n'],
|
||||
trans: ['@lingui/react', 'Trans'],
|
||||
},
|
||||
stripNonEssentialFields: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createPoFileRule() {
|
||||
return {
|
||||
test: /\.po$/,
|
||||
type: 'javascript/auto',
|
||||
use: {
|
||||
loader: path.join(__dirname, 'po-loader.mjs'),
|
||||
},
|
||||
};
|
||||
}
|
||||
164
fluxer/fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
164
fluxer/fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export default function poLoader(source) {
|
||||
const callback = this.async();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
this.cacheable?.();
|
||||
|
||||
const poPath = this.resourcePath;
|
||||
const compiledPath = `${poPath}.mjs`;
|
||||
|
||||
this.addDependency?.(poPath);
|
||||
|
||||
try {
|
||||
await fs.access(compiledPath);
|
||||
this.addDependency?.(compiledPath);
|
||||
const compiledSource = await fs.readFile(compiledPath, 'utf8');
|
||||
callback(null, compiledSource);
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
const content = Buffer.isBuffer(source) ? source.toString('utf8') : String(source);
|
||||
const messages = parsePoFile(content);
|
||||
|
||||
const code = `export const messages = ${JSON.stringify(messages, null, 2)};\nexport default messages;\n`;
|
||||
|
||||
callback(null, code);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function parsePoFile(content) {
|
||||
const messages = {};
|
||||
const entries = splitEntries(content);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parsed = parseEntry(entry);
|
||||
if (!parsed) continue;
|
||||
messages[parsed.key] = parsed.value;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function splitEntries(content) {
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
return normalized
|
||||
.split(/\n{2,}/g)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEntry(entry) {
|
||||
const lines = entry.split('\n');
|
||||
|
||||
let msgctxt = null;
|
||||
let msgid = null;
|
||||
let msgidPlural = null;
|
||||
|
||||
const msgstrMap = new Map();
|
||||
|
||||
let active = null;
|
||||
let activeMsgstrIndex = 0;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
if (line.startsWith('msgctxt ')) {
|
||||
active = 'msgctxt';
|
||||
activeMsgstrIndex = 0;
|
||||
msgctxt = extractPoString(line.slice('msgctxt '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid_plural ')) {
|
||||
active = 'msgidPlural';
|
||||
activeMsgstrIndex = 0;
|
||||
msgidPlural = extractPoString(line.slice('msgid_plural '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid ')) {
|
||||
active = 'msgid';
|
||||
activeMsgstrIndex = 0;
|
||||
msgid = extractPoString(line.slice('msgid '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgstrIndexed = line.match(/^msgstr\[(\d+)\]\s+/);
|
||||
if (msgstrIndexed) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = Number(msgstrIndexed[1]);
|
||||
const rest = line.slice(msgstrIndexed[0].length);
|
||||
msgstrMap.set(activeMsgstrIndex, extractPoString(rest));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgstr ')) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = 0;
|
||||
msgstrMap.set(0, extractPoString(line.slice('msgstr '.length)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
const part = extractPoString(line);
|
||||
|
||||
if (active === 'msgctxt') msgctxt = (msgctxt ?? '') + part;
|
||||
else if (active === 'msgid') msgid = (msgid ?? '') + part;
|
||||
else if (active === 'msgidPlural') msgidPlural = (msgidPlural ?? '') + part;
|
||||
else if (active === 'msgstr') msgstrMap.set(activeMsgstrIndex, (msgstrMap.get(activeMsgstrIndex) ?? '') + part);
|
||||
}
|
||||
}
|
||||
|
||||
if (msgid == null) return null;
|
||||
|
||||
if (msgid === '') return null;
|
||||
|
||||
const key = msgctxt ? `${msgctxt}\u0004${msgid}` : msgid;
|
||||
|
||||
if (msgidPlural != null) {
|
||||
const keys = Array.from(msgstrMap.keys());
|
||||
const maxIndex = keys.length ? Math.max(...keys.map((n) => Number(n))) : 0;
|
||||
|
||||
const arr = [];
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
arr[i] = msgstrMap.get(i) ?? '';
|
||||
}
|
||||
|
||||
return {key, value: arr};
|
||||
}
|
||||
|
||||
return {key, value: msgstrMap.get(0) ?? ''};
|
||||
}
|
||||
|
||||
function extractPoString(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
|
||||
|
||||
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
122
fluxer/fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
122
fluxer/fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {sources} from '@rspack/core';
|
||||
|
||||
function normalizeEndpoint(staticCdnEndpoint) {
|
||||
if (!staticCdnEndpoint) return '';
|
||||
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
|
||||
}
|
||||
|
||||
function generateManifest(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
const manifest = {
|
||||
name: 'Fluxer',
|
||||
short_name: 'Fluxer',
|
||||
description:
|
||||
'Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
theme_color: '#4641D9',
|
||||
background_color: '#2b2d31',
|
||||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
|
||||
sizes: '32x32',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
|
||||
sizes: '16x16',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
function generateBrowserConfig(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
|
||||
<TileColor>#4641D9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>`;
|
||||
}
|
||||
|
||||
function generateRobotsTxt() {
|
||||
return 'User-agent: *\nAllow: /\n';
|
||||
}
|
||||
|
||||
export class StaticFilesPlugin {
|
||||
constructor(options) {
|
||||
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('StaticFilesPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: 'StaticFilesPlugin',
|
||||
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
|
||||
},
|
||||
() => {
|
||||
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.staticCdnEndpoint)));
|
||||
compilation.emitAsset(
|
||||
'browserconfig.xml',
|
||||
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
|
||||
);
|
||||
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function staticFilesPlugin(options) {
|
||||
return new StaticFilesPlugin(options);
|
||||
}
|
||||
63
fluxer/fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
63
fluxer/fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function wasmLoader(_source) {
|
||||
const callback = this.async();
|
||||
|
||||
if (!callback) {
|
||||
throw new Error('Async loader not supported');
|
||||
}
|
||||
|
||||
const wasmPath = this.resourcePath;
|
||||
|
||||
fs.promises
|
||||
.readFile(wasmPath)
|
||||
.then((wasmContent) => {
|
||||
const base64 = wasmContent.toString('base64');
|
||||
const code = `
|
||||
const wasmBase64 = "${base64}";
|
||||
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
|
||||
export default wasmBinary;
|
||||
`;
|
||||
callback(null, code);
|
||||
})
|
||||
.catch((err) => {
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
export function wasmModuleRule() {
|
||||
return {
|
||||
test: /\.wasm$/,
|
||||
exclude: [/node_modules/],
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: path.join(__dirname, 'wasm.mjs'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
24
fluxer/fluxer_app/scripts/build/tsconfig.json
Normal file
24
fluxer/fluxer_app/scripts/build/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023"],
|
||||
"noEmit": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@app_scripts/*": ["./../*"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
82
fluxer/fluxer_app/scripts/build/types.d.ts
vendored
Normal file
82
fluxer/fluxer_app/scripts/build/types.d.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare module 'postcss' {
|
||||
interface ProcessOptions {
|
||||
from?: string;
|
||||
to?: string;
|
||||
map?: boolean | {inline?: boolean; prev?: boolean | string | object; annotation?: boolean | string};
|
||||
parser?: unknown;
|
||||
stringifier?: unknown;
|
||||
syntax?: unknown;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
css: string;
|
||||
map?: unknown;
|
||||
root: unknown;
|
||||
processor: unknown;
|
||||
messages: Array<unknown>;
|
||||
opts: ProcessOptions;
|
||||
}
|
||||
|
||||
interface Plugin {
|
||||
postcssPlugin: string;
|
||||
Once?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
Root?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
RootExit?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRule?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRuleExit?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Rule?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
RuleExit?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Declaration?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
DeclarationExit?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
Comment?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
CommentExit?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Processor {
|
||||
process(css: string, options?: ProcessOptions): Promise<Result>;
|
||||
}
|
||||
|
||||
function postcss(plugins?: Array<Plugin | ((options?: unknown) => Plugin)>): Processor;
|
||||
|
||||
export default postcss;
|
||||
}
|
||||
|
||||
declare module 'postcss-modules' {
|
||||
interface PostcssModulesOptions {
|
||||
localsConvention?:
|
||||
| 'camelCase'
|
||||
| 'camelCaseOnly'
|
||||
| 'dashes'
|
||||
| 'dashesOnly'
|
||||
| ((originalClassName: string, generatedClassName: string, inputFile: string) => string);
|
||||
generateScopedName?: string | ((name: string, filename: string, css: string) => string);
|
||||
getJSON?(cssFileName: string, json: Record<string, string>, outputFileName?: string): void;
|
||||
hashPrefix?: string;
|
||||
scopeBehaviour?: 'global' | 'local';
|
||||
globalModulePaths?: Array<RegExp>;
|
||||
root?: string;
|
||||
}
|
||||
|
||||
function postcssModules(options?: PostcssModulesOptions): import('postcss').Plugin;
|
||||
|
||||
export default postcssModules;
|
||||
}
|
||||
75
fluxer/fluxer_app/scripts/build/utils/Assets.tsx
Normal file
75
fluxer/fluxer_app/scripts/build/utils/Assets.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
export async function copyPublicAssets(): Promise<void> {
|
||||
if (!fs.existsSync(PUBLIC_DIR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(PUBLIC_DIR, file.toString());
|
||||
const destPath = path.join(DIST_DIR, file.toString());
|
||||
|
||||
const stat = await fs.promises.stat(srcPath);
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyWasmFiles(): Promise<void> {
|
||||
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
|
||||
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
|
||||
|
||||
if (fs.existsSync(wasmFile)) {
|
||||
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keepNames = new Set<string>();
|
||||
for (const file of keepFiles) {
|
||||
const base = path.basename(file);
|
||||
keepNames.add(base);
|
||||
if (base.endsWith('.css')) {
|
||||
keepNames.add(`${base}.map`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(assetsDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
|
||||
continue;
|
||||
}
|
||||
if (keepNames.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
|
||||
}
|
||||
}
|
||||
147
fluxer/fluxer_app/scripts/build/utils/CssDts.tsx
Normal file
147
fluxer/fluxer_app/scripts/build/utils/CssDts.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {PKGS_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import postcss from 'postcss';
|
||||
import postcssModules from 'postcss-modules';
|
||||
|
||||
const RESERVED_KEYWORDS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'new',
|
||||
'return',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'try',
|
||||
'typeof',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'enum',
|
||||
'implements',
|
||||
'interface',
|
||||
'let',
|
||||
'package',
|
||||
'private',
|
||||
'protected',
|
||||
'public',
|
||||
'static',
|
||||
'await',
|
||||
'class',
|
||||
'const',
|
||||
]);
|
||||
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
if (RESERVED_KEYWORDS.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
||||
}
|
||||
|
||||
function generateDtsContent(classNames: Record<string, string>): string {
|
||||
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
|
||||
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
|
||||
const defaultExportType =
|
||||
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
|
||||
|
||||
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
|
||||
}
|
||||
|
||||
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
|
||||
const files: Array<string> = [];
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
|
||||
await walk(fullPath);
|
||||
}
|
||||
} else if (entry.name.endsWith('.module.css')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function generateDtsForFile(cssPath: string): Promise<void> {
|
||||
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
|
||||
let exportedClassNames: Record<string, string> = {};
|
||||
|
||||
await postcss([
|
||||
postcssModules({
|
||||
localsConvention: 'camelCaseOnly',
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
getJSON(_cssFileName: string, json: Record<string, string>) {
|
||||
exportedClassNames = json;
|
||||
},
|
||||
}),
|
||||
]).process(cssContent, {from: cssPath});
|
||||
|
||||
const dtsPath = `${cssPath}.d.ts`;
|
||||
const dtsContent = generateDtsContent(exportedClassNames);
|
||||
await fs.promises.writeFile(dtsPath, dtsContent);
|
||||
}
|
||||
|
||||
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
|
||||
if (!cssPath.endsWith('.module.css')) {
|
||||
return;
|
||||
}
|
||||
await generateDtsForFile(cssPath);
|
||||
}
|
||||
|
||||
export async function generateAllCssDts(): Promise<void> {
|
||||
const srcFiles = await findCssModuleFiles(SRC_DIR);
|
||||
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
|
||||
const allFiles = [...srcFiles, ...pkgsFiles];
|
||||
|
||||
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
|
||||
|
||||
await Promise.all(allFiles.map(generateDtsForFile));
|
||||
|
||||
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
|
||||
}
|
||||
94
fluxer/fluxer_app/scripts/build/utils/Html.tsx
Normal file
94
fluxer/fluxer_app/scripts/build/utils/Html.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
interface BuildOutput {
|
||||
mainScript: string | null;
|
||||
cssFiles: Array<string>;
|
||||
jsFiles: Array<string>;
|
||||
cssBundleFile: string | null;
|
||||
vendorScripts: Array<string>;
|
||||
}
|
||||
|
||||
interface GenerateHtmlOptions {
|
||||
buildOutput: BuildOutput;
|
||||
production: boolean;
|
||||
}
|
||||
|
||||
async function findCssModulesFile(): Promise<string | null> {
|
||||
if (!fs.existsSync(ASSETS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
const files = await fs.promises.readdir(ASSETS_DIR);
|
||||
const stylesFiles = files.filter((name) => name.startsWith('styles.') && name.endsWith('.css'));
|
||||
if (stylesFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestFile: string | null = null;
|
||||
let latestMtime = 0;
|
||||
|
||||
for (const fileName of stylesFiles) {
|
||||
const filePath = path.join(ASSETS_DIR, fileName);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (latestFile === null || stats.mtimeMs > latestMtime) {
|
||||
latestFile = fileName;
|
||||
latestMtime = stats.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
return latestFile ? `assets/${latestFile}` : null;
|
||||
}
|
||||
|
||||
export async function generateHtml(options: GenerateHtmlOptions): Promise<string> {
|
||||
const {buildOutput, production} = options;
|
||||
|
||||
const indexHtmlPath = path.join(ROOT_DIR, 'index.html');
|
||||
let html = await fs.promises.readFile(indexHtmlPath, 'utf-8');
|
||||
|
||||
const baseUrl = production ? `${CDN_ENDPOINT}/` : '/';
|
||||
|
||||
const cssModulesFile = buildOutput.cssBundleFile ?? (await findCssModulesFile());
|
||||
const cssFiles = cssModulesFile ? [cssModulesFile] : buildOutput.cssFiles;
|
||||
|
||||
const cssLinks = cssFiles.map((file) => `<link rel="stylesheet" href="${baseUrl}${file}">`).join('\n');
|
||||
|
||||
const crossOriginAttr = production && baseUrl.startsWith('http') ? ' crossorigin="anonymous"' : '';
|
||||
|
||||
const jsScripts = buildOutput.mainScript
|
||||
? `<script type="module" src="${baseUrl}${buildOutput.mainScript}"${crossOriginAttr}></script>`
|
||||
: '';
|
||||
|
||||
const buildScriptPreload = (file: string): string =>
|
||||
`<link rel="preload" as="script" href="${baseUrl}${file}"${crossOriginAttr}>`;
|
||||
|
||||
const preloadScripts = [
|
||||
...(buildOutput.vendorScripts ?? []).map(buildScriptPreload),
|
||||
...buildOutput.jsFiles.filter((file) => !file.includes('messages')).map(buildScriptPreload),
|
||||
].join('\n');
|
||||
|
||||
html = html.replace(/<script type="module" src="\/src\/index\.tsx"><\/script>/, jsScripts);
|
||||
const headInsert = [cssLinks, preloadScripts].filter(Boolean).join('\n');
|
||||
html = html.replace('</head>', `${headInsert}\n</head>`);
|
||||
|
||||
return html;
|
||||
}
|
||||
48
fluxer/fluxer_app/scripts/build/utils/Resolve.tsx
Normal file
48
fluxer/fluxer_app/scripts/build/utils/Resolve.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {RESOLVE_EXTENSIONS} from '@app_scripts/build/Config';
|
||||
|
||||
export function tryResolveWithExtensions(basePath: string): string | null {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isFile()) {
|
||||
return basePath;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const indexPath = path.join(basePath, `index${ext}`);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return indexPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const withExt = `${basePath}${ext}`;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return withExt;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
37
fluxer/fluxer_app/scripts/build/utils/ServiceWorker.tsx
Normal file
37
fluxer/fluxer_app/scripts/build/utils/ServiceWorker.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
export async function buildServiceWorker(production: boolean): Promise<void> {
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: path.join(DIST_DIR, 'sw.js'),
|
||||
minify: production,
|
||||
sourcemap: true,
|
||||
target: 'esnext',
|
||||
define: {
|
||||
__WB_MANIFEST: '[]',
|
||||
},
|
||||
});
|
||||
}
|
||||
86
fluxer/fluxer_app/scripts/build/utils/Sourcemaps.tsx
Normal file
86
fluxer/fluxer_app/scripts/build/utils/Sourcemaps.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function traverseDir(dir: string, callback: (filePath: string) => Promise<void>): Promise<void> {
|
||||
const entries = await fs.promises.readdir(dir, {withFileTypes: true});
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await traverseDir(entryPath, callback);
|
||||
return;
|
||||
}
|
||||
await callback(entryPath);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanEmptySourceMaps(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await traverseDir(dir, async (filePath) => {
|
||||
if (!filePath.endsWith('.js.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = (parsed as {sources?: Array<unknown>}).sources ?? [];
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.promises.rm(filePath, {force: true});
|
||||
|
||||
const jsPath = filePath.slice(0, -4);
|
||||
if (!(await fileExists(jsPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsContent = await fs.promises.readFile(jsPath, 'utf-8');
|
||||
const cleaned = jsContent.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*$/, '');
|
||||
if (cleaned !== jsContent) {
|
||||
await fs.promises.writeFile(jsPath, cleaned);
|
||||
}
|
||||
});
|
||||
}
|
||||
363
fluxer/fluxer_app/scripts/translate-i18n.mjs
Normal file
363
fluxer/fluxer_app/scripts/translate-i18n.mjs
Normal file
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.error('Error: OPENROUTER_API_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const LOCALES_DIR = new URL('../src/locales', import.meta.url).pathname;
|
||||
const SOURCE_LOCALE = 'en-US';
|
||||
const BATCH_SIZE = 20;
|
||||
const CONCURRENT_LOCALES = 10;
|
||||
const CONCURRENT_BATCHES_PER_LOCALE = 3;
|
||||
|
||||
const LOCALE_NAMES = {
|
||||
ar: 'Arabic',
|
||||
bg: 'Bulgarian',
|
||||
cs: 'Czech',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
'en-GB': 'British English',
|
||||
'es-ES': 'Spanish (Spain)',
|
||||
'es-419': 'Spanish (Latin America)',
|
||||
fi: 'Finnish',
|
||||
fr: 'French',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
'sv-SE': 'Swedish',
|
||||
th: 'Thai',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukrainian',
|
||||
vi: 'Vietnamese',
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-TW': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
function parsePo(content) {
|
||||
const entries = [];
|
||||
const lines = content.split('\n');
|
||||
let currentEntry = null;
|
||||
let currentField = null;
|
||||
let isHeader = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#. ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.comments.push(line);
|
||||
} else if (line.startsWith('#: ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.references.push(line);
|
||||
} else if (line.startsWith('msgid "')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.msgid = line.slice(7, -1);
|
||||
currentField = 'msgid';
|
||||
} else if (line.startsWith('msgstr "')) {
|
||||
if (currentEntry) {
|
||||
currentEntry.msgstr = line.slice(8, -1);
|
||||
currentField = 'msgstr';
|
||||
}
|
||||
} else if (line.startsWith('"') && line.endsWith('"')) {
|
||||
if (currentEntry && currentField) {
|
||||
currentEntry[currentField] += line.slice(1, -1);
|
||||
}
|
||||
} else if (line === '' && currentEntry) {
|
||||
if (isHeader && currentEntry.msgid === '') {
|
||||
isHeader = false;
|
||||
} else if (currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
currentEntry = null;
|
||||
currentField = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry && currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rebuildPo(content, translations) {
|
||||
const translationMap = new Map(translations.map((t) => [t.msgid, t.msgstr]));
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const blocks = normalized.trimEnd().split(/\n{2,}/g);
|
||||
const nextBlocks = blocks.map((block) => rebuildPoBlock(block, translationMap));
|
||||
return `${nextBlocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function rebuildPoBlock(block, translationMap) {
|
||||
const lines = block.split('\n');
|
||||
const msgidRange = getFieldRange(lines, 'msgid');
|
||||
const msgstrRange = getFieldRange(lines, 'msgstr');
|
||||
|
||||
if (!msgidRange || !msgstrRange) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const hasReferences = lines.some((line) => line.startsWith('#: '));
|
||||
const msgid = readFieldRawValue(lines, msgidRange);
|
||||
if (!hasReferences && msgid === '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
const currentMsgstr = readFieldRawValue(lines, msgstrRange);
|
||||
if (currentMsgstr !== '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
if (!translationMap.has(msgid)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const newMsgstr = translationMap.get(msgid);
|
||||
const newMsgstrLine = `msgstr "${escapePo(newMsgstr)}"`;
|
||||
return [...lines.slice(0, msgstrRange.startIndex), newMsgstrLine, ...lines.slice(msgstrRange.endIndex)].join('\n');
|
||||
}
|
||||
|
||||
function getFieldRange(lines, field) {
|
||||
const startIndex = lines.findIndex((line) => line.startsWith(`${field} `));
|
||||
if (startIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
let endIndex = startIndex + 1;
|
||||
while (endIndex < lines.length && lines[endIndex].startsWith('"') && lines[endIndex].endsWith('"')) {
|
||||
endIndex++;
|
||||
}
|
||||
return {startIndex, endIndex};
|
||||
}
|
||||
|
||||
function readFieldRawValue(lines, range) {
|
||||
const firstLine = lines[range.startIndex];
|
||||
const match = firstLine.match(/^[a-z]+\s+"(.*)"$/);
|
||||
let value = match ? match[1] : '';
|
||||
for (let i = range.startIndex + 1; i < range.endIndex; i++) {
|
||||
value += lines[i].slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapePo(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function unescapePo(str) {
|
||||
return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
async function translateBatch(strings, targetLocale) {
|
||||
const localeName = LOCALE_NAMES[targetLocale] || targetLocale;
|
||||
|
||||
const prompt = `You are a professional translator. Translate the following UI strings from English to ${localeName}.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Preserve ALL placeholders exactly as they appear: {0}, {1}, {name}, {count}, etc.
|
||||
2. Preserve ICU plural syntax exactly: {0, plural, one {...} other {...}}
|
||||
3. Keep technical terms, brand names, and special characters intact
|
||||
4. Match the tone and formality of a modern chat/messaging application
|
||||
5. Return ONLY a JSON array of translated strings in the same order as input
|
||||
6. Do NOT add any explanations or notes
|
||||
|
||||
Input strings (JSON array):
|
||||
${JSON.stringify(strings, null, 2)}
|
||||
|
||||
Output (JSON array of translated strings only):`;
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://fluxer.dev',
|
||||
'X-Title': 'Fluxer i18n Translation',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/gpt-4o-mini',
|
||||
messages: [{role: 'user', content: prompt}],
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Empty response from API');
|
||||
}
|
||||
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(`Failed to parse JSON from response: ${content}`);
|
||||
}
|
||||
|
||||
const translations = JSON.parse(jsonMatch[0]);
|
||||
|
||||
if (translations.length !== strings.length) {
|
||||
throw new Error(`Translation count mismatch: expected ${strings.length}, got ${translations.length}`);
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
async function pMap(items, mapper, concurrency) {
|
||||
const results = [];
|
||||
const executing = new Set();
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const promise = Promise.resolve().then(() => mapper(item, index));
|
||||
results.push(promise);
|
||||
executing.add(promise);
|
||||
|
||||
const clean = () => executing.delete(promise);
|
||||
promise.then(clean, clean);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
async function processLocale(locale) {
|
||||
const poPath = join(LOCALES_DIR, locale, 'messages.po');
|
||||
console.log(`[${locale}] Starting...`);
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(poPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Error reading file: ${error.message}`);
|
||||
return {locale, translated: 0, errors: 1};
|
||||
}
|
||||
|
||||
const entries = parsePo(content);
|
||||
const untranslated = entries.filter((e) => e.msgstr === '');
|
||||
|
||||
if (untranslated.length === 0) {
|
||||
console.log(`[${locale}] No untranslated strings`);
|
||||
return {locale, translated: 0, errors: 0};
|
||||
}
|
||||
|
||||
console.log(`[${locale}] Found ${untranslated.length} untranslated strings`);
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
|
||||
batches.push({
|
||||
index: Math.floor(i / BATCH_SIZE),
|
||||
total: Math.ceil(untranslated.length / BATCH_SIZE),
|
||||
entries: untranslated.slice(i, i + BATCH_SIZE),
|
||||
});
|
||||
}
|
||||
|
||||
let errorCount = 0;
|
||||
const allTranslations = [];
|
||||
|
||||
const batchResults = await pMap(
|
||||
batches,
|
||||
async (batch) => {
|
||||
const batchStrings = batch.entries.map((e) => unescapePo(e.msgid));
|
||||
|
||||
try {
|
||||
const translatedStrings = await translateBatch(batchStrings, locale);
|
||||
console.log(`[${locale}] Batch ${batch.index + 1}/${batch.total} complete`);
|
||||
|
||||
return batch.entries.map((entry, j) => ({
|
||||
msgid: entry.msgid,
|
||||
msgstr: translatedStrings[j],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Batch ${batch.index + 1}/${batch.total} error: ${error.message}`);
|
||||
errorCount++;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
CONCURRENT_BATCHES_PER_LOCALE,
|
||||
);
|
||||
|
||||
for (const translations of batchResults) {
|
||||
allTranslations.push(...translations);
|
||||
}
|
||||
|
||||
if (allTranslations.length > 0) {
|
||||
const updatedContent = rebuildPo(content, allTranslations);
|
||||
writeFileSync(poPath, updatedContent, 'utf-8');
|
||||
console.log(`[${locale}] Updated ${allTranslations.length} translations`);
|
||||
}
|
||||
|
||||
return {locale, translated: allTranslations.length, errors: errorCount};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting i18n translation...');
|
||||
console.log(`Locales directory: ${LOCALES_DIR}`);
|
||||
console.log(`Concurrency: ${CONCURRENT_LOCALES} locales, ${CONCURRENT_BATCHES_PER_LOCALE} batches per locale`);
|
||||
|
||||
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE_LOCALE && LOCALE_NAMES[d]);
|
||||
|
||||
console.log(`Found ${locales.length} locales to process\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = await pMap(locales, processLocale, CONCURRENT_LOCALES);
|
||||
|
||||
const totalTranslated = results.reduce((sum, r) => sum + (r?.translated || 0), 0);
|
||||
const totalErrors = results.reduce((sum, r) => sum + (r?.errors || 0), 0);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\nTranslation complete in ${elapsed}s`);
|
||||
console.log(`Total: ${totalTranslated} strings translated, ${totalErrors} errors`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
55
fluxer/fluxer_app/src/App.module.css
Normal file
55
fluxer/fluxer_app/src/App.module.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
:global(html:not(.auth-page)) :local(.appContainer) {
|
||||
height: 100svh;
|
||||
min-height: 100svh;
|
||||
box-sizing: border-box;
|
||||
background: var(--background-primary);
|
||||
padding-top: 0;
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
:global(html) :local(.overlayScope) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-index-overlay);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) :local(.overlayScope) {
|
||||
top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html) :local(.overlayScope) > :not([data-overlay-pass-through]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.quickSwitcherPortal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
467
fluxer/fluxer_app/src/App.tsx
Normal file
467
fluxer/fluxer_app/src/App.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import '@app/components/modals/SudoVerificationModal';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import styles from '@app/App.module.css';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import * as WindowActionCreators from '@app/actions/WindowActionCreators';
|
||||
import Config from '@app/Config';
|
||||
import {DndContext} from '@app/components/layout/DndContext';
|
||||
import GlobalOverlays from '@app/components/layout/GlobalOverlays';
|
||||
import {NativeTitlebar} from '@app/components/layout/NativeTitlebar';
|
||||
import {NativeTrafficLightsBackdrop} from '@app/components/layout/NativeTrafficLightsBackdrop';
|
||||
import {UserSettingsModal} from '@app/components/modals/UserSettingsModal';
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '@app/components/quick_switcher/QuickSwitcherConstants';
|
||||
import FocusRingScope from '@app/components/uikit/focus_ring/FocusRingScope';
|
||||
import {SVGMasks} from '@app/components/uikit/SVGMasks';
|
||||
import {IncomingCallManager} from '@app/components/voice/IncomingCallManager';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/contexts/LayoutVariantContext';
|
||||
import {showMyselfTypingHelper} from '@app/devtools/ShowMyselfTypingHelper';
|
||||
import {useActivityRecorder} from '@app/hooks/useActivityRecorder';
|
||||
import {useElectronScreenSharePicker} from '@app/hooks/useElectronScreenSharePicker';
|
||||
import {useNativePlatform} from '@app/hooks/useNativePlatform';
|
||||
import {useTextInputContextMenu} from '@app/hooks/useTextInputContextMenu';
|
||||
import CaptchaInterceptorStore from '@app/lib/CaptchaInterceptor';
|
||||
import FocusManager from '@app/lib/FocusManager';
|
||||
import KeybindManager from '@app/lib/KeybindManager';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {startReadStateCleanup} from '@app/lib/ReadStateCleanup';
|
||||
import {Outlet, RouterProvider} from '@app/lib/router/React';
|
||||
import {router} from '@app/Router';
|
||||
import AccessibilityStore, {HdrDisplayMode} from '@app/stores/AccessibilityStore';
|
||||
import ModalStore from '@app/stores/ModalStore';
|
||||
import PopoutStore from '@app/stores/PopoutStore';
|
||||
import ReadStateStore from '@app/stores/ReadStateStore';
|
||||
import RuntimeCrashStore from '@app/stores/RuntimeCrashStore';
|
||||
import ThemeStore from '@app/stores/ThemeStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import MediaEngineStore from '@app/stores/voice/MediaEngineFacade';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/utils/AutostartUtils';
|
||||
import {startDeepLinkHandling} from '@app/utils/DeepLinkUtils';
|
||||
import {attachExternalLinkInterceptor, getElectronAPI, getNativePlatform} from '@app/utils/NativeUtils';
|
||||
import {i18n} from '@lingui/core';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {RoomAudioRenderer, RoomContext} from '@livekit/components-react';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const logger = new Logger('App');
|
||||
|
||||
interface AppWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const saturationFactor = AccessibilityStore.saturationFactor;
|
||||
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
|
||||
const enableTextSelection = AccessibilityStore.textSelectionEnabled;
|
||||
const fontSize = AccessibilityStore.fontSize;
|
||||
const messageGutter = AccessibilityStore.messageGutter;
|
||||
const messageGroupSpacing = AccessibilityStore.messageGroupSpacingValue;
|
||||
const reducedMotion = AccessibilityStore.useReducedMotion;
|
||||
const hdrDisplayMode = AccessibilityStore.hdrDisplayMode;
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
useElectronScreenSharePicker();
|
||||
const customThemeCss = AccessibilityStore.customThemeCss;
|
||||
const effectiveTheme = ThemeStore.effectiveTheme;
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('app');
|
||||
const layoutVariantContextValue = useMemo(
|
||||
() => ({variant: layoutVariant, setVariant: setLayoutVariant}),
|
||||
[layoutVariant],
|
||||
);
|
||||
|
||||
const popouts = PopoutStore.getPopouts();
|
||||
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
|
||||
const topPopoutRequiresBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
|
||||
|
||||
const room = MediaEngineStore.room;
|
||||
const ringsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const overlayScopeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const recordActivity = useActivityRecorder();
|
||||
const handleUserActivity = useCallback(() => recordActivity(), [recordActivity]);
|
||||
const handleImmediateActivity = useCallback(() => recordActivity(true), [recordActivity]);
|
||||
const handleResize = useCallback(() => WindowActionCreators.resized(), []);
|
||||
useTextInputContextMenu();
|
||||
|
||||
const hasBlockingModal = ModalStore.hasModalOpen();
|
||||
|
||||
useEffect(() => {
|
||||
const node = ringsContainerRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const shouldBlockBackground = hasBlockingModal || topPopoutRequiresBackdrop;
|
||||
|
||||
node.toggleAttribute('inert', shouldBlockBackground);
|
||||
|
||||
return () => {
|
||||
node.removeAttribute('inert');
|
||||
};
|
||||
}, [hasBlockingModal, topPopoutRequiresBackdrop]);
|
||||
|
||||
useEffect(() => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startReadStateCleanup();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postBadgeUpdate = (count: number) => {
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
controller.postMessage({type: 'APP_UPDATE_BADGE', count});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to post badge update to service worker', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBadgeFromReadState = () => {
|
||||
const channelIds = ReadStateStore.getChannelIds();
|
||||
const totalMentions = channelIds.reduce((sum, channelId) => sum + ReadStateStore.getMentionCount(channelId), 0);
|
||||
postBadgeUpdate(totalMentions);
|
||||
};
|
||||
|
||||
const unsubscribe = ReadStateStore.subscribe(() => {
|
||||
updateBadgeFromReadState();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void KeybindManager.init(i18n);
|
||||
void CaptchaInterceptorStore;
|
||||
return () => {
|
||||
KeybindManager.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void AccessibilityStore.applyStoredZoom();
|
||||
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
const unsubZoomIn = electronApi.onZoomIn?.(() => void AccessibilityStore.adjustZoom(0.1));
|
||||
const unsubZoomOut = electronApi.onZoomOut?.(() => void AccessibilityStore.adjustZoom(-0.1));
|
||||
const unsubZoomReset = electronApi.onZoomReset?.(() => AccessibilityStore.updateSettings({zoomLevel: 1.0}));
|
||||
const unsubOpenSettings = electronApi.onOpenSettings?.(() => {
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <UserSettingsModal />));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubZoomIn?.();
|
||||
unsubZoomOut?.();
|
||||
unsubZoomReset?.();
|
||||
unsubOpenSettings?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('reduced-motion', reducedMotion);
|
||||
return () => {
|
||||
root.classList.remove('reduced-motion');
|
||||
};
|
||||
}, [reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_TIMESTAMP) {
|
||||
const buildInfo = Config.PUBLIC_BUILD_NUMBER
|
||||
? `build ${Config.PUBLIC_BUILD_NUMBER} (${Config.PUBLIC_BUILD_SHA})`
|
||||
: Config.PUBLIC_BUILD_SHA;
|
||||
logger.info(`[BUILD INFO] ${Config.PUBLIC_RELEASE_CHANNEL} - ${buildInfo} - ${Config.PUBLIC_BUILD_TIMESTAMP}`);
|
||||
}
|
||||
|
||||
FocusManager.init();
|
||||
|
||||
const shouldRegisterWindowListeners = !isNative;
|
||||
if (shouldRegisterWindowListeners && document.hasFocus()) {
|
||||
document.documentElement.classList.add('window-focused');
|
||||
}
|
||||
|
||||
const preventScroll = (event: Event) => event.preventDefault();
|
||||
const handleBlur = () => {
|
||||
WindowActionCreators.focused(false);
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.documentElement.classList.remove('window-focused');
|
||||
}
|
||||
};
|
||||
const handleFocus = () => {
|
||||
WindowActionCreators.focused(true);
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.documentElement.classList.add('window-focused');
|
||||
}
|
||||
handleImmediateActivity();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
WindowActionCreators.visibilityChanged(!document.hidden);
|
||||
};
|
||||
|
||||
const preventPinchZoom = (event: TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.addEventListener('scroll', preventScroll);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('mousedown', handleImmediateActivity);
|
||||
window.addEventListener('keydown', handleUserActivity);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('touchstart', handleImmediateActivity);
|
||||
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
|
||||
document.addEventListener('touchmove', preventPinchZoom, {passive: false});
|
||||
}
|
||||
|
||||
return () => {
|
||||
FocusManager.destroy();
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.removeEventListener('scroll', preventScroll);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('mousedown', handleImmediateActivity);
|
||||
window.removeEventListener('keydown', handleUserActivity);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('touchstart', handleImmediateActivity);
|
||||
document.removeEventListener('touchstart', preventPinchZoom);
|
||||
document.removeEventListener('touchmove', preventPinchZoom);
|
||||
}
|
||||
};
|
||||
}, [handleImmediateActivity, handleResize, isNative]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNative) {
|
||||
return;
|
||||
}
|
||||
const htmlNode = document.documentElement;
|
||||
const updateClass = (focused: boolean) => {
|
||||
htmlNode.classList.toggle('window-focused', focused);
|
||||
};
|
||||
const handleFocus = () => {
|
||||
updateClass(true);
|
||||
WindowActionCreators.focused(true);
|
||||
handleImmediateActivity();
|
||||
};
|
||||
const handleBlur = () => {
|
||||
updateClass(false);
|
||||
WindowActionCreators.focused(false);
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
WindowActionCreators.visibilityChanged(!document.hidden);
|
||||
};
|
||||
const preventPinchZoom = (event: TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
updateClass(document.hasFocus());
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('mousedown', handleImmediateActivity);
|
||||
window.addEventListener('keydown', handleUserActivity);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('touchstart', handleImmediateActivity);
|
||||
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
|
||||
document.addEventListener('touchmove', preventPinchZoom, {passive: false});
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('mousedown', handleImmediateActivity);
|
||||
window.removeEventListener('keydown', handleUserActivity);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('touchstart', handleImmediateActivity);
|
||||
document.removeEventListener('touchstart', preventPinchZoom);
|
||||
document.removeEventListener('touchmove', preventPinchZoom);
|
||||
};
|
||||
}, [handleImmediateActivity, handleResize, isNative]);
|
||||
|
||||
useEffect(() => {
|
||||
const htmlNode = document.documentElement;
|
||||
const platformClasses = [isNative ? 'platform-native' : 'platform-web', `platform-${platform}`];
|
||||
|
||||
htmlNode.classList.add(...platformClasses);
|
||||
|
||||
return () => {
|
||||
htmlNode.classList.remove(...platformClasses);
|
||||
};
|
||||
}, [isNative, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
const htmlNode = document.documentElement;
|
||||
htmlNode.classList.add(`theme-${effectiveTheme}`);
|
||||
htmlNode.style.setProperty('--saturation-factor', saturationFactor.toString());
|
||||
htmlNode.style.setProperty('--user-select', enableTextSelection ? 'auto' : 'none');
|
||||
htmlNode.style.setProperty('--font-size', `${fontSize}px`);
|
||||
htmlNode.style.setProperty('--chat-horizontal-padding', `${messageGutter}px`);
|
||||
htmlNode.style.setProperty('--message-group-spacing', `${messageGroupSpacing}px`);
|
||||
htmlNode.style.setProperty('dynamic-range-limit', hdrDisplayMode === HdrDisplayMode.FULL ? 'high' : 'standard');
|
||||
|
||||
if (alwaysUnderlineLinks) {
|
||||
htmlNode.style.setProperty('--link-decoration', 'underline');
|
||||
} else {
|
||||
htmlNode.style.removeProperty('--link-decoration');
|
||||
}
|
||||
|
||||
return () => {
|
||||
htmlNode.classList.remove(`theme-${effectiveTheme}`);
|
||||
htmlNode.style.removeProperty('--saturation-factor');
|
||||
htmlNode.style.removeProperty('--link-decoration');
|
||||
htmlNode.style.removeProperty('--user-select');
|
||||
htmlNode.style.removeProperty('--font-size');
|
||||
htmlNode.style.removeProperty('--chat-horizontal-padding');
|
||||
htmlNode.style.removeProperty('--message-group-spacing');
|
||||
htmlNode.style.removeProperty('dynamic-range-limit');
|
||||
};
|
||||
}, [
|
||||
effectiveTheme,
|
||||
saturationFactor,
|
||||
alwaysUnderlineLinks,
|
||||
enableTextSelection,
|
||||
fontSize,
|
||||
messageGutter,
|
||||
messageGroupSpacing,
|
||||
hdrDisplayMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const styleElementId = 'fluxer-custom-theme-style';
|
||||
const existing = document.getElementById(styleElementId) as HTMLStyleElement | null;
|
||||
|
||||
const css = customThemeCss?.trim() ?? '';
|
||||
|
||||
if (!css) {
|
||||
if (existing?.parentNode) {
|
||||
existing.parentNode.removeChild(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = existing ?? document.createElement('style');
|
||||
styleElement.id = styleElementId;
|
||||
styleElement.textContent = css;
|
||||
|
||||
if (!existing) {
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
}, [customThemeCss]);
|
||||
|
||||
return (
|
||||
<LayoutVariantProvider value={layoutVariantContextValue}>
|
||||
<SVGMasks />
|
||||
<RoomContext.Provider value={room ?? undefined}>
|
||||
{room && <RoomAudioRenderer />}
|
||||
<div ref={ringsContainerRef} className={styles.appContainer}>
|
||||
<FocusRingScope containerRef={ringsContainerRef}>
|
||||
<NativeTrafficLightsBackdrop variant={layoutVariant} />
|
||||
{isNative && !isMacOS && <NativeTitlebar platform={platform} />}
|
||||
{children}
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
<div ref={overlayScopeRef} className={styles.overlayScope}>
|
||||
<div
|
||||
id={QUICK_SWITCHER_PORTAL_ID}
|
||||
className={styles.quickSwitcherPortal}
|
||||
data-overlay-pass-through="true"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<GlobalOverlays />
|
||||
<IncomingCallManager />
|
||||
</div>
|
||||
</RoomContext.Provider>
|
||||
</LayoutVariantProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export const App = observer((): React.ReactElement => {
|
||||
const currentUser = UserStore.currentUser;
|
||||
const fatalError = RuntimeCrashStore.fatalError;
|
||||
|
||||
if (fatalError) {
|
||||
throw fatalError;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const initAutostart = async () => {
|
||||
const platform = await getNativePlatform();
|
||||
if (platform === 'macos') {
|
||||
void ensureAutostartDefaultEnabled();
|
||||
}
|
||||
};
|
||||
|
||||
void initAutostart();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void startDeepLinkHandling();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const detach = attachExternalLinkInterceptor();
|
||||
return () => detach?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
Sentry.setUser({
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: currentUser.email ?? undefined,
|
||||
});
|
||||
} else {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={{color: 'currentColor', weight: 'fill'}}>
|
||||
<DndContext>
|
||||
<RouterProvider router={router}>
|
||||
<AppWrapper>
|
||||
<Outlet />
|
||||
</AppWrapper>
|
||||
</RouterProvider>
|
||||
</DndContext>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
62
fluxer/fluxer_app/src/AppConstants.tsx
Normal file
62
fluxer/fluxer_app/src/AppConstants.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OAuth2Scope} from '@fluxer/constants/src/OAuth2Constants';
|
||||
import {isStatusType, normalizeStatus, type StatusType, StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||
import type {I18n, MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const OAuth2ScopeDescriptorsInternal: Record<OAuth2Scope, MessageDescriptor> = {
|
||||
identify: msg`Access your basic profile information (username, avatar, etc.)`,
|
||||
email: msg`View your email address`,
|
||||
guilds: msg`View the communities you are a member of`,
|
||||
connections: msg`View your connected accounts`,
|
||||
bot: msg`Add a bot to a community with requested permissions`,
|
||||
admin: msg`Access administrative endpoints`,
|
||||
};
|
||||
|
||||
export function getOAuth2ScopeDescription(i18n: I18n, scope: OAuth2Scope): string {
|
||||
return i18n._(OAuth2ScopeDescriptorsInternal[scope]);
|
||||
}
|
||||
|
||||
const StatusTypeToLabelDescriptorsInternal: Record<StatusType, MessageDescriptor> = {
|
||||
[StatusTypes.ONLINE]: msg`Online`,
|
||||
[StatusTypes.DND]: msg`Do Not Disturb`,
|
||||
[StatusTypes.IDLE]: msg`Idle`,
|
||||
[StatusTypes.INVISIBLE]: msg`Invisible`,
|
||||
[StatusTypes.OFFLINE]: msg`Offline`,
|
||||
};
|
||||
|
||||
export function getStatusTypeLabel(i18n: I18n, statusType: StatusType | string): string {
|
||||
const normalized = isStatusType(statusType) ? statusType : normalizeStatus(statusType);
|
||||
return i18n._(StatusTypeToLabelDescriptorsInternal[normalized]);
|
||||
}
|
||||
|
||||
const StatusTypeToDescriptionDescriptorsInternal: Record<StatusType, MessageDescriptor> = {
|
||||
[StatusTypes.ONLINE]: msg`Charged up on 1.21 gigawatts, ready to talk`,
|
||||
[StatusTypes.DND]: msg`In the zone, please do not disturb`,
|
||||
[StatusTypes.IDLE]: msg`Took the DeLorean out, but I'll be back in time`,
|
||||
[StatusTypes.INVISIBLE]: msg`Currently stuck in 1885, appearing offline`,
|
||||
[StatusTypes.OFFLINE]: msg`Currently stuck in 1885, appearing offline`,
|
||||
};
|
||||
|
||||
export function getStatusTypeDescription(i18n: I18n, statusType: StatusType | string): string {
|
||||
const normalized = isStatusType(statusType) ? statusType : normalizeStatus(statusType);
|
||||
return i18n._(StatusTypeToDescriptionDescriptorsInternal[normalized]);
|
||||
}
|
||||
53
fluxer/fluxer_app/src/Config.tsx
Normal file
53
fluxer/fluxer_app/src/Config.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as v from 'valibot';
|
||||
|
||||
const envSchema = v.object({
|
||||
PUBLIC_BUILD_SHA: v.nullish(v.string(), 'dev'),
|
||||
PUBLIC_BUILD_NUMBER: v.nullish(v.pipe(v.string(), v.transform(Number), v.number()), '0'),
|
||||
PUBLIC_BUILD_TIMESTAMP: v.nullish(
|
||||
v.pipe(v.string(), v.transform(Number), v.number()),
|
||||
`${Math.floor(Date.now() / 1000)}`,
|
||||
),
|
||||
PUBLIC_RELEASE_CHANNEL: v.nullish(v.picklist(['stable', 'canary', 'nightly']), 'nightly'),
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: v.nullish(v.string(), '/api'),
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: v.nullish(v.string()),
|
||||
PUBLIC_RELAY_DIRECTORY_URL: v.nullish(v.string()),
|
||||
});
|
||||
|
||||
const env = v.parse(envSchema, {
|
||||
PUBLIC_BUILD_SHA: import.meta.env.PUBLIC_BUILD_SHA,
|
||||
PUBLIC_BUILD_NUMBER: import.meta.env.PUBLIC_BUILD_NUMBER,
|
||||
PUBLIC_BUILD_TIMESTAMP: import.meta.env.PUBLIC_BUILD_TIMESTAMP,
|
||||
PUBLIC_RELEASE_CHANNEL: import.meta.env.PUBLIC_RELEASE_CHANNEL,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT,
|
||||
PUBLIC_RELAY_DIRECTORY_URL: import.meta.env.PUBLIC_RELAY_DIRECTORY_URL,
|
||||
});
|
||||
|
||||
export default {
|
||||
PUBLIC_BUILD_SHA: env.PUBLIC_BUILD_SHA,
|
||||
PUBLIC_BUILD_NUMBER: env.PUBLIC_BUILD_NUMBER,
|
||||
PUBLIC_BUILD_TIMESTAMP: env.PUBLIC_BUILD_TIMESTAMP,
|
||||
PUBLIC_RELEASE_CHANNEL: env.PUBLIC_RELEASE_CHANNEL,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT ?? env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
PUBLIC_RELAY_DIRECTORY_URL: env.PUBLIC_RELAY_DIRECTORY_URL ?? null,
|
||||
};
|
||||
258
fluxer/fluxer_app/src/Endpoints.tsx
Normal file
258
fluxer/fluxer_app/src/Endpoints.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
export const Endpoints = {
|
||||
INSTANCE: '/instance',
|
||||
|
||||
AUTH_LOGIN: '/auth/login',
|
||||
AUTH_LOGIN_MFA_TOTP: '/auth/login/mfa/totp',
|
||||
AUTH_LOGIN_MFA_SMS_SEND: '/auth/login/mfa/sms/send',
|
||||
AUTH_LOGIN_MFA_SMS: '/auth/login/mfa/sms',
|
||||
AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS: '/auth/login/mfa/webauthn/authentication-options',
|
||||
AUTH_LOGIN_MFA_WEBAUTHN: '/auth/login/mfa/webauthn',
|
||||
AUTH_WEBAUTHN_OPTIONS: '/auth/webauthn/authentication-options',
|
||||
AUTH_WEBAUTHN_AUTHENTICATE: '/auth/webauthn/authenticate',
|
||||
AUTH_LOGOUT: '/auth/logout',
|
||||
AUTH_REGISTER: '/auth/register',
|
||||
AUTH_USERNAME_SUGGESTIONS: '/auth/username-suggestions',
|
||||
AUTH_SESSIONS: '/auth/sessions',
|
||||
AUTH_SESSIONS_LOGOUT: '/auth/sessions/logout',
|
||||
AUTH_HANDOFF_INITIATE: '/auth/handoff/initiate',
|
||||
AUTH_HANDOFF_COMPLETE: '/auth/handoff/complete',
|
||||
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
|
||||
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
|
||||
AUTH_FORGOT_PASSWORD: '/auth/forgot',
|
||||
AUTH_RESET_PASSWORD: '/auth/reset',
|
||||
AUTH_EMAIL_REVERT: '/auth/email-revert',
|
||||
AUTH_VERIFY_EMAIL: '/auth/verify',
|
||||
AUTH_RESEND_VERIFICATION: '/auth/verify/resend',
|
||||
AUTH_AUTHORIZE_IP: '/auth/authorize-ip',
|
||||
AUTH_IP_AUTHORIZATION_RESEND: '/auth/ip-authorization/resend',
|
||||
AUTH_IP_AUTHORIZATION_POLL: (ticket: string) => {
|
||||
const params = new URLSearchParams({ticket});
|
||||
return `/auth/ip-authorization/poll?${params}`;
|
||||
},
|
||||
AUTH_SSO_START: '/auth/sso/start',
|
||||
AUTH_SSO_COMPLETE: '/auth/sso/complete',
|
||||
|
||||
SUDO_MFA_METHODS: '/users/@me/sudo/mfa-methods',
|
||||
SUDO_SMS_SEND: '/users/@me/sudo/mfa/sms/send',
|
||||
SUDO_WEBAUTHN_OPTIONS: '/users/@me/sudo/webauthn/authentication-options',
|
||||
|
||||
OAUTH_AUTHORIZE: '/oauth2/authorize',
|
||||
OAUTH_CONSENT: '/oauth2/authorize/consent',
|
||||
|
||||
OAUTH_APPLICATIONS: '/oauth2/applications',
|
||||
OAUTH_APPLICATIONS_LIST: '/oauth2/applications/@me',
|
||||
OAUTH_APPLICATION: (applicationId: string) => `/oauth2/applications/${applicationId}`,
|
||||
OAUTH_APPLICATION_BOT_TOKEN_RESET: (applicationId: string) => `/oauth2/applications/${applicationId}/bot/reset-token`,
|
||||
OAUTH_APPLICATION_CLIENT_SECRET_RESET: (applicationId: string) =>
|
||||
`/oauth2/applications/${applicationId}/client-secret/reset`,
|
||||
OAUTH_APPLICATION_BOT_PROFILE: (applicationId: string) => `/oauth2/applications/${applicationId}/bot`,
|
||||
OAUTH_PUBLIC_APPLICATION: (applicationId: string) => `/oauth2/applications/${applicationId}/public`,
|
||||
|
||||
OAUTH_AUTHORIZATIONS: '/oauth2/@me/authorizations',
|
||||
OAUTH_AUTHORIZATION: (applicationId: string) => `/oauth2/@me/authorizations/${applicationId}`,
|
||||
|
||||
CHANNEL: (channelId: string) => `/channels/${channelId}`,
|
||||
CHANNEL_ATTACHMENTS: (channelId: string) => `/channels/${channelId}/attachments`,
|
||||
CHANNEL_INVITES: (channelId: string) => `/channels/${channelId}/invites`,
|
||||
CHANNEL_RECIPIENT: (channelId: string, userId: string) => `/channels/${channelId}/recipients/${userId}`,
|
||||
CHANNEL_MESSAGES: (channelId: string) => `/channels/${channelId}/messages`,
|
||||
CHANNEL_MESSAGE_SCHEDULE: (channelId: string) => `/channels/${channelId}/messages/schedule`,
|
||||
CHANNEL_MESSAGE: (channelId: string, messageId: string) => `/channels/${channelId}/messages/${messageId}`,
|
||||
CHANNEL_MESSAGE_ATTACHMENT: (channelId: string, messageId: string, attachmentId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/attachments/${attachmentId}`,
|
||||
CHANNEL_MESSAGE_ACK: (channelId: string, messageId: string) => `/channels/${channelId}/messages/${messageId}/ack`,
|
||||
CHANNEL_MESSAGE_REACTION: (channelId: string, messageId: string, emoji: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions/${emoji}`,
|
||||
CHANNEL_MESSAGE_REACTION_QUERY: (channelId: string, messageId: string, emoji: string, query = ME) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions/${emoji}/${query}`,
|
||||
CHANNEL_MESSAGE_REACTIONS: (channelId: string, messageId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions`,
|
||||
CHANNEL_MESSAGES_ACK: (channelId: string) => `/channels/${channelId}/messages/ack`,
|
||||
CHANNEL_PIN: (channelId: string, messageId: string) => `/channels/${channelId}/pins/${messageId}`,
|
||||
CHANNEL_PINS: (channelId: string) => `/channels/${channelId}/messages/pins`,
|
||||
CHANNEL_PINS_ACK: (channelId: string) => `/channels/${channelId}/pins/ack`,
|
||||
CHANNEL_TYPING: (channelId: string) => `/channels/${channelId}/typing`,
|
||||
CHANNEL_WEBHOOKS: (channelId: string) => `/channels/${channelId}/webhooks`,
|
||||
CHANNEL_RTC_REGIONS: (channelId: string) => `/channels/${channelId}/rtc-regions`,
|
||||
CHANNEL_CALL: (channelId: string) => `/channels/${channelId}/call`,
|
||||
CHANNEL_CALL_RING: (channelId: string) => `/channels/${channelId}/call/ring`,
|
||||
CHANNEL_CALL_STOP_RINGING: (channelId: string) => `/channels/${channelId}/call/stop-ringing`,
|
||||
|
||||
GUILDS: '/guilds',
|
||||
GUILD: (guildId: string) => `/guilds/${guildId}`,
|
||||
GUILD_CHANNELS: (guildId: string) => `/guilds/${guildId}/channels`,
|
||||
GUILD_MEMBER: (guildId: string, query = ME) => `/guilds/${guildId}/members/${query}`,
|
||||
GUILD_MEMBERS: (guildId: string) => `/guilds/${guildId}/members`,
|
||||
GUILD_MEMBERS_SEARCH: (guildId: string) => `/guilds/${guildId}/members-search`,
|
||||
GUILD_MEMBER_ROLE: (guildId: string, userId: string, roleId: string) =>
|
||||
`/guilds/${guildId}/members/${userId}/roles/${roleId}`,
|
||||
GUILD_BAN: (guildId: string, userId: string) => `/guilds/${guildId}/bans/${userId}`,
|
||||
GUILD_BANS: (guildId: string) => `/guilds/${guildId}/bans`,
|
||||
GUILD_ROLE: (guildId: string, roleId: string) => `/guilds/${guildId}/roles/${roleId}`,
|
||||
GUILD_ROLES: (guildId: string) => `/guilds/${guildId}/roles`,
|
||||
GUILD_ROLE_HOIST_POSITIONS: (guildId: string) => `/guilds/${guildId}/roles/hoist-positions`,
|
||||
GUILD_DELETE: (guildId: string) => `/guilds/${guildId}/delete`,
|
||||
GUILD_TRANSFER_OWNERSHIP: (guildId: string) => `/guilds/${guildId}/transfer-ownership`,
|
||||
GUILD_TEXT_CHANNEL_FLEXIBLE_NAMES: (guildId: string) => `/guilds/${guildId}/text-channel-flexible-names`,
|
||||
GUILD_DETACHED_BANNER: (guildId: string) => `/guilds/${guildId}/detached-banner`,
|
||||
GUILD_EMOJI: (guildId: string, emojiId: string) => `/guilds/${guildId}/emojis/${emojiId}`,
|
||||
GUILD_EMOJIS: (guildId: string) => `/guilds/${guildId}/emojis`,
|
||||
GUILD_STICKER: (guildId: string, stickerId: string) => `/guilds/${guildId}/stickers/${stickerId}`,
|
||||
GUILD_STICKERS: (guildId: string) => `/guilds/${guildId}/stickers`,
|
||||
GUILD_INVITES: (guildId: string) => `/guilds/${guildId}/invites`,
|
||||
GUILD_VANITY_URL: (guildId: string) => `/guilds/${guildId}/vanity-url`,
|
||||
GUILD_WEBHOOKS: (guildId: string) => `/guilds/${guildId}/webhooks`,
|
||||
GUILD_AUDIT_LOGS: (guildId: string) => `/guilds/${guildId}/audit-logs`,
|
||||
|
||||
INVITE: (code: string) => `/invites/${code}`,
|
||||
|
||||
GIFT: (code: string) => `/gifts/${code}`,
|
||||
GIFT_REDEEM: (code: string) => `/gifts/${code}/redeem`,
|
||||
USER_GIFTS: '/users/@me/gifts',
|
||||
|
||||
PREMIUM_VISIONARY_REJOIN: '/premium/visionary/rejoin',
|
||||
PREMIUM_OPERATOR_REJOIN: '/premium/operator/rejoin',
|
||||
PREMIUM_PRICE_IDS: '/premium/price-ids',
|
||||
PREMIUM_CUSTOMER_PORTAL: '/premium/customer-portal',
|
||||
PREMIUM_CANCEL_SUBSCRIPTION: '/premium/cancel-subscription',
|
||||
PREMIUM_REACTIVATE_SUBSCRIPTION: '/premium/reactivate-subscription',
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION: '/stripe/checkout/subscription',
|
||||
STRIPE_CHECKOUT_GIFT: '/stripe/checkout/gift',
|
||||
|
||||
SWISH_AVAILABLE: '/swish/available',
|
||||
SWISH_PRICES: '/swish/prices',
|
||||
SWISH_CHECKOUT: '/swish/checkout',
|
||||
SWISH_PAYMENT: (paymentId: string) => `/swish/payments/${paymentId}`,
|
||||
SWISH_PAYMENTS: '/swish/payments',
|
||||
|
||||
READ_STATES_ACK_BULK: '/read-states/ack-bulk',
|
||||
|
||||
DSA_REPORT_EMAIL_SEND: '/reports/dsa/email/send',
|
||||
DSA_REPORT_EMAIL_VERIFY: '/reports/dsa/email/verify',
|
||||
DSA_REPORT_CREATE: '/reports/dsa',
|
||||
|
||||
KLIPY_FEATURED: '/klipy/featured',
|
||||
KLIPY_REGISTER_SHARE: '/klipy/register-share',
|
||||
KLIPY_SEARCH: '/klipy/search',
|
||||
KLIPY_SUGGEST: '/klipy/suggest',
|
||||
KLIPY_TRENDING_GIFS: '/klipy/trending-gifs',
|
||||
|
||||
TENOR_FEATURED: '/tenor/featured',
|
||||
TENOR_REGISTER_SHARE: '/tenor/register-share',
|
||||
TENOR_SEARCH: '/tenor/search',
|
||||
TENOR_SUGGEST: '/tenor/suggest',
|
||||
TENOR_TRENDING_GIFS: '/tenor/trending-gifs',
|
||||
|
||||
USER_CHANNELS: '/users/@me/channels',
|
||||
USER_CHANNEL_PIN: (channelId: string) => `/users/@me/channels/${channelId}/pin`,
|
||||
USER_GUILDS_LIST: '/users/@me/guilds',
|
||||
USER_GUILDS: (guildId: string) => `/users/@me/guilds/${guildId}`,
|
||||
USER_ME: '/users/@me',
|
||||
USER_MENTION: (messageId: string) => `/users/@me/mentions/${messageId}`,
|
||||
USER_MENTIONS: '/users/@me/mentions',
|
||||
USER_MFA_BACKUP_CODES: '/users/@me/mfa/backup-codes',
|
||||
USER_MFA_TOTP_DISABLE: '/users/@me/mfa/totp/disable',
|
||||
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
|
||||
USER_MFA_SMS_ENABLE: '/users/@me/mfa/sms/enable',
|
||||
USER_MFA_SMS_DISABLE: '/users/@me/mfa/sms/disable',
|
||||
USER_AUTHORIZED_IPS: '/users/@me/authorized-ips',
|
||||
USER_MFA_WEBAUTHN_CREDENTIALS: '/users/@me/mfa/webauthn/credentials',
|
||||
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
|
||||
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
|
||||
USER_PHONE_VERIFY: '/users/@me/phone/verify',
|
||||
USER_PHONE: '/users/@me/phone',
|
||||
USER_EMAIL_CHANGE_START: '/users/@me/email-change/start',
|
||||
USER_EMAIL_CHANGE_RESEND_ORIGINAL: '/users/@me/email-change/resend-original',
|
||||
USER_EMAIL_CHANGE_VERIFY_ORIGINAL: '/users/@me/email-change/verify-original',
|
||||
USER_EMAIL_CHANGE_REQUEST_NEW: '/users/@me/email-change/request-new',
|
||||
USER_EMAIL_CHANGE_RESEND_NEW: '/users/@me/email-change/resend-new',
|
||||
USER_EMAIL_CHANGE_VERIFY_NEW: '/users/@me/email-change/verify-new',
|
||||
USER_EMAIL_CHANGE_BOUNCED_REQUEST_NEW: '/users/@me/email-change/bounced/request-new',
|
||||
USER_EMAIL_CHANGE_BOUNCED_RESEND_NEW: '/users/@me/email-change/bounced/resend-new',
|
||||
USER_EMAIL_CHANGE_BOUNCED_VERIFY_NEW: '/users/@me/email-change/bounced/verify-new',
|
||||
USER_PASSWORD_CHANGE_START: '/users/@me/password-change/start',
|
||||
USER_PASSWORD_CHANGE_RESEND: '/users/@me/password-change/resend',
|
||||
USER_PASSWORD_CHANGE_VERIFY: '/users/@me/password-change/verify',
|
||||
USER_PASSWORD_CHANGE_COMPLETE: '/users/@me/password-change/complete',
|
||||
USER_DISABLE: '/users/@me/disable',
|
||||
USER_DELETE: '/users/@me/delete',
|
||||
USER_BULK_DELETE_MESSAGES: '/users/@me/messages/delete',
|
||||
USER_BULK_DELETE_MESSAGES_TEST: '/users/@me/messages/delete/test',
|
||||
USER_PREMIUM_RESET: '/users/@me/premium/reset',
|
||||
USER_HARVEST: '/users/@me/harvest',
|
||||
USER_HARVEST_LATEST: '/users/@me/harvest/latest',
|
||||
USER_HARVEST_STATUS: (harvestId: string) => `/users/@me/harvest/${harvestId}`,
|
||||
USER_PRELOAD_MESSAGES: '/users/@me/preload-messages',
|
||||
USER_NOTE: (userId: string) => `/users/@me/notes/${userId}`,
|
||||
USER_CHECK_TAG: '/users/check-tag',
|
||||
USER_PROFILE: (query = ME) => `/users/${query}/profile`,
|
||||
USER_RELATIONSHIP: (userId: string) => `/users/@me/relationships/${userId}`,
|
||||
USER_RELATIONSHIPS: '/users/@me/relationships',
|
||||
USER_THEMES: '/users/@me/themes',
|
||||
USER_SAVED_MESSAGE: (messageId: string) => `/users/@me/saved-messages/${messageId}`,
|
||||
USER_SAVED_MESSAGES: '/users/@me/saved-messages',
|
||||
USER_SCHEDULED_MESSAGES: '/users/@me/scheduled-messages',
|
||||
USER_SCHEDULED_MESSAGE: (messageId: string) => `/users/@me/scheduled-messages/${messageId}`,
|
||||
USER_FAVORITE_MEMES: (query = ME) => `/users/${query}/memes`,
|
||||
USER_FAVORITE_MEME: (query = ME, memeId: string) => `/users/${query}/memes/${memeId}`,
|
||||
CHANNEL_MESSAGE_FAVORITE_MEMES: (channelId: string, messageId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/memes`,
|
||||
STREAM_PREVIEW: (streamKey: string) => `/streams/${streamKey}/preview`,
|
||||
USER_SETTINGS: '/users/@me/settings',
|
||||
USER_GUILD_SETTINGS_ME: '/users/@me/guilds/@me/settings',
|
||||
USER_GUILD_SETTINGS: (guildId: string) => `/users/@me/guilds/${guildId}/settings`,
|
||||
USER_PUSH_SUBSCRIBE: '/users/@me/push/subscribe',
|
||||
USER_PUSH_SUBSCRIPTIONS: '/users/@me/push/subscriptions',
|
||||
USER_PUSH_SUBSCRIPTION: (subscriptionId: string) => `/users/@me/push/subscriptions/${subscriptionId}`,
|
||||
PACKS: '/packs',
|
||||
PACK: (packId: string) => `/packs/${packId}`,
|
||||
PACK_CREATE: (packType: 'emoji' | 'sticker') => `/packs/${packType}`,
|
||||
PACK_INSTALL: (packId: string) => `/packs/${packId}/install`,
|
||||
PACK_EMOJIS: (packId: string) => `/packs/emojis/${packId}`,
|
||||
PACK_EMOJI: (packId: string, emojiId: string) => `/packs/emojis/${packId}/${emojiId}`,
|
||||
PACK_EMOJI_BULK: (packId: string) => `/packs/emojis/${packId}/bulk`,
|
||||
PACK_STICKERS: (packId: string) => `/packs/stickers/${packId}`,
|
||||
PACK_STICKER: (packId: string, stickerId: string) => `/packs/stickers/${packId}/${stickerId}`,
|
||||
PACK_STICKERS_BULK: (packId: string) => `/packs/stickers/${packId}/bulk`,
|
||||
PACK_INVITES: (packId: string) => `/packs/${packId}/invites`,
|
||||
|
||||
WEBHOOK: (webhookId: string) => `/webhooks/${webhookId}`,
|
||||
|
||||
REPORT_MESSAGE: '/reports/message',
|
||||
REPORT_USER: '/reports/user',
|
||||
REPORT_GUILD: '/reports/guild',
|
||||
|
||||
DISCOVERY_GUILDS: '/discovery/guilds',
|
||||
DISCOVERY_CATEGORIES: '/discovery/categories',
|
||||
DISCOVERY_JOIN: (guildId: string) => `/discovery/guilds/${guildId}/join`,
|
||||
GUILD_DISCOVERY: (guildId: string) => `/guilds/${guildId}/discovery`,
|
||||
|
||||
CONNECTIONS: '/users/@me/connections',
|
||||
CONNECTIONS_VERIFY_AND_CREATE: '/users/@me/connections/verify',
|
||||
BLUESKY_AUTHORIZE: '/users/@me/connections/bluesky/authorize',
|
||||
CONNECTION: (type: string, connectionId: string) => `/users/@me/connections/${type}/${connectionId}`,
|
||||
CONNECTION_VERIFY: (type: string, connectionId: string) => `/users/@me/connections/${type}/${connectionId}/verify`,
|
||||
CONNECTIONS_REORDER: '/users/@me/connections/reorder',
|
||||
} as const;
|
||||
260
fluxer/fluxer_app/src/I18n.tsx
Normal file
260
fluxer/fluxer_app/src/I18n.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import AppStorage from '@app/lib/AppStorage';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {getNativeLocaleIdentifier} from '@app/lib/Platform';
|
||||
import {messages as messagesAr} from '@app/locales/ar/messages.mjs';
|
||||
import {messages as messagesBg} from '@app/locales/bg/messages.mjs';
|
||||
import {messages as messagesCs} from '@app/locales/cs/messages.mjs';
|
||||
import {messages as messagesDa} from '@app/locales/da/messages.mjs';
|
||||
import {messages as messagesDe} from '@app/locales/de/messages.mjs';
|
||||
import {messages as messagesEl} from '@app/locales/el/messages.mjs';
|
||||
import {messages as messagesEnGB} from '@app/locales/en-GB/messages.mjs';
|
||||
import {messages as messagesEnUS} from '@app/locales/en-US/messages.mjs';
|
||||
import {messages as messagesEs419} from '@app/locales/es-419/messages.mjs';
|
||||
import {messages as messagesEsES} from '@app/locales/es-ES/messages.mjs';
|
||||
import {messages as messagesFi} from '@app/locales/fi/messages.mjs';
|
||||
import {messages as messagesFr} from '@app/locales/fr/messages.mjs';
|
||||
import {messages as messagesHe} from '@app/locales/he/messages.mjs';
|
||||
import {messages as messagesHi} from '@app/locales/hi/messages.mjs';
|
||||
import {messages as messagesHr} from '@app/locales/hr/messages.mjs';
|
||||
import {messages as messagesHu} from '@app/locales/hu/messages.mjs';
|
||||
import {messages as messagesId} from '@app/locales/id/messages.mjs';
|
||||
import {messages as messagesIt} from '@app/locales/it/messages.mjs';
|
||||
import {messages as messagesJa} from '@app/locales/ja/messages.mjs';
|
||||
import {messages as messagesKo} from '@app/locales/ko/messages.mjs';
|
||||
import {messages as messagesLt} from '@app/locales/lt/messages.mjs';
|
||||
import {messages as messagesNl} from '@app/locales/nl/messages.mjs';
|
||||
import {messages as messagesNo} from '@app/locales/no/messages.mjs';
|
||||
import {messages as messagesPl} from '@app/locales/pl/messages.mjs';
|
||||
import {messages as messagesPtBR} from '@app/locales/pt-BR/messages.mjs';
|
||||
import {messages as messagesRo} from '@app/locales/ro/messages.mjs';
|
||||
import {messages as messagesRu} from '@app/locales/ru/messages.mjs';
|
||||
import {messages as messagesSvSE} from '@app/locales/sv-SE/messages.mjs';
|
||||
import {messages as messagesTh} from '@app/locales/th/messages.mjs';
|
||||
import {messages as messagesTr} from '@app/locales/tr/messages.mjs';
|
||||
import {messages as messagesUk} from '@app/locales/uk/messages.mjs';
|
||||
import {messages as messagesVi} from '@app/locales/vi/messages.mjs';
|
||||
import {messages as messagesZhCN} from '@app/locales/zh-CN/messages.mjs';
|
||||
import {messages as messagesZhTW} from '@app/locales/zh-TW/messages.mjs';
|
||||
import {i18n, type Messages} from '@lingui/core';
|
||||
|
||||
const supportedLocales = [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-ES',
|
||||
'es-419',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
] as const;
|
||||
|
||||
type LocaleCode = (typeof supportedLocales)[number];
|
||||
const DEFAULT_LOCALE: LocaleCode = 'en-US';
|
||||
const supportedLocaleSet = new Set<LocaleCode>(supportedLocales);
|
||||
|
||||
const logger = new Logger('i18n');
|
||||
|
||||
const LANGUAGE_OVERRIDES: Record<string, LocaleCode> = {
|
||||
en: 'en-US',
|
||||
};
|
||||
|
||||
type LocaleLoader = () => {messages: Messages};
|
||||
|
||||
const loaders: Record<LocaleCode, LocaleLoader> = {
|
||||
ar: () => ({messages: messagesAr}),
|
||||
bg: () => ({messages: messagesBg}),
|
||||
cs: () => ({messages: messagesCs}),
|
||||
da: () => ({messages: messagesDa}),
|
||||
de: () => ({messages: messagesDe}),
|
||||
el: () => ({messages: messagesEl}),
|
||||
'en-GB': () => ({messages: messagesEnGB}),
|
||||
'en-US': () => ({messages: messagesEnUS}),
|
||||
'es-ES': () => ({messages: messagesEsES}),
|
||||
'es-419': () => ({messages: messagesEs419}),
|
||||
fi: () => ({messages: messagesFi}),
|
||||
fr: () => ({messages: messagesFr}),
|
||||
he: () => ({messages: messagesHe}),
|
||||
hi: () => ({messages: messagesHi}),
|
||||
hr: () => ({messages: messagesHr}),
|
||||
hu: () => ({messages: messagesHu}),
|
||||
id: () => ({messages: messagesId}),
|
||||
it: () => ({messages: messagesIt}),
|
||||
ja: () => ({messages: messagesJa}),
|
||||
ko: () => ({messages: messagesKo}),
|
||||
lt: () => ({messages: messagesLt}),
|
||||
nl: () => ({messages: messagesNl}),
|
||||
no: () => ({messages: messagesNo}),
|
||||
pl: () => ({messages: messagesPl}),
|
||||
'pt-BR': () => ({messages: messagesPtBR}),
|
||||
ro: () => ({messages: messagesRo}),
|
||||
ru: () => ({messages: messagesRu}),
|
||||
'sv-SE': () => ({messages: messagesSvSE}),
|
||||
th: () => ({messages: messagesTh}),
|
||||
tr: () => ({messages: messagesTr}),
|
||||
uk: () => ({messages: messagesUk}),
|
||||
vi: () => ({messages: messagesVi}),
|
||||
'zh-CN': () => ({messages: messagesZhCN}),
|
||||
'zh-TW': () => ({messages: messagesZhTW}),
|
||||
};
|
||||
|
||||
function formatLocaleValue(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const segments = trimmed.split(/[-_]/).filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const language = segments[0].toLowerCase();
|
||||
if (segments.length === 1) {
|
||||
return language;
|
||||
}
|
||||
|
||||
const region = segments
|
||||
.slice(1)
|
||||
.map((segment) => segment.toUpperCase())
|
||||
.join('-');
|
||||
|
||||
return `${language}-${region}`;
|
||||
}
|
||||
|
||||
function normalizeLocale(value?: string | null): LocaleCode {
|
||||
if (!value) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const formatted = formatLocaleValue(value);
|
||||
if (!formatted) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
if (supportedLocaleSet.has(formatted as LocaleCode)) {
|
||||
return formatted as LocaleCode;
|
||||
}
|
||||
|
||||
const [language] = formatted.split('-');
|
||||
if (!language) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const override = LANGUAGE_OVERRIDES[language];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
const fallback = supportedLocales.find((code) => code.split('-')[0].toLowerCase() === language);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
function detectBrowserLocale(): string | null {
|
||||
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
||||
return navigator.languages[0];
|
||||
}
|
||||
|
||||
return navigator.language ?? null;
|
||||
}
|
||||
|
||||
function detectPreferredLocale(forceLocale?: string): LocaleCode {
|
||||
if (forceLocale) {
|
||||
return normalizeLocale(forceLocale);
|
||||
}
|
||||
|
||||
const storedLocale = AppStorage.getItem('locale');
|
||||
if (storedLocale) {
|
||||
return normalizeLocale(storedLocale);
|
||||
}
|
||||
|
||||
const nativeLocale = getNativeLocaleIdentifier();
|
||||
if (nativeLocale) {
|
||||
return normalizeLocale(nativeLocale);
|
||||
}
|
||||
|
||||
const browserLocale = detectBrowserLocale();
|
||||
if (browserLocale) {
|
||||
return normalizeLocale(browserLocale);
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function loadLocaleCatalog(localeCode: string): LocaleCode {
|
||||
const normalized = normalizeLocale(localeCode);
|
||||
const {messages} = loaders[normalized]();
|
||||
i18n.loadAndActivate({locale: normalized, messages});
|
||||
AppStorage.setItem('locale', normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
let initPromise: Promise<typeof i18n> | null = null;
|
||||
|
||||
export async function initI18n(forceLocale?: string) {
|
||||
if (!initPromise) {
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
const localeToLoad = detectPreferredLocale(forceLocale);
|
||||
loadLocaleCatalog(localeToLoad);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize i18n, falling back to default locale', error);
|
||||
loadLocaleCatalog(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
return i18n;
|
||||
})();
|
||||
}
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
36
fluxer/fluxer_app/src/Router.tsx
Normal file
36
fluxer/fluxer_app/src/Router.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createRouter} from '@app/lib/router/Core';
|
||||
import {buildRoutes} from '@app/router/routes/Routes';
|
||||
import NavigationSideEffectsStore from '@app/stores/NavigationSideEffectsStore';
|
||||
import NavigationStore from '@app/stores/NavigationStore';
|
||||
import * as RouterUtils from '@app/utils/RouterUtils';
|
||||
|
||||
const routes = buildRoutes();
|
||||
|
||||
export const router = createRouter({
|
||||
routes,
|
||||
history: RouterUtils.getHistory() ?? undefined,
|
||||
notFoundRouteId: '__notFound',
|
||||
scrollRestoration: 'top',
|
||||
});
|
||||
|
||||
NavigationStore.initialize(router);
|
||||
NavigationSideEffectsStore.initialize();
|
||||
92
fluxer/fluxer_app/src/Routes.tsx
Normal file
92
fluxer/fluxer_app/src/Routes.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {marketingUrl} from '@app/utils/UrlUtils';
|
||||
|
||||
export const Routes = {
|
||||
HOME: '/',
|
||||
LOGIN: '/login',
|
||||
REGISTER: '/register',
|
||||
FORGOT_PASSWORD: '/forgot',
|
||||
RESET_PASSWORD: '/reset',
|
||||
VERIFY_EMAIL: '/verify',
|
||||
AUTHORIZE_IP: '/authorize-ip',
|
||||
EMAIL_REVERT: '/wasntme',
|
||||
PENDING: '/pending',
|
||||
OAUTH_AUTHORIZE: '/oauth2/authorize',
|
||||
|
||||
INVITE_REGISTER: '/invite/:code',
|
||||
INVITE_LOGIN: '/invite/:code/login',
|
||||
GIFT_REGISTER: '/gift/:code',
|
||||
GIFT_LOGIN: '/gift/:code/login',
|
||||
THEME_REGISTER: '/theme/:themeId',
|
||||
THEME_LOGIN: '/theme/:themeId/login',
|
||||
|
||||
ME: '/channels/@me',
|
||||
FAVORITES: '/channels/@favorites',
|
||||
BOOKMARKS: '/bookmarks',
|
||||
MENTIONS: '/mentions',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
YOU: '/you',
|
||||
REPORT: '/report',
|
||||
PREMIUM_CALLBACK: '/premium-callback',
|
||||
CONNECTION_CALLBACK: '/connection-callback',
|
||||
|
||||
terms: () => marketingUrl('terms'),
|
||||
privacy: () => marketingUrl('privacy'),
|
||||
guidelines: () => marketingUrl('guidelines'),
|
||||
careers: () => marketingUrl('careers'),
|
||||
partners: () => marketingUrl('partners'),
|
||||
bugs: () => marketingUrl('bugs'),
|
||||
plutonium: () => marketingUrl('plutonium'),
|
||||
help: () => marketingUrl('help'),
|
||||
helpArticle: (slug: string) => marketingUrl(`help/${slug}`),
|
||||
|
||||
dmChannel: (channelId: string) => `/channels/@me/${channelId}`,
|
||||
favoritesChannel: (channelId: string) => `/channels/@favorites/${channelId}`,
|
||||
guildMembers: (guildId: string) => `/channels/${guildId}/members`,
|
||||
guildChannel: (guildId: string, channelId?: string) =>
|
||||
channelId ? `/channels/${guildId}/${channelId}` : `/channels/${guildId}`,
|
||||
channelMessage: (guildId: string, channelId: string, messageId: string) =>
|
||||
`${Routes.guildChannel(guildId, channelId)}/${messageId}`,
|
||||
dmChannelMessage: (channelId: string, messageId: string) => `${Routes.dmChannel(channelId)}/${messageId}`,
|
||||
favoritesChannelMessage: (channelId: string, messageId: string) =>
|
||||
`${Routes.favoritesChannel(channelId)}/${messageId}`,
|
||||
inviteRegister: (code: string) => `/invite/${code}`,
|
||||
inviteLogin: (code: string) => `/invite/${code}/login`,
|
||||
giftRegister: (code: string) => `/gift/${code}`,
|
||||
giftLogin: (code: string) => `/gift/${code}/login`,
|
||||
theme: (themeId: string) => `/theme/${themeId}`,
|
||||
themeRegister: (themeId: string) => `/theme/${themeId}`,
|
||||
themeLogin: (themeId: string) => `/theme/${themeId}/login`,
|
||||
|
||||
isSpecialPage: (pathname: string) =>
|
||||
pathname === Routes.BOOKMARKS ||
|
||||
pathname === Routes.MENTIONS ||
|
||||
pathname === Routes.NOTIFICATIONS ||
|
||||
pathname === Routes.YOU,
|
||||
|
||||
isDMRoute: (pathname: string) => pathname.startsWith('/channels/@me'),
|
||||
isFavoritesRoute: (pathname: string) => pathname.startsWith('/channels/@favorites'),
|
||||
isChannelRoute: (pathname: string) => pathname.startsWith('/channels/'),
|
||||
isGuildChannelRoute: (pathname: string) =>
|
||||
pathname.startsWith('/channels/') &&
|
||||
!pathname.startsWith('/channels/@me') &&
|
||||
!pathname.startsWith('/channels/@favorites'),
|
||||
} as const;
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import AccessibilityStore, {type AccessibilitySettings} from '@app/stores/AccessibilityStore';
|
||||
|
||||
export function update(settings: Partial<AccessibilitySettings>): void {
|
||||
AccessibilityStore.updateSettings(settings);
|
||||
}
|
||||
65
fluxer/fluxer_app/src/actions/AuthSessionActionCreators.tsx
Normal file
65
fluxer/fluxer_app/src/actions/AuthSessionActionCreators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import AuthSessionStore from '@app/stores/AuthSessionStore';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
const logger = new Logger('AuthSessionsService');
|
||||
|
||||
export async function fetch(): Promise<void> {
|
||||
logger.debug('Fetching authentication sessions');
|
||||
AuthSessionStore.fetchPending();
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<AuthSessionResponse>>({url: Endpoints.AUTH_SESSIONS, retries: 2});
|
||||
const sessions = response.body ?? [];
|
||||
logger.info(`Fetched ${sessions.length} authentication sessions`);
|
||||
AuthSessionStore.fetchSuccess(sessions);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch authentication sessions:', error);
|
||||
AuthSessionStore.fetchError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(sessionIdHashes: Array<string>): Promise<void> {
|
||||
if (!sessionIdHashes.length) {
|
||||
logger.warn('Attempted to logout with empty session list');
|
||||
return;
|
||||
}
|
||||
logger.debug(`Logging out ${sessionIdHashes.length} sessions`);
|
||||
AuthSessionStore.logoutPending();
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_SESSIONS_LOGOUT,
|
||||
body: {session_id_hashes: sessionIdHashes},
|
||||
timeout: 10000,
|
||||
retries: 0,
|
||||
});
|
||||
logger.info(`Successfully logged out ${sessionIdHashes.length} sessions`);
|
||||
AuthSessionStore.logoutSuccess(sessionIdHashes);
|
||||
} catch (error) {
|
||||
logger.error('Failed to log out sessions:', error);
|
||||
AuthSessionStore.logoutError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
635
fluxer/fluxer_app/src/actions/AuthenticationActionCreators.tsx
Normal file
635
fluxer/fluxer_app/src/actions/AuthenticationActionCreators.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import type {UserData} from '@app/lib/AccountStorage';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import AuthenticationStore from '@app/stores/AuthenticationStore';
|
||||
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
|
||||
import {getApiErrorCode} from '@app/utils/ApiErrorUtils';
|
||||
import {isDesktop} from '@app/utils/NativeUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
|
||||
const logger = new Logger('AuthService');
|
||||
|
||||
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
|
||||
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'X-Fluxer-Platform': getPlatformHeaderValue(),
|
||||
...(headers ?? {}),
|
||||
});
|
||||
|
||||
export const VerificationResult = {
|
||||
SUCCESS: 'SUCCESS',
|
||||
EXPIRED_TOKEN: 'EXPIRED_TOKEN',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
} as const;
|
||||
export type VerificationResult = ValueOf<typeof VerificationResult>;
|
||||
|
||||
interface RegisterData {
|
||||
email?: string;
|
||||
global_name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
date_of_birth: string;
|
||||
consent: boolean;
|
||||
captchaToken?: string;
|
||||
captchaType?: 'turnstile' | 'hcaptcha';
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
interface StandardLoginResponse {
|
||||
mfa: false;
|
||||
user_id: string;
|
||||
token: string;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
interface MfaLoginResponse {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
allowed_methods?: Array<string>;
|
||||
sms_phone_hint?: string | null;
|
||||
}
|
||||
|
||||
type LoginResponse = StandardLoginResponse | MfaLoginResponse;
|
||||
|
||||
export interface IpAuthorizationRequiredResponse {
|
||||
ip_authorization_required: true;
|
||||
ticket: string;
|
||||
email: string;
|
||||
resend_available_in: number;
|
||||
}
|
||||
|
||||
export function isIpAuthorizationRequiredResponse(
|
||||
response: LoginResponse | IpAuthorizationRequiredResponse,
|
||||
): response is IpAuthorizationRequiredResponse {
|
||||
return (response as IpAuthorizationRequiredResponse).ip_authorization_required === true;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
user_id: string;
|
||||
token: string;
|
||||
theme?: string;
|
||||
redirect_to?: string;
|
||||
}
|
||||
|
||||
export type ResetPasswordResponse = TokenResponse | MfaLoginResponse;
|
||||
|
||||
interface DesktopHandoffInitiateResponse {
|
||||
code: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface DesktopHandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export async function login({
|
||||
email,
|
||||
password,
|
||||
captchaToken,
|
||||
inviteCode,
|
||||
captchaType,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
captchaToken?: string;
|
||||
inviteCode?: string;
|
||||
captchaType?: 'turnstile' | 'hcaptcha';
|
||||
}): Promise<LoginResponse | IpAuthorizationRequiredResponse> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (captchaToken) {
|
||||
headers['X-Captcha-Token'] = captchaToken;
|
||||
headers['X-Captcha-Type'] = captchaType || 'hcaptcha';
|
||||
}
|
||||
const body: {
|
||||
email: string;
|
||||
password: string;
|
||||
invite_code?: string;
|
||||
} = {email, password};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<LoginResponse>({
|
||||
url: Endpoints.AUTH_LOGIN,
|
||||
body,
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
logger.debug('Login successful', {mfa: response.body?.mfa});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
error.status === 403 &&
|
||||
getApiErrorCode(error) === APIErrorCodes.IP_AUTHORIZATION_REQUIRED
|
||||
) {
|
||||
logger.info('Login requires IP authorization', {email});
|
||||
const body = error.body as Record<string, unknown> | undefined;
|
||||
return {
|
||||
ip_authorization_required: true,
|
||||
ticket: body?.ticket as string,
|
||||
email: body?.email as string,
|
||||
resend_available_in: (body?.resend_available_in as number) ?? 30,
|
||||
};
|
||||
}
|
||||
logger.error('Login failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaTotp(code: string, ticket: string, inviteCode?: string): Promise<TokenResponse> {
|
||||
try {
|
||||
const body: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {code, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_TOTP,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA TOTP authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA TOTP authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaSmsSend(ticket: string): Promise<void> {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_SMS_SEND,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.debug('SMS MFA code sent');
|
||||
} catch (error) {
|
||||
logger.error('Failed to send SMS MFA code', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaSms(code: string, ticket: string, inviteCode?: string): Promise<TokenResponse> {
|
||||
try {
|
||||
const body: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {code, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_SMS,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA SMS authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA SMS authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaWebAuthn(
|
||||
response: AuthenticationResponseJSON,
|
||||
challenge: string,
|
||||
ticket: string,
|
||||
inviteCode?: string,
|
||||
): Promise<TokenResponse> {
|
||||
try {
|
||||
const body: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {response, challenge, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const httpResponse = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_WEBAUTHN,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('MFA WebAuthn authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA WebAuthn authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn MFA options retrieved');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get WebAuthn MFA options', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>({
|
||||
url: Endpoints.AUTH_WEBAUTHN_OPTIONS,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn authentication options retrieved');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get WebAuthn authentication options', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateWithWebAuthn(
|
||||
response: AuthenticationResponseJSON,
|
||||
challenge: string,
|
||||
inviteCode?: string,
|
||||
): Promise<TokenResponse> {
|
||||
try {
|
||||
const body: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
invite_code?: string;
|
||||
} = {response, challenge};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const httpResponse = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_WEBAUTHN_AUTHENTICATE,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('WebAuthn authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(data: RegisterData): Promise<TokenResponse> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (data.captchaToken) {
|
||||
headers['X-Captcha-Token'] = data.captchaToken;
|
||||
headers['X-Captcha-Type'] = data.captchaType || 'hcaptcha';
|
||||
}
|
||||
const {captchaToken: _, captchaType: __, ...bodyData} = data;
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_REGISTER,
|
||||
body: bodyData,
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Registration successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Registration failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface UsernameSuggestionsResponse {
|
||||
suggestions: Array<string>;
|
||||
}
|
||||
|
||||
export async function getUsernameSuggestions(globalName: string): Promise<Array<string>> {
|
||||
try {
|
||||
const response = await http.post<UsernameSuggestionsResponse>({
|
||||
url: Endpoints.AUTH_USERNAME_SUGGESTIONS,
|
||||
body: {global_name: globalName},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
|
||||
return responseBody?.suggestions ?? [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch username suggestions', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function forgotPassword(
|
||||
email: string,
|
||||
captchaToken?: string,
|
||||
captchaType?: 'turnstile' | 'hcaptcha',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (captchaToken) {
|
||||
headers['X-Captcha-Token'] = captchaToken;
|
||||
headers['X-Captcha-Type'] = captchaType || 'hcaptcha';
|
||||
}
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_FORGOT_PASSWORD,
|
||||
body: {email},
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
logger.debug('Password reset email sent');
|
||||
} catch (error) {
|
||||
logger.warn('Password reset request failed, but returning success to user', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, password: string): Promise<ResetPasswordResponse> {
|
||||
try {
|
||||
const response = await http.post<ResetPasswordResponse>({
|
||||
url: Endpoints.AUTH_RESET_PASSWORD,
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Password reset successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Password reset failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function revertEmailChange(token: string, password: string): Promise<TokenResponse> {
|
||||
try {
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_EMAIL_REVERT,
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Email revert successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Email revert failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_VERIFY_EMAIL,
|
||||
body: {token},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('Email verification successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 400) {
|
||||
logger.warn('Email verification failed - expired or invalid token');
|
||||
return VerificationResult.EXPIRED_TOKEN;
|
||||
}
|
||||
logger.error('Email verification failed - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendVerificationEmail(): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_RESEND_VERIFICATION,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('Verification email resent');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 429) {
|
||||
logger.warn('Rate limited when resending verification email');
|
||||
return VerificationResult.RATE_LIMITED;
|
||||
}
|
||||
logger.error('Failed to resend verification email - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await AccountManager.logout();
|
||||
}
|
||||
|
||||
export async function authorizeIp(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_AUTHORIZE_IP,
|
||||
body: {token},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('IP authorization successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 400) {
|
||||
logger.warn('IP authorization failed - expired or invalid token');
|
||||
return VerificationResult.EXPIRED_TOKEN;
|
||||
}
|
||||
logger.error('IP authorization failed - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendIpAuthorization(ticket: string): Promise<void> {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_IP_AUTHORIZATION_RESEND,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
}
|
||||
|
||||
export interface IpAuthorizationPollResult {
|
||||
completed: boolean;
|
||||
token?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
|
||||
const response = await http.get<IpAuthorizationPollResult>({
|
||||
url: Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket),
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function initiateDesktopHandoff(): Promise<DesktopHandoffInitiateResponse> {
|
||||
const response = await http.post<DesktopHandoffInitiateResponse>({
|
||||
url: Endpoints.AUTH_HANDOFF_INITIATE,
|
||||
skipAuth: true,
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function pollDesktopHandoffStatus(code: string): Promise<DesktopHandoffStatusResponse> {
|
||||
const response = await http.get<DesktopHandoffStatusResponse>({
|
||||
url: Endpoints.AUTH_HANDOFF_STATUS(code),
|
||||
skipAuth: true,
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function completeDesktopHandoff({
|
||||
code,
|
||||
token,
|
||||
userId,
|
||||
}: {
|
||||
code: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_HANDOFF_COMPLETE,
|
||||
body: {code, token, user_id: userId},
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function startSession(token: string, options: {startGateway?: boolean} = {}): void {
|
||||
const {startGateway = true} = options;
|
||||
|
||||
logger.info('Starting new session');
|
||||
AuthenticationStore.handleSessionStart({token});
|
||||
|
||||
if (!startGateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
GatewayConnectionStore.startSession(token);
|
||||
}
|
||||
|
||||
let sessionStartInProgress = false;
|
||||
|
||||
export async function ensureSessionStarted(): Promise<void> {
|
||||
if (sessionStartInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AccountManager.isSwitching) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AuthenticationStore.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (GatewayConnectionStore.isConnected || GatewayConnectionStore.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (GatewayConnectionStore.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStartInProgress = true;
|
||||
|
||||
try {
|
||||
logger.info('Ensuring session is started');
|
||||
|
||||
const token = AuthenticationStore.authToken;
|
||||
if (token) {
|
||||
GatewayConnectionStore.startSession(token);
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
sessionStartInProgress = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeLogin({
|
||||
token,
|
||||
userId,
|
||||
userData,
|
||||
}: {
|
||||
token: string;
|
||||
userId: string;
|
||||
userData?: UserData;
|
||||
}): Promise<void> {
|
||||
logger.info('Completing login process');
|
||||
|
||||
if (userId && token) {
|
||||
await AccountManager.switchToNewAccount(userId, token, userData, false);
|
||||
} else {
|
||||
startSession(token, {startGateway: true});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSso(redirectTo?: string): Promise<{authorization_url: string}> {
|
||||
const response = await http.post<{authorization_url: string}>({
|
||||
url: Endpoints.AUTH_SSO_START,
|
||||
body: {redirect_to: redirectTo},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function completeSso({code, state}: {code: string; state: string}): Promise<TokenResponse> {
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_SSO_COMPLETE,
|
||||
body: {code, state},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
interface SetMfaTicketPayload {
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
|
||||
export function setMfaTicket({ticket, sms, totp, webauthn}: SetMfaTicketPayload): void {
|
||||
logger.debug('Setting MFA ticket');
|
||||
AuthenticationStore.handleMfaTicketSet({ticket, sms, totp, webauthn});
|
||||
}
|
||||
|
||||
export function clearMfaTicket(): void {
|
||||
logger.debug('Clearing MFA ticket');
|
||||
AuthenticationStore.handleMfaTicketClear();
|
||||
}
|
||||
207
fluxer/fluxer_app/src/actions/CallActionCreators.tsx
Normal file
207
fluxer/fluxer_app/src/actions/CallActionCreators.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import HttpClient from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import CallInitiatorStore from '@app/stores/CallInitiatorStore';
|
||||
import CallStateStore from '@app/stores/CallStateStore';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import GeoIPStore from '@app/stores/GeoIPStore';
|
||||
import RtcRegionsStore from '@app/stores/RtcRegionsStore';
|
||||
import SoundStore from '@app/stores/SoundStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import MediaEngineStore from '@app/stores/voice/MediaEngineFacade';
|
||||
import type {RtcRegionResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {reaction} from 'mobx';
|
||||
|
||||
interface PendingRing {
|
||||
channelId: string;
|
||||
recipients: Array<string>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const logger = new Logger('CallActionCreators');
|
||||
|
||||
let pendingRing: PendingRing | null = null;
|
||||
|
||||
export async function checkCallEligibility(channelId: string): Promise<{ringable: boolean}> {
|
||||
const response = await HttpClient.get<{ringable: boolean}>(Endpoints.CHANNEL_CALL(channelId));
|
||||
return response.body ?? {ringable: false};
|
||||
}
|
||||
|
||||
async function ringCallRecipients(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
const latitude = GeoIPStore.latitude;
|
||||
const longitude = GeoIPStore.longitude;
|
||||
const body: {recipients?: Array<string>; latitude?: string; longitude?: string} = {};
|
||||
if (recipients) {
|
||||
body.recipients = recipients;
|
||||
}
|
||||
if (latitude && longitude) {
|
||||
body.latitude = latitude;
|
||||
body.longitude = longitude;
|
||||
}
|
||||
await HttpClient.post(Endpoints.CHANNEL_CALL_RING(channelId), body);
|
||||
}
|
||||
|
||||
async function stopRingingCallRecipients(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
await HttpClient.post(Endpoints.CHANNEL_CALL_STOP_RINGING(channelId), recipients ? {recipients} : {});
|
||||
}
|
||||
|
||||
export async function ringParticipants(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
return ringCallRecipients(channelId, recipients);
|
||||
}
|
||||
|
||||
export async function stopRingingParticipants(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
return stopRingingCallRecipients(channelId, recipients);
|
||||
}
|
||||
|
||||
function clearPendingRing(): void {
|
||||
if (pendingRing) {
|
||||
pendingRing.dispose();
|
||||
pendingRing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPendingRing(channelId: string, recipients: Array<string>): void {
|
||||
clearPendingRing();
|
||||
|
||||
const dispose = reaction(
|
||||
() => ({
|
||||
connected: MediaEngineStore.connected,
|
||||
currentChannelId: MediaEngineStore.channelId,
|
||||
}),
|
||||
({connected, currentChannelId}) => {
|
||||
if (connected && currentChannelId === channelId && pendingRing?.channelId === channelId) {
|
||||
void ringCallRecipients(channelId, pendingRing.recipients).catch((error) => {
|
||||
logger.error('Failed to ring call recipients:', error);
|
||||
});
|
||||
clearPendingRing();
|
||||
}
|
||||
},
|
||||
{fireImmediately: true},
|
||||
);
|
||||
|
||||
pendingRing = {channelId, recipients, dispose};
|
||||
}
|
||||
|
||||
export function startCall(channelId: string, silent = false): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const recipients = channel ? channel.recipientIds.filter((id) => id !== currentUser.id) : [];
|
||||
|
||||
CallInitiatorStore.markInitiated(channelId, recipients);
|
||||
|
||||
if (silent) {
|
||||
clearPendingRing();
|
||||
void ringCallRecipients(channelId, []).catch((error) => {
|
||||
logger.error('Failed to start silent call:', error);
|
||||
});
|
||||
} else {
|
||||
setupPendingRing(channelId, recipients);
|
||||
}
|
||||
|
||||
void MediaEngineStore.connectToVoiceChannel(null, channelId);
|
||||
}
|
||||
|
||||
export function joinCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
SoundStore.stopIncomingRing();
|
||||
void MediaEngineStore.connectToVoiceChannel(null, channelId);
|
||||
}
|
||||
|
||||
export async function leaveCall(channelId: string): Promise<void> {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingRing?.channelId === channelId) {
|
||||
clearPendingRing();
|
||||
}
|
||||
|
||||
SoundStore.stopIncomingRing();
|
||||
|
||||
const call = CallStateStore.getCall(channelId);
|
||||
const callRinging = call?.ringing ?? [];
|
||||
const initiatedRecipients = CallInitiatorStore.getInitiatedRecipients(channelId);
|
||||
const toStop =
|
||||
initiatedRecipients.length > 0 ? callRinging.filter((userId) => initiatedRecipients.includes(userId)) : callRinging;
|
||||
|
||||
if (toStop.length > 0) {
|
||||
try {
|
||||
await stopRingingCallRecipients(channelId, toStop);
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop ringing pending recipients:', error);
|
||||
}
|
||||
}
|
||||
|
||||
CallInitiatorStore.clearChannel(channelId);
|
||||
|
||||
void MediaEngineStore.disconnectFromVoiceChannel('user');
|
||||
}
|
||||
|
||||
export function rejectCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
const connectedChannelId = MediaEngineStore.channelId;
|
||||
if (connectedChannelId === channelId) {
|
||||
void MediaEngineStore.disconnectFromVoiceChannel('user');
|
||||
}
|
||||
void stopRingingCallRecipients(channelId).catch((error) => {
|
||||
logger.error('Failed to stop ringing:', error);
|
||||
});
|
||||
SoundStore.stopIncomingRing();
|
||||
CallInitiatorStore.clearChannel(channelId);
|
||||
}
|
||||
|
||||
export function ignoreCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
void stopRingingCallRecipients(channelId, [currentUser.id]).catch((error) => {
|
||||
logger.error('Failed to stop ringing:', error);
|
||||
});
|
||||
SoundStore.stopIncomingRing();
|
||||
}
|
||||
|
||||
export async function fetchCallRegions(channelId: string): Promise<Array<RtcRegionResponse>> {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (channel?.isPrivate()) {
|
||||
return RtcRegionsStore.getRegions();
|
||||
}
|
||||
const response = await HttpClient.get<Array<RtcRegionResponse>>({url: Endpoints.CHANNEL_RTC_REGIONS(channelId)});
|
||||
return response.body ?? [];
|
||||
}
|
||||
|
||||
export async function updateCallRegion(channelId: string, region: string | null): Promise<void> {
|
||||
await HttpClient.patch(Endpoints.CHANNEL_CALL(channelId), {region});
|
||||
}
|
||||
149
fluxer/fluxer_app/src/actions/ChannelActionCreators.tsx
Normal file
149
fluxer/fluxer_app/src/actions/ChannelActionCreators.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import InviteStore from '@app/stores/InviteStore';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
const logger = new Logger('Channels');
|
||||
|
||||
export interface ChannelRtcRegion {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export async function create(
|
||||
guildId: string,
|
||||
params: Pick<Channel, 'name' | 'url' | 'type' | 'parent_id' | 'bitrate' | 'user_limit'>,
|
||||
) {
|
||||
try {
|
||||
const response = await http.post<Channel>(Endpoints.GUILD_CHANNELS(guildId), params);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
channelId: string,
|
||||
params: Partial<Pick<Channel, 'name' | 'topic' | 'url' | 'nsfw' | 'icon' | 'owner_id' | 'rtc_region'>>,
|
||||
) {
|
||||
try {
|
||||
const response = await http.patch<Channel>(Endpoints.CHANNEL(channelId), params);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGroupDMNickname(channelId: string, userId: string, nickname: string | null) {
|
||||
try {
|
||||
const response = await http.patch<Channel>({
|
||||
url: Endpoints.CHANNEL(channelId),
|
||||
body: {
|
||||
nicks: {
|
||||
[userId]: nickname,
|
||||
},
|
||||
},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update nickname for user ${userId} in channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemoveChannelOptions {
|
||||
optimistic?: boolean;
|
||||
}
|
||||
|
||||
export async function remove(channelId: string, silent?: boolean, options?: RemoveChannelOptions) {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const isPrivateChannel =
|
||||
channel != null && !channel.guildId && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM);
|
||||
const shouldOptimisticallyRemove = options?.optimistic ?? isPrivateChannel;
|
||||
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.removeChannelOptimistically(channelId);
|
||||
}
|
||||
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.CHANNEL(channelId),
|
||||
query: silent ? {silent: true} : undefined,
|
||||
});
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.clearOptimisticallyRemovedChannel(channelId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.rollbackChannelDeletion(channelId);
|
||||
}
|
||||
logger.error(`Failed to delete channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePermissionOverwrites(
|
||||
channelId: string,
|
||||
permissionOverwrites: Array<{id: string; type: 0 | 1; allow: string; deny: string}>,
|
||||
) {
|
||||
try {
|
||||
const response = await http.patch<Channel>({
|
||||
url: Endpoints.CHANNEL(channelId),
|
||||
body: {permission_overwrites: permissionOverwrites},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update permission overwrites for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelInvites(channelId: string): Promise<Array<Invite>> {
|
||||
try {
|
||||
InviteStore.handleChannelInvitesFetchPending(channelId);
|
||||
const response = await http.get<Array<Invite>>({url: Endpoints.CHANNEL_INVITES(channelId)});
|
||||
const data = response.body ?? [];
|
||||
InviteStore.handleChannelInvitesFetchSuccess(channelId, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invites for channel ${channelId}:`, error);
|
||||
InviteStore.handleChannelInvitesFetchError(channelId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRtcRegions(channelId: string): Promise<Array<ChannelRtcRegion>> {
|
||||
try {
|
||||
const response = await http.get<Array<ChannelRtcRegion>>({url: Endpoints.CHANNEL_RTC_REGIONS(channelId)});
|
||||
return response.body ?? [];
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch RTC regions for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
148
fluxer/fluxer_app/src/actions/ChannelPinsActionCreators.tsx
Normal file
148
fluxer/fluxer_app/src/actions/ChannelPinsActionCreators.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import {PinFailedModal, type PinFailureReason} from '@app/components/alerts/PinFailedModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import type {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ChannelPinsStore from '@app/stores/ChannelPinsStore';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const getApiErrorCode = (error: HttpError): string | undefined => {
|
||||
const body = typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
return body?.code;
|
||||
};
|
||||
|
||||
const logger = new Logger('Pins');
|
||||
const PIN_PAGE_SIZE = 25;
|
||||
|
||||
const shouldBlockPinsFetch = (channelId: string): boolean => {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel || channel.isPrivate()) {
|
||||
return false;
|
||||
}
|
||||
return GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
|
||||
};
|
||||
|
||||
interface ChannelPinResponse {
|
||||
message: Message;
|
||||
pinned_at: string;
|
||||
}
|
||||
interface ChannelPinsPayload {
|
||||
items: Array<ChannelPinResponse>;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export async function fetch(channelId: string) {
|
||||
if (shouldBlockPinsFetch(channelId)) {
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, [], false);
|
||||
return [];
|
||||
}
|
||||
|
||||
ChannelPinsStore.handleFetchPending(channelId);
|
||||
try {
|
||||
const response = await http.get<ChannelPinsPayload>({
|
||||
url: Endpoints.CHANNEL_PINS(channelId),
|
||||
query: {limit: PIN_PAGE_SIZE},
|
||||
});
|
||||
const body = response.body ?? {items: [], has_more: false};
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, body.items, body.has_more);
|
||||
return body.items.map((pin) => pin.message);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch pins for channel ${channelId}:`, error);
|
||||
ChannelPinsStore.handleChannelPinsFetchError(channelId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMore(channelId: string): Promise<Array<Message>> {
|
||||
if (shouldBlockPinsFetch(channelId)) {
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, [], false);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!ChannelPinsStore.getHasMore(channelId) || ChannelPinsStore.getIsLoading(channelId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const before = ChannelPinsStore.getOldestPinnedAt(channelId);
|
||||
if (!before) {
|
||||
return [];
|
||||
}
|
||||
|
||||
ChannelPinsStore.handleFetchPending(channelId);
|
||||
try {
|
||||
logger.debug(`Loading more pins for channel ${channelId} before ${before}`);
|
||||
const response = await http.get<ChannelPinsPayload>({
|
||||
url: Endpoints.CHANNEL_PINS(channelId),
|
||||
query: {
|
||||
limit: PIN_PAGE_SIZE,
|
||||
before,
|
||||
},
|
||||
});
|
||||
const body = response.body ?? {items: [], has_more: false};
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, body.items, body.has_more);
|
||||
return body.items.map((pin) => pin.message);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load more pins for channel ${channelId}:`, error);
|
||||
ChannelPinsStore.handleChannelPinsFetchError(channelId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const getFailureReason = (error: HttpError): PinFailureReason => {
|
||||
const errorCode = getApiErrorCode(error);
|
||||
if (errorCode === APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER) {
|
||||
return 'dm_restricted';
|
||||
}
|
||||
return 'generic';
|
||||
};
|
||||
|
||||
export async function pin(channelId: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
await http.put({url: Endpoints.CHANNEL_PIN(channelId, messageId)});
|
||||
logger.debug(`Pinned message ${messageId} in channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to pin message ${messageId} in channel ${channelId}:`, error);
|
||||
const reason = getFailureReason(error as HttpError);
|
||||
ModalActionCreators.push(modal(() => <PinFailedModal reason={reason} />));
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpin(channelId: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.CHANNEL_PIN(channelId, messageId)});
|
||||
logger.debug(`Unpinned message ${messageId} from channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to unpin message ${messageId} from channel ${channelId}:`, error);
|
||||
const reason = getFailureReason(error as HttpError);
|
||||
ModalActionCreators.push(modal(() => <PinFailedModal isUnpin reason={reason} />));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildStickerRecord} from '@app/records/GuildStickerRecord';
|
||||
import ChannelStickerStore from '@app/stores/ChannelStickerStore';
|
||||
|
||||
export function setPendingSticker(channelId: string, sticker: GuildStickerRecord): void {
|
||||
ChannelStickerStore.setPendingSticker(channelId, sticker);
|
||||
}
|
||||
|
||||
export function removePendingSticker(channelId: string): void {
|
||||
ChannelStickerStore.removePendingSticker(channelId);
|
||||
}
|
||||
196
fluxer/fluxer_app/src/actions/ConnectionActionCreators.tsx
Normal file
196
fluxer/fluxer_app/src/actions/ConnectionActionCreators.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import UserConnectionStore from '@app/stores/UserConnectionStore';
|
||||
import * as ApiErrorUtils from '@app/utils/ApiErrorUtils';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import type {
|
||||
ConnectionListResponse,
|
||||
ConnectionResponse,
|
||||
ConnectionVerificationResponse,
|
||||
CreateConnectionRequest,
|
||||
ReorderConnectionsRequest,
|
||||
UpdateConnectionRequest,
|
||||
VerifyAndCreateConnectionRequest,
|
||||
} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
|
||||
import type {I18n, MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('Connections');
|
||||
|
||||
function showErrorToast(i18n: I18n, error: unknown, fallbackMessage: MessageDescriptor): void {
|
||||
const errorMessage = ApiErrorUtils.getApiErrorMessage(error);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: errorMessage ?? i18n._(fallbackMessage),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchConnections(): Promise<void> {
|
||||
try {
|
||||
const response = await http.get<ConnectionListResponse>(Endpoints.CONNECTIONS);
|
||||
UserConnectionStore.setConnections(response.body);
|
||||
logger.debug('Successfully fetched connections');
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch connections:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initiateConnection(
|
||||
i18n: I18n,
|
||||
type: ConnectionType,
|
||||
identifier: string,
|
||||
): Promise<ConnectionVerificationResponse> {
|
||||
try {
|
||||
const payload: CreateConnectionRequest = {
|
||||
type,
|
||||
identifier,
|
||||
};
|
||||
|
||||
const response = await http.post<ConnectionVerificationResponse>(Endpoints.CONNECTIONS, payload);
|
||||
logger.debug(`Successfully initiated connection: ${type}/${identifier}`);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initiate connection ${type}/${identifier}:`, error);
|
||||
showErrorToast(i18n, error, msg`Failed to initiate connection`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeBlueskyConnection(i18n: I18n, handle: string): Promise<void> {
|
||||
try {
|
||||
const response = await http.post<{authorize_url: string}>(Endpoints.BLUESKY_AUTHORIZE, {handle});
|
||||
window.open(response.body.authorize_url, '_blank');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start Bluesky OAuth flow for ${handle}:`, error);
|
||||
showErrorToast(i18n, error, msg`Failed to start Bluesky authorisation`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAndCreateConnection(
|
||||
i18n: I18n,
|
||||
initiationToken: string,
|
||||
visibilityFlags?: number,
|
||||
): Promise<ConnectionResponse> {
|
||||
try {
|
||||
const payload: VerifyAndCreateConnectionRequest = {
|
||||
initiation_token: initiationToken,
|
||||
visibility_flags: visibilityFlags,
|
||||
};
|
||||
|
||||
const response = await http.post<ConnectionResponse>(Endpoints.CONNECTIONS_VERIFY_AND_CREATE, payload);
|
||||
UserConnectionStore.addConnection(response.body);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Connection verified`),
|
||||
});
|
||||
|
||||
logger.debug('Successfully verified and created connection');
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify and create connection:', error);
|
||||
showErrorToast(i18n, error, msg`Failed to verify connection`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
i18n: I18n,
|
||||
type: string,
|
||||
connectionId: string,
|
||||
patch: UpdateConnectionRequest,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.patch(Endpoints.CONNECTION(type, connectionId), patch);
|
||||
UserConnectionStore.updateConnection(connectionId, patch);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Connection updated`),
|
||||
});
|
||||
|
||||
logger.debug(`Successfully updated connection: ${type}/${connectionId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update connection ${type}/${connectionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConnection(i18n: I18n, type: string, connectionId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.CONNECTION(type, connectionId)});
|
||||
UserConnectionStore.removeConnection(connectionId);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Connection removed`),
|
||||
});
|
||||
|
||||
logger.debug(`Successfully deleted connection: ${type}/${connectionId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete connection ${type}/${connectionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyConnection(i18n: I18n, type: string, connectionId: string): Promise<void> {
|
||||
try {
|
||||
const response = await http.post<ConnectionResponse>(Endpoints.CONNECTION_VERIFY(type, connectionId), {});
|
||||
UserConnectionStore.updateConnection(connectionId, response.body);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Connection verified`),
|
||||
});
|
||||
|
||||
logger.debug(`Successfully verified connection: ${type}/${connectionId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to verify connection ${type}/${connectionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderConnections(i18n: I18n, connectionIds: Array<string>): Promise<void> {
|
||||
try {
|
||||
const payload: ReorderConnectionsRequest = {
|
||||
connection_ids: connectionIds,
|
||||
};
|
||||
|
||||
await http.patch(Endpoints.CONNECTIONS_REORDER, payload);
|
||||
await fetchConnections();
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Connections reordered`),
|
||||
});
|
||||
|
||||
logger.debug('Successfully reordered connections');
|
||||
} catch (error) {
|
||||
logger.error('Failed to reorder connections:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
174
fluxer/fluxer_app/src/actions/ContextMenuActionCreators.tsx
Normal file
174
fluxer/fluxer_app/src/actions/ContextMenuActionCreators.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ContextMenu, ContextMenuConfig, ContextMenuTargetElement} from '@app/stores/ContextMenuStore';
|
||||
import ContextMenuStore from '@app/stores/ContextMenuStore';
|
||||
import type React from 'react';
|
||||
|
||||
const nativeContextMenuTarget: ContextMenuTargetElement = {
|
||||
tagName: 'ReactNativeContextMenu',
|
||||
isConnected: true,
|
||||
focus: (): void => undefined,
|
||||
addEventListener: (..._args: Parameters<HTMLElement['addEventListener']>) => undefined,
|
||||
removeEventListener: (..._args: Parameters<HTMLElement['removeEventListener']>) => undefined,
|
||||
};
|
||||
|
||||
const makeId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const getViewportCenterForElement = (el: Element) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
return {x: rect.left + rect.width / 2 + scrollX, y: rect.top + rect.height / 2 + scrollY};
|
||||
};
|
||||
|
||||
const toHTMLElement = (value: unknown): HTMLElement | null => {
|
||||
if (!value) return null;
|
||||
if (value instanceof HTMLElement) return value;
|
||||
if (value instanceof Element) {
|
||||
return (value.closest('button,[role="button"],a,[data-contextmenu-anchor="true"]') as HTMLElement | null) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function close(): void {
|
||||
ContextMenuStore.close();
|
||||
}
|
||||
|
||||
type RenderFn = (props: {onClose: () => void}) => React.ReactNode;
|
||||
|
||||
export function openAtPoint(
|
||||
point: {x: number; y: number},
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
target: ContextMenuTargetElement = nativeContextMenuTarget,
|
||||
): void {
|
||||
const contextMenu: ContextMenu = {
|
||||
id: makeId('context-menu'),
|
||||
target: {x: point.x, y: point.y, target},
|
||||
render,
|
||||
config: {noBlurEvent: true, ...config},
|
||||
};
|
||||
|
||||
ContextMenuStore.open(contextMenu);
|
||||
}
|
||||
|
||||
export function openForElement(
|
||||
element: HTMLElement,
|
||||
render: RenderFn,
|
||||
options?: {point?: {x: number; y: number}; config?: ContextMenuConfig},
|
||||
): void {
|
||||
const point = options?.point ?? getViewportCenterForElement(element);
|
||||
openAtPoint(point, render, options?.config, element);
|
||||
}
|
||||
|
||||
export function openFromEvent(
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
): void {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
|
||||
|
||||
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
|
||||
const target = 'target' in event ? toHTMLElement(event.target) : null;
|
||||
const anchor = currentTarget ?? target;
|
||||
|
||||
const hasPointerCoords = !(event.pageX === 0 && event.pageY === 0 && nativeEvent.detail === 0);
|
||||
const point = hasPointerCoords
|
||||
? {x: event.pageX + 2, y: event.pageY + 2}
|
||||
: anchor
|
||||
? (() => {
|
||||
const c = getViewportCenterForElement(anchor);
|
||||
return {x: c.x + 2, y: c.y + 2};
|
||||
})()
|
||||
: {x: 0, y: 0};
|
||||
|
||||
openAtPoint(point, render, config, anchor ?? nativeContextMenuTarget);
|
||||
}
|
||||
|
||||
export function openFromElementBottomRight(
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
): void {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
|
||||
const target = 'target' in event ? toHTMLElement(event.target) : null;
|
||||
const anchor = currentTarget ?? target;
|
||||
|
||||
if (!anchor) {
|
||||
openFromEvent(event, render, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
const point = {x: rect.right + scrollX, y: rect.bottom + scrollY + 4};
|
||||
|
||||
openAtPoint(point, render, {align: 'top-right', ...config}, anchor);
|
||||
}
|
||||
|
||||
export function openFromElementTopLeft(
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
): void {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
|
||||
const target = 'target' in event ? toHTMLElement(event.target) : null;
|
||||
const anchor = currentTarget ?? target;
|
||||
|
||||
if (!anchor) {
|
||||
openFromEvent(event, render, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
const point = {x: rect.left + scrollX, y: rect.top + scrollY};
|
||||
|
||||
openAtPoint(point, render, {align: 'bottom-left', ...config}, anchor);
|
||||
}
|
||||
|
||||
export function openNativeContextMenu(render: RenderFn, config?: ContextMenu['config']): void {
|
||||
const contextMenu: ContextMenu = {
|
||||
id: makeId('native-context-menu'),
|
||||
target: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
target: nativeContextMenuTarget,
|
||||
},
|
||||
render,
|
||||
config: {
|
||||
returnFocus: false,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
|
||||
ContextMenuStore.open(contextMenu);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import {UserRecord} from '@app/records/UserRecord';
|
||||
import DeveloperOptionsStore, {type DeveloperOptionsState} from '@app/stores/DeveloperOptionsStore';
|
||||
import MockIncomingCallStore from '@app/stores/MockIncomingCallStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const logger = new Logger('DeveloperOptions');
|
||||
|
||||
export function updateOption<K extends keyof DeveloperOptionsState>(key: K, value: DeveloperOptionsState[K]): void {
|
||||
logger.debug(`Updating developer option: ${String(key)} = ${value}`);
|
||||
DeveloperOptionsStore.updateOption(key, value);
|
||||
}
|
||||
|
||||
export function setAttachmentMock(
|
||||
attachmentId: string,
|
||||
mock: DeveloperOptionsState['mockAttachmentStates'][string] | null,
|
||||
): void {
|
||||
const next = {...DeveloperOptionsStore.mockAttachmentStates};
|
||||
if (mock === null) {
|
||||
delete next[attachmentId];
|
||||
} else {
|
||||
next[attachmentId] = mock;
|
||||
}
|
||||
updateOption('mockAttachmentStates', next);
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
|
||||
export function clearAllAttachmentMocks(): void {
|
||||
updateOption('mockAttachmentStates', {});
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
|
||||
export function triggerMockIncomingCall(): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
logger.warn('Cannot trigger mock incoming call: No current user');
|
||||
return;
|
||||
}
|
||||
|
||||
const mockChannelId = generateSnowflake().toString();
|
||||
|
||||
const initiatorPartial: UserPartial = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
discriminator: currentUser.discriminator,
|
||||
global_name: currentUser.globalName,
|
||||
avatar: currentUser.avatar ?? null,
|
||||
avatar_color: currentUser.avatarColor ?? null,
|
||||
flags: currentUser.flags ?? 0,
|
||||
};
|
||||
|
||||
const channelData: Channel = {
|
||||
id: mockChannelId,
|
||||
type: ChannelTypes.DM,
|
||||
recipients: [initiatorPartial],
|
||||
};
|
||||
|
||||
const channelRecord = new ChannelRecord(channelData);
|
||||
const initiatorRecord = new UserRecord(initiatorPartial);
|
||||
|
||||
MockIncomingCallStore.setMockCall({
|
||||
channel: channelRecord,
|
||||
initiator: initiatorRecord,
|
||||
});
|
||||
|
||||
logger.info(`Triggered mock incoming call from user ${currentUser.username}`);
|
||||
}
|
||||
38
fluxer/fluxer_app/src/actions/DimensionActionCreators.tsx
Normal file
38
fluxer/fluxer_app/src/actions/DimensionActionCreators.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import DimensionStore from '@app/stores/DimensionStore';
|
||||
|
||||
const logger = new Logger('DimensionActions');
|
||||
|
||||
export function updateChannelListScroll(guildId: string, scrollTop: number): void {
|
||||
logger.debug(`Updating channel list scroll: guildId=${guildId}, scrollTop=${scrollTop}`);
|
||||
DimensionStore.updateGuildDimensions(guildId, scrollTop, undefined);
|
||||
}
|
||||
|
||||
export function clearChannelListScrollTo(guildId: string): void {
|
||||
logger.debug(`Clearing channel list scroll target: guildId=${guildId}`);
|
||||
DimensionStore.updateGuildDimensions(guildId, undefined, null);
|
||||
}
|
||||
|
||||
export function updateGuildListScroll(scrollTop: number): void {
|
||||
logger.debug(`Updating guild list scroll: scrollTop=${scrollTop}`);
|
||||
DimensionStore.updateGuildListDimensions(scrollTop);
|
||||
}
|
||||
87
fluxer/fluxer_app/src/actions/DiscoveryActionCreators.tsx
Normal file
87
fluxer/fluxer_app/src/actions/DiscoveryActionCreators.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
|
||||
const logger = new Logger('Discovery');
|
||||
|
||||
export interface DiscoveryGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: string | null;
|
||||
category_type: number;
|
||||
member_count: number;
|
||||
online_count: number;
|
||||
features: Array<string>;
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
interface DiscoverySearchResponse {
|
||||
guilds: Array<DiscoveryGuild>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface DiscoveryCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function searchGuilds(params: {
|
||||
query?: string;
|
||||
category?: number;
|
||||
sort_by?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<DiscoverySearchResponse> {
|
||||
const query: Record<string, string> = {
|
||||
limit: String(params.limit),
|
||||
offset: String(params.offset),
|
||||
};
|
||||
if (params.query) {
|
||||
query.query = params.query;
|
||||
}
|
||||
if (params.category !== undefined) {
|
||||
query.category = String(params.category);
|
||||
}
|
||||
if (params.sort_by) {
|
||||
query.sort_by = params.sort_by;
|
||||
}
|
||||
const response = await http.get<DiscoverySearchResponse>({
|
||||
url: Endpoints.DISCOVERY_GUILDS,
|
||||
query,
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function getCategories(): Promise<Array<DiscoveryCategory>> {
|
||||
const response = await http.get<Array<DiscoveryCategory>>({
|
||||
url: Endpoints.DISCOVERY_CATEGORIES,
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export async function joinGuild(guildId: string): Promise<void> {
|
||||
await http.post({
|
||||
url: Endpoints.DISCOVERY_JOIN(guildId),
|
||||
});
|
||||
logger.info('Joined guild via discovery', {guildId});
|
||||
}
|
||||
33
fluxer/fluxer_app/src/actions/DraftActionCreators.tsx
Normal file
33
fluxer/fluxer_app/src/actions/DraftActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import DraftStore from '@app/stores/DraftStore';
|
||||
|
||||
const logger = new Logger('Draft');
|
||||
|
||||
export function createDraft(channelId: string, content: string): void {
|
||||
logger.debug(`Creating draft for channel ${channelId}`);
|
||||
DraftStore.createDraft(channelId, content);
|
||||
}
|
||||
|
||||
export function deleteDraft(channelId: string): void {
|
||||
logger.debug(`Deleting draft for channel ${channelId}`);
|
||||
DraftStore.deleteDraft(channelId);
|
||||
}
|
||||
28
fluxer/fluxer_app/src/actions/EmojiActionCreators.tsx
Normal file
28
fluxer/fluxer_app/src/actions/EmojiActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import EmojiStore from '@app/stores/EmojiStore';
|
||||
|
||||
const logger = new Logger('Emoji');
|
||||
|
||||
export function setSkinTone(skinTone: string): void {
|
||||
logger.debug(`Setting emoji skin tone: ${skinTone}`);
|
||||
EmojiStore.setSkinTone(skinTone);
|
||||
}
|
||||
40
fluxer/fluxer_app/src/actions/EmojiPickerActionCreators.tsx
Normal file
40
fluxer/fluxer_app/src/actions/EmojiPickerActionCreators.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import EmojiPickerStore from '@app/stores/EmojiPickerStore';
|
||||
import type {FlatEmoji} from '@app/types/EmojiTypes';
|
||||
|
||||
function getEmojiKey(emoji: FlatEmoji): string {
|
||||
if (emoji.id) {
|
||||
return `custom:${emoji.guildId}:${emoji.id}`;
|
||||
}
|
||||
return `unicode:${emoji.uniqueName}`;
|
||||
}
|
||||
|
||||
export function trackEmojiUsage(emoji: FlatEmoji): void {
|
||||
EmojiPickerStore.trackEmojiUsage(getEmojiKey(emoji));
|
||||
}
|
||||
|
||||
export function toggleFavorite(emoji: FlatEmoji): void {
|
||||
EmojiPickerStore.toggleFavorite(getEmojiKey(emoji));
|
||||
}
|
||||
|
||||
export function toggleCategory(category: string): void {
|
||||
EmojiPickerStore.toggleCategory(category);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ExpressionPickerTabType} from '@app/components/popouts/ExpressionPickerPopout';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ExpressionPickerStore from '@app/stores/ExpressionPickerStore';
|
||||
|
||||
const logger = new Logger('ExpressionPicker');
|
||||
|
||||
export function open(channelId: string, tab?: ExpressionPickerTabType): void {
|
||||
logger.debug(`Opening expression picker for channel ${channelId}, tab: ${tab}`);
|
||||
ExpressionPickerStore.open(channelId, tab);
|
||||
}
|
||||
|
||||
export function close(): void {
|
||||
logger.debug('Closing expression picker');
|
||||
ExpressionPickerStore.close();
|
||||
}
|
||||
|
||||
export function toggle(channelId: string, tab: ExpressionPickerTabType): void {
|
||||
logger.debug(`Toggling expression picker for channel ${channelId}, tab: ${tab}`);
|
||||
ExpressionPickerStore.toggle(channelId, tab);
|
||||
}
|
||||
|
||||
export function setTab(tab: ExpressionPickerTabType): void {
|
||||
logger.debug(`Setting expression picker tab to: ${tab}`);
|
||||
ExpressionPickerStore.setTab(tab);
|
||||
}
|
||||
177
fluxer/fluxer_app/src/actions/FavoriteMemeActionCreators.tsx
Normal file
177
fluxer/fluxer_app/src/actions/FavoriteMemeActionCreators.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {MaxFavoriteMemesModal} from '@app/components/alerts/MaxFavoriteMemesModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {FavoriteMeme} from '@app/records/FavoriteMemeRecord';
|
||||
import FavoriteMemeStore from '@app/stores/FavoriteMemeStore';
|
||||
import {getApiErrorCode} from '@app/utils/ApiErrorUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('FavoriteMemes');
|
||||
|
||||
export async function createFavoriteMeme(
|
||||
i18n: I18n,
|
||||
{
|
||||
channelId,
|
||||
messageId,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
}: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
attachmentId?: string;
|
||||
embedIndex?: number;
|
||||
name: string;
|
||||
altText?: string;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.post<FavoriteMeme>(Endpoints.CHANNEL_MESSAGE_FAVORITE_MEMES(channelId, messageId), {
|
||||
attachment_id: attachmentId,
|
||||
embed_index: embedIndex,
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
});
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to saved media`),
|
||||
});
|
||||
logger.debug(`Successfully added favorite meme from message ${messageId}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Failed to add favorite meme from message ${messageId}:`, error);
|
||||
|
||||
if (getApiErrorCode(error) === APIErrorCodes.MAX_FAVORITE_MEMES) {
|
||||
ModalActionCreators.push(modal(() => <MaxFavoriteMemesModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFavoriteMemeFromUrl(
|
||||
i18n: I18n,
|
||||
{
|
||||
url,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
klipySlug,
|
||||
tenorSlugId,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
altText?: string;
|
||||
tags?: Array<string>;
|
||||
klipySlug?: string;
|
||||
tenorSlugId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.post<FavoriteMeme>(Endpoints.USER_FAVORITE_MEMES(ME), {
|
||||
url,
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
klipy_slug: klipySlug,
|
||||
tenor_slug_id: tenorSlugId,
|
||||
});
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to saved media`),
|
||||
});
|
||||
logger.debug(`Successfully added favorite meme from URL ${url}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Failed to add favorite meme from URL ${url}:`, error);
|
||||
|
||||
if (getApiErrorCode(error) === APIErrorCodes.MAX_FAVORITE_MEMES) {
|
||||
ModalActionCreators.push(modal(() => <MaxFavoriteMemesModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFavoriteMeme(
|
||||
i18n: I18n,
|
||||
{
|
||||
memeId,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
}: {
|
||||
memeId: string;
|
||||
name?: string;
|
||||
altText?: string | null;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await http.patch<FavoriteMeme>(Endpoints.USER_FAVORITE_MEME(ME, memeId), {
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
});
|
||||
|
||||
FavoriteMemeStore.updateMeme(response.body);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Updated saved media`),
|
||||
});
|
||||
logger.debug(`Successfully updated favorite meme ${memeId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update favorite meme ${memeId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFavoriteMeme(i18n: I18n, memeId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_FAVORITE_MEME(ME, memeId)});
|
||||
|
||||
FavoriteMemeStore.deleteMeme(memeId);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed from saved media`),
|
||||
});
|
||||
logger.debug(`Successfully deleted favorite meme ${memeId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete favorite meme ${memeId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
51
fluxer/fluxer_app/src/actions/FavoritesActionCreators.tsx
Normal file
51
fluxer/fluxer_app/src/actions/FavoritesActionCreators.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AccessibilityActionCreators from '@app/actions/AccessibilityActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
|
||||
export function confirmHideFavorites(onConfirm: (() => void) | undefined, i18n: I18n): void {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Hide Favorites`)}
|
||||
description={
|
||||
<div>
|
||||
<Trans>
|
||||
This will hide all favorites-related UI elements including buttons and menu items. Your existing favorites
|
||||
will be preserved and can be re-enabled anytime from{' '}
|
||||
<strong>User Settings → Look & Feel → Favorites</strong>.
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
primaryText={i18n._(msg`Hide Favorites`)}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={() => {
|
||||
AccessibilityActionCreators.update({showFavorites: false});
|
||||
onConfirm?.();
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
174
fluxer/fluxer_app/src/actions/GifActionCreators.tsx
Normal file
174
fluxer/fluxer_app/src/actions/GifActionCreators.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import RuntimeConfigStore, {type GifProvider} from '@app/stores/RuntimeConfigStore';
|
||||
import * as LocaleUtils from '@app/utils/LocaleUtils';
|
||||
|
||||
const logger = new Logger('GIF');
|
||||
|
||||
const getLocale = (): string => LocaleUtils.getCurrentLocale();
|
||||
|
||||
export interface Gif {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GifCategory {
|
||||
name: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
}
|
||||
|
||||
export interface GifFeatured {
|
||||
categories: Array<GifCategory>;
|
||||
gifs: Array<Gif>;
|
||||
}
|
||||
|
||||
function getProvider(): GifProvider {
|
||||
return RuntimeConfigStore.gifProvider;
|
||||
}
|
||||
|
||||
function getProviderEndpoints(provider: GifProvider): {
|
||||
search: string;
|
||||
featured: string;
|
||||
trending: string;
|
||||
registerShare: string;
|
||||
suggest: string;
|
||||
} {
|
||||
if (provider === 'tenor') {
|
||||
return {
|
||||
search: Endpoints.TENOR_SEARCH,
|
||||
featured: Endpoints.TENOR_FEATURED,
|
||||
trending: Endpoints.TENOR_TRENDING_GIFS,
|
||||
registerShare: Endpoints.TENOR_REGISTER_SHARE,
|
||||
suggest: Endpoints.TENOR_SUGGEST,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
search: Endpoints.KLIPY_SEARCH,
|
||||
featured: Endpoints.KLIPY_FEATURED,
|
||||
trending: Endpoints.KLIPY_TRENDING_GIFS,
|
||||
registerShare: Endpoints.KLIPY_REGISTER_SHARE,
|
||||
suggest: Endpoints.KLIPY_SUGGEST,
|
||||
};
|
||||
}
|
||||
|
||||
let featuredCache: Partial<Record<GifProvider, GifFeatured>> = {};
|
||||
|
||||
export async function search(q: string): Promise<Array<Gif>> {
|
||||
const provider = getProvider();
|
||||
const endpoints = getProviderEndpoints(provider);
|
||||
|
||||
try {
|
||||
logger.debug({provider, q}, 'Searching for GIFs');
|
||||
const response = await http.get<Array<Gif>>({
|
||||
url: endpoints.search,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error({provider, q, error}, 'Failed to search for GIFs');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeatured(): Promise<GifFeatured> {
|
||||
const provider = getProvider();
|
||||
const endpoints = getProviderEndpoints(provider);
|
||||
|
||||
const cached = featuredCache[provider];
|
||||
if (cached) {
|
||||
logger.debug({provider}, 'Returning cached featured GIF content');
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug({provider}, 'Fetching featured GIF content');
|
||||
const response = await http.get<GifFeatured>({
|
||||
url: endpoints.featured,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
const featured = response.body;
|
||||
featuredCache[provider] = featured;
|
||||
return featured;
|
||||
} catch (error) {
|
||||
logger.error({provider, error}, 'Failed to fetch featured GIF content');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrending(): Promise<Array<Gif>> {
|
||||
const provider = getProvider();
|
||||
const endpoints = getProviderEndpoints(provider);
|
||||
|
||||
try {
|
||||
logger.debug({provider}, 'Fetching trending GIFs');
|
||||
const response = await http.get<Array<Gif>>({
|
||||
url: endpoints.trending,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error({provider, error}, 'Failed to fetch trending GIFs');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerShare(id: string, q: string): Promise<void> {
|
||||
const provider = getProvider();
|
||||
const endpoints = getProviderEndpoints(provider);
|
||||
|
||||
try {
|
||||
logger.debug({provider, id, q}, 'Registering GIF share');
|
||||
await http.post({url: endpoints.registerShare, body: {id, q, locale: getLocale()}});
|
||||
} catch (error) {
|
||||
// Share registration is best-effort; it should never block sending a GIF.
|
||||
logger.error({provider, id, error}, 'Failed to register GIF share');
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggest(q: string): Promise<Array<string>> {
|
||||
const provider = getProvider();
|
||||
const endpoints = getProviderEndpoints(provider);
|
||||
|
||||
try {
|
||||
logger.debug({provider, q}, 'Getting GIF search suggestions');
|
||||
const response = await http.get<Array<string>>({
|
||||
url: endpoints.suggest,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error({provider, q, error}, 'Failed to get GIF search suggestions');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFeaturedCache(): void {
|
||||
featuredCache = {};
|
||||
}
|
||||
226
fluxer/fluxer_app/src/actions/GiftActionCreators.tsx
Normal file
226
fluxer/fluxer_app/src/actions/GiftActionCreators.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {GiftAcceptModal} from '@app/components/modals/GiftAcceptModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore';
|
||||
import GiftStore from '@app/stores/GiftStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const logger = new Logger('Gifts');
|
||||
|
||||
export interface Gift {
|
||||
code: string;
|
||||
duration_months: number;
|
||||
redeemed: boolean;
|
||||
created_by?: UserPartial;
|
||||
}
|
||||
|
||||
export interface GiftMetadata {
|
||||
code: string;
|
||||
duration_months: number;
|
||||
created_at: string;
|
||||
created_by: UserPartial;
|
||||
redeemed_at: string | null;
|
||||
redeemed_by: UserPartial | null;
|
||||
}
|
||||
|
||||
export async function fetch(code: string): Promise<Gift> {
|
||||
try {
|
||||
const response = await http.get<Gift>({url: Endpoints.GIFT(code)});
|
||||
const gift = response.body;
|
||||
logger.debug('Gift fetched', {code});
|
||||
return gift;
|
||||
} catch (error) {
|
||||
logger.error('Gift fetch failed', error);
|
||||
|
||||
if (error instanceof HttpError && error.status === 404) {
|
||||
GiftStore.markAsInvalid(code);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithCoalescing(code: string): Promise<Gift> {
|
||||
return GiftStore.fetchGift(code);
|
||||
}
|
||||
|
||||
export async function openAcceptModal(code: string): Promise<void> {
|
||||
void fetchWithCoalescing(code).catch(() => {});
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <GiftAcceptModal code={code} />),
|
||||
`gift-accept-${code}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function redeem(i18n: I18n, code: string): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.GIFT_REDEEM(code)});
|
||||
logger.info('Gift redeemed', {code});
|
||||
GiftStore.markAsRedeemed(code);
|
||||
ToastActionCreators.success(i18n._(msg`Gift redeemed successfully!`));
|
||||
} catch (error) {
|
||||
logger.error('Gift redeem failed', error);
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const errorResponse = error.body as ApiErrorResponse;
|
||||
const errorCode = errorResponse?.code;
|
||||
|
||||
switch (errorCode) {
|
||||
case APIErrorCodes.CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY:
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Cannot Redeem Gift`)}
|
||||
message={i18n._(msg`You cannot redeem Plutonium gift codes while you have Visionary premium.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case APIErrorCodes.UNKNOWN_GIFT_CODE:
|
||||
GiftStore.markAsInvalid(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Invalid Gift Code`)}
|
||||
message={i18n._(msg`This gift code is invalid or has already been redeemed.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case APIErrorCodes.GIFT_CODE_ALREADY_REDEEMED:
|
||||
GiftStore.markAsRedeemed(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Gift Already Redeemed`)}
|
||||
message={i18n._(msg`This gift code has already been redeemed.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
if (error.status === 404) {
|
||||
GiftStore.markAsInvalid(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Gift Not Found`)}
|
||||
message={i18n._(msg`This gift code could not be found.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Failed to Redeem Gift`)}
|
||||
message={i18n._(msg`We couldn't redeem this gift code. Please try again.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Failed to Redeem Gift`)}
|
||||
message={i18n._(msg`We couldn't redeem this gift code. Please try again.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserGifts(): Promise<Array<GiftMetadata>> {
|
||||
if (DeveloperOptionsStore.mockGiftInventory) {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const userPartial: UserPartial = currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
discriminator: currentUser.discriminator,
|
||||
global_name: currentUser.globalName,
|
||||
avatar: currentUser.avatar,
|
||||
avatar_color: currentUser.avatarColor ?? null,
|
||||
flags: currentUser.flags,
|
||||
}
|
||||
: {
|
||||
id: '000000000000000000',
|
||||
username: 'MockUser',
|
||||
discriminator: '0000',
|
||||
global_name: null,
|
||||
avatar: null,
|
||||
avatar_color: null,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * MS_PER_DAY);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * MS_PER_DAY);
|
||||
|
||||
const durationMonths = DeveloperOptionsStore.mockGiftDurationMonths ?? 12;
|
||||
const isRedeemed = DeveloperOptionsStore.mockGiftRedeemed ?? false;
|
||||
|
||||
const mockGift: GiftMetadata = {
|
||||
code: 'MOCK-GIFT-TEST-1234',
|
||||
duration_months: durationMonths,
|
||||
created_at: sevenDaysAgo.toISOString(),
|
||||
created_by: userPartial,
|
||||
redeemed_at: isRedeemed ? twoDaysAgo.toISOString() : null,
|
||||
redeemed_by: isRedeemed ? userPartial : null,
|
||||
};
|
||||
|
||||
logger.debug('Returning mock user gifts', {count: 1});
|
||||
return [mockGift];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<GiftMetadata>>({url: Endpoints.USER_GIFTS});
|
||||
const gifts = response.body;
|
||||
logger.debug('User gifts fetched', {count: gifts.length});
|
||||
return gifts;
|
||||
} catch (error) {
|
||||
logger.error('User gifts fetch failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
434
fluxer/fluxer_app/src/actions/GuildActionCreators.tsx
Normal file
434
fluxer/fluxer_app/src/actions/GuildActionCreators.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelMoveOperation} from '@app/components/layout/utils/ChannelMoveOperation';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import InviteStore from '@app/stores/InviteStore';
|
||||
import type {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import type {
|
||||
AuditLogWebhookResponse,
|
||||
GuildAuditLogEntryResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import type {
|
||||
DiscoveryApplicationRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryStatusResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRole} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
const logger = new Logger('GuildActionCreators');
|
||||
|
||||
export interface GuildAuditLogFetchParams {
|
||||
userId?: string;
|
||||
actionType?: AuditLogActionType;
|
||||
limit?: number;
|
||||
beforeLogId?: string;
|
||||
afterLogId?: string;
|
||||
}
|
||||
|
||||
interface GuildAuditLogFetchResponse {
|
||||
audit_log_entries: Array<GuildAuditLogEntryResponse>;
|
||||
users: Array<UserPartial>;
|
||||
webhooks: Array<AuditLogWebhookResponse>;
|
||||
}
|
||||
|
||||
export interface GuildBan {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
reason: string | null;
|
||||
moderator_id: string;
|
||||
banned_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export async function create(params: Pick<Guild, 'name'> & {icon?: string | null}): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.post<Guild>(Endpoints.GUILDS, params);
|
||||
const guild = response.body;
|
||||
logger.debug(`Created new guild: ${params['name']}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create guild:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
guildId: string,
|
||||
params: Partial<
|
||||
Pick<
|
||||
Guild,
|
||||
| 'name'
|
||||
| 'icon'
|
||||
| 'banner'
|
||||
| 'splash'
|
||||
| 'embed_splash'
|
||||
| 'splash_card_alignment'
|
||||
| 'afk_channel_id'
|
||||
| 'afk_timeout'
|
||||
| 'system_channel_id'
|
||||
| 'system_channel_flags'
|
||||
| 'features'
|
||||
| 'default_message_notifications'
|
||||
| 'message_history_cutoff'
|
||||
| 'verification_level'
|
||||
| 'mfa_level'
|
||||
| 'nsfw_level'
|
||||
| 'explicit_content_filter'
|
||||
>
|
||||
>,
|
||||
): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD(guildId), params);
|
||||
const guild = response.body;
|
||||
logger.debug(`Updated guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveChannel(guildId: string, operation: ChannelMoveOperation): Promise<void> {
|
||||
try {
|
||||
await http.patch({
|
||||
url: Endpoints.GUILD_CHANNELS(guildId),
|
||||
body: [
|
||||
{
|
||||
id: operation.channelId,
|
||||
parent_id: operation.newParentId,
|
||||
preceding_sibling_id: operation.precedingSiblingId,
|
||||
lock_permissions: false,
|
||||
position: operation.position,
|
||||
},
|
||||
],
|
||||
retries: 5,
|
||||
});
|
||||
logger.debug(`Moved channel ${operation.channelId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to move channel ${operation.channelId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVanityURL(guildId: string): Promise<{code: string | null; uses: number}> {
|
||||
try {
|
||||
const response = await http.get<{code: string | null; uses: number}>(Endpoints.GUILD_VANITY_URL(guildId));
|
||||
const result = response.body;
|
||||
logger.debug(`Fetched vanity URL for guild ${guildId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch vanity URL for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVanityURL(guildId: string, code: string | null): Promise<string> {
|
||||
try {
|
||||
const response = await http.patch<{code: string}>(Endpoints.GUILD_VANITY_URL(guildId), {code});
|
||||
logger.debug(`Updated vanity URL for guild ${guildId} to ${code || 'none'}`);
|
||||
return response.body.code;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update vanity URL for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(guildId: string, name: string): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_ROLES(guildId), body: {name}});
|
||||
logger.debug(`Created role "${name}" in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create role in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRole(guildId: string, roleId: string, patch: Partial<GuildRole>): Promise<void> {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_ROLE(guildId, roleId), body: patch});
|
||||
logger.debug(`Updated role ${roleId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role ${roleId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRole(guildId: string, roleId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_ROLE(guildId, roleId)});
|
||||
logger.debug(`Deleted role ${roleId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete role ${roleId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRoleOrder(guildId: string, orderedRoleIds: Array<string>): Promise<void> {
|
||||
try {
|
||||
const filteredIds = orderedRoleIds.filter((id) => id !== guildId);
|
||||
const payload = filteredIds.map((id, index) => ({id, position: filteredIds.length - index}));
|
||||
await http.patch({url: Endpoints.GUILD_ROLES(guildId), body: payload, retries: 5});
|
||||
logger.debug(`Updated role ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRoleHoistOrder(guildId: string, orderedRoleIds: Array<string>): Promise<void> {
|
||||
try {
|
||||
const filteredIds = orderedRoleIds.filter((id) => id !== guildId);
|
||||
const payload = filteredIds.map((id, index) => ({id, hoist_position: filteredIds.length - index}));
|
||||
await http.patch({url: Endpoints.GUILD_ROLE_HOIST_POSITIONS(guildId), body: payload, retries: 5});
|
||||
logger.debug(`Updated role hoist ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role hoist ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetRoleHoistOrder(guildId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_ROLE_HOIST_POSITIONS(guildId)});
|
||||
logger.debug(`Reset role hoist ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reset role hoist ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(guildId: string): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_DELETE(guildId), body: {}});
|
||||
logger.debug(`Deleted guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function leave(guildId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_GUILDS(guildId)});
|
||||
logger.debug(`Left guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to leave guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGuildInvites(guildId: string): Promise<Array<Invite>> {
|
||||
try {
|
||||
InviteStore.handleGuildInvitesFetchPending(guildId);
|
||||
const response = await http.get<Array<Invite>>(Endpoints.GUILD_INVITES(guildId));
|
||||
const invites = response.body;
|
||||
InviteStore.handleGuildInvitesFetchSuccess(guildId, invites);
|
||||
return invites;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invites for guild ${guildId}:`, error);
|
||||
InviteStore.handleGuildInvitesFetchError(guildId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleInvitesDisabled(guildId: string, disabled: boolean): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD(guildId), {
|
||||
features: disabled ? ['INVITES_DISABLED'] : [],
|
||||
});
|
||||
const guild = response.body;
|
||||
logger.debug(`${disabled ? 'Disabled' : 'Enabled'} invites for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${disabled ? 'disable' : 'enable'} invites for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleTextChannelFlexibleNames(guildId: string, enabled: boolean): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD_TEXT_CHANNEL_FLEXIBLE_NAMES(guildId), {enabled});
|
||||
const guild = response.body;
|
||||
logger.debug(`${enabled ? 'Enabled' : 'Disabled'} flexible text channel names for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${enabled ? 'enable' : 'disable'} flexible text channel names for guild ${guildId}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleDetachedBanner(guildId: string, enabled: boolean): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD_DETACHED_BANNER(guildId), {enabled});
|
||||
const guild = response.body;
|
||||
logger.debug(`${enabled ? 'Enabled' : 'Disabled'} detached banner for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${enabled ? 'enable' : 'disable'} detached banner for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferOwnership(guildId: string, newOwnerId: string): Promise<Guild> {
|
||||
try {
|
||||
const response = await http.post<Guild>(Endpoints.GUILD_TRANSFER_OWNERSHIP(guildId), {
|
||||
new_owner_id: newOwnerId,
|
||||
});
|
||||
const guild = response.body;
|
||||
logger.debug(`Transferred ownership of guild ${guildId} to ${newOwnerId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to transfer ownership of guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function banMember(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
deleteMessageDays?: number,
|
||||
reason?: string,
|
||||
banDurationSeconds?: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.GUILD_BAN(guildId, userId),
|
||||
body: {
|
||||
delete_message_days: deleteMessageDays ?? 0,
|
||||
reason: reason ?? null,
|
||||
ban_duration_seconds: banDurationSeconds,
|
||||
},
|
||||
});
|
||||
logger.debug(`Banned user ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ban user ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unbanMember(guildId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_BAN(guildId, userId)});
|
||||
logger.debug(`Unbanned user ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to unban user ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBans(guildId: string): Promise<Array<GuildBan>> {
|
||||
try {
|
||||
const response = await http.get<Array<GuildBan>>(Endpoints.GUILD_BANS(guildId));
|
||||
const bans = response.body;
|
||||
logger.debug(`Fetched ${bans.length} bans for guild ${guildId}`);
|
||||
return bans;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch bans for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGuildAuditLogs(
|
||||
guildId: string,
|
||||
params: GuildAuditLogFetchParams,
|
||||
): Promise<GuildAuditLogFetchResponse> {
|
||||
try {
|
||||
const query: Record<string, string | number> = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
if (params.beforeLogId !== undefined) query.before = params.beforeLogId;
|
||||
if (params.afterLogId !== undefined) query.after = params.afterLogId;
|
||||
if (params.userId) query.user_id = params.userId;
|
||||
if (params.actionType !== undefined) query.action_type = params.actionType;
|
||||
|
||||
const response = await http.get<GuildAuditLogFetchResponse>({
|
||||
url: Endpoints.GUILD_AUDIT_LOGS(guildId),
|
||||
query,
|
||||
});
|
||||
|
||||
const data = response.body;
|
||||
logger.debug(`Fetched ${data.audit_log_entries.length} audit log entries for guild ${guildId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch audit logs for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDiscoveryStatus(guildId: string): Promise<DiscoveryStatusResponse> {
|
||||
try {
|
||||
const response = await http.get<DiscoveryStatusResponse>(Endpoints.GUILD_DISCOVERY(guildId));
|
||||
logger.debug(`Fetched discovery status for guild ${guildId}`);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch discovery status for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyForDiscovery(
|
||||
guildId: string,
|
||||
params: DiscoveryApplicationRequest,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
try {
|
||||
const response = await http.post<DiscoveryApplicationResponse>(Endpoints.GUILD_DISCOVERY(guildId), params);
|
||||
logger.debug(`Applied for discovery for guild ${guildId}`);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply for discovery for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDiscoveryApplication(
|
||||
guildId: string,
|
||||
params: Partial<DiscoveryApplicationRequest>,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
try {
|
||||
const response = await http.patch<DiscoveryApplicationResponse>(Endpoints.GUILD_DISCOVERY(guildId), params);
|
||||
logger.debug(`Updated discovery application for guild ${guildId}`);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update discovery application for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withdrawDiscoveryApplication(guildId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_DISCOVERY(guildId)});
|
||||
logger.debug(`Withdrew discovery application for guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to withdraw discovery application for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
92
fluxer/fluxer_app/src/actions/GuildEmojiActionCreators.tsx
Normal file
92
fluxer/fluxer_app/src/actions/GuildEmojiActionCreators.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {GuildEmojiWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
|
||||
const logger = new Logger('Emojis');
|
||||
|
||||
export function sanitizeEmojiName(fileName: string): string {
|
||||
const name =
|
||||
fileName
|
||||
.split('.')
|
||||
.shift()
|
||||
?.replace(/[^a-zA-Z0-9_]/g, '') ?? '';
|
||||
return name.padEnd(2, '_').slice(0, 32);
|
||||
}
|
||||
|
||||
export async function list(guildId: string): Promise<ReadonlyArray<GuildEmojiWithUser>> {
|
||||
try {
|
||||
const response = await http.get<ReadonlyArray<GuildEmojiWithUser>>({url: Endpoints.GUILD_EMOJIS(guildId)});
|
||||
const emojis = response.body;
|
||||
logger.debug(`Retrieved ${emojis.length} emojis for guild ${guildId}`);
|
||||
return emojis;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list emojis for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpload(
|
||||
guildId: string,
|
||||
emojis: Array<{name: string; image: string}>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{success: Array<GuildEmojiWithUser>; failed: Array<{name: string; error: string}>}> {
|
||||
try {
|
||||
const response = await http.post<{
|
||||
success: Array<GuildEmojiWithUser>;
|
||||
failed: Array<{name: string; error: string}>;
|
||||
}>({
|
||||
url: `${Endpoints.GUILD_EMOJIS(guildId)}/bulk`,
|
||||
body: {emojis},
|
||||
signal,
|
||||
});
|
||||
const result = response.body;
|
||||
logger.debug(`Bulk uploaded ${result.success.length} emojis to guild ${guildId}, ${result.failed.length} failed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to bulk upload emojis to guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(guildId: string, emojiId: string, data: {name: string}): Promise<void> {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_EMOJI(guildId, emojiId), body: data});
|
||||
logger.debug(`Updated emoji ${emojiId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update emoji ${emojiId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(guildId: string, emojiId: string, purge = false): Promise<void> {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.GUILD_EMOJI(guildId, emojiId),
|
||||
query: purge ? {purge: true} : undefined,
|
||||
});
|
||||
logger.debug(`Removed emoji ${emojiId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove emoji ${emojiId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
114
fluxer/fluxer_app/src/actions/GuildMemberActionCreators.tsx
Normal file
114
fluxer/fluxer_app/src/actions/GuildMemberActionCreators.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
|
||||
const logger = new Logger('GuildMembers');
|
||||
|
||||
export async function update(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
params: Partial<GuildMemberData> & {channel_id?: string | null; connection_id?: string},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_MEMBER(guildId, userId), body: params});
|
||||
logger.debug(`Updated member ${userId} in guild ${guildId}`, {connection_id: params['connection_id']});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRole(guildId: string, userId: string, roleId: string): Promise<void> {
|
||||
try {
|
||||
await http.put({url: Endpoints.GUILD_MEMBER_ROLE(guildId, userId, roleId)});
|
||||
logger.debug(`Added role ${roleId} to member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add role ${roleId} to member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRole(guildId: string, userId: string, roleId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_MEMBER_ROLE(guildId, userId, roleId)});
|
||||
logger.debug(`Removed role ${roleId} from member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove role ${roleId} from member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
guildId: string,
|
||||
params: {
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
bio?: string | null;
|
||||
pronouns?: string | null;
|
||||
accent_color?: number | null;
|
||||
nick?: string | null;
|
||||
profile_flags?: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_MEMBER(guildId), body: params});
|
||||
logger.debug(`Updated current user's per-guild profile in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update current user's per-guild profile in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function kick(guildId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_MEMBER(guildId, userId)});
|
||||
logger.debug(`Kicked member ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to kick member ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function timeout(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
communicationDisabledUntil: string | null,
|
||||
timeoutReason?: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const body: Record<string, string | null> = {
|
||||
communication_disabled_until: communicationDisabledUntil,
|
||||
};
|
||||
if (timeoutReason) {
|
||||
body.timeout_reason = timeoutReason;
|
||||
}
|
||||
await http.patch({
|
||||
url: Endpoints.GUILD_MEMBER(guildId, userId),
|
||||
body,
|
||||
});
|
||||
logger.debug(`Updated timeout for member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update timeout for member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
28
fluxer/fluxer_app/src/actions/GuildNSFWActionCreators.tsx
Normal file
28
fluxer/fluxer_app/src/actions/GuildNSFWActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
|
||||
|
||||
export function agreeToChannel(channelId: string): void {
|
||||
GuildNSFWAgreeStore.agreeToChannel(channelId);
|
||||
}
|
||||
|
||||
export function agreeToGuild(guildId: string): void {
|
||||
GuildNSFWAgreeStore.agreeToGuild(guildId);
|
||||
}
|
||||
86
fluxer/fluxer_app/src/actions/GuildStickerActionCreators.tsx
Normal file
86
fluxer/fluxer_app/src/actions/GuildStickerActionCreators.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {GuildStickerWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
|
||||
|
||||
const logger = new Logger('Stickers');
|
||||
|
||||
export function sanitizeStickerName(fileName: string): string {
|
||||
const name =
|
||||
fileName
|
||||
.split('.')
|
||||
.shift()
|
||||
?.replace(/[^a-zA-Z0-9_]/g, '') ?? '';
|
||||
return name.padEnd(2, '_').slice(0, 30);
|
||||
}
|
||||
|
||||
export async function list(guildId: string): Promise<ReadonlyArray<GuildStickerWithUser>> {
|
||||
try {
|
||||
const response = await http.get<ReadonlyArray<GuildStickerWithUser>>({url: Endpoints.GUILD_STICKERS(guildId)});
|
||||
const stickers = response.body;
|
||||
logger.debug(`Retrieved ${stickers.length} stickers for guild ${guildId}`);
|
||||
return stickers;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list stickers for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(
|
||||
guildId: string,
|
||||
sticker: {name: string; description: string; tags: Array<string>; image: string},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_STICKERS(guildId), body: sticker});
|
||||
logger.debug(`Created sticker ${sticker.name} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create sticker ${sticker.name} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
guildId: string,
|
||||
stickerId: string,
|
||||
data: {name?: string; description?: string; tags?: Array<string>},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_STICKER(guildId, stickerId), body: data});
|
||||
logger.debug(`Updated sticker ${stickerId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update sticker ${stickerId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(guildId: string, stickerId: string, purge = false): Promise<void> {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.GUILD_STICKER(guildId, stickerId),
|
||||
query: purge ? {purge: true} : undefined,
|
||||
});
|
||||
logger.debug(`Removed sticker ${stickerId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove sticker ${stickerId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
28
fluxer/fluxer_app/src/actions/HighlightActionCreators.tsx
Normal file
28
fluxer/fluxer_app/src/actions/HighlightActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import AutocompleteStore from '@app/stores/AutocompleteStore';
|
||||
|
||||
export function highlightChannel(channelId: string): void {
|
||||
AutocompleteStore.highlightChannel(channelId);
|
||||
}
|
||||
|
||||
export function clearChannelHighlight(): void {
|
||||
AutocompleteStore.highlightChannelClear();
|
||||
}
|
||||
90
fluxer/fluxer_app/src/actions/IARActionCreators.tsx
Normal file
90
fluxer/fluxer_app/src/actions/IARActionCreators.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
|
||||
const logger = new Logger('IAR');
|
||||
|
||||
export async function reportMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Reporting message ${messageId} in channel ${channelId}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_MESSAGE,
|
||||
body: {
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('Message report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit message report:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportUser(
|
||||
userId: string,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
guildId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Reporting user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_USER,
|
||||
body: {
|
||||
user_id: userId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
guild_id: guildId || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('User report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit user report:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportGuild(guildId: string, category: string, additionalInfo?: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Reporting guild ${guildId}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_GUILD,
|
||||
body: {
|
||||
guild_id: guildId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('Guild report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit guild report:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
25
fluxer/fluxer_app/src/actions/InboxActionCreators.tsx
Normal file
25
fluxer/fluxer_app/src/actions/InboxActionCreators.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {InboxTab} from '@app/stores/InboxStore';
|
||||
import InboxStore from '@app/stores/InboxStore';
|
||||
|
||||
export function setTab(tab: InboxTab): void {
|
||||
InboxStore.setTab(tab);
|
||||
}
|
||||
388
fluxer/fluxer_app/src/actions/InviteActionCreators.tsx
Normal file
388
fluxer/fluxer_app/src/actions/InviteActionCreators.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {GuildAtCapacityModal} from '@app/components/alerts/GuildAtCapacityModal';
|
||||
import {InviteAcceptFailedModal} from '@app/components/alerts/InviteAcceptFailedModal';
|
||||
import {InvitesDisabledModal} from '@app/components/alerts/InvitesDisabledModal';
|
||||
import {MaxGuildsModal} from '@app/components/alerts/MaxGuildsModal';
|
||||
import {TemporaryInviteRequiresPresenceModal} from '@app/components/alerts/TemporaryInviteRequiresPresenceModal';
|
||||
import {UserBannedFromGuildModal} from '@app/components/alerts/UserBannedFromGuildModal';
|
||||
import {UserIpBannedFromGuildModal} from '@app/components/alerts/UserIpBannedFromGuildModal';
|
||||
import {InviteAcceptModal} from '@app/components/modals/InviteAcceptModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import AuthenticationStore from '@app/stores/AuthenticationStore';
|
||||
import GuildMemberStore from '@app/stores/GuildMemberStore';
|
||||
import InviteStore from '@app/stores/InviteStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '@app/types/InviteTypes';
|
||||
import {getApiErrorCode, getApiErrorMessage} from '@app/utils/ApiErrorUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
|
||||
const logger = new Logger('Invites');
|
||||
|
||||
const isUnclaimedAccountInviteError = (code?: string): boolean => {
|
||||
return code === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_JOIN_GROUP_DMS;
|
||||
};
|
||||
|
||||
const shouldOpenInviteGuildChannel = (channelType: number): boolean =>
|
||||
channelType !== ChannelTypes.GUILD_CATEGORY && channelType !== ChannelTypes.GUILD_LINK;
|
||||
|
||||
export async function fetch(code: string): Promise<Invite> {
|
||||
try {
|
||||
logger.debug(`Fetching invite with code ${code}`);
|
||||
const response = await http.get<Invite>(Endpoints.INVITE(code));
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithCoalescing(code: string): Promise<Invite> {
|
||||
return InviteStore.fetchInvite(code);
|
||||
}
|
||||
|
||||
const accept = async (code: string): Promise<Invite> => {
|
||||
try {
|
||||
logger.debug(`Accepting invite with code ${code}`);
|
||||
const response = await http.post<Invite>(Endpoints.INVITE(code), {} as Invite);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to accept invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptInvite = accept;
|
||||
|
||||
export async function acceptAndTransitionToChannel(code: string, i18n: I18n): Promise<void> {
|
||||
let invite: Invite | null = null;
|
||||
try {
|
||||
logger.debug(`Fetching invite details before accepting: ${code}`);
|
||||
invite = await fetchWithCoalescing(code);
|
||||
if (!invite) {
|
||||
throw new Error(`Invite ${code} returned no data`);
|
||||
}
|
||||
|
||||
if (isPackInvite(invite)) {
|
||||
await accept(code);
|
||||
const packLabel = invite.pack.type === 'emoji' ? 'emoji pack' : 'sticker pack';
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: (
|
||||
<Trans>
|
||||
The {packLabel} {invite.pack.name} has been installed.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupDmInvite(invite)) {
|
||||
const channelId = invite.channel.id;
|
||||
logger.debug(`Accepting group DM invite ${code} and opening channel ${channelId}`);
|
||||
await accept(code);
|
||||
NavigationActionCreators.selectChannel(ME, channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGuildInvite(invite)) {
|
||||
throw new Error(`Invite ${code} is not a guild, group DM, or pack invite`);
|
||||
}
|
||||
|
||||
const channelId = invite.channel.id;
|
||||
const inviteTargetAllowed = shouldOpenInviteGuildChannel(invite.channel.type);
|
||||
const targetChannelId = inviteTargetAllowed ? channelId : undefined;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const guildId = invite.guild.id;
|
||||
const isMember = currentUserId ? GuildMemberStore.getMember(guildId, currentUserId) != null : false;
|
||||
if (isMember) {
|
||||
logger.debug(
|
||||
inviteTargetAllowed
|
||||
? `User already in guild ${guildId}, transitioning to channel ${channelId}`
|
||||
: `User already in guild ${guildId}, invite target is non-viewable, transitioning to guild root`,
|
||||
);
|
||||
NavigationActionCreators.selectChannel(guildId, targetChannelId);
|
||||
return;
|
||||
}
|
||||
logger.debug(`User not in guild ${guildId}, accepting invite ${code}`);
|
||||
await accept(code);
|
||||
logger.debug(
|
||||
inviteTargetAllowed
|
||||
? `Transitioning to channel ${channelId} in guild ${guildId}`
|
||||
: `Invite target channel ${channelId} in guild ${guildId} is non-viewable, transitioning to guild root`,
|
||||
);
|
||||
NavigationActionCreators.selectChannel(guildId, targetChannelId);
|
||||
} catch (error) {
|
||||
const httpError = error instanceof HttpError ? error : null;
|
||||
const errorCode = getApiErrorCode(error);
|
||||
logger.error(`Failed to accept invite and transition for code ${code}:`, error);
|
||||
|
||||
if (httpError?.status === 404 || errorCode === APIErrorCodes.UNKNOWN_INVITE) {
|
||||
logger.debug(`Invite ${code} not found, removing from store`);
|
||||
InviteStore.handleInviteDelete(code);
|
||||
}
|
||||
|
||||
if (handlePackInviteError({invite, errorCode, httpError, i18n})) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (errorCode === APIErrorCodes.INVITES_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <InvitesDisabledModal />));
|
||||
} else if (httpError?.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (errorCode === APIErrorCodes.MAX_GUILD_MEMBERS) {
|
||||
ModalActionCreators.push(modal(() => <GuildAtCapacityModal />));
|
||||
} else if (errorCode === APIErrorCodes.MAX_GUILDS) {
|
||||
const currentUser = UserStore.currentUser;
|
||||
if (currentUser) {
|
||||
ModalActionCreators.push(modal(() => <MaxGuildsModal user={currentUser} />));
|
||||
}
|
||||
} else if (errorCode === APIErrorCodes.TEMPORARY_INVITE_REQUIRES_PRESENCE) {
|
||||
ModalActionCreators.push(modal(() => <TemporaryInviteRequiresPresenceModal />));
|
||||
} else if (errorCode === APIErrorCodes.USER_BANNED_FROM_GUILD) {
|
||||
ModalActionCreators.push(modal(() => <UserBannedFromGuildModal />));
|
||||
} else if (errorCode === APIErrorCodes.USER_IP_BANNED_FROM_GUILD) {
|
||||
ModalActionCreators.push(modal(() => <UserIpBannedFromGuildModal />));
|
||||
} else if (isUnclaimedAccountInviteError(errorCode)) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Account Verification Required`)}
|
||||
message={i18n._(
|
||||
msg`Please verify your account by setting an email and password before joining communities.`,
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else if (httpError?.status && httpError.status >= 400) {
|
||||
ModalActionCreators.push(modal(() => <InviteAcceptFailedModal />));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAcceptModal(code: string): Promise<void> {
|
||||
void fetchWithCoalescing(code).catch(() => {});
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <InviteAcceptModal code={code} />),
|
||||
`invite-accept-${code}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface HandlePackInviteErrorParams {
|
||||
invite: Invite | null;
|
||||
errorCode?: string;
|
||||
httpError?: HttpError | null;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
interface PackLimitPayload {
|
||||
packType?: 'emoji' | 'sticker';
|
||||
limit?: number;
|
||||
action?: 'create' | 'install';
|
||||
}
|
||||
|
||||
const getPackLimitPayload = (httpError?: HttpError | null): PackLimitPayload | null => {
|
||||
const body = httpError?.body;
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const record = body as Record<string, unknown>;
|
||||
const data = record.data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
const limit = dataRecord.limit;
|
||||
const packType = dataRecord.pack_type;
|
||||
const action = dataRecord.action;
|
||||
return {
|
||||
packType: packType === 'emoji' || packType === 'sticker' ? packType : undefined,
|
||||
limit: typeof limit === 'number' ? limit : undefined,
|
||||
action: action === 'create' || action === 'install' ? action : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const buildPackLimitStrings = (
|
||||
i18n: I18n,
|
||||
packType: 'emoji' | 'sticker',
|
||||
action: 'install' | 'create',
|
||||
limit?: number,
|
||||
): {title: string; message: string} => {
|
||||
switch (packType) {
|
||||
case 'emoji': {
|
||||
switch (action) {
|
||||
case 'install': {
|
||||
const title = i18n._(msg`Emoji pack limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have installed the maximum of ${limit} emoji pack. Remove one to install another.`
|
||||
: msg`You have installed the maximum of ${limit} emoji packs. Remove one to install another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for installing emoji packs. Remove one of your installed packs to install another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
default: {
|
||||
const title = i18n._(msg`Emoji pack creation limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have created the maximum of ${limit} emoji pack. Delete one to create another.`
|
||||
: msg`You have created the maximum of ${limit} emoji packs. Delete one to create another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for creating emoji packs. Delete one of your packs to create another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
}
|
||||
}
|
||||
default: {
|
||||
switch (action) {
|
||||
case 'install': {
|
||||
const title = i18n._(msg`Sticker pack limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have installed the maximum of ${limit} sticker pack. Remove one to install another.`
|
||||
: msg`You have installed the maximum of ${limit} sticker packs. Remove one to install another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for installing sticker packs. Remove one of your installed packs to install another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
default: {
|
||||
const title = i18n._(msg`Sticker pack creation limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have created the maximum of ${limit} sticker pack. Delete one to create another.`
|
||||
: msg`You have created the maximum of ${limit} sticker packs. Delete one to create another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for creating sticker packs. Delete one of your packs to create another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function handlePackInviteError(params: HandlePackInviteErrorParams): boolean {
|
||||
const {invite, errorCode, httpError, i18n} = params;
|
||||
if (!invite || !isPackInvite(invite)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isEmojiPack = invite.pack.type === 'emoji';
|
||||
const cannotInstallTitle = isEmojiPack
|
||||
? i18n._(msg`Cannot install emoji pack`)
|
||||
: i18n._(msg`Cannot install sticker pack`);
|
||||
const cannotInstallMessage = isEmojiPack
|
||||
? i18n._(msg`You don't have permission to install this emoji pack.`)
|
||||
: i18n._(msg`You don't have permission to install this sticker pack.`);
|
||||
const defaultTitle = isEmojiPack
|
||||
? i18n._(msg`Unable to install emoji pack`)
|
||||
: i18n._(msg`Unable to install sticker pack`);
|
||||
const defaultMessage = isEmojiPack
|
||||
? i18n._(msg`Failed to install this emoji pack. Please try again later.`)
|
||||
: i18n._(msg`Failed to install this sticker pack. Please try again later.`);
|
||||
|
||||
if (errorCode === APIErrorCodes.MISSING_ACCESS) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => <GenericErrorModal title={cannotInstallTitle} message={cannotInstallMessage} />),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (errorCode === APIErrorCodes.MAX_PACKS) {
|
||||
const payload = getPackLimitPayload(httpError);
|
||||
const packType = payload?.packType ?? invite.pack.type;
|
||||
const action = payload?.action ?? 'install';
|
||||
const limit = payload?.limit;
|
||||
const {title, message} = buildPackLimitStrings(i18n, packType, action, limit);
|
||||
|
||||
ModalActionCreators.push(modal(() => <GenericErrorModal title={title} message={message} />));
|
||||
return true;
|
||||
}
|
||||
|
||||
const fallbackMessage = httpError ? getApiErrorMessage(httpError) : null;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => <GenericErrorModal title={defaultTitle} message={fallbackMessage || defaultMessage} />),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function create(
|
||||
channelId: string,
|
||||
params?: {max_age?: number; max_uses?: number; temporary?: boolean},
|
||||
): Promise<Invite> {
|
||||
try {
|
||||
logger.debug(`Creating invite for channel ${channelId}`);
|
||||
const response = await http.post<Invite>(Endpoints.CHANNEL_INVITES(channelId), params ?? {});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invite for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(channelId: string): Promise<Array<Invite>> {
|
||||
try {
|
||||
logger.debug(`Listing invites for channel ${channelId}`);
|
||||
const response = await http.get<Array<Invite>>(Endpoints.CHANNEL_INVITES(channelId));
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list invites for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(code: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Deleting invite with code ${code}`);
|
||||
await http.delete({url: Endpoints.INVITE(code)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
132
fluxer/fluxer_app/src/actions/KlipyActionCreators.tsx
Normal file
132
fluxer/fluxer_app/src/actions/KlipyActionCreators.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import * as LocaleUtils from '@app/utils/LocaleUtils';
|
||||
|
||||
const logger = new Logger('KLIPY');
|
||||
|
||||
const getLocale = (): string => LocaleUtils.getCurrentLocale();
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface KlipyCategory {
|
||||
name: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
}
|
||||
|
||||
export interface KlipyFeatured {
|
||||
categories: Array<KlipyCategory>;
|
||||
gifs: Array<KlipyGif>;
|
||||
}
|
||||
|
||||
let klipyFeaturedCache: KlipyFeatured | null = null;
|
||||
|
||||
export async function search(q: string): Promise<Array<KlipyGif>> {
|
||||
try {
|
||||
logger.debug(`Searching for GIFs with query: "${q}"`);
|
||||
const response = await http.get<Array<KlipyGif>>({
|
||||
url: Endpoints.KLIPY_SEARCH,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
const gifs = response.body;
|
||||
logger.debug(`Found ${gifs.length} GIFs for query "${q}"`);
|
||||
return gifs;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to search for GIFs with query "${q}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFeatured(): Promise<KlipyFeatured> {
|
||||
if (klipyFeaturedCache) {
|
||||
logger.debug('Returning cached featured KLIPY content');
|
||||
return klipyFeaturedCache;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('Fetching featured KLIPY content');
|
||||
const response = await http.get<KlipyFeatured>({
|
||||
url: Endpoints.KLIPY_FEATURED,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
const featured = response.body;
|
||||
klipyFeaturedCache = featured;
|
||||
logger.debug(
|
||||
`Fetched featured KLIPY content: ${featured.categories.length} categories and ${featured.gifs.length} GIFs`,
|
||||
);
|
||||
return featured;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch featured KLIPY content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrending(): Promise<Array<KlipyGif>> {
|
||||
try {
|
||||
logger.debug('Fetching trending KLIPY GIFs');
|
||||
const response = await http.get<Array<KlipyGif>>({
|
||||
url: Endpoints.KLIPY_TRENDING_GIFS,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
const gifs = response.body;
|
||||
logger.debug(`Fetched ${gifs.length} trending KLIPY GIFs`);
|
||||
return gifs;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch trending KLIPY GIFs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerShare(id: string, q: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Registering GIF share: id=${id}, query="${q}"`);
|
||||
await http.post({url: Endpoints.KLIPY_REGISTER_SHARE, body: {id, q, locale: getLocale()}});
|
||||
logger.debug(`Successfully registered GIF share for id=${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to register GIF share for id=${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggest(q: string): Promise<Array<string>> {
|
||||
try {
|
||||
logger.debug(`Getting KLIPY search suggestions for: "${q}"`);
|
||||
const response = await http.get<Array<string>>({
|
||||
url: Endpoints.KLIPY_SUGGEST,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
const suggestions = response.body;
|
||||
logger.debug(`Received ${suggestions.length} suggestions for query "${q}"`);
|
||||
return suggestions;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get suggestions for query "${q}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
33
fluxer/fluxer_app/src/actions/LayoutActionCreators.tsx
Normal file
33
fluxer/fluxer_app/src/actions/LayoutActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import MemberListStore from '@app/stores/MemberListStore';
|
||||
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
|
||||
|
||||
const logger = new Logger('Layout');
|
||||
|
||||
export function updateMobileLayoutState(navExpanded: boolean, chatExpanded: boolean): void {
|
||||
logger.debug(`Updating mobile layout state: nav=${navExpanded}, chat=${chatExpanded}`);
|
||||
MobileLayoutStore.updateState({navExpanded, chatExpanded});
|
||||
}
|
||||
|
||||
export function toggleMembers(_isOpen: boolean): void {
|
||||
MemberListStore.toggleMembers();
|
||||
}
|
||||
41
fluxer/fluxer_app/src/actions/MediaViewerActionCreators.tsx
Normal file
41
fluxer/fluxer_app/src/actions/MediaViewerActionCreators.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MessageRecord} from '@app/records/MessageRecord';
|
||||
import MediaViewerStore, {type MediaViewerItem} from '@app/stores/MediaViewerStore';
|
||||
|
||||
export function openMediaViewer(
|
||||
items: ReadonlyArray<MediaViewerItem>,
|
||||
currentIndex: number,
|
||||
options?: {
|
||||
channelId?: string;
|
||||
messageId?: string;
|
||||
message?: MessageRecord;
|
||||
},
|
||||
): void {
|
||||
MediaViewerStore.open(items, currentIndex, options?.channelId, options?.messageId, options?.message);
|
||||
}
|
||||
|
||||
export function closeMediaViewer(): void {
|
||||
MediaViewerStore.close();
|
||||
}
|
||||
|
||||
export function navigateMediaViewer(index: number): void {
|
||||
MediaViewerStore.navigate(index);
|
||||
}
|
||||
584
fluxer/fluxer_app/src/actions/MessageActionCreators.tsx
Normal file
584
fluxer/fluxer_app/src/actions/MessageActionCreators.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
|
||||
import * as ReadStateActionCreators from '@app/actions/ReadStateActionCreators';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {MessageDeleteFailedModal} from '@app/components/alerts/MessageDeleteFailedModal';
|
||||
import {MessageDeleteTooQuickModal} from '@app/components/alerts/MessageDeleteTooQuickModal';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import type {JumpOptions} from '@app/lib/ChannelMessages';
|
||||
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import MessageQueue from '@app/lib/MessageQueue';
|
||||
import type {MessageRecord} from '@app/records/MessageRecord';
|
||||
import AuthenticationStore from '@app/stores/AuthenticationStore';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore';
|
||||
import GuildMemberStore from '@app/stores/GuildMemberStore';
|
||||
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
|
||||
import MessageEditMobileStore from '@app/stores/MessageEditMobileStore';
|
||||
import MessageEditStore from '@app/stores/MessageEditStore';
|
||||
import MessageReferenceStore from '@app/stores/MessageReferenceStore';
|
||||
import MessageReplyStore from '@app/stores/MessageReplyStore';
|
||||
import MessageStore from '@app/stores/MessageStore';
|
||||
import ReadStateStore from '@app/stores/ReadStateStore';
|
||||
import {getApiErrorCode} from '@app/utils/ApiErrorUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {JumpType} from '@fluxer/constants/src/JumpConstants';
|
||||
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {MessageId} from '@fluxer/schema/src/branded/WireIds';
|
||||
import type {
|
||||
AllowedMentions,
|
||||
Message,
|
||||
MessageReference,
|
||||
MessageStickerItem,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('MessageActionCreators');
|
||||
|
||||
const pendingDeletePromises = new Map<string, Promise<void>>();
|
||||
const pendingFetchPromises = new Map<string, Promise<Array<Message>>>();
|
||||
|
||||
function shouldBlockMessageFetch(channelId: string): boolean {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel || channel.isPrivate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
|
||||
}
|
||||
|
||||
function makeFetchKey(
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
channelId,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
jump: jump
|
||||
? {
|
||||
present: !!jump.present,
|
||||
messageId: jump.messageId ?? null,
|
||||
offset: jump.offset ?? 0,
|
||||
flash: !!jump.flash,
|
||||
returnMessageId: jump.returnMessageId ?? null,
|
||||
jumpType: jump.jumpType ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestMissingGuildMembers(channelId: string, messages: Array<Message>): Promise<void> {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel?.guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = channel.guildId;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
|
||||
const authorIds = messages
|
||||
.filter((msg) => !msg.webhook_id && msg.author.id !== currentUserId)
|
||||
.map((msg) => msg.author.id);
|
||||
|
||||
if (authorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await GuildMemberStore.ensureMembersLoaded(guildId, authorIds);
|
||||
}
|
||||
|
||||
interface SendMessageParams {
|
||||
content: string;
|
||||
nonce: string;
|
||||
hasAttachments?: boolean;
|
||||
allowedMentions?: AllowedMentions;
|
||||
messageReference?: MessageReference;
|
||||
flags?: number;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
export function jumpToPresent(channelId: string, limit = MAX_MESSAGES_PER_CHANNEL): void {
|
||||
NavigationActionCreators.clearMessageIdForChannel(channelId);
|
||||
|
||||
logger.debug(`Jumping to present in channel ${channelId}`);
|
||||
ReadStateActionCreators.clearStickyUnread(channelId);
|
||||
|
||||
const jump: JumpOptions = {
|
||||
present: true,
|
||||
};
|
||||
|
||||
if (MessageStore.hasPresent(channelId)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
||||
} else {
|
||||
fetchMessages(channelId, null, null, limit, jump);
|
||||
}
|
||||
}
|
||||
|
||||
export function jumpToMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
flash = true,
|
||||
offset?: number,
|
||||
returnTargetId?: string,
|
||||
jumpType?: JumpType,
|
||||
): void {
|
||||
logger.debug(`Jumping to message ${messageId} in channel ${channelId}`);
|
||||
|
||||
fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL, {
|
||||
messageId: messageId as MessageId,
|
||||
flash,
|
||||
offset,
|
||||
returnMessageId: returnTargetId as MessageId | null | undefined,
|
||||
jumpType,
|
||||
});
|
||||
}
|
||||
|
||||
const tryFetchMessagesCached = (
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): boolean => {
|
||||
const messages = MessageStore.getMessages(channelId);
|
||||
|
||||
if (jump?.messageId && messages.has(jump.messageId, true)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
||||
return true;
|
||||
} else if (before && messages.hasBeforeCached(before)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, before, limit});
|
||||
return true;
|
||||
} else if (after && messages.hasAfterCached(after)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, after, limit});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function fetchMessages(
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): Promise<Array<Message>> {
|
||||
const key = makeFetchKey(channelId, before, after, limit, jump);
|
||||
const inFlight = pendingFetchPromises.get(key);
|
||||
if (inFlight) {
|
||||
logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`);
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
if (shouldBlockMessageFetch(channelId)) {
|
||||
logger.debug(`Skipping message fetch for gated channel ${channelId}`);
|
||||
MessageStore.handleLoadMessagesBlocked({channelId});
|
||||
return [];
|
||||
}
|
||||
|
||||
if (tryFetchMessagesCached(channelId, before, after, limit, jump)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (DeveloperOptionsStore.slowMessageLoad) {
|
||||
logger.debug('Slow message load enabled, delaying by 3 seconds');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
MessageStore.handleLoadMessages({channelId, jump});
|
||||
|
||||
try {
|
||||
const timeStart = Date.now();
|
||||
logger.debug(`Fetching messages for channel ${channelId}`);
|
||||
|
||||
const around = jump?.messageId;
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
query: {before, after, limit, around: around ?? null},
|
||||
retries: 2,
|
||||
});
|
||||
const messages = response.body ?? [];
|
||||
|
||||
const isBefore = before != null;
|
||||
const isAfter = after != null;
|
||||
const isReplacement = before == null && after == null;
|
||||
|
||||
const halfLimit = Math.floor(limit / 2);
|
||||
let hasMoreBefore = around != null || (messages.length === limit && (isBefore || isReplacement));
|
||||
let hasMoreAfter = around != null || (isAfter && messages.length === limit);
|
||||
|
||||
if (around) {
|
||||
const knownLatestMessageId =
|
||||
ReadStateStore.lastMessageId(channelId) ?? ChannelStore.getChannel(channelId)?.lastMessageId ?? null;
|
||||
const newestFetchedMessageId = messages[0]?.id ?? null;
|
||||
const targetIndex = messages.findIndex((msg: Message) => msg.id === around);
|
||||
const pageFilled = messages.length === limit;
|
||||
|
||||
if (targetIndex === -1) {
|
||||
logger.warn(`Target message ${around} not found in response!`);
|
||||
} else {
|
||||
const messagesNewerThanTarget = targetIndex;
|
||||
const messagesOlderThanTarget = messages.length - targetIndex - 1;
|
||||
const isAtKnownLatest = newestFetchedMessageId != null && newestFetchedMessageId === knownLatestMessageId;
|
||||
|
||||
hasMoreBefore = pageFilled || messagesOlderThanTarget >= halfLimit;
|
||||
hasMoreAfter = pageFilled || (messagesNewerThanTarget >= halfLimit && !isAtKnownLatest);
|
||||
|
||||
logger.debug(
|
||||
`Jump to message ${around}: targetIndex=${targetIndex}, messagesNewer=${messagesNewerThanTarget}, messagesOlder=${messagesOlderThanTarget}, pageFilled=${pageFilled}, hasMoreBefore=${hasMoreBefore}, hasMoreAfter=${hasMoreAfter}, limit=${limit}, knownLatestMessageId=${knownLatestMessageId}, newestFetched=${newestFetchedMessageId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
|
||||
|
||||
MessageStore.handleLoadMessagesSuccess({
|
||||
channelId,
|
||||
messages,
|
||||
isBefore,
|
||||
isAfter,
|
||||
hasMoreBefore,
|
||||
hasMoreAfter,
|
||||
cached: false,
|
||||
jump,
|
||||
});
|
||||
ReadStateStore.handleLoadMessages({
|
||||
channelId,
|
||||
isAfter,
|
||||
messages,
|
||||
});
|
||||
MessageReferenceStore.handleMessagesFetchSuccess(channelId, messages);
|
||||
|
||||
void requestMissingGuildMembers(channelId, messages);
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch messages for channel ${channelId}:`, error);
|
||||
MessageStore.handleLoadMessagesFailure({channelId});
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
pendingFetchPromises.set(key, promise);
|
||||
promise.finally(() => pendingFetchPromises.delete(key));
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function send(channelId: string, params: SendMessageParams): Promise<Message | null> {
|
||||
return new Promise<Message | null>((resolve) => {
|
||||
logger.debug(`Enqueueing message for channel ${channelId}`);
|
||||
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'send',
|
||||
channelId,
|
||||
nonce: params.nonce,
|
||||
content: params.content,
|
||||
hasAttachments: params.hasAttachments,
|
||||
allowedMentions: params.allowedMentions,
|
||||
messageReference: params.messageReference,
|
||||
flags: params.flags,
|
||||
favoriteMemeId: params.favoriteMemeId,
|
||||
stickers: params.stickers,
|
||||
tts: params.tts,
|
||||
},
|
||||
(result, error) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message sent successfully in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
if (error) {
|
||||
logger.debug(`Message send failed in channel ${channelId}`, error);
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function edit(channelId: string, messageId: string, content?: string, flags?: number): Promise<Message | null> {
|
||||
return new Promise<Message | null>((resolve) => {
|
||||
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
|
||||
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'edit',
|
||||
channelId,
|
||||
messageId,
|
||||
content,
|
||||
flags,
|
||||
},
|
||||
(result, error) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
if (error) {
|
||||
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(channelId: string, messageId: string): Promise<void> {
|
||||
const pendingPromise = pendingDeletePromises.get(messageId);
|
||||
if (pendingPromise) {
|
||||
logger.debug(`Using in-flight delete request for message ${messageId}`);
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
const deletePromise = (async () => {
|
||||
try {
|
||||
logger.debug(`Deleting message ${messageId} in channel ${channelId}`);
|
||||
await http.delete({url: Endpoints.CHANNEL_MESSAGE(channelId, messageId)});
|
||||
logger.debug(`Successfully deleted message ${messageId} in channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete message ${messageId} in channel ${channelId}:`, error);
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const {status} = error;
|
||||
const errorCode = getApiErrorCode(error);
|
||||
|
||||
if (status === 429) {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteTooQuickModal />));
|
||||
} else if (status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (status === 404) {
|
||||
logger.debug(`Message ${messageId} was already deleted (404 response)`);
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteFailedModal />));
|
||||
}
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteFailedModal />));
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingDeletePromises.delete(messageId);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingDeletePromises.set(messageId, deletePromise);
|
||||
return deletePromise;
|
||||
}
|
||||
|
||||
interface ShowDeleteConfirmationOptions {
|
||||
message: MessageRecord;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function showDeleteConfirmation(i18n: I18n, {message, onDelete}: ShowDeleteConfirmationOptions): void {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Delete Message`)}
|
||||
description={i18n._(msg`This will create a rift in the space-time continuum and cannot be undone.`)}
|
||||
message={message}
|
||||
primaryText={i18n._(msg`Delete`)}
|
||||
onPrimary={() => {
|
||||
remove(message.channelId, message.id);
|
||||
onDelete?.();
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteLocal(channelId: string, messageId: string): void {
|
||||
logger.debug(`Deleting message ${messageId} locally in channel ${channelId}`);
|
||||
MessageStore.handleMessageDelete({id: messageId, channelId});
|
||||
}
|
||||
|
||||
export function revealMessage(channelId: string, messageId: string | null): void {
|
||||
logger.debug(`Revealing message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleMessageReveal({channelId, messageId});
|
||||
}
|
||||
|
||||
export function startReply(channelId: string, messageId: string, mentioning: boolean): void {
|
||||
logger.debug(`Starting reply to message ${messageId} in channel ${channelId}, mentioning=${mentioning}`);
|
||||
MessageReplyStore.startReply(channelId, messageId, mentioning);
|
||||
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {channelId});
|
||||
}
|
||||
|
||||
export function stopReply(channelId: string): void {
|
||||
logger.debug(`Stopping reply in channel ${channelId}`);
|
||||
MessageReplyStore.stopReply(channelId);
|
||||
}
|
||||
|
||||
export function setReplyMentioning(channelId: string, mentioning: boolean): void {
|
||||
logger.debug(`Setting reply mentioning in channel ${channelId}: ${mentioning}`);
|
||||
MessageReplyStore.setMentioning(channelId, mentioning);
|
||||
}
|
||||
|
||||
export function startEdit(channelId: string, messageId: string, initialContent: string): void {
|
||||
logger.debug(`Starting edit for message ${messageId} in channel ${channelId}`);
|
||||
const draftContent = MessageEditStore.getDraftContent(messageId);
|
||||
const contentToUse = draftContent ?? initialContent;
|
||||
MessageEditStore.startEditing(channelId, messageId, contentToUse);
|
||||
}
|
||||
|
||||
export function stopEdit(channelId: string): void {
|
||||
logger.debug(`Stopping edit in channel ${channelId}`);
|
||||
MessageEditStore.stopEditing(channelId);
|
||||
}
|
||||
|
||||
export function startEditMobile(channelId: string, messageId: string): void {
|
||||
logger.debug(`Starting mobile edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageEditMobileStore.startEditingMobile(channelId, messageId);
|
||||
}
|
||||
|
||||
export function stopEditMobile(channelId: string): void {
|
||||
logger.debug(`Stopping mobile edit in channel ${channelId}`);
|
||||
MessageEditMobileStore.stopEditingMobile(channelId);
|
||||
}
|
||||
|
||||
export function createOptimistic(channelId: string, message: Message): void {
|
||||
logger.debug(`Creating optimistic message in channel ${channelId}`);
|
||||
MessageStore.handleIncomingMessage({channelId, message});
|
||||
}
|
||||
|
||||
export function deleteOptimistic(channelId: string, messageId: string): void {
|
||||
logger.debug(`Deleting optimistic message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleMessageDelete({channelId, id: messageId});
|
||||
}
|
||||
|
||||
export function sendError(channelId: string, nonce: string): void {
|
||||
logger.debug(`Message send error for nonce ${nonce} in channel ${channelId}`);
|
||||
MessageStore.handleSendFailed({channelId, nonce});
|
||||
}
|
||||
|
||||
export function retryLocal(channelId: string, messageId: string): void {
|
||||
logger.debug(`Retrying optimistic message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleSendRetry({channelId, messageId});
|
||||
}
|
||||
|
||||
export function editOptimistic(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
): {originalContent: string; originalEditedTimestamp: string | null} | null {
|
||||
logger.debug(`Applying optimistic edit for message ${messageId} in channel ${channelId}`);
|
||||
return MessageStore.handleOptimisticEdit({channelId, messageId, content});
|
||||
}
|
||||
|
||||
export function editRollback(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
originalContent: string,
|
||||
originalEditedTimestamp: string | null,
|
||||
): void {
|
||||
logger.debug(`Rolling back edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleEditRollback({channelId, messageId, originalContent, originalEditedTimestamp});
|
||||
}
|
||||
|
||||
export async function forward(
|
||||
channelIds: Array<string>,
|
||||
messageReference: {message_id: string; channel_id: string; guild_id?: string | null},
|
||||
optionalMessage?: string,
|
||||
): Promise<void> {
|
||||
logger.debug(`Forwarding message ${messageReference.message_id} to ${channelIds.length} channels`);
|
||||
|
||||
try {
|
||||
for (const channelId of channelIds) {
|
||||
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
await send(channelId, {
|
||||
content: '',
|
||||
nonce,
|
||||
messageReference: {
|
||||
message_id: messageReference.message_id,
|
||||
channel_id: messageReference.channel_id,
|
||||
guild_id: messageReference.guild_id || undefined,
|
||||
type: 1,
|
||||
},
|
||||
flags: 1,
|
||||
});
|
||||
|
||||
if (optionalMessage) {
|
||||
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
|
||||
await send(channelId, {
|
||||
content: optionalMessage,
|
||||
nonce: commentNonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.debug('Successfully forwarded message to all channels');
|
||||
} catch (error) {
|
||||
logger.error('Failed to forward message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleSuppressEmbeds(channelId: string, messageId: string, currentFlags: number): Promise<void> {
|
||||
try {
|
||||
const isSuppressed = (currentFlags & MessageFlags.SUPPRESS_EMBEDS) === MessageFlags.SUPPRESS_EMBEDS;
|
||||
const newFlags = isSuppressed
|
||||
? currentFlags & ~MessageFlags.SUPPRESS_EMBEDS
|
||||
: currentFlags | MessageFlags.SUPPRESS_EMBEDS;
|
||||
|
||||
logger.debug(`${isSuppressed ? 'Unsuppressing' : 'Suppressing'} embeds for message ${messageId}`);
|
||||
|
||||
await http.patch<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
|
||||
body: {flags: newFlags},
|
||||
});
|
||||
|
||||
logger.debug(`Successfully ${isSuppressed ? 'unsuppressed' : 'suppressed'} embeds for message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle suppress embeds:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAttachment(channelId: string, messageId: string, attachmentId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Deleting attachment ${attachmentId} from message ${messageId}`);
|
||||
|
||||
await http.delete({
|
||||
url: Endpoints.CHANNEL_MESSAGE_ATTACHMENT(channelId, messageId, attachmentId),
|
||||
});
|
||||
|
||||
logger.debug(`Successfully deleted attachment ${attachmentId} from message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete attachment:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
71
fluxer/fluxer_app/src/actions/MfaActionCreators.tsx
Normal file
71
fluxer/fluxer_app/src/actions/MfaActionCreators.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import SudoStore from '@app/stores/SudoStore';
|
||||
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
|
||||
const logger = new Logger('MFA');
|
||||
|
||||
export async function enableMfaTotp(secret: string, code: string): Promise<Array<BackupCode>> {
|
||||
try {
|
||||
logger.debug('Enabling TOTP-based MFA');
|
||||
const response = await http.post<{backup_codes: Array<BackupCode>}>({
|
||||
url: Endpoints.USER_MFA_TOTP_ENABLE,
|
||||
body: {secret, code},
|
||||
});
|
||||
const result = response.body;
|
||||
logger.debug('Successfully enabled TOTP-based MFA');
|
||||
SudoStore.clearToken();
|
||||
return result.backup_codes;
|
||||
} catch (error) {
|
||||
logger.error('Failed to enable TOTP-based MFA:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableMfaTotp(code: string): Promise<void> {
|
||||
try {
|
||||
logger.debug('Disabling TOTP-based MFA');
|
||||
await http.post({url: Endpoints.USER_MFA_TOTP_DISABLE, body: {code}});
|
||||
logger.debug('Successfully disabled TOTP-based MFA');
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable TOTP-based MFA:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBackupCodes(regenerate = false): Promise<Array<BackupCode>> {
|
||||
try {
|
||||
logger.debug(`${regenerate ? 'Regenerating' : 'Fetching'} MFA backup codes`);
|
||||
const response = await http.post<{backup_codes: Array<BackupCode>}>({
|
||||
url: Endpoints.USER_MFA_BACKUP_CODES,
|
||||
body: {regenerate},
|
||||
});
|
||||
const result = response.body;
|
||||
|
||||
logger.debug(`Successfully ${regenerate ? 'regenerated' : 'fetched'} MFA backup codes`);
|
||||
return result.backup_codes;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${regenerate ? 'regenerate' : 'fetch'} MFA backup codes:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
113
fluxer/fluxer_app/src/actions/ModalActionCreators.tsx
Normal file
113
fluxer/fluxer_app/src/actions/ModalActionCreators.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ModalRender} from '@app/actions/ModalRender';
|
||||
import {ChannelSettingsModal} from '@app/components/modals/ChannelSettingsModal';
|
||||
import {GuildSettingsModal} from '@app/components/modals/GuildSettingsModal';
|
||||
import {UserSettingsModal} from '@app/components/modals/UserSettingsModal';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ModalStore from '@app/stores/ModalStore';
|
||||
import lodash from 'lodash';
|
||||
import type React from 'react';
|
||||
|
||||
const logger = new Logger('Modal');
|
||||
|
||||
const BACKGROUND_MODAL_TYPES = [UserSettingsModal, GuildSettingsModal, ChannelSettingsModal] as const;
|
||||
|
||||
const isBackgroundModal = (element: React.ReactElement): boolean => {
|
||||
return BACKGROUND_MODAL_TYPES.some((type) => element.type === type);
|
||||
};
|
||||
|
||||
export function modal(render: () => React.ReactElement): ModalRender {
|
||||
return render as ModalRender;
|
||||
}
|
||||
|
||||
export function push(modal: ModalRender): void {
|
||||
const renderedModal = modal();
|
||||
const isBackground = isBackgroundModal(renderedModal);
|
||||
|
||||
if (renderedModal.type === UserSettingsModal && ModalStore.hasModalOfType(UserSettingsModal)) {
|
||||
logger.debug('Skipping duplicate UserSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === GuildSettingsModal && ModalStore.hasModalOfType(GuildSettingsModal)) {
|
||||
logger.debug('Skipping duplicate GuildSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === ChannelSettingsModal && ModalStore.hasModalOfType(ChannelSettingsModal)) {
|
||||
logger.debug('Skipping duplicate ChannelSettingsModal');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = lodash.uniqueId('modal');
|
||||
logger.debug(`Pushing modal: ${key} (background=${isBackground})`);
|
||||
ModalStore.push(modal, key, {isBackground});
|
||||
}
|
||||
|
||||
export function pushWithKey(modal: ModalRender, key: string): void {
|
||||
const renderedModal = modal();
|
||||
const isBackground = isBackgroundModal(renderedModal);
|
||||
|
||||
if (renderedModal.type === UserSettingsModal && ModalStore.hasModalOfType(UserSettingsModal)) {
|
||||
logger.debug('Skipping duplicate UserSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === GuildSettingsModal && ModalStore.hasModalOfType(GuildSettingsModal)) {
|
||||
logger.debug('Skipping duplicate GuildSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === ChannelSettingsModal && ModalStore.hasModalOfType(ChannelSettingsModal)) {
|
||||
logger.debug('Skipping duplicate ChannelSettingsModal');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ModalStore.hasModal(key)) {
|
||||
logger.debug(`Updating existing modal with key: ${key}`);
|
||||
ModalStore.update(key, () => modal, {isBackground});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Pushing modal with key: ${key} (background=${isBackground})`);
|
||||
ModalStore.push(modal, key, {isBackground});
|
||||
}
|
||||
|
||||
export function update(key: string, updater: (currentModal: ModalRender) => ModalRender): void {
|
||||
logger.debug(`Updating modal with key: ${key}`);
|
||||
ModalStore.update(key, updater);
|
||||
}
|
||||
|
||||
export function pop(): void {
|
||||
logger.debug('Popping most recent modal');
|
||||
ModalStore.pop();
|
||||
}
|
||||
|
||||
export function popWithKey(key: string): void {
|
||||
logger.debug(`Popping modal with key: ${key}`);
|
||||
ModalStore.pop(key);
|
||||
}
|
||||
|
||||
export function popByType<T>(component: React.ComponentType<T>): void {
|
||||
logger.debug(`Popping modal by type: ${component.displayName ?? component.name ?? 'unknown'}`);
|
||||
ModalStore.popByType(component);
|
||||
}
|
||||
|
||||
export function popAll(): void {
|
||||
logger.debug('Popping all modals');
|
||||
ModalStore.popAll();
|
||||
}
|
||||
22
fluxer/fluxer_app/src/actions/ModalRender.tsx
Normal file
22
fluxer/fluxer_app/src/actions/ModalRender.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export type ModalRender = () => React.ReactElement;
|
||||
48
fluxer/fluxer_app/src/actions/NagbarActionCreators.tsx
Normal file
48
fluxer/fluxer_app/src/actions/NagbarActionCreators.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import NagbarStore, {type NagbarToggleKey} from '@app/stores/NagbarStore';
|
||||
|
||||
export function dismissNagbar(nagbarType: NagbarToggleKey): void {
|
||||
NagbarStore.dismiss(nagbarType);
|
||||
}
|
||||
|
||||
export function dismissInvitesDisabledNagbar(guildId: string): void {
|
||||
NagbarStore.dismissInvitesDisabled(guildId);
|
||||
}
|
||||
|
||||
export function resetNagbar(nagbarType: NagbarToggleKey): void {
|
||||
NagbarStore.reset(nagbarType);
|
||||
}
|
||||
|
||||
export function resetAllNagbars(): void {
|
||||
NagbarStore.resetAll();
|
||||
}
|
||||
|
||||
export function setForceHideNagbar(key: NagbarToggleKey, value: boolean): void {
|
||||
NagbarStore.setFlag(key, value);
|
||||
}
|
||||
|
||||
export function dismissPendingBulkDeletionNagbar(scheduleKey: string): void {
|
||||
NagbarStore.dismissPendingBulkDeletion(scheduleKey);
|
||||
}
|
||||
|
||||
export function clearPendingBulkDeletionNagbarDismissal(scheduleKey: string): void {
|
||||
NagbarStore.clearPendingBulkDeletionDismissed(scheduleKey);
|
||||
}
|
||||
82
fluxer/fluxer_app/src/actions/NavigationActionCreators.tsx
Normal file
82
fluxer/fluxer_app/src/actions/NavigationActionCreators.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import NavigationStore from '@app/stores/NavigationStore';
|
||||
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
const logger = new Logger('Navigation');
|
||||
|
||||
type NavigationMode = 'push' | 'replace';
|
||||
|
||||
export function selectChannel(
|
||||
guildId?: string,
|
||||
channelId?: string | null,
|
||||
messageId?: string,
|
||||
mode: NavigationMode = 'push',
|
||||
): void {
|
||||
logger.debug(`Selecting channel: guildId=${guildId}, channelId=${channelId}, messageId=${messageId}`);
|
||||
|
||||
if (!guildId || guildId === ME) {
|
||||
NavigationStore.navigateToDM(channelId ?? undefined, messageId, mode);
|
||||
} else if (guildId === FAVORITES_GUILD_ID || guildId === '@favorites') {
|
||||
NavigationStore.navigateToFavorites(channelId ?? undefined, messageId, mode);
|
||||
} else {
|
||||
NavigationStore.navigateToGuild(guildId, channelId ?? undefined, messageId, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectGuild(guildId: string, channelId?: string, mode: NavigationMode = 'push'): void {
|
||||
logger.debug(`Selecting guild: ${guildId}`);
|
||||
|
||||
if (guildId === ME) {
|
||||
NavigationStore.navigateToDM(channelId, undefined, mode);
|
||||
} else if (guildId === FAVORITES_GUILD_ID || guildId === '@favorites') {
|
||||
NavigationStore.navigateToFavorites(channelId, undefined, mode);
|
||||
} else {
|
||||
NavigationStore.navigateToGuild(guildId, channelId, undefined, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function deselectGuild(): void {
|
||||
logger.debug('Deselecting guild');
|
||||
NavigationStore.navigateToDM();
|
||||
}
|
||||
|
||||
export function navigateToMessage(
|
||||
guildId: string | null | undefined,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
mode: NavigationMode = 'push',
|
||||
): void {
|
||||
logger.debug(`Navigating to message: channel=${channelId}, message=${messageId}`);
|
||||
|
||||
if (!guildId || guildId === ME) {
|
||||
NavigationStore.navigateToDM(channelId, messageId, mode);
|
||||
} else if (guildId === FAVORITES_GUILD_ID || guildId === '@favorites') {
|
||||
NavigationStore.navigateToFavorites(channelId, messageId, mode);
|
||||
} else {
|
||||
NavigationStore.navigateToGuild(guildId, channelId, messageId, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMessageIdForChannel(channelId: string, mode: NavigationMode = 'replace'): void {
|
||||
logger.debug(`Clearing messageId for channel: ${channelId}`);
|
||||
NavigationStore.clearMessageIdForChannel(channelId, mode);
|
||||
}
|
||||
65
fluxer/fluxer_app/src/actions/NotificationActionCreators.tsx
Normal file
65
fluxer/fluxer_app/src/actions/NotificationActionCreators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import NotificationStore from '@app/stores/NotificationStore';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
|
||||
const logger = new Logger('Notification');
|
||||
|
||||
export function permissionDenied(i18n: I18n, suppressModal = false): void {
|
||||
logger.debug('Notification permission denied');
|
||||
NotificationStore.handleNotificationPermissionDenied();
|
||||
|
||||
if (suppressModal) return;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Notifications Blocked`)}
|
||||
description={
|
||||
<p>
|
||||
<Trans>
|
||||
Desktop notifications have been blocked. You can enable them later in your browser settings or in User
|
||||
Settings > Notifications.
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
primaryText={i18n._(msg`OK`)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export function permissionGranted(): void {
|
||||
logger.debug('Notification permission granted');
|
||||
NotificationStore.handleNotificationPermissionGranted();
|
||||
}
|
||||
|
||||
export function toggleUnreadMessageBadge(enabled: boolean): void {
|
||||
NotificationStore.handleNotificationSoundToggle(enabled);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
|
||||
const logger = new Logger('OAuth2AuthorizationActionCreators');
|
||||
|
||||
export interface OAuth2Authorization {
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: string | null;
|
||||
bot_public: boolean;
|
||||
};
|
||||
scopes: Array<string>;
|
||||
authorized_at: string;
|
||||
}
|
||||
|
||||
export async function listAuthorizations(): Promise<Array<OAuth2Authorization>> {
|
||||
try {
|
||||
const response = await http.get<Array<OAuth2Authorization>>({url: Endpoints.OAUTH_AUTHORIZATIONS});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to list OAuth2 authorizations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deauthorize(applicationId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({url: Endpoints.OAUTH_AUTHORIZATION(applicationId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to deauthorize application:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
98
fluxer/fluxer_app/src/actions/PackActionCreators.tsx
Normal file
98
fluxer/fluxer_app/src/actions/PackActionCreators.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {PackDashboardResponse, PackSummaryResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
|
||||
|
||||
const logger = new Logger('Packs');
|
||||
|
||||
export async function list(): Promise<PackDashboardResponse> {
|
||||
try {
|
||||
logger.debug('Requesting pack dashboard');
|
||||
const response = await http.get<PackDashboardResponse>({url: Endpoints.PACKS});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch pack dashboard:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(
|
||||
type: 'emoji' | 'sticker',
|
||||
name: string,
|
||||
description?: string | null,
|
||||
): Promise<PackSummaryResponse> {
|
||||
try {
|
||||
logger.debug(`Creating ${type} pack ${name}`);
|
||||
const response = await http.post<PackSummaryResponse>({
|
||||
url: Endpoints.PACK_CREATE(type),
|
||||
body: {name, description: description ?? null},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create ${type} pack:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
packId: string,
|
||||
data: {name?: string; description?: string | null},
|
||||
): Promise<PackSummaryResponse> {
|
||||
try {
|
||||
logger.debug(`Updating pack ${packId}`);
|
||||
const response = await http.patch<PackSummaryResponse>({url: Endpoints.PACK(packId), body: data});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(packId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Deleting pack ${packId}`);
|
||||
await http.delete({url: Endpoints.PACK(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function install(packId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Installing pack ${packId}`);
|
||||
await http.post({url: Endpoints.PACK_INSTALL(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to install pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstall(packId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Uninstalling pack ${packId}`);
|
||||
await http.delete({url: Endpoints.PACK_INSTALL(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to uninstall pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
50
fluxer/fluxer_app/src/actions/PackInviteActionCreators.tsx
Normal file
50
fluxer/fluxer_app/src/actions/PackInviteActionCreators.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {PackInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
const logger = new Logger('PackInvites');
|
||||
|
||||
export interface CreatePackInviteParams {
|
||||
packId: string;
|
||||
maxUses?: number;
|
||||
maxAge?: number;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
export async function createInvite(params: CreatePackInviteParams): Promise<PackInviteMetadataResponse> {
|
||||
try {
|
||||
logger.debug(`Creating invite for pack ${params.packId}`);
|
||||
const response = await http.post<PackInviteMetadataResponse>({
|
||||
url: Endpoints.PACK_INVITES(params.packId),
|
||||
body: {
|
||||
max_uses: params.maxUses ?? 0,
|
||||
max_age: params.maxAge ?? 0,
|
||||
unique: params.unique ?? false,
|
||||
},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invite for pack ${params.packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
36
fluxer/fluxer_app/src/actions/PiPActionCreators.tsx
Normal file
36
fluxer/fluxer_app/src/actions/PiPActionCreators.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import PiPStore, {type PiPContent, type PiPCorner} from '@app/stores/PiPStore';
|
||||
|
||||
export function openPiP(content: PiPContent): void {
|
||||
PiPStore.open(content);
|
||||
}
|
||||
|
||||
export function closePiP(): void {
|
||||
PiPStore.close();
|
||||
}
|
||||
|
||||
export function showFocusedTileMirror(content: PiPContent, corner: PiPCorner = 'top-right'): void {
|
||||
PiPStore.showFocusedTileMirror(content, corner);
|
||||
}
|
||||
|
||||
export function hideFocusedTileMirror(): void {
|
||||
PiPStore.hideFocusedTileMirror();
|
||||
}
|
||||
33
fluxer/fluxer_app/src/actions/PopoutActionCreators.tsx
Normal file
33
fluxer/fluxer_app/src/actions/PopoutActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Popout} from '@app/components/uikit/popout';
|
||||
import PopoutStore from '@app/stores/PopoutStore';
|
||||
|
||||
export function open(popout: Popout): void {
|
||||
PopoutStore.open(popout);
|
||||
}
|
||||
|
||||
export function close(key?: string | number): void {
|
||||
PopoutStore.close(key);
|
||||
}
|
||||
|
||||
export function closeAll(): void {
|
||||
PopoutStore.closeAll();
|
||||
}
|
||||
104
fluxer/fluxer_app/src/actions/PremiumActionCreators.tsx
Normal file
104
fluxer/fluxer_app/src/actions/PremiumActionCreators.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {PriceIdsResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
|
||||
const logger = new Logger('Premium');
|
||||
|
||||
export type PriceIds = PriceIdsResponse;
|
||||
|
||||
export async function fetchPriceIds(countryCode?: string): Promise<PriceIds> {
|
||||
try {
|
||||
const response = await http.get<PriceIds>({
|
||||
url: Endpoints.PREMIUM_PRICE_IDS,
|
||||
query: countryCode ? {country_code: countryCode} : undefined,
|
||||
});
|
||||
logger.debug('Price IDs fetched', response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Price IDs fetch failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCustomerPortalSession(): Promise<string> {
|
||||
try {
|
||||
const response = await http.post<{url: string}>(Endpoints.PREMIUM_CUSTOMER_PORTAL);
|
||||
logger.info('Customer portal session created');
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Customer portal session creation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(priceId: string, isGift: boolean = false): Promise<string> {
|
||||
try {
|
||||
const url = isGift ? Endpoints.STRIPE_CHECKOUT_GIFT : Endpoints.STRIPE_CHECKOUT_SUBSCRIPTION;
|
||||
const response = await http.post<{url: string}>(url, {price_id: priceId});
|
||||
logger.info('Checkout session created', {priceId, isGift});
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Checkout session creation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelSubscriptionAtPeriodEnd(): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_CANCEL_SUBSCRIPTION});
|
||||
logger.info('Subscription set to cancel at period end');
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel subscription at period end', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reactivateSubscription(): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_REACTIVATE_SUBSCRIPTION});
|
||||
logger.info('Subscription reactivated');
|
||||
} catch (error) {
|
||||
logger.error('Failed to reactivate subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejoinVisionaryGuild(): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_VISIONARY_REJOIN});
|
||||
logger.info('Visionary guild rejoin requested');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rejoin Visionary guild', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejoinOperatorGuild(): Promise<void> {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_OPERATOR_REJOIN});
|
||||
logger.info('Operator guild rejoin requested');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rejoin Operator guild', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
40
fluxer/fluxer_app/src/actions/PremiumModalActionCreators.tsx
Normal file
40
fluxer/fluxer_app/src/actions/PremiumModalActionCreators.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import {PremiumModal} from '@app/components/modals/PremiumModal';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
|
||||
interface OpenOptions {
|
||||
defaultGiftMode?: boolean;
|
||||
}
|
||||
|
||||
export function open(optionsOrDefaultGiftMode: OpenOptions | boolean = {}): void {
|
||||
if (RuntimeConfigStore.isSelfHosted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options =
|
||||
typeof optionsOrDefaultGiftMode === 'boolean'
|
||||
? {defaultGiftMode: optionsOrDefaultGiftMode}
|
||||
: optionsOrDefaultGiftMode;
|
||||
const {defaultGiftMode = false} = options;
|
||||
ModalActionCreators.push(modal(() => <PremiumModal defaultGiftMode={defaultGiftMode} />));
|
||||
}
|
||||
130
fluxer/fluxer_app/src/actions/PrivateChannelActionCreators.tsx
Normal file
130
fluxer/fluxer_app/src/actions/PrivateChannelActionCreators.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
|
||||
const logger = new Logger('PrivateChannelActionCreators');
|
||||
|
||||
export async function create(userId: string) {
|
||||
try {
|
||||
const response = await http.post<Channel>({
|
||||
url: Endpoints.USER_CHANNELS,
|
||||
body: {recipient_id: userId},
|
||||
});
|
||||
const channel = response.body;
|
||||
return channel;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create private channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGroupDM(recipientIds: Array<string>) {
|
||||
try {
|
||||
const response = await http.post<Channel>({
|
||||
url: Endpoints.USER_CHANNELS,
|
||||
body: {recipients: recipientIds},
|
||||
});
|
||||
const channel = response.body;
|
||||
return channel;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create group DM:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRecipient(channelId: string, userId: string) {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.CHANNEL_RECIPIENT(channelId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove recipient:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDMChannel(userId: string): Promise<string> {
|
||||
try {
|
||||
const existingChannels = ChannelStore.dmChannels;
|
||||
const existingChannel = existingChannels.find(
|
||||
(channel) => channel.type === ChannelTypes.DM && channel.recipientIds.includes(userId),
|
||||
);
|
||||
|
||||
if (existingChannel) {
|
||||
return existingChannel.id;
|
||||
}
|
||||
|
||||
const channel = await create(userId);
|
||||
return channel.id;
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openDMChannel(userId: string): Promise<void> {
|
||||
try {
|
||||
const channelId = await ensureDMChannel(userId);
|
||||
NavigationActionCreators.selectChannel(ME, channelId);
|
||||
} catch (error) {
|
||||
logger.error('Failed to open DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pinDmChannel(channelId: string): Promise<void> {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.USER_CHANNEL_PIN(channelId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to pin DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpinDmChannel(channelId: string): Promise<void> {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.USER_CHANNEL_PIN(channelId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpin DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRecipient(channelId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.CHANNEL_RECIPIENT(channelId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to add recipient:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
142
fluxer/fluxer_app/src/actions/QuickSwitcherActionCreators.tsx
Normal file
142
fluxer/fluxer_app/src/actions/QuickSwitcherActionCreators.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
|
||||
import * as PrivateChannelActionCreators from '@app/actions/PrivateChannelActionCreators';
|
||||
import {UserSettingsModal} from '@app/components/modals/UserSettingsModal';
|
||||
import {Routes} from '@app/Routes';
|
||||
import type {QuickSwitcherExecutableResult} from '@app/stores/QuickSwitcherStore';
|
||||
import QuickSwitcherStore from '@app/stores/QuickSwitcherStore';
|
||||
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
|
||||
import {goToMessage, parseMessagePath} from '@app/utils/MessageNavigator';
|
||||
import * as RouterUtils from '@app/utils/RouterUtils';
|
||||
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
|
||||
|
||||
const QUICK_SWITCHER_MODAL_KEY = 'quick_switcher';
|
||||
|
||||
export function hide(): void {
|
||||
QuickSwitcherStore.hide();
|
||||
}
|
||||
|
||||
export function search(query: string): void {
|
||||
QuickSwitcherStore.search(query);
|
||||
}
|
||||
|
||||
export function select(selectedIndex: number): void {
|
||||
QuickSwitcherStore.select(selectedIndex);
|
||||
}
|
||||
|
||||
export function moveSelection(direction: 'up' | 'down'): void {
|
||||
const nextIndex = QuickSwitcherStore.findNextSelectableIndex(direction);
|
||||
select(nextIndex);
|
||||
}
|
||||
|
||||
export async function confirmSelection(): Promise<void> {
|
||||
const result = QuickSwitcherStore.getSelectedResult();
|
||||
if (!result) return;
|
||||
await switchTo(result);
|
||||
}
|
||||
|
||||
export async function switchTo(result: QuickSwitcherExecutableResult): Promise<void> {
|
||||
try {
|
||||
switch (result.type) {
|
||||
case QuickSwitcherResultTypes.USER: {
|
||||
if (result.dmChannelId) {
|
||||
NavigationActionCreators.selectChannel(ME, result.dmChannelId);
|
||||
} else {
|
||||
await PrivateChannelActionCreators.openDMChannel(result.user.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.GROUP_DM: {
|
||||
NavigationActionCreators.selectChannel(ME, result.channel.id);
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL: {
|
||||
if (result.viewContext === FAVORITES_GUILD_ID) {
|
||||
NavigationActionCreators.selectChannel(FAVORITES_GUILD_ID, result.channel.id);
|
||||
} else if (result.guild) {
|
||||
NavigationActionCreators.selectChannel(result.guild.id, result.channel.id);
|
||||
} else {
|
||||
NavigationActionCreators.selectChannel(ME, result.channel.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL: {
|
||||
if (result.viewContext === FAVORITES_GUILD_ID) {
|
||||
NavigationActionCreators.selectChannel(FAVORITES_GUILD_ID, result.channel.id);
|
||||
} else if (result.guild) {
|
||||
NavigationActionCreators.selectChannel(result.guild.id, result.channel.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.GUILD: {
|
||||
const channelId = SelectedChannelStore.selectedChannelIds.get(result.guild.id);
|
||||
NavigationActionCreators.selectGuild(result.guild.id, channelId);
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.VIRTUAL_GUILD: {
|
||||
if (result.virtualGuildType === 'favorites') {
|
||||
const validChannelId = SelectedChannelStore.getValidatedFavoritesChannel();
|
||||
NavigationActionCreators.selectGuild(FAVORITES_GUILD_ID, validChannelId ?? undefined);
|
||||
} else if (result.virtualGuildType === 'home') {
|
||||
const dmChannelId = SelectedChannelStore.selectedChannelIds.get(ME);
|
||||
NavigationActionCreators.selectGuild(ME, dmChannelId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.SETTINGS: {
|
||||
const initialTab = result.settingsTab.type;
|
||||
const initialSubtab = result.settingsSubtab?.type;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => <UserSettingsModal initialTab={initialTab} initialSubtab={initialSubtab} />),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.QUICK_ACTION: {
|
||||
result.action();
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.LINK: {
|
||||
const parsed = parseMessagePath(result.path);
|
||||
if (parsed) {
|
||||
const viewContext = result.path.startsWith(Routes.favoritesChannel(parsed.channelId))
|
||||
? 'favorites'
|
||||
: undefined;
|
||||
goToMessage(parsed.channelId, parsed.messageId, {viewContext});
|
||||
} else {
|
||||
RouterUtils.transitionTo(result.path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
export function getModalKey(): string {
|
||||
return QUICK_SWITCHER_MODAL_KEY;
|
||||
}
|
||||
289
fluxer/fluxer_app/src/actions/ReactionActionCreators.tsx
Normal file
289
fluxer/fluxer_app/src/actions/ReactionActionCreators.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {TooManyReactionsModal} from '@app/components/alerts/TooManyReactionsModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import AuthenticationStore from '@app/stores/AuthenticationStore';
|
||||
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
|
||||
import MessageReactionsStore from '@app/stores/MessageReactionsStore';
|
||||
import MessageStore from '@app/stores/MessageStore';
|
||||
import {getApiErrorCode, getApiErrorRetryAfter} from '@app/utils/ApiErrorUtils';
|
||||
import type {ReactionEmoji} from '@app/utils/ReactionUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('MessageReactions');
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
const checkReactionResponse = (i18n: I18n, error: HttpError, retry: () => void): boolean => {
|
||||
const errorCode = getApiErrorCode(error);
|
||||
|
||||
if (error.status === 403) {
|
||||
if (errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
logger.debug('Feature temporarily disabled, not retrying');
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
return true;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.COMMUNICATION_DISABLED) {
|
||||
logger.debug('Communication disabled while timed out, not retrying');
|
||||
ToastActionCreators.createToast({
|
||||
type: 'info',
|
||||
children: i18n._(msg`You can't add new reactions while you're on timeout.`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (error.status === 429) {
|
||||
const retryAfter = getApiErrorRetryAfter(error) || 1000;
|
||||
logger.debug(`Rate limited, retrying after ${retryAfter}ms`);
|
||||
setTimeout(retry, retryAfter);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.status === 400) {
|
||||
switch (errorCode) {
|
||||
case APIErrorCodes.MAX_REACTIONS:
|
||||
logger.debug(`Reaction limit reached: ${errorCode}`);
|
||||
ModalActionCreators.push(modal(() => <TooManyReactionsModal />));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const optimisticUpdate = (
|
||||
type:
|
||||
| 'MESSAGE_REACTION_ADD'
|
||||
| 'MESSAGE_REACTION_REMOVE'
|
||||
| 'MESSAGE_REACTION_REMOVE_ALL'
|
||||
| 'MESSAGE_REACTION_REMOVE_EMOJI',
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void => {
|
||||
const actualUserId = userId ?? AuthenticationStore.currentUserId;
|
||||
|
||||
if (!actualUserId) {
|
||||
logger.warn('Skipping optimistic reaction update because user ID is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'MESSAGE_REACTION_ADD') {
|
||||
MessageReactionsStore.handleReactionAdd(messageId, actualUserId, emoji);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE') {
|
||||
MessageReactionsStore.handleReactionRemove(messageId, actualUserId, emoji);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_ALL') {
|
||||
MessageReactionsStore.handleReactionRemoveAll(messageId);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_EMOJI') {
|
||||
MessageReactionsStore.handleReactionRemoveEmoji(messageId, emoji);
|
||||
}
|
||||
|
||||
if (type === 'MESSAGE_REACTION_ADD' || type === 'MESSAGE_REACTION_REMOVE') {
|
||||
MessageStore.handleReaction({
|
||||
type,
|
||||
channelId,
|
||||
messageId,
|
||||
userId: actualUserId,
|
||||
emoji,
|
||||
optimistic: true,
|
||||
});
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_ALL') {
|
||||
MessageStore.handleRemoveAllReactions({channelId, messageId});
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_EMOJI') {
|
||||
MessageStore.handleRemoveReactionEmoji({channelId, messageId, emoji});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Optimistically applied ${type} for message ${messageId} ` +
|
||||
`with emoji ${emoji.name}${emoji.id ? `:${emoji.id}` : ''} by user ${actualUserId}`,
|
||||
);
|
||||
};
|
||||
|
||||
const makeUrl = ({
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
}: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
emoji: ReactionEmoji;
|
||||
userId?: string;
|
||||
}): string => {
|
||||
const emojiCode = encodeURIComponent(emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name);
|
||||
return userId
|
||||
? Endpoints.CHANNEL_MESSAGE_REACTION_QUERY(channelId, messageId, emojiCode, userId)
|
||||
: Endpoints.CHANNEL_MESSAGE_REACTION(channelId, messageId, emojiCode);
|
||||
};
|
||||
|
||||
async function retryWithExponentialBackoff<T>(func: () => Promise<T>, attempts = 0): Promise<T> {
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
try {
|
||||
return await func();
|
||||
} catch (error) {
|
||||
const status = error instanceof HttpError ? error.status : undefined;
|
||||
if (status !== 429) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempts < MAX_RETRIES) {
|
||||
const backoffTime = 2 ** attempts * 1000;
|
||||
logger.debug(`Rate limited, retrying in ${backoffTime}ms (attempt ${attempts + 1}/${MAX_RETRIES})`);
|
||||
await delay(backoffTime);
|
||||
return retryWithExponentialBackoff(func, attempts + 1);
|
||||
}
|
||||
|
||||
logger.error(`Operation failed after ${MAX_RETRIES} attempts:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const performReactionAction = (
|
||||
i18n: I18n,
|
||||
type: 'MESSAGE_REACTION_ADD' | 'MESSAGE_REACTION_REMOVE',
|
||||
apiFunc: () => Promise<unknown>,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void => {
|
||||
optimisticUpdate(type, channelId, messageId, emoji, userId);
|
||||
|
||||
retryWithExponentialBackoff(apiFunc).catch((error) => {
|
||||
if (
|
||||
checkReactionResponse(i18n, error, () =>
|
||||
performReactionAction(i18n, type, apiFunc, channelId, messageId, emoji, userId),
|
||||
)
|
||||
) {
|
||||
logger.debug(`Reverting optimistic update for reaction in message ${messageId}`);
|
||||
optimisticUpdate(
|
||||
type === 'MESSAGE_REACTION_ADD' ? 'MESSAGE_REACTION_REMOVE' : 'MESSAGE_REACTION_ADD',
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export async function getReactions(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
limit?: number,
|
||||
): Promise<Array<UserPartial>> {
|
||||
MessageReactionsStore.handleFetchPending(messageId, emoji);
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`Fetching reactions for message ${messageId} in channel ${channelId} with emoji ${emoji.name}${limit ? ` (limit: ${limit})` : ''}`,
|
||||
);
|
||||
|
||||
const query: Record<string, number> = {};
|
||||
if (limit !== undefined) query['limit'] = limit;
|
||||
|
||||
const response = await http.get<Array<UserPartial>>({
|
||||
url: makeUrl({channelId, messageId, emoji}),
|
||||
query: Object.keys(query).length > 0 ? query : undefined,
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
MessageReactionsStore.handleFetchSuccess(messageId, data, emoji);
|
||||
|
||||
logger.debug(`Retrieved ${data.length} reactions for message ${messageId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get reactions for message ${messageId}:`, error);
|
||||
MessageReactionsStore.handleFetchError(messageId, emoji);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function addReaction(i18n: I18n, channelId: string, messageId: string, emoji: ReactionEmoji): void {
|
||||
logger.debug(`Adding reaction ${emoji.name} to message ${messageId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.put({
|
||||
url: makeUrl({channelId, messageId, emoji, userId: ME}),
|
||||
query: {session_id: GatewayConnectionStore.sessionId ?? null},
|
||||
});
|
||||
|
||||
performReactionAction(i18n, 'MESSAGE_REACTION_ADD', apiFunc, channelId, messageId, emoji);
|
||||
}
|
||||
|
||||
export function removeReaction(
|
||||
i18n: I18n,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void {
|
||||
logger.debug(`Removing reaction ${emoji.name} from message ${messageId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.delete({
|
||||
url: makeUrl({channelId, messageId, emoji, userId: userId || ME}),
|
||||
query: {session_id: GatewayConnectionStore.sessionId ?? null},
|
||||
});
|
||||
|
||||
performReactionAction(i18n, 'MESSAGE_REACTION_REMOVE', apiFunc, channelId, messageId, emoji, userId);
|
||||
}
|
||||
|
||||
export function removeAllReactions(i18n: I18n, channelId: string, messageId: string): void {
|
||||
logger.debug(`Removing all reactions from message ${messageId} in channel ${channelId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.delete({
|
||||
url: Endpoints.CHANNEL_MESSAGE_REACTIONS(channelId, messageId),
|
||||
});
|
||||
|
||||
retryWithExponentialBackoff(apiFunc).catch((error) => {
|
||||
checkReactionResponse(i18n, error, () => removeAllReactions(i18n, channelId, messageId));
|
||||
});
|
||||
}
|
||||
|
||||
export function removeReactionEmoji(i18n: I18n, channelId: string, messageId: string, emoji: ReactionEmoji): void {
|
||||
logger.debug(`Removing all ${emoji.name} reactions from message ${messageId} in channel ${channelId}`);
|
||||
|
||||
optimisticUpdate('MESSAGE_REACTION_REMOVE_EMOJI', channelId, messageId, emoji);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.delete({
|
||||
url: makeUrl({channelId, messageId, emoji}),
|
||||
});
|
||||
|
||||
retryWithExponentialBackoff(apiFunc).catch((error) => {
|
||||
checkReactionResponse(i18n, error, () => removeReactionEmoji(i18n, channelId, messageId, emoji));
|
||||
});
|
||||
}
|
||||
155
fluxer/fluxer_app/src/actions/ReadStateActionCreators.tsx
Normal file
155
fluxer/fluxer_app/src/actions/ReadStateActionCreators.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import MessageStore from '@app/stores/MessageStore';
|
||||
import ReadStateStore from '@app/stores/ReadStateStore';
|
||||
import {atPreviousMillisecond} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
|
||||
const logger = new Logger('ReadStateActionCreators');
|
||||
|
||||
export function ack(channelId: string, immediate = false, force = false): void {
|
||||
logger.debug(`Acking channel ${channelId}, immediate=${immediate}, force=${force}`);
|
||||
ReadStateStore.handleChannelAck({channelId, immediate, force});
|
||||
}
|
||||
|
||||
export function ackWithStickyUnread(channelId: string): void {
|
||||
logger.debug(`Acking channel ${channelId} with sticky unread preservation`);
|
||||
ReadStateStore.handleChannelAckWithStickyUnread({channelId});
|
||||
}
|
||||
|
||||
export async function manualAck(channelId: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Manual ack: ${messageId} in ${channelId}`);
|
||||
const mentionCount = ReadStateStore.getManualAckMentionCount(channelId, messageId);
|
||||
|
||||
await http.post({
|
||||
url: Endpoints.CHANNEL_MESSAGE_ACK(channelId, messageId),
|
||||
body: {
|
||||
manual: true,
|
||||
mention_count: mentionCount,
|
||||
},
|
||||
});
|
||||
|
||||
ReadStateStore.handleMessageAck({channelId, messageId, manual: true});
|
||||
logger.debug(`Successfully manual acked ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to manual ack ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAsUnread(channelId: string, messageId: string): Promise<void> {
|
||||
const messages = MessageStore.getMessages(channelId);
|
||||
const messagesArray = messages.toArray();
|
||||
const messageIndex = messagesArray.findIndex((m) => m.id === messageId);
|
||||
|
||||
logger.debug(`Marking message ${messageId} as unread, index: ${messageIndex}, total: ${messagesArray.length}`);
|
||||
|
||||
if (messageIndex < 0) {
|
||||
logger.debug('Message not found in cache; skipping mark-as-unread request');
|
||||
return;
|
||||
}
|
||||
|
||||
const ackMessageId = messageIndex > 0 ? messagesArray[messageIndex - 1].id : atPreviousMillisecond(messageId);
|
||||
|
||||
if (!ackMessageId || ackMessageId === '0') {
|
||||
logger.debug('Unable to determine a previous message to ack; skipping mark-as-unread request');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Acking ${ackMessageId} to mark ${messageId} as unread`);
|
||||
await manualAck(channelId, ackMessageId);
|
||||
}
|
||||
|
||||
export function clearManualAck(channelId: string): void {
|
||||
ReadStateStore.handleClearManualAck({channelId});
|
||||
}
|
||||
|
||||
export function clearStickyUnread(channelId: string): void {
|
||||
logger.debug(`Clearing sticky unread for ${channelId}`);
|
||||
ReadStateStore.clearStickyUnread(channelId);
|
||||
}
|
||||
|
||||
interface BulkAckEntry {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
const BULK_ACK_BATCH_SIZE = 100;
|
||||
|
||||
function chunkEntries<T>(entries: Array<T>, size: number): Array<Array<T>> {
|
||||
const chunks: Array<Array<T>> = [];
|
||||
for (let i = 0; i < entries.length; i += size) {
|
||||
chunks.push(entries.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function createBulkEntry(channelId: string): BulkAckEntry | null {
|
||||
const messageId =
|
||||
ReadStateStore.lastMessageId(channelId) ?? ChannelStore.getChannel(channelId)?.lastMessageId ?? null;
|
||||
|
||||
if (messageId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {channelId, messageId};
|
||||
}
|
||||
|
||||
async function sendBulkAck(entries: Array<BulkAckEntry>): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.READ_STATES_ACK_BULK,
|
||||
body: {
|
||||
read_states: entries.map((entry) => ({
|
||||
channel_id: entry.channelId,
|
||||
message_id: entry.messageId,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk ack read states:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateReadStatesLocally(entries: Array<BulkAckEntry>): void {
|
||||
for (const entry of entries) {
|
||||
ReadStateStore.handleMessageAck({channelId: entry.channelId, messageId: entry.messageId, manual: false});
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkAckChannels(channelIds: Array<string>): Promise<void> {
|
||||
const entries = channelIds
|
||||
.map((channelId) => createBulkEntry(channelId))
|
||||
.filter((entry): entry is BulkAckEntry => entry != null);
|
||||
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const chunks = chunkEntries(entries, BULK_ACK_BATCH_SIZE);
|
||||
for (const chunk of chunks) {
|
||||
updateReadStatesLocally(chunk);
|
||||
await sendBulkAck(chunk);
|
||||
}
|
||||
}
|
||||
102
fluxer/fluxer_app/src/actions/RecentMentionActionCreators.tsx
Normal file
102
fluxer/fluxer_app/src/actions/RecentMentionActionCreators.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {MentionFilters} from '@app/stores/RecentMentionsStore';
|
||||
import RecentMentionsStore from '@app/stores/RecentMentionsStore';
|
||||
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
|
||||
const logger = new Logger('Mentions');
|
||||
|
||||
export async function fetch(): Promise<Array<Message>> {
|
||||
RecentMentionsStore.handleFetchPending();
|
||||
try {
|
||||
const filters = RecentMentionsStore.getFilters();
|
||||
logger.debug('Fetching recent mentions');
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.USER_MENTIONS,
|
||||
query: {
|
||||
everyone: filters.includeEveryone,
|
||||
roles: filters.includeRoles,
|
||||
guilds: filters.includeGuilds,
|
||||
limit: MAX_MESSAGES_PER_CHANNEL,
|
||||
},
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
RecentMentionsStore.handleRecentMentionsFetchSuccess(data);
|
||||
logger.debug(`Successfully fetched ${data.length} recent mentions`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
RecentMentionsStore.handleRecentMentionsFetchError();
|
||||
logger.error('Failed to fetch recent mentions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMore(): Promise<Array<Message>> {
|
||||
const recentMentions = RecentMentionsStore.recentMentions;
|
||||
if (recentMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lastMessage = recentMentions[recentMentions.length - 1];
|
||||
const filters = RecentMentionsStore.getFilters();
|
||||
|
||||
RecentMentionsStore.handleFetchPending();
|
||||
try {
|
||||
logger.debug(`Loading more mentions before ${lastMessage.id}`);
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.USER_MENTIONS,
|
||||
query: {
|
||||
everyone: filters.includeEveryone,
|
||||
roles: filters.includeRoles,
|
||||
guilds: filters.includeGuilds,
|
||||
limit: MAX_MESSAGES_PER_CHANNEL,
|
||||
before: lastMessage.id,
|
||||
},
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
RecentMentionsStore.handleRecentMentionsFetchSuccess(data);
|
||||
logger.debug(`Successfully loaded ${data.length} more mentions`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
RecentMentionsStore.handleRecentMentionsFetchError();
|
||||
logger.error('Failed to load more mentions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateFilters(filters: Partial<MentionFilters>): void {
|
||||
RecentMentionsStore.updateFilters(filters);
|
||||
}
|
||||
|
||||
export async function remove(messageId: string): Promise<void> {
|
||||
try {
|
||||
RecentMentionsStore.handleMessageDelete(messageId);
|
||||
logger.debug(`Removing message ${messageId} from recent mentions`);
|
||||
await http.delete({url: Endpoints.USER_MENTION(messageId)});
|
||||
logger.debug(`Successfully removed message ${messageId} from recent mentions`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove message ${messageId} from recent mentions:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
79
fluxer/fluxer_app/src/actions/RelationshipActionCreators.tsx
Normal file
79
fluxer/fluxer_app/src/actions/RelationshipActionCreators.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
const logger = new Logger('RelationshipActionCreators');
|
||||
|
||||
export async function sendFriendRequest(userId: string) {
|
||||
try {
|
||||
await http.post({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send friend request:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendFriendRequestByTag(username: string, discriminator: string) {
|
||||
try {
|
||||
await http.post({url: Endpoints.USER_RELATIONSHIPS, body: {username, discriminator}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send friend request by tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptFriendRequest(userId: string) {
|
||||
try {
|
||||
await http.put({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept friend request:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRelationship(userId: string) {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove relationship:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function blockUser(userId: string) {
|
||||
try {
|
||||
await http.put({url: Endpoints.USER_RELATIONSHIP(userId), body: {type: RelationshipTypes.BLOCKED}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to block user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFriendNickname(userId: string, nickname: string | null) {
|
||||
try {
|
||||
await http.patch({url: Endpoints.USER_RELATIONSHIP(userId), body: {nickname}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update friend nickname:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
92
fluxer/fluxer_app/src/actions/SavedMessageActionCreators.tsx
Normal file
92
fluxer/fluxer_app/src/actions/SavedMessageActionCreators.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {MaxBookmarksModal} from '@app/components/alerts/MaxBookmarksModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import http from '@app/lib/HttpClient';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {type SavedMessageEntry, SavedMessageEntryRecord} from '@app/records/SavedMessageEntryRecord';
|
||||
import SavedMessagesStore from '@app/stores/SavedMessagesStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {getApiErrorCode} from '@app/utils/ApiErrorUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('SavedMessages');
|
||||
|
||||
export async function fetch(): Promise<Array<SavedMessageEntryRecord>> {
|
||||
try {
|
||||
logger.debug('Fetching saved messages');
|
||||
const response = await http.get<Array<SavedMessageEntry>>({url: Endpoints.USER_SAVED_MESSAGES});
|
||||
const data = response.body ?? [];
|
||||
const entries = data.map(SavedMessageEntryRecord.fromResponse);
|
||||
SavedMessagesStore.fetchSuccess(entries);
|
||||
logger.debug(`Successfully fetched ${entries.length} saved messages`);
|
||||
return entries;
|
||||
} catch (error) {
|
||||
SavedMessagesStore.fetchError();
|
||||
logger.error('Failed to fetch saved messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(i18n: I18n, channelId: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Saving message ${messageId} from channel ${channelId}`);
|
||||
await http.post({url: Endpoints.USER_SAVED_MESSAGES, body: {channel_id: channelId, message_id: messageId}});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to bookmarks`),
|
||||
});
|
||||
logger.debug(`Successfully saved message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save message ${messageId}:`, error);
|
||||
|
||||
if (getApiErrorCode(error) === APIErrorCodes.MAX_BOOKMARKS) {
|
||||
const currentUser = UserStore.currentUser;
|
||||
if (!currentUser) {
|
||||
throw error;
|
||||
}
|
||||
ModalActionCreators.push(modal(() => <MaxBookmarksModal user={currentUser} />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(i18n: I18n, messageId: string): Promise<void> {
|
||||
try {
|
||||
SavedMessagesStore.handleMessageDelete(messageId);
|
||||
logger.debug(`Removing message ${messageId} from saved messages`);
|
||||
await http.delete({url: Endpoints.USER_SAVED_MESSAGE(messageId)});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed from bookmarks`),
|
||||
});
|
||||
logger.debug(`Successfully removed message ${messageId} from saved messages`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove message ${messageId} from saved messages:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
422
fluxer/fluxer_app/src/actions/ScheduledMessageActionCreators.tsx
Normal file
422
fluxer/fluxer_app/src/actions/ScheduledMessageActionCreators.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as DraftActionCreators from '@app/actions/DraftActionCreators';
|
||||
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as SlowmodeActionCreators from '@app/actions/SlowmodeActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {FileSizeTooLargeModal} from '@app/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageSendFailedModal} from '@app/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '@app/components/alerts/MessageSendTooQuickModal';
|
||||
import {NSFWContentRejectedModal} from '@app/components/alerts/NSFWContentRejectedModal';
|
||||
import {SlowmodeRateLimitedModal} from '@app/components/alerts/SlowmodeRateLimitedModal';
|
||||
import {Endpoints} from '@app/Endpoints';
|
||||
import {CloudUpload} from '@app/lib/CloudUpload';
|
||||
import http, {type HttpResponse} from '@app/lib/HttpClient';
|
||||
import type {HttpError} from '@app/lib/HttpError';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {
|
||||
ScheduledAttachment,
|
||||
ScheduledMessagePayload,
|
||||
ScheduledMessageResponse,
|
||||
} from '@app/records/ScheduledMessageRecord';
|
||||
import {ScheduledMessageRecord} from '@app/records/ScheduledMessageRecord';
|
||||
import ScheduledMessagesStore from '@app/stores/ScheduledMessagesStore';
|
||||
import {prepareAttachmentsForNonce} from '@app/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
type NormalizedMessageContent,
|
||||
normalizeMessageContent,
|
||||
} from '@app/utils/MessageRequestUtils';
|
||||
import * as MessageSubmitUtils from '@app/utils/MessageSubmitUtils';
|
||||
import {TypingUtils} from '@app/utils/TypingUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
AllowedMentions,
|
||||
MessageReference,
|
||||
MessageStickerItem,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const logger = new Logger('ScheduledMessages');
|
||||
|
||||
type ScheduledMessageRequest = MessageCreateRequest & {
|
||||
scheduled_local_at: string;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: number | string;
|
||||
retry_after?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleMessageParams {
|
||||
channelId: string;
|
||||
content: string;
|
||||
scheduledLocalAt: string;
|
||||
timezone: string;
|
||||
messageReference?: MessageReference;
|
||||
replyMentioning?: boolean;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
interface UpdateScheduledMessageParams {
|
||||
channelId: string;
|
||||
scheduledMessageId: string;
|
||||
scheduledLocalAt: string;
|
||||
timezone: string;
|
||||
normalized: NormalizedMessageContent;
|
||||
payload: ScheduledMessagePayload;
|
||||
replyMentioning?: boolean;
|
||||
}
|
||||
|
||||
const formatScheduledLabel = (local: string, timezone: string): string => {
|
||||
return `${local.replace('T', ' ')} (${timezone})`;
|
||||
};
|
||||
|
||||
function mapScheduledAttachments(
|
||||
attachments?: ReadonlyArray<ScheduledAttachment>,
|
||||
): Array<ApiAttachmentMetadata> | undefined {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
title: attachment.title ?? attachment.filename,
|
||||
description: attachment.description ?? undefined,
|
||||
flags: attachment.flags,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchScheduledMessages(): Promise<Array<ScheduledMessageRecord>> {
|
||||
logger.debug('Fetching scheduled messages');
|
||||
ScheduledMessagesStore.fetchStart();
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<ScheduledMessageResponse>>({
|
||||
url: Endpoints.USER_SCHEDULED_MESSAGES,
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
const messages = data.map(ScheduledMessageRecord.fromResponse);
|
||||
ScheduledMessagesStore.fetchSuccess(messages);
|
||||
logger.debug('Scheduled messages fetched successfully');
|
||||
return messages;
|
||||
} catch (error) {
|
||||
ScheduledMessagesStore.fetchError();
|
||||
logger.error('Failed to fetch scheduled messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleMessage(i18n: I18n, params: ScheduleMessageParams): Promise<ScheduledMessageRecord> {
|
||||
logger.debug('Scheduling message', params);
|
||||
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
const normalized = normalizeMessageContent(params.content, params.favoriteMemeId);
|
||||
const allowedMentions: AllowedMentions = {replied_user: params.replyMentioning ?? true};
|
||||
|
||||
if (params.hasAttachments) {
|
||||
MessageSubmitUtils.claimMessageAttachments(
|
||||
params.channelId,
|
||||
nonce,
|
||||
params.content,
|
||||
params.messageReference,
|
||||
params.replyMentioning,
|
||||
params.favoriteMemeId,
|
||||
);
|
||||
}
|
||||
|
||||
let attachments: Array<ApiAttachmentMetadata> | undefined;
|
||||
let files: Array<File> | undefined;
|
||||
|
||||
if (params.hasAttachments) {
|
||||
const result = await prepareAttachmentsForNonce(nonce, params.favoriteMemeId);
|
||||
attachments = result.attachments;
|
||||
files = result.files;
|
||||
}
|
||||
|
||||
const requestBody = buildMessageCreateRequest({
|
||||
content: normalized.content,
|
||||
nonce,
|
||||
attachments,
|
||||
allowedMentions,
|
||||
messageReference: params.messageReference,
|
||||
flags: normalized.flags,
|
||||
favoriteMemeId: params.favoriteMemeId,
|
||||
stickers: params.stickers,
|
||||
tts: params.tts,
|
||||
});
|
||||
|
||||
const payload: ScheduledMessageRequest = {
|
||||
...requestBody,
|
||||
scheduled_local_at: params.scheduledLocalAt,
|
||||
timezone: params.timezone,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await scheduleMessageRequest(params.channelId, payload, files, nonce);
|
||||
const record = ScheduledMessageRecord.fromResponse(response.body);
|
||||
ScheduledMessagesStore.upsert(record);
|
||||
DraftActionCreators.deleteDraft(params.channelId);
|
||||
TypingUtils.clear(params.channelId);
|
||||
MessageActionCreators.stopReply(params.channelId);
|
||||
if (params.hasAttachments) {
|
||||
CloudUpload.removeMessageUpload(nonce);
|
||||
}
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Scheduled message for ${formatScheduledLabel(params.scheduledLocalAt, params.timezone)}`),
|
||||
});
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
handleScheduleError(
|
||||
i18n,
|
||||
error as HttpError,
|
||||
params.channelId,
|
||||
nonce,
|
||||
params.content,
|
||||
params.messageReference,
|
||||
params.replyMentioning,
|
||||
params.hasAttachments,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateScheduledMessage(
|
||||
i18n: I18n,
|
||||
params: UpdateScheduledMessageParams,
|
||||
): Promise<ScheduledMessageRecord> {
|
||||
logger.debug('Updating scheduled message', params);
|
||||
const requestBody: ScheduledMessageRequest = {
|
||||
content: params.normalized.content,
|
||||
attachments: mapScheduledAttachments(params.payload.attachments),
|
||||
allowed_mentions: params.payload.allowed_mentions ?? (params.replyMentioning ? {replied_user: true} : undefined),
|
||||
message_reference:
|
||||
params.payload.message_reference?.channel_id && params.payload.message_reference.message_id
|
||||
? {
|
||||
channel_id: params.payload.message_reference.channel_id,
|
||||
message_id: params.payload.message_reference.message_id,
|
||||
guild_id: params.payload.message_reference.guild_id,
|
||||
type: params.payload.message_reference.type,
|
||||
}
|
||||
: undefined,
|
||||
flags: params.normalized.flags,
|
||||
favorite_meme_id: params.payload.favorite_meme_id ?? undefined,
|
||||
sticker_ids: params.payload.sticker_ids,
|
||||
tts: params.payload.tts ? true : undefined,
|
||||
scheduled_local_at: params.scheduledLocalAt,
|
||||
timezone: params.timezone,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await http.patch<ScheduledMessageResponse>({
|
||||
url: Endpoints.USER_SCHEDULED_MESSAGE(params.scheduledMessageId),
|
||||
body: requestBody,
|
||||
rejectWithError: true,
|
||||
});
|
||||
const record = ScheduledMessageRecord.fromResponse(response.body);
|
||||
ScheduledMessagesStore.upsert(record);
|
||||
DraftActionCreators.deleteDraft(params.channelId);
|
||||
TypingUtils.clear(params.channelId);
|
||||
MessageActionCreators.stopReply(params.channelId);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(
|
||||
msg`Updated scheduled message for ${formatScheduledLabel(params.scheduledLocalAt, params.timezone)}`,
|
||||
),
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update scheduled message', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelScheduledMessage(i18n: I18n, scheduledMessageId: string): Promise<void> {
|
||||
logger.debug('Canceling scheduled message', scheduledMessageId);
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId)});
|
||||
ScheduledMessagesStore.remove(scheduledMessageId);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed scheduled message`),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel scheduled message', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreDraftAfterScheduleFailure(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
content: string,
|
||||
messageReference?: MessageReference,
|
||||
replyMentioning?: boolean,
|
||||
hadAttachments?: boolean,
|
||||
): void {
|
||||
if (hadAttachments) {
|
||||
CloudUpload.restoreAttachmentsToTextarea(nonce);
|
||||
}
|
||||
DraftActionCreators.createDraft(channelId, content);
|
||||
if (messageReference && replyMentioning !== undefined) {
|
||||
MessageActionCreators.startReply(channelId, messageReference.message_id, replyMentioning);
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleMessageRequest(
|
||||
channelId: string,
|
||||
payload: ScheduledMessageRequest,
|
||||
files?: Array<File>,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<ScheduledMessageResponse>> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
if (files?.length) {
|
||||
return await scheduleMultipartMessage(channelId, payload, files, abortController.signal, nonce);
|
||||
}
|
||||
return await http.post<ScheduledMessageResponse>({
|
||||
url: Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId),
|
||||
body: payload,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
} finally {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleMultipartMessage(
|
||||
channelId: string,
|
||||
payload: ScheduledMessageRequest,
|
||||
files: Array<File>,
|
||||
signal: AbortSignal,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<ScheduledMessageResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('payload_json', JSON.stringify(payload));
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`files[${index}]`, file);
|
||||
});
|
||||
|
||||
return http.post<ScheduledMessageResponse>({
|
||||
url: Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId),
|
||||
body: formData,
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
onRequestProgress: nonce
|
||||
? (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const progress = (event.loaded / event.total) * 100;
|
||||
CloudUpload.updateSendingProgress(nonce, progress);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
|
||||
return typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
};
|
||||
|
||||
function handleScheduleError(
|
||||
i18n: I18n,
|
||||
error: HttpError,
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
content: string,
|
||||
messageReference?: MessageReference,
|
||||
replyMentioning?: boolean,
|
||||
hadAttachments?: boolean,
|
||||
): void {
|
||||
restoreDraftAfterScheduleFailure(channelId, nonce, content, messageReference, replyMentioning, hadAttachments);
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
handleScheduleRateLimit(i18n, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSlowmodeError(error)) {
|
||||
const retryAfter = Math.ceil(getApiErrorBody(error)?.retry_after ?? 0);
|
||||
const timestamp = Date.now() - retryAfter * 1000;
|
||||
SlowmodeActionCreators.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
ModalActionCreators.push(modal(() => <SlowmodeRateLimitedModal retryAfter={retryAfter} />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExplicitContentError(error)) {
|
||||
ModalActionCreators.push(modal(() => <NSFWContentRejectedModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFileTooLargeError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FileSizeTooLargeModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
ModalActionCreators.push(modal(() => <MessageSendFailedModal />));
|
||||
}
|
||||
|
||||
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
ModalActionCreators.push(
|
||||
modal(() => <MessageSendTooQuickModal retryAfter={retryAfterSeconds} onRetry={undefined} />),
|
||||
);
|
||||
logger.warn('Scheduled message rate limited, retry after', retryAfterSeconds);
|
||||
}
|
||||
|
||||
function isRateLimitError(error: HttpError): boolean {
|
||||
return error?.status === 429;
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
return error?.status === 400 && getApiErrorBody(error)?.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
|
||||
}
|
||||
|
||||
function isFeatureDisabledError(error: HttpError): boolean {
|
||||
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
|
||||
}
|
||||
|
||||
function isExplicitContentError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
|
||||
}
|
||||
|
||||
function isFileTooLargeError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
|
||||
}
|
||||
30
fluxer/fluxer_app/src/actions/SlowmodeActionCreators.tsx
Normal file
30
fluxer/fluxer_app/src/actions/SlowmodeActionCreators.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import ChannelStickerStore from '@app/stores/ChannelStickerStore';
|
||||
import SlowmodeStore from '@app/stores/SlowmodeStore';
|
||||
|
||||
export function recordMessageSend(channelId: string): void {
|
||||
ChannelStickerStore.clearPendingStickerOnMessageSend(channelId);
|
||||
SlowmodeStore.recordMessageSend(channelId);
|
||||
}
|
||||
|
||||
export function updateSlowmodeTimestamp(channelId: string, timestamp: number): void {
|
||||
SlowmodeStore.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
}
|
||||
37
fluxer/fluxer_app/src/actions/SoundActionCreators.tsx
Normal file
37
fluxer/fluxer_app/src/actions/SoundActionCreators.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import SoundStore from '@app/stores/SoundStore';
|
||||
import type {SoundType} from '@app/utils/SoundUtils';
|
||||
|
||||
export function playSound(sound: SoundType, loop?: boolean): void {
|
||||
SoundStore.playSound(sound, loop);
|
||||
}
|
||||
|
||||
export function stopAllSounds(): void {
|
||||
SoundStore.stopAllSounds();
|
||||
}
|
||||
|
||||
export function updateSoundSettings(settings: {
|
||||
allSoundsDisabled?: boolean;
|
||||
soundType?: SoundType;
|
||||
enabled?: boolean;
|
||||
}): void {
|
||||
SoundStore.updateSettings(settings);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user