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:
Vish
2026-03-13 00:55:14 -07:00
parent 5ceda343b8
commit 3b9d759b4b
5859 changed files with 1923440 additions and 0 deletions

View 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",
]

View 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"] }

View 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,
}
}

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

View 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())
}

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

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

View 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())
}

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "1.93.0"

View 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();

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

View 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();

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

View 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;
}

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

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

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

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

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

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

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

View 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"]
}

View 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;
}

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

View 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.`);
}

View 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;
}

View 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;
}

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

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

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

View 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;
}

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

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

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

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

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

View 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();

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

View File

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

View 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;
}
}

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

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

View 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;
}
}

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

View File

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

View 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;
}
}

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

View 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 {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}`);
}

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

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

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

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

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

View File

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

View 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;
}
}

View 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?.();
}}
/>
)),
);
}

View 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 = {};
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

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

View 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;
}
}

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

View 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;
}
}

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

View 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;
}
}

View 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;
}
}

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

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

View 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;
}
}

View 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;
}
}

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

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

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

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

View 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 &gt; 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);
}

View 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/>.
*/
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;
}
}

View 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;
}
}

View 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;
}
}

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

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

View 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;
}
}

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

View 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;
}
}

View 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;
}

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

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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

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

View 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