diff --git a/.env.example b/.env.example
index 04a1dd8..1390efc 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,6 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
PORT=3000
+APP_BASE_URL=http://localhost:3000
# Authentik OIDC app values
OIDC_CLIENT_ID=replace-me
@@ -16,6 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr
TURN_USERNAME=replace-me
TURN_PASSWORD=replace-me
-# 32+ random chars; used to sign session cookies
-SESSION_SECRET=replace-with-long-random-secret
-COOKIE_SECURE=false
+# Cloudflare R2 media uploads
+R2_ACCOUNT_ID=replace-me
+R2_ACCESS_KEY_ID=replace-me
+R2_SECRET_ACCESS_KEY=replace-me
+R2_BUCKET=chattz-media
+MEDIA_BASE_URL=https://media.example.com
diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml
index 30aa0d7..c76ae86 100644
--- a/.forgejo/workflows/pipeline.yaml
+++ b/.forgejo/workflows/pipeline.yaml
@@ -11,6 +11,14 @@ jobs:
with:
node-version: 24
- uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Update version from tag
+ run: |
+ # Extract version from tag (e.g. v1.2.3 -> 1.2.3)
+ VERSION=${GITHUB_REF_NAME#v}
+ echo "Bumping version to $VERSION"
+ npm version $VERSION --no-git-tag-version
- run: npm ci
- run: npm run dist:linux
- name: Build Windows installer with 32-bit Wine prefix
@@ -31,11 +39,23 @@ jobs:
cp "$file" "static/installers/$out"
fi
}
+ # Copy versioned artifacts for electron-updater
+ cp dist/*.AppImage static/installers/ || true
+ cp dist/*.exe static/installers/ || true
+ cp dist/*.rpm static/installers/ || true
+ cp dist/*.deb static/installers/ || true
+ cp dist/*.msi static/installers/ || true
+
+ # Maintain generic names for stable website links
copy_first '*.rpm' 'chattz-linux.rpm'
copy_first '*.deb' 'chattz-linux.deb'
copy_first '*.AppImage' 'chattz-linux.AppImage'
copy_first '*.exe' 'chattz-windows.exe'
copy_first '*.msi' 'chattz-windows.msi'
+
+ # Metadata files for electron-updater
+ copy_first 'latest-linux.yml' 'latest-linux.yml'
+ copy_first 'latest.yml' 'latest.yml'
- name: Cache Cargo registry
uses: actions/cache@v4
with:
diff --git a/Cargo.lock b/Cargo.lock
index 190bd84..c44764d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -92,6 +92,418 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "aws-config"
+version = "1.8.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-sdk-sts",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.5",
+ "aws-smithy-json 0.62.4",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "http 1.4.0",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "aws-credential-types"
+version = "1.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+]
+
+[[package]]
+name = "aws-runtime"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75"
+dependencies = [
+ "aws-credential-types",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.63.5",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "bytes-utils",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "percent-encoding",
+ "pin-project-lite",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "aws-sdk-s3"
+version = "1.119.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-checksums",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.62.6",
+ "aws-smithy-json 0.61.9",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-smithy-xml",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "hex",
+ "hmac",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "lru",
+ "percent-encoding",
+ "regex-lite",
+ "sha2",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "aws-sdk-sts"
+version = "1.99.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449"
+dependencies = [
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.5",
+ "aws-smithy-json 0.62.4",
+ "aws-smithy-observability",
+ "aws-smithy-query",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-smithy-xml",
+ "aws-types",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "regex-lite",
+ "tracing",
+]
+
+[[package]]
+name = "aws-sigv4"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-eventstream",
+ "aws-smithy-http 0.63.5",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "form_urlencoded",
+ "hex",
+ "hmac",
+ "http 0.2.12",
+ "http 1.4.0",
+ "percent-encoding",
+ "sha2",
+ "time",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-async"
+version = "1.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13"
+dependencies = [
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "aws-smithy-checksums"
+version = "0.63.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae"
+dependencies = [
+ "aws-smithy-http 0.62.6",
+ "aws-smithy-types",
+ "bytes",
+ "crc-fast",
+ "hex",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "md-5",
+ "pin-project-lite",
+ "sha1",
+ "sha2",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-eventstream"
+version = "0.60.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79"
+dependencies = [
+ "aws-smithy-types",
+ "bytes",
+ "crc32fast",
+]
+
+[[package]]
+name = "aws-smithy-http"
+version = "0.62.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b"
+dependencies = [
+ "aws-smithy-eventstream",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "futures-util",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "percent-encoding",
+ "pin-project-lite",
+ "pin-utils",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-http"
+version = "0.63.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project-lite",
+ "pin-utils",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-http-client"
+version = "1.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "h2 0.3.27",
+ "h2 0.4.13",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper 1.8.1",
+ "hyper-rustls 0.24.2",
+ "hyper-rustls 0.27.7",
+ "hyper-util",
+ "pin-project-lite",
+ "rustls 0.21.12",
+ "rustls 0.23.36",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-json"
+version = "0.61.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551"
+dependencies = [
+ "aws-smithy-types",
+]
+
+[[package]]
+name = "aws-smithy-json"
+version = "0.62.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb"
+dependencies = [
+ "aws-smithy-types",
+]
+
+[[package]]
+name = "aws-smithy-observability"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b"
+dependencies = [
+ "aws-smithy-runtime-api",
+]
+
+[[package]]
+name = "aws-smithy-query"
+version = "0.60.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0"
+dependencies = [
+ "aws-smithy-types",
+ "urlencoding",
+]
+
+[[package]]
+name = "aws-smithy-runtime"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-http 0.63.5",
+ "aws-smithy-http-client",
+ "aws-smithy-observability",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "http-body-util",
+ "pin-project-lite",
+ "pin-utils",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-runtime-api"
+version = "1.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-types",
+ "bytes",
+ "http 0.2.12",
+ "http 1.4.0",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-smithy-types"
+version = "1.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3"
+dependencies = [
+ "base64-simd",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "http 0.2.12",
+ "http 1.4.0",
+ "http-body 0.4.6",
+ "http-body 1.0.1",
+ "http-body-util",
+ "itoa",
+ "num-integer",
+ "pin-project-lite",
+ "pin-utils",
+ "ryu",
+ "serde",
+ "time",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "aws-smithy-xml"
+version = "0.60.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa"
+dependencies = [
+ "xmlparser",
+]
+
+[[package]]
+name = "aws-types"
+version = "1.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "rustc_version",
+ "tracing",
+]
+
[[package]]
name = "axum"
version = "0.8.8"
@@ -104,10 +516,10 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
+ "hyper 1.8.1",
"hyper-util",
"itoa",
"matchit",
@@ -138,8 +550,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
@@ -166,6 +578,16 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -208,6 +630,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bytes-utils"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
+dependencies = [
+ "bytes",
+ "either",
+]
+
[[package]]
name = "cc"
version = "1.2.56"
@@ -215,6 +647,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
+ "jobserver",
+ "libc",
"shlex",
]
@@ -235,17 +669,20 @@ name = "chattz"
version = "0.1.0"
dependencies = [
"anyhow",
+ "aws-config",
+ "aws-sdk-s3",
"axum",
"chrono",
"dotenvy",
"futures-util",
- "jsonwebtoken",
"reqwest",
"sea-orm",
"sea-orm-migration",
"serde",
"serde_json",
+ "sha2",
"tokio",
+ "tower",
"tower-http",
"tracing",
"tracing-subscriber",
@@ -267,6 +704,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cmake"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -282,6 +728,16 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -312,6 +768,28 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+[[package]]
+name = "crc-fast"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3"
+dependencies = [
+ "crc",
+ "digest",
+ "rand 0.9.2",
+ "regex",
+ "rustversion",
+]
+
+[[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-queue"
version = "0.3.12"
@@ -448,6 +926,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "either"
version = "1.15.0"
@@ -494,6 +978,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -532,6 +1022,12 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
[[package]]
name = "futures"
version = "0.3.31"
@@ -673,6 +1169,44 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.0",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -744,6 +1278,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -754,6 +1299,17 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -761,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -772,8 +1328,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -795,6 +1351,30 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
[[package]]
name = "hyper"
version = "1.8.1"
@@ -805,8 +1385,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "http",
- "http-body",
+ "h2 0.4.13",
+ "http 1.4.0",
+ "http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
@@ -817,19 +1398,35 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
+dependencies = [
+ "futures-util",
+ "http 0.2.12",
+ "hyper 0.14.32",
+ "log",
+ "rustls 0.21.12",
+ "tokio",
+ "tokio-rustls 0.24.1",
+]
+
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
- "hyper",
+ "http 1.4.0",
+ "hyper 1.8.1",
"hyper-util",
- "rustls",
+ "rustls 0.23.36",
+ "rustls-native-certs",
"rustls-pki-types",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.6",
]
@@ -844,14 +1441,14 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "hyper 1.8.1",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.2",
"tokio",
"tower-service",
"tracing",
@@ -1032,6 +1629,16 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
[[package]]
name = "js-sys"
version = "0.3.85"
@@ -1042,21 +1649,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "jsonwebtoken"
-version = "9.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
-dependencies = [
- "base64",
- "js-sys",
- "pem",
- "ring",
- "serde",
- "serde_json",
- "simple_asn1",
-]
-
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1120,6 +1712,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1193,7 +1794,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
- "http",
+ "http 1.4.0",
"httparse",
"memchr",
"mime",
@@ -1210,16 +1811,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[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-bigint-dig"
version = "0.8.6"
@@ -1278,6 +1869,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -1311,6 +1908,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -1340,16 +1943,6 @@ dependencies = [
"windows-link",
]
-[[package]]
-name = "pem"
-version = "3.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
-dependencies = [
- "base64",
- "serde_core",
-]
-
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -1484,8 +2077,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
- "rustls",
- "socket2",
+ "rustls 0.23.36",
+ "socket2 0.6.2",
"thiserror",
"tokio",
"tracing",
@@ -1504,7 +2097,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
- "rustls",
+ "rustls 0.23.36",
"rustls-pki-types",
"slab",
"thiserror",
@@ -1522,7 +2115,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@@ -1642,6 +2235,12 @@ dependencies = [
"regex-syntax",
]
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
[[package]]
name = "regex-syntax"
version = "0.8.9"
@@ -1657,25 +2256,25 @@ dependencies = [
"base64",
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
- "hyper-rustls",
+ "hyper 1.8.1",
+ "hyper-rustls 0.27.7",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls",
+ "rustls 0.23.36",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower",
"tower-http",
"tower-service",
@@ -1735,20 +2334,45 @@ dependencies = [
"semver",
]
+[[package]]
+name = "rustls"
+version = "0.21.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.7",
+ "sct",
+]
+
[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
+ "aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
- "rustls-webpki",
+ "rustls-webpki 0.103.9",
"subtle",
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -1759,12 +2383,23 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-webpki"
+version = "0.101.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
+ "aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -1782,12 +2417,31 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
[[package]]
name = "sea-bae"
version = "0.2.1"
@@ -1935,6 +2589,29 @@ dependencies = [
"syn",
]
+[[package]]
+name = "security-framework"
+version = "3.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "semver"
version = "1.0.27"
@@ -2054,18 +2731,6 @@ dependencies = [
"rand_core 0.6.4",
]
-[[package]]
-name = "simple_asn1"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
-dependencies = [
- "num-bigint",
- "num-traits",
- "thiserror",
- "time",
-]
-
[[package]]
name = "slab"
version = "0.4.12"
@@ -2081,6 +2746,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "socket2"
version = "0.6.2"
@@ -2147,7 +2822,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
- "rustls",
+ "rustls 0.23.36",
"serde",
"serde_json",
"sha2",
@@ -2410,7 +3085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
- "itoa",
"num-conv",
"powerfmt",
"serde_core",
@@ -2469,7 +3143,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -2485,13 +3159,23 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
+dependencies = [
+ "rustls 0.21.12",
+ "tokio",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls",
+ "rustls 0.23.36",
"tokio",
]
@@ -2557,8 +3241,8 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
- "http",
- "http-body",
+ "http 1.4.0",
+ "http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
@@ -2663,7 +3347,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
- "http",
+ "http 1.4.0",
"httparse",
"log",
"rand 0.9.2",
@@ -2783,6 +3467,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
[[package]]
name = "want"
version = "0.3.1"
@@ -3213,6 +3903,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+[[package]]
+name = "xmlparser"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
+
[[package]]
name = "yansi"
version = "1.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 5ab0c14..6dbd768 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,17 +5,20 @@ edition = "2024"
[dependencies]
anyhow = "1"
+aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] }
+aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
-jsonwebtoken = "9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
-sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
+sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] }
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+sha2 = "0.10"
futures-util = "0.3"
-tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
+tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
diff --git a/README.md b/README.md
index af5e8f0..2708a60 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using:
## What this includes
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
-- Signed session cookie auth
+- HttpOnly session cookie auth
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
- Guild invite codes (create + join)
- Direct messages (DM) between users
@@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`:
- `TURN_USERNAME`
- `TURN_PASSWORD`
+For production deployments:
+- `APP_BASE_URL` must be your public app origin and should use `https`
+- `MEDIA_BASE_URL` should be a separate media origin for user uploads
+- uploads and soundboard require R2/object storage to be configured
+
3. Run app:
```bash
@@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`.
## Authentik setup notes
Create an Authentik OAuth2/OIDC provider + application and set:
-- Redirect URI: `http://localhost:3000/auth/callback`
+- Redirect URI: `${APP_BASE_URL}/auth/callback`
- Scopes including at least: `openid profile email`
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
@@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug.
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
All endpoints except health and auth flow require the session cookie from successful login.
+Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session.
## Notes
@@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
+User uploads are served from the configured media origin, not from `/static`.
Mic filter modes in the UI:
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)
diff --git a/build/icon.png b/build/icon.png
new file mode 100644
index 0000000..8169e48
Binary files /dev/null and b/build/icon.png differ
diff --git a/desktop/app.js b/desktop/app.js
index 530bc53..e0b9fa7 100644
--- a/desktop/app.js
+++ b/desktop/app.js
@@ -1,1742 +1 @@
-const state = {
- me: null,
- guilds: [],
- channels: [],
- dmConversations: [],
- members: [],
- voicePresence: new Map(),
- selectedGuildId: null,
- selectedTextChannelId: null,
- selectedDmUserId: null,
- selectedDmDisplayName: null,
- selectedVoiceChannelId: null,
- voice: {
- ws: null,
- joinedChannelId: null,
- localStream: null,
- rawStream: null,
- videoStream: null,
- screenStream: null,
- peerConnections: new Map(),
- videoSenders: new Map(),
- screenSenders: new Map(),
- muted: false,
- sharingVideo: false,
- sharingScreen: false,
- noiseFilterMode: 'off',
- activeNoiseFilter: 'off',
- deepFilterProcessor: null,
- deepFilterModule: null,
- viewMode: 'chat', // 'chat' or 'video'
- iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
- },
- voicePresencePollId: null,
- chatWs: null,
- lastMessageId: null,
- onlineUsers: new Set(),
-};
-
-// --- Desktop Backend Configuration ---
-let API_BASE_URL = ''; // Will be initialized via IPC
-
-async function initializeConfig() {
- try {
- const config = await window.electronAPI.getConfig();
- API_BASE_URL = config.backendUrl;
- console.log(`Backend initialized: ${API_BASE_URL}`);
-
- // Start the app now that config is ready
- init();
- } catch (err) {
- console.error("Failed to fetch config via IPC", err);
- // Fallback to URL search params if IPC fails (unlikely in desktop app)
- const urlParams = new URLSearchParams(window.location.search);
- API_BASE_URL = urlParams.get('backend') || '';
- }
-}
-
-// Start config fetch immediately
-initializeConfig();
-
-function getWsUrl(path) {
- let urlStr;
- if (!API_BASE_URL) {
- const proto = location.protocol === "https:" ? "wss" : "ws";
- urlStr = `${proto}://${location.host}${path}`;
- } else {
- const url = new URL(API_BASE_URL);
- const proto = url.protocol === "https:" ? "wss" : "ws";
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
- urlStr = `${proto}://${url.host}${normalizedPath}`;
- }
-
- const token = localStorage.getItem("chattz_token");
- if (token) {
- urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`;
- }
- return urlStr;
-}
-
-
-
-
-
-
-const el = {
- authScreen: document.getElementById("auth-screen"),
- loginBtn: document.getElementById("login-btn"),
- main: document.getElementById("main"),
- status: document.getElementById("status"),
-
- // Guilds
- guildList: document.getElementById("guild-list"),
- addGuildBtn: document.getElementById("add-guild-btn"),
- guildTitle: document.getElementById("guild-title"),
- createInviteBtn: document.getElementById("create-invite-btn"),
- inviteCopiedBadge: document.getElementById("invite-copied-badge"),
-
- // Channels
- channelList: document.getElementById("channel-list"),
- voiceChannelList: document.getElementById("voice-channel-list"),
- addTextBtn: document.getElementById("add-text-btn"),
- addVoiceBtn: document.getElementById("add-voice-btn"),
- dmList: document.getElementById("dm-list"),
- channelTitle: document.getElementById("channel-title"),
-
- // Messages
- messageList: document.getElementById("message-list"),
- messageForm: document.getElementById("message-form"),
- messageBody: document.getElementById("message-body"),
-
- // User Panel
- userName: document.getElementById("user-name"),
- userAvatar: document.getElementById("user-avatar"),
- logoutBtn: document.getElementById("logout-btn"),
-
- // Voice Connection
- voiceConnection: document.getElementById("voice-connection"),
- vcChannelName: document.getElementById("vc-channel-name"),
- voiceVideoBtn: document.getElementById("voice-video-btn"),
- voiceScreenBtn: document.getElementById("voice-screen-btn"),
- voiceMuteBtn: document.getElementById("voice-mute-btn"),
- voiceLeaveBtn: document.getElementById("voice-leave-btn"),
- videoGrid: document.getElementById("video-grid"),
- messageForm: document.getElementById("message-form"),
- messageInputWrapper: document.querySelector(".chat-input-wrapper"),
-
- // Members
- memberList: document.getElementById("member-list"),
-
- // Modals
- modalContainer: document.getElementById("modal-container"),
- guildForm: document.getElementById("guild-form"),
- guildName: document.getElementById("guild-name"),
- modalCancel: document.getElementById("modal-cancel"),
-
- channelModal: document.getElementById("channel-modal"),
- channelForm: document.getElementById("channel-form"),
- channelName: document.getElementById("channel-name"),
- channelModalCancel: document.getElementById("channel-modal-cancel"),
-
- // Sound Board
- soundboard: document.getElementById('soundboard'),
- soundboardGrid: document.getElementById('soundboard-grid'),
- addSoundBtn: document.getElementById('add-sound-btn'),
- soundModal: document.getElementById('sound-modal'),
- soundForm: document.getElementById('sound-form'),
- soundName: document.getElementById('sound-name'),
- soundIcon: document.getElementById('sound-icon'),
- soundFile: document.getElementById('sound-file'),
- soundModalCancel: document.getElementById('sound-modal-cancel'),
- soundSubmitBtn: document.getElementById('sound-submit-btn'),
-
- // Mobile
- mobileMenuBtn: document.getElementById("mobile-menu-btn"),
- mobileMembersBtn: document.getElementById("mobile-members-btn"),
- overlay: document.getElementById("overlay"),
- utilitySidebar: document.getElementById("utility-sidebar"),
-
- // GIF Picker
- gifBtn: document.getElementById("gif-btn"),
- gifPicker: document.getElementById("gif-picker"),
- gifPickerClose: document.getElementById("gif-picker-close"),
- gifSearchInput: document.getElementById("gif-search-input"),
- gifResults: document.getElementById("gif-results"),
-};
-
-// --- API Helpers ---
-
-async function api(path, options = {}) {
- const fullUrl = API_BASE_URL ? (path.startsWith('http') ? path : `${API_BASE_URL}${path}`) : path;
-
- const headers = {
- "content-type": "application/json",
- ...(options.headers || {}),
- };
-
- const token = localStorage.getItem("chattz_token");
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
- }
-
- let res = await fetch(fullUrl, {
- ...options,
- headers,
- });
-
- // Handle 401 Unauthorized via Refresh Token
- if (res.status === 401 && !path.includes('/auth/refresh')) {
- const refreshToken = localStorage.getItem("chattz_refresh_token");
- if (refreshToken) {
- try {
- const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh';
- const refreshRes = await fetch(refreshUrl, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ refresh_token: refreshToken })
- });
-
- if (refreshRes.ok) {
- const newTokens = await refreshRes.json();
- localStorage.setItem("chattz_token", newTokens.access_token);
- localStorage.setItem("chattz_refresh_token", newTokens.refresh_token);
-
- // Retry original request with new token
- headers["Authorization"] = `Bearer ${newTokens.access_token}`;
- res = await fetch(fullUrl, { ...options, headers });
- } else {
- // Both tokens invalid/expired, wipe out to force clear state
- throw new Error("Refresh token expired or invalid");
- }
- } catch (err) {
- localStorage.removeItem("chattz_token");
- localStorage.removeItem("chattz_refresh_token");
- location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login";
- throw new Error("Session expired, please log in again");
- }
- }
- }
-
- if (!res.ok) {
- let detail = "request failed";
- try {
- const body = await res.json();
- detail = body.error || detail;
- } catch { }
- throw new Error(`${res.status}: ${detail}`);
- }
-
- if (res.status === 204) return null;
- return res.json();
-}
-
-// --- Utils ---
-
-function shortName(name) {
- if (!name || typeof name !== 'string') return "?";
- const trimmed = name.trim();
- if (!trimmed) return "?";
- const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
- if (words.length === 0) return "?";
- if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
- return words.map((w) => w[0]?.toUpperCase() || "").join("");
-}
-
-function escapeHtml(s) {
- if (s === null || s === undefined) return "";
- return String(s)
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
-function formatMessageBody(body) {
- const escaped = escapeHtml(body);
- // Detect GIF URLs (simple regex for demo)
- const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
- if (urlRegex.test(body)) {
- return escaped.replace(urlRegex, (url) => {
- return `

`;
- });
- }
- return escaped;
-}
-
-function formatDate(isoString) {
- const d = new Date(isoString);
- const now = new Date();
- const isToday = d.toDateString() === now.toDateString();
-
- if (isToday) {
- return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
- }
- return d.toLocaleDateString();
-}
-
-let audioCtx = null;
-
-// Global interaction listener to unlock AudioContext (autoplay policy)
-window.addEventListener('click', () => {
- if (audioCtx && audioCtx.state === 'suspended') {
- audioCtx.resume();
- }
-}, { once: true });
-
-function playSound(type) {
- try {
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- if (audioCtx.state === 'suspended') audioCtx.resume();
-
- const sounds = {
- message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 },
- dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 },
- join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 },
- leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 },
- 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 },
- 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 },
- };
-
- const s = sounds[type];
- if (!s) return;
-
- const osc = audioCtx.createOscillator();
- const gain = audioCtx.createGain();
-
- osc.type = s.type;
- if (Array.isArray(s.freq)) {
- osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime);
- osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration);
- } else {
- osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime);
- }
-
- gain.gain.setValueAtTime(s.volume, audioCtx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration);
-
- osc.connect(gain);
- gain.connect(audioCtx.destination);
-
- osc.start();
- osc.stop(audioCtx.currentTime + s.duration);
- } catch (err) {
- console.warn("playSound failed", err);
- }
-}
-const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
-
-// --- Renderers ---
-
-function renderGuilds() {
- el.guildList.innerHTML = "";
-
- for (const guild of state.guilds) {
- const btn = document.createElement("button");
- btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
- btn.title = guild.name;
- btn.textContent = shortName(guild.name);
- btn.onclick = async () => {
- state.selectedGuildId = guild.id;
- state.selectedTextChannelId = null;
- state.selectedVoiceChannelId = null;
- state.selectedDmUserId = null;
- state.selectedDmDisplayName = null;
- localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
- renderGuilds();
- renderDMs();
- await loadChannels();
- await loadGuildMembers();
- await refreshVoicePresence();
- startVoicePresencePolling();
- renderMessages([]);
- updateHeaderLabels();
- };
- el.guildList.appendChild(btn);
- }
-}
-
-function renderChannels() {
- el.channelList.innerHTML = "";
- el.voiceChannelList.innerHTML = "";
-
- for (const channel of state.channels) {
- const row = document.createElement("button");
- row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
- (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
- }`;
-
- const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
- row.innerHTML = ` ${escapeHtml(channel.name)}`;
-
- row.onclick = async () => {
- if (channel.kind === 'text') {
- state.selectedDmUserId = null;
- state.selectedDmDisplayName = null;
- state.selectedTextChannelId = channel.id;
- state.voice.viewMode = 'chat';
- renderChannels();
- renderDMs();
- updateView();
- updateHeaderLabels();
- const messages = await api(`/channels/${channel.id}/messages?limit=100`);
- renderMessages(messages);
- } else {
- state.selectedVoiceChannelId = channel.id;
- state.voice.viewMode = 'video';
- renderChannels();
- renderDMs();
- updateView();
- updateHeaderLabels();
- joinVoice();
- }
- if (window.innerWidth <= 768) closeMobileMenus();
- };
-
- if (channel.kind === 'text') {
- el.channelList.appendChild(row);
- } else {
- el.voiceChannelList.appendChild(row);
-
- const participants = state.voicePresence.get(channel.id) || [];
- if (participants.length > 0) {
- const pList = document.createElement("div");
- pList.className = "voice-row-members channel-list";
- pList.style.paddingLeft = "24px";
- for (const p of participants) {
- const pRow = document.createElement("div");
- pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
- pRow.style.padding = "2px 8px";
- pRow.innerHTML = `${shortName(p.display_name)}
${escapeHtml(p.display_name)}`;
- pList.appendChild(pRow);
- }
- el.voiceChannelList.appendChild(pList);
- }
- }
- }
- lucide.createIcons();
-}
-
-function renderDMs() {
- el.dmList.innerHTML = "";
- for (const dm of state.dmConversations) {
- const isOnline = state.onlineUsers.has(dm.user_id);
- const row = document.createElement("button");
- row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
- row.innerHTML = `
-
- ${escapeHtml(dm.display_name)}
- `;
- row.onclick = async () => {
- state.selectedDmUserId = dm.user_id;
- state.selectedDmDisplayName = dm.display_name;
- state.selectedTextChannelId = null;
- state.selectedVoiceChannelId = null;
- state.voice.viewMode = 'chat';
- renderChannels();
- renderDMs();
- updateView();
- updateHeaderLabels();
- const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
- renderMessages(messages);
- };
- el.dmList.appendChild(row);
- }
-
- lucide.createIcons();
-}
-
-function renderMessages(messages) {
- el.messageList.innerHTML = "";
- let lastAuthorId = null;
- let lastTime = null;
-
- for (const m of messages.slice().reverse()) {
- const row = document.createElement("div");
- const mDate = new Date(m.created_at);
- const isGrouped = lastAuthorId === m.author_user_id &&
- lastTime && (mDate - lastTime < 300000); // 5 minutes
-
- row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
-
- const displayName = m.author_display_name || "Unknown User";
-
- if (isGrouped) {
- row.innerHTML = `${formatMessageBody(m.body)}
`;
- } else {
- row.innerHTML = `
- ${shortName(displayName)}
-
-
-
${formatMessageBody(m.body)}
-
- `;
- }
- el.messageList.appendChild(row);
- lastAuthorId = m.author_user_id;
- lastTime = mDate;
- }
-
- if (messages.length > 0) {
- state.lastMessageId = messages[0].id;
- }
-
- el.messageList.scrollTop = el.messageList.scrollHeight;
-}
-
-function renderMembers() {
- el.memberList.innerHTML = "";
- for (const m of state.members) {
- const isOnline = state.onlineUsers.has(m.id);
- const row = document.createElement("div");
- row.className = "member-row";
- row.innerHTML = `
-
- ${shortName(m.display_name)}
-
-
- ${escapeHtml(m.display_name)}
- `;
- row.style.cursor = "pointer";
- row.onclick = async () => {
- if (state.me && m.id === state.me.id) return;
- state.selectedDmUserId = m.id;
- state.selectedDmDisplayName = m.display_name;
- state.selectedTextChannelId = null;
- state.selectedVoiceChannelId = null;
- state.voice.viewMode = 'chat';
- renderChannels();
- renderDMs();
- updateView();
- updateHeaderLabels();
- const messages = await api(`/dms/${m.id}/messages?limit=100`);
- renderMessages(messages);
- };
- el.memberList.appendChild(row);
- }
-}
-
-function updateHeaderLabels() {
- const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
- el.guildTitle.textContent = guild ? guild.name : "No server selected";
-
- if (state.selectedDmUserId) {
- const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
- const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
- const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
- const dmName = displayName ? `@${displayName}` : "Direct Message";
- el.channelTitle.textContent = dmName;
- el.messageBody.placeholder = displayName
- ? `Message @${displayName}`
- : "Message";
- return;
- }
-
- if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) {
- const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId);
- el.channelTitle.textContent = channel ? channel.name : "Voice Arena";
- return;
- }
-
- const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
- el.channelTitle.textContent = channel ? channel.name : "Select a channel";
- el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
-}
-
-async function createInviteLink() {
- if (!state.selectedGuildId) {
- alert("Select a server first.");
- return;
- }
- try {
- const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
- method: "POST",
- body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
- });
- // Use the backend URL if available, otherwise fallback to current origin
- const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin;
- const link = `${base}/?invite=${encodeURIComponent(invite.code)}`;
-
- // Use Electron's native clipboard API if available (defined in preload.js)
- if (window.electronAPI && window.electronAPI.copyToClipboard) {
- try {
- await window.electronAPI.copyToClipboard(link);
- showCopiedBadge();
- return;
- } catch (e) {
- console.error("Electron clipboard write failed:", e);
- }
- }
-
- // Fallback to navigator.clipboard
- try {
- await navigator.clipboard.writeText(link);
- showCopiedBadge();
- } catch (err) {
- console.error("Navigator clipboard write failed:", err);
- prompt("Copy invite link:", link);
- }
- } catch (err) {
- alert(err.message);
- }
-}
-
-function showCopiedBadge() {
- el.inviteCopiedBadge.classList.remove("hidden");
- setTimeout(() => {
- el.inviteCopiedBadge.classList.add("hidden");
- }, 2000);
-}
-
-// --- Logic ---
-
-async function loadGuilds() {
- state.guilds = await api("/guilds");
- renderGuilds();
-}
-
-async function loadDMConversations() {
- state.dmConversations = await api("/dms");
- renderDMs();
-}
-
-async function loadGuildMembers() {
- if (!state.selectedGuildId) {
- state.members = [];
- renderMembers();
- return;
- }
- state.members = await api(`/guilds/${state.selectedGuildId}/members`);
- renderMembers();
-}
-
-async function loadChannels() {
- if (!state.selectedGuildId) {
- state.channels = [];
- renderChannels();
- return;
- }
-
- state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
- renderChannels();
-}
-
-async function refreshVoicePresence() {
- if (!state.selectedGuildId) {
- state.voicePresence.clear();
- renderChannels();
- return;
- }
-
- try {
- const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
- state.voicePresence.clear();
- for (const entry of res.channels || []) {
- state.voicePresence.set(entry.channel_id, entry.participants || []);
- }
- renderChannels();
- } catch (err) {
- console.warn("voice presence failed", err);
- }
-}
-
-function startVoicePresencePolling() {
- if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
- state.voicePresencePollId = setInterval(() => {
- refreshVoicePresence().catch(() => { });
- }, 3000);
-}
-
-function initChatWs() {
- if (state.chatWs) state.chatWs.close();
- const wsUrl = getWsUrl('/ws');
- const ws = new WebSocket(wsUrl);
- state.chatWs = ws;
-
- ws.onmessage = (event) => {
- const msg = JSON.parse(event.data);
- const authorId = msg.message?.author_user_id;
- const isFromMe = authorId && state.me && authorId === state.me.id;
-
- if (msg.type === "message_created") {
- if (!isFromMe) playSound('message');
- if (state.selectedTextChannelId === msg.channel_id) {
- api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages);
- }
- } else if (msg.type === "dm_created") {
- if (!isFromMe) playSound('dm');
- if (state.selectedDmUserId === msg.other_user_id) {
- api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages);
- playSound('dm');
- loadDMConversations().then(renderDMs);
- } else {
- loadDMConversations().then(renderDMs);
- }
- } else if (msg.type === "user_presence") {
- if (msg.online) {
- state.onlineUsers.add(msg.user_id);
- } else {
- state.onlineUsers.delete(msg.user_id);
- }
- renderMembers();
- renderDMs();
- }
- };
-
- ws.onclose = () => {
- console.log("Chat WS closed, reconnecting...");
- setTimeout(initChatWs, 3000);
- };
-}
-
-// --- Voice ---
-
-function getVoiceWsUrl(channelId) {
- return getWsUrl(`/channels/${channelId}/voice/ws`);
-}
-
-function shouldInitiateOffer(peerId) {
- if (!state.me || !state.me.id) return false;
- return state.me.id > peerId;
-}
-
-function stopAndClearAudioPipeline() {
- if (state.voice.deepFilterProcessor) {
- state.voice.deepFilterProcessor.destroy();
- state.voice.deepFilterProcessor = null;
- }
- if (state.voice.localStream) {
- for (const track of state.voice.localStream.getTracks()) track.stop();
- }
- if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
- for (const track of state.voice.rawStream.getTracks()) track.stop();
- }
- if (state.voice.audioContext) {
- state.voice.audioContext.close().catch(() => { });
- }
- state.voice.localStream = null;
- state.voice.rawStream = null;
- state.voice.audioContext = null;
- state.voice.activeNoiseFilter = 'off';
-}
-
-function stopAndClearVideoPipeline() {
- if (state.voice.videoStream) {
- for (const track of state.voice.videoStream.getTracks()) track.stop();
- }
- state.voice.videoStream = null;
- state.voice.sharingVideo = false;
- document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove();
-}
-
-function stopAndClearScreenPipeline() {
- if (state.voice.screenStream) {
- for (const track of state.voice.screenStream.getTracks()) track.stop();
- }
- state.voice.screenStream = null;
- state.voice.sharingScreen = false;
- document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove();
-}
-
-function updateVideoGridVisibility() {
- // Persistence logic: we always update the grid visibility based on participants
- // however the actual container visibility in the UI is managed by updateView()
-}
-
-function updateView() {
- if (state.voice.viewMode === 'video') {
- el.videoGrid.classList.remove("hidden");
- el.messageList.classList.add("hidden");
- el.messageInputWrapper.classList.add("hidden");
- } else {
- el.videoGrid.classList.add("hidden");
- el.messageList.classList.remove("hidden");
- el.messageInputWrapper.classList.remove("hidden");
- }
-}
-
-function renderVideo(peerId, displayName, stream, source) {
- const videoId = `video-${peerId}-${source}`;
- let videoEl = document.getElementById(videoId);
- if (!videoEl) {
- const container = document.createElement("div");
- container.className = "video-item";
- const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName);
- container.innerHTML = `
-
- ${label}
- `;
- container.onclick = () => {
- const isFullscreen = container.classList.contains('fullscreen');
- // Reset all
- document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen'));
-
- if (isFullscreen) {
- el.videoGrid.classList.remove('has-fullscreen');
- } else {
- container.classList.add('fullscreen');
- el.videoGrid.classList.add('has-fullscreen');
- }
- };
-
- el.videoGrid.appendChild(container);
- videoEl = container.querySelector("video");
- }
- videoEl.srcObject = stream;
-}
-
-async function buildAudioPipeline(rawStream) {
- const ctx = new AudioContext();
- state.voice.audioContext = ctx;
- const source = ctx.createMediaStreamSource(rawStream);
- let processedSource = source;
- state.voice.activeNoiseFilter = 'off';
-
- if (state.voice.noiseFilterMode === 'deepfilter') {
- try {
- const deepFilter = await getDeepFilterModule();
- const processor = new deepFilter.DeepFilterNet3Core({
- sampleRate: 48000,
- noiseReductionLevel: 70,
- });
- const timeoutMs = 2500;
- const withTimeout = (promise, label) =>
- Promise.race([
- promise,
- new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs)),
- ]);
-
- await withTimeout(processor.initialize(), "DeepFilter initialize");
- const workletNode = await withTimeout(processor.createAudioWorkletNode(ctx), "DeepFilter worklet");
- processor.setNoiseSuppressionEnabled(true);
- state.voice.deepFilterProcessor = processor;
- source.connect(workletNode);
- processedSource = workletNode;
- state.voice.activeNoiseFilter = 'deepfilter';
- } catch (err) {
- console.warn("DeepFilterNet3 unavailable, falling back to raw mic audio", err);
- state.voice.activeNoiseFilter = 'off';
- }
- }
- // Metering/Speaking detection with adaptive threshold and hysteresis.
- const analyser = ctx.createAnalyser();
- analyser.fftSize = 1024;
- processedSource.connect(analyser);
-
- const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function';
- const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize);
- let localIsSpeaking = false;
- let speechFrames = 0;
- let silenceFrames = 0;
- let noiseFloor = 0.004;
- let levelEma = 0;
-
- const checkVolume = () => {
- if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
-
- try {
- if (supportsFloatTimeDomain) {
- analyser.getFloatTimeDomainData(timeData);
- } else {
- analyser.getByteTimeDomainData(timeData);
- }
- } catch (err) {
- setTimeout(checkVolume, 60);
- return;
- }
-
- let sumSquares = 0;
- let peak = 0;
- for (let i = 0; i < timeData.length; i++) {
- const v = supportsFloatTimeDomain
- ? timeData[i]
- : (timeData[i] - 128) / 128;
- sumSquares += v * v;
- const abs = Math.abs(v);
- if (abs > peak) peak = abs;
- }
-
- const rms = Math.sqrt(sumSquares / timeData.length);
- const level = Math.max(rms, peak * 0.5);
- levelEma = levelEma * 0.75 + level * 0.25;
-
- if (!localIsSpeaking) {
- // Learn room noise slowly while idle.
- noiseFloor = noiseFloor * 0.98 + levelEma * 0.02;
- } else {
- // Do not let noise floor jump up while speaking.
- noiseFloor = Math.min(noiseFloor, levelEma);
- }
-
- const startThreshold = Math.max(noiseFloor * 2.2, 0.010);
- const stopThreshold = Math.max(noiseFloor * 1.5, 0.006);
-
- if (levelEma > startThreshold) {
- speechFrames = Math.min(speechFrames + 1, 8);
- silenceFrames = 0;
- } else if (levelEma < stopThreshold) {
- silenceFrames = Math.min(silenceFrames + 1, 8);
- speechFrames = Math.max(speechFrames - 1, 0);
- }
-
- const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2;
-
- if (newSpeakingState !== localIsSpeaking) {
- localIsSpeaking = newSpeakingState;
- if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
- state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking }));
- }
- // Local UI update
- state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
- if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking;
- });
- renderChannels();
- const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`);
- if (myVideo) {
- myVideo.parentElement.classList.toggle('speaking', localIsSpeaking);
- }
- }
- setTimeout(checkVolume, 60);
- };
- checkVolume();
-
- const destination = ctx.createMediaStreamDestination();
- processedSource.connect(destination);
- return destination.stream;
-}
-
-function resolveDeepFilterModuleUrl() {
- if (location.protocol === 'file:') {
- return new URL('vendor/deepfilternet3-noise-filter.esm.js', location.href).toString();
- }
- return '/static/vendor/deepfilternet3-noise-filter.esm.js';
-}
-
-async function getDeepFilterModule() {
- if (state.voice.deepFilterModule) return state.voice.deepFilterModule;
- const moduleUrl = resolveDeepFilterModuleUrl();
- state.voice.deepFilterModule = await import(moduleUrl);
- return state.voice.deepFilterModule;
-}
-
-async function createLocalVoiceStream() {
- const useDeepFilter = state.voice.noiseFilterMode === 'deepfilter';
- const constraints = {
- audio: {
- channelCount: 1,
- sampleRate: 48000,
- echoCancellation: true,
- noiseSuppression: !useDeepFilter,
- autoGainControl: false,
- },
- video: false,
- };
- const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
- let localStream = rawStream;
- try {
- localStream = await buildAudioPipeline(rawStream);
- } catch (err) {
- console.warn('voice audio pipeline failed, using raw mic stream', err);
- state.voice.activeNoiseFilter = 'off';
- }
- state.voice.rawStream = rawStream;
- state.voice.localStream = localStream;
- if (state.voice.muted) {
- state.voice.localStream.getAudioTracks().forEach((t) => {
- t.enabled = false;
- });
- }
-}
-
-function ensurePeerConnection(peerId) {
- if (state.voice.peerConnections.has(peerId)) {
- return state.voice.peerConnections.get(peerId);
- }
-
- const pc = new RTCPeerConnection({
- iceServers: state.voice.iceServers
- });
-
- pc.makingOffer = false;
- pc.ignoreOffer = false;
- pc.polite = !shouldInitiateOffer(peerId);
-
- // 1. Audio
- if (state.voice.localStream) {
- state.voice.localStream.getAudioTracks().forEach(track => {
- pc.addTrack(track, state.voice.localStream);
- });
- } else {
- pc.addTransceiver('audio', { direction: 'recvonly' });
- }
-
- // 2. Camera Video
- if (state.voice.videoStream) {
- state.voice.videoStream.getVideoTracks().forEach(track => {
- const sender = pc.addTrack(track, state.voice.videoStream);
- state.voice.videoSenders.set(peerId, sender);
- });
- }
-
- // 3. Screen Video
- if (state.voice.screenStream) {
- state.voice.screenStream.getVideoTracks().forEach(track => {
- const sender = pc.addTrack(track, state.voice.screenStream);
- state.voice.screenSenders.set(peerId, sender);
- });
- }
-
- pc.onicecandidate = (event) => {
- if (!event.candidate || !state.voice.ws) return;
- state.voice.ws.send(JSON.stringify({
- type: "signal",
- to_user_id: peerId,
- kind: "ice",
- data: event.candidate,
- }));
- };
-
- pc.ontrack = (event) => {
- const stream = event.streams[0];
- if (event.track.kind === "audio") {
- let audio = document.getElementById(`audio-${peerId}`);
- if (!audio) {
- audio = document.createElement("audio");
- audio.id = `audio-${peerId}`;
- audio.autoplay = true;
- audio.playsInline = true;
- document.body.appendChild(audio);
- }
- audio.srcObject = stream;
- } else if (event.track.kind === "video") {
- const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
- renderVideo(peerId, peer.display_name, stream, event.track.id);
-
- event.track.onmute = () => {
- document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
- };
- event.track.onended = () => {
- document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
- };
- }
- };
-
- pc.onnegotiationneeded = async () => {
- try {
- pc.makingOffer = true;
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- state.voice.ws.send(JSON.stringify({
- type: "signal",
- to_user_id: peerId,
- kind: "offer",
- data: pc.localDescription,
- }));
- } catch (err) {
- console.error("negotiation failed", err);
- } finally {
- pc.makingOffer = false;
- }
- };
-
- state.voice.peerConnections.set(peerId, pc);
- return pc;
-}
-
-async function handleSignal(fromPeerId, kind, data) {
- const pc = ensurePeerConnection(fromPeerId);
-
- try {
- if (kind === "offer") {
- const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
- pc.ignoreOffer = !pc.polite && offerCollision;
- if (pc.ignoreOffer) return;
-
- await pc.setRemoteDescription(new RTCSessionDescription(data));
- const answer = await pc.createAnswer();
- await pc.setLocalDescription(answer);
- state.voice.ws.send(JSON.stringify({
- type: "signal",
- to_user_id: fromPeerId,
- kind: "answer",
- data: pc.localDescription,
- }));
- } else if (kind === "answer") {
- await pc.setRemoteDescription(new RTCSessionDescription(data));
- } else if (kind === "ice") {
- try {
- await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
- } catch (err) {
- if (!pc.ignoreOffer) {
- console.warn("failed to add ice candidate", err);
- }
- }
- }
- } catch (err) {
- console.error("handleSignal failed", err);
- }
-}
-
-async function joinVoice() {
- if (!state.selectedVoiceChannelId) return;
- if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return;
-
- await leaveVoice();
-
- try {
- await createLocalVoiceStream();
- } catch (err) {
- console.error('failed to initialize local voice stream', err);
- return;
- }
-
- const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
- state.voice.ws = ws;
- state.voice.joinedChannelId = state.selectedVoiceChannelId;
-
- ws.onopen = () => {
- const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
- el.vcChannelName.textContent = channel ? channel.name : "Voice";
- el.voiceConnection.classList.remove("hidden");
- el.soundboard.classList.remove("hidden");
- playSound('join');
- refreshVoicePresence().catch(() => { });
- loadSounds().catch(() => { });
- };
-
- ws.onmessage = async (event) => {
- const msg = JSON.parse(event.data);
- if (msg.type === "peers") {
- for (const peer of msg.peers) {
- ensurePeerConnection(peer.user_id);
- }
- } else if (msg.type === "peer_joined") {
- playSound('peer-join');
- ensurePeerConnection(msg.user_id);
- } else if (msg.type === "peer_left") {
- playSound('peer-leave');
- const pc = state.voice.peerConnections.get(msg.user_id);
- if (pc) {
- pc.close();
- state.voice.peerConnections.delete(msg.user_id);
- }
- document.getElementById(`audio-${msg.user_id}`)?.remove();
- } else if (msg.type === "signal") {
- await handleSignal(msg.from_user_id, msg.kind, msg.data);
- } else if (msg.type === "video_status_changed") {
- if (!msg.is_sharing_video) {
- document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove();
- }
- } else if (msg.type === "screen_status_changed") {
- if (!msg.is_sharing_screen) {
- document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove();
- }
- } else if (msg.type === "speaking_status_changed") {
- state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
- if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking;
- });
- renderChannels();
- const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`);
- if (videoEl) {
- videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
- }
- } else if (msg.type === "play_sound") {
- const audio = new Audio(msg.sound_url);
- audio.play().catch(console.error);
- }
- refreshVoicePresence().catch(() => { });
- };
-
- ws.onclose = () => {
- el.voiceConnection.classList.add("hidden");
- el.soundboard.classList.add("hidden");
- for (const pc of state.voice.peerConnections.values()) pc.close();
- state.voice.peerConnections.clear();
- state.voice.videoSenders.clear();
- state.voice.screenSenders.clear();
- stopAndClearAudioPipeline();
- state.voice.joinedChannelId = null;
- state.voice.ws = null;
- playSound('leave');
- refreshVoicePresence().catch(() => { });
- };
-}
-
-async function leaveVoice() {
- if (state.voice.ws) state.voice.ws.close();
-}
-
-function toggleMute() {
- if (!state.voice.localStream) return;
- state.voice.muted = !state.voice.muted;
- state.voice.localStream.getAudioTracks().forEach((t) => {
- t.enabled = !state.voice.muted;
- });
- el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : '';
- el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
- lucide.createIcons();
-}
-
-async function toggleVideo() {
- if (state.voice.sharingVideo) {
- stopAndClearVideoPipeline();
- if (state.voice.ws) {
- state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
- }
- for (const [peerId, pc] of state.voice.peerConnections) {
- const sender = state.voice.videoSenders.get(peerId);
- if (sender) {
- pc.removeTrack(sender);
- state.voice.videoSenders.delete(peerId);
- }
- }
- } else {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ video: true });
- state.voice.videoStream = stream;
- state.voice.sharingVideo = true;
- renderVideo(state.me.id, state.me.display_name, stream, 'camera');
-
- if (state.voice.ws) {
- state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
- }
-
- for (const [peerId, pc] of state.voice.peerConnections) {
- stream.getVideoTracks().forEach(track => {
- const sender = pc.addTrack(track, stream);
- state.voice.videoSenders.set(peerId, sender);
- });
- }
- } catch (err) {
- console.error("camera denied", err);
- alert("Could not access camera.");
- return;
- }
- }
-
- el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : '';
- el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)';
- lucide.createIcons();
-}
-
-async function toggleScreenShare() {
- if (state.voice.sharingScreen) {
- stopAndClearScreenPipeline();
- if (state.voice.ws) {
- state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
- }
- for (const [peerId, pc] of state.voice.peerConnections) {
- const sender = state.voice.screenSenders.get(peerId);
- if (sender) {
- pc.removeTrack(sender);
- state.voice.screenSenders.delete(peerId);
- }
- }
- } else {
- try {
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
- state.voice.screenStream = stream;
- state.voice.sharingScreen = true;
- renderVideo(state.me.id, state.me.display_name, stream, 'screen');
-
- // Stop sharing if user clicks "Stop sharing" in browser UI
- stream.getVideoTracks()[0].onended = () => {
- if (state.voice.sharingScreen) toggleScreenShare();
- };
-
- if (state.voice.ws) {
- state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
- }
-
- for (const [peerId, pc] of state.voice.peerConnections) {
- stream.getVideoTracks().forEach(track => {
- const sender = pc.addTrack(track, stream);
- state.voice.screenSenders.set(peerId, sender);
- });
- }
- } catch (err) {
- console.error("screen share denied", err);
- return;
- }
- }
-
- el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : '';
- el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)';
- lucide.createIcons();
-}
-
-function toggleWatchVideo() {
- state.voice.watchingVideo = !state.voice.watchingVideo;
- updateVideoGridVisibility();
-
- el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : '';
- el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)';
- el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching';
- lucide.createIcons();
-}
-
-// --- Sound Board ---
-
-async function loadSounds() {
- if (!state.selectedGuildId) return;
- try {
- const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`);
- renderSounds(sounds);
- } catch (err) {
- console.error("failed to load sounds", err);
- }
-}
-
-function renderSounds(sounds) {
- el.soundboardGrid.innerHTML = '';
- sounds.forEach(sound => {
- const item = document.createElement('div');
- item.className = 'sound-item';
-
- // Only show delete button if user is creator or owner
- const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id));
-
- item.innerHTML = `
- ${sound.icon}
- ${sound.name}
- ${canDelete ? `` : ''}
- `;
-
- item.onclick = (e) => {
- if (e.target.closest('.sound-delete')) {
- e.stopPropagation();
- if (confirm(`Are you sure you want to delete "${sound.name}"?`)) {
- deleteSound(sound.id);
- }
- return;
- }
- playRemoteSound(sound.file_path);
- };
-
- el.soundboardGrid.appendChild(item);
- });
- lucide.createIcons();
-}
-
-async function deleteSound(soundId) {
- if (!state.selectedGuildId) return;
- try {
- const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`;
- const response = await fetch(url, {
- method: 'DELETE'
- });
- if (!response.ok) {
- const err = await response.json();
- throw new Error(err.error || 'Delete failed');
- }
- await loadSounds();
- } catch (err) {
- alert(err.message);
- }
-}
-
-function playRemoteSound(url) {
- const fullUrl = API_BASE_URL && !url.startsWith('http') ? `${API_BASE_URL}${url}` : url;
- if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
- state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl }));
- }
- // Also play locally immediately
- const audio = new Audio(fullUrl);
- audio.play().catch(console.error);
-}
-
-// --- Mobile Logic ---
-
-function toggleMobileMenu() {
- const isOpen = el.main.classList.contains("menu-open");
- if (isOpen) {
- el.main.classList.remove("menu-open");
- el.overlay.classList.add("hidden");
- } else {
- el.main.classList.add("menu-open");
- el.utilitySidebar.classList.remove("active"); // close members if open
- el.overlay.classList.remove("hidden");
- }
-}
-
-function toggleMobileMembers() {
- const isOpen = el.utilitySidebar.classList.contains("active");
- if (isOpen) {
- el.utilitySidebar.classList.remove("active");
- el.overlay.classList.add("hidden");
- } else {
- el.utilitySidebar.classList.add("active");
- el.main.classList.remove("menu-open"); // close menu if open
- el.overlay.classList.remove("hidden");
- }
-}
-
-function closeMobileMenus() {
- el.main.classList.remove("menu-open");
- el.utilitySidebar.classList.remove("active");
- el.overlay.classList.add("hidden");
-}
-
-// --- Initialization ---
-
-async function init() {
- lucide.createIcons();
-
- try {
- const searchParams = new URLSearchParams(location.search);
- const jwtToken = searchParams.get("token");
- const refreshToken = searchParams.get("refresh_token");
-
- if (jwtToken || refreshToken) {
- if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
- if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
-
- searchParams.delete("token");
- searchParams.delete("refresh_token");
- const nextQuery = searchParams.toString();
- const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
- history.replaceState(null, "", nextUrl);
- }
- } catch (e) {
- console.error("Failed to parse token from URL:", e);
- }
-
- el.loginBtn.onclick = () => {
- const loginPath = "/auth/login";
- location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath;
- };
-
- el.logoutBtn.onclick = async () => {
- await leaveVoice();
- localStorage.removeItem("chattz_token");
- localStorage.removeItem("chattz_refresh_token");
- try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
- location.reload();
- };
-
- el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
- el.createInviteBtn.onclick = createInviteLink;
- el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
-
- el.addTextBtn.onclick = () => {
- if (!state.selectedGuildId) {
- alert("Create or select a server first.");
- return;
- }
- document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
- el.channelModal.classList.remove("hidden");
- };
- el.addVoiceBtn.onclick = () => {
- if (!state.selectedGuildId) {
- alert("Create or select a server first.");
- return;
- }
- document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
- el.channelModal.classList.remove("hidden");
- };
- el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
-
- // Mobile Listeners
- if (el.mobileMenuBtn) el.mobileMenuBtn.onclick = toggleMobileMenu;
- if (el.mobileMembersBtn) el.mobileMembersBtn.onclick = toggleMobileMembers;
- if (el.overlay) el.overlay.onclick = closeMobileMenus;
-
- el.guildForm.onsubmit = async (e) => {
- e.preventDefault();
- try {
- const guild = await api("/guilds", {
- method: "POST",
- body: JSON.stringify({ name: el.guildName.value }),
- });
- el.guildName.value = "";
- el.modalContainer.classList.add("hidden");
- state.guilds.push(guild);
- state.selectedGuildId = guild.id;
- state.selectedTextChannelId = null;
- state.selectedVoiceChannelId = null;
- state.selectedDmUserId = null;
- state.selectedDmDisplayName = null;
- localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
- renderGuilds();
- await loadChannels();
- await loadGuildMembers();
- await refreshVoicePresence();
- renderMessages([]);
- updateHeaderLabels();
- } catch (err) { alert(err.message); }
- };
-
- el.channelForm.onsubmit = async (e) => {
- e.preventDefault();
- if (!state.selectedGuildId) {
- alert("Select a server first.");
- return;
- }
- const kindInput = document.querySelector('input[name="channel-kind"]:checked');
- const kind = kindInput ? kindInput.value : "text";
- const channelName = el.channelName.value.trim();
- if (!channelName) {
- alert("Channel name is required.");
- return;
- }
- try {
- const created = await api("/channels", {
- method: "POST",
- body: JSON.stringify({
- guild_id: state.selectedGuildId,
- name: channelName,
- kind: kind,
- }),
- });
- el.channelName.value = "";
- el.channelModal.classList.add("hidden");
- await loadChannels();
- if (created.kind === "text") {
- state.selectedTextChannelId = created.id;
- state.selectedVoiceChannelId = null;
- state.selectedDmUserId = null;
- state.selectedDmDisplayName = null;
- renderChannels();
- renderDMs();
- updateHeaderLabels();
- const messages = await api(`/channels/${created.id}/messages?limit=100`);
- renderMessages(messages);
- } else {
- state.selectedVoiceChannelId = created.id;
- state.selectedTextChannelId = null;
- state.selectedDmUserId = null;
- state.selectedDmDisplayName = null;
- renderChannels();
- renderDMs();
- updateHeaderLabels();
- }
- } catch (err) { alert(err.message); }
- };
-
- el.messageForm.onsubmit = async (e) => {
- e.preventDefault();
- const body = el.messageBody.value.trim();
- if (!body) return;
-
- try {
- if (state.selectedTextChannelId) {
- await api(`/channels/${state.selectedTextChannelId}/messages`, {
- method: "POST",
- body: JSON.stringify({ body }),
- });
- } else if (state.selectedDmUserId) {
- await api(`/dms/${state.selectedDmUserId}/messages`, {
- method: "POST",
- body: JSON.stringify({ body }),
- });
- } else {
- return;
- }
- el.messageBody.value = "";
- const messages = state.selectedTextChannelId
- ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
- : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
- renderMessages(messages);
- await loadDMConversations();
- renderDMs();
- } catch (err) { alert(err.message); }
- };
-
- el.voiceVideoBtn.onclick = toggleVideo;
- el.voiceScreenBtn.onclick = toggleScreenShare;
- el.voiceMuteBtn.onclick = toggleMute;
- el.voiceLeaveBtn.onclick = leaveVoice;
-
- el.addSoundBtn.onclick = () => {
- el.soundModal.classList.remove("hidden");
- };
- el.soundModalCancel.onclick = () => {
- el.soundModal.classList.add("hidden");
- };
-
- el.soundForm.onsubmit = async (e) => {
- e.preventDefault();
- if (!state.selectedGuildId) return;
-
- const formData = new FormData();
- formData.append('name', el.soundName.value);
- formData.append('icon', el.soundIcon.value);
- formData.append('file', el.soundFile.files[0]);
-
- el.soundSubmitBtn.disabled = true;
- el.soundSubmitBtn.textContent = 'Uploading...';
-
- try {
- // Need to use fetch directly because api() might not handle FormData correctly depending on implementation
- const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`;
- const response = await fetch(url, {
- method: 'POST',
- body: formData
- });
- if (!response.ok) {
- const err = await response.json();
- throw new Error(err.error || 'Upload failed');
- }
- el.soundModal.classList.add("hidden");
- el.soundForm.reset();
- await loadSounds();
- } catch (err) {
- alert(err.message);
- } finally {
- el.soundSubmitBtn.disabled = false;
- el.soundSubmitBtn.textContent = 'Add Sound';
- }
- };
-
- // --- GIF Picker Logic ---
- const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; // Placeholder / Public key if available, otherwise "YOUR_API_KEY"
- const TENOR_CLIENT_KEY = "pavel-discord";
-
- el.gifBtn.onclick = () => {
- el.gifPicker.classList.remove("hidden");
- searchGifs(""); // Initial featured search
- };
-
- el.gifPickerClose.onclick = () => {
- el.gifPicker.classList.add("hidden");
- };
-
- let searchTimeout = null;
- el.gifSearchInput.oninput = () => {
- clearTimeout(searchTimeout);
- searchTimeout = setTimeout(() => {
- searchGifs(el.gifSearchInput.value);
- }, 500);
- };
-
- async function searchGifs(query) {
- el.gifResults.innerHTML = 'Searching...
';
- const baseUrl = "https://api.klipy.com/v2";
- const endpoint = query
- ? `${baseUrl}/search?q=${encodeURIComponent(query)}&key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`
- : `${baseUrl}/featured?key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`;
-
- try {
- const res = await fetch(endpoint);
- const data = await res.json();
- renderGifs(data.results);
- } catch (err) {
- el.gifResults.innerHTML = 'Error loading GIFs
';
- }
- }
-
- function renderGifs(gifs) {
- el.gifResults.innerHTML = "";
- if (!gifs || gifs.length === 0) {
- el.gifResults.innerHTML = 'No GIFs found
';
- return;
- }
-
- gifs.forEach(gif => {
- // Use tinygif for preview, standard gif for sending
- const previewUrl = gif.media_formats.tinygif.url;
- const fullUrl = gif.media_formats.gif.url;
-
- const item = document.createElement("div");
- item.className = "gif-item";
- item.innerHTML = `
`;
- item.onclick = () => {
- sendGif(fullUrl);
- el.gifPicker.classList.add("hidden");
- el.gifSearchInput.value = "";
- };
- el.gifResults.appendChild(item);
- });
- }
-
- async function sendGif(url) {
- try {
- if (state.selectedTextChannelId) {
- await api(`/channels/${state.selectedTextChannelId}/messages`, {
- method: "POST",
- body: JSON.stringify({ body: url }),
- });
- } else if (state.selectedDmUserId) {
- await api(`/dms/${state.selectedDmUserId}/messages`, {
- method: "POST",
- body: JSON.stringify({ body: url }),
- });
- }
- const messages = state.selectedTextChannelId
- ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
- : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
- renderMessages(messages);
- } catch (err) {
- console.error("Failed to send GIF", err);
- }
- }
-
- try {
- state.me = await api("/me");
- if (state.me && state.me.display_name) {
- el.userName.textContent = state.me.display_name;
- el.userAvatar.textContent = shortName(state.me.display_name);
- }
- el.authScreen.classList.add("hidden");
- el.main.classList.remove("hidden");
-
- const params = new URLSearchParams(location.search);
- const inviteCode = params.get("invite");
- if (inviteCode) {
- try {
- const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
- method: "POST",
- });
- localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
- } catch (err) {
- alert(`Failed to join invite: ${err.message}`);
- } finally {
- params.delete("invite");
- const nextQuery = params.toString();
- const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
- history.replaceState(null, "", nextUrl);
- }
- }
-
- await loadGuilds();
- await loadDMConversations();
-
- try {
- const online = await api("/presence");
- state.onlineUsers = new Set(online);
- } catch (err) { console.warn("presence sync failed", err); }
-
- if (state.guilds.length > 0) {
- const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
- const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
- state.selectedGuildId = guild.id;
- localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
- renderGuilds();
- await loadChannels();
- await loadGuildMembers();
- await refreshVoicePresence();
- updateHeaderLabels();
- } else {
- state.members = [];
- renderMembers();
- updateHeaderLabels();
- }
-
- initChatWs();
- startVoicePresencePolling();
- lucide.createIcons();
- } catch (err) {
- console.error("init failed", err);
- el.authScreen.classList.remove("hidden");
- el.main.classList.add("hidden");
- }
-}
-
-// Config fetcher will trigger init()
-initializeConfig();
+import "../static/shared/app-core.js";
diff --git a/desktop/index.html b/desktop/index.html
index 2bd8957..1684077 100644
--- a/desktop/index.html
+++ b/desktop/index.html
@@ -1,3 +1,4 @@
+
@@ -5,12 +6,9 @@
Chattz
-
-
-
-
+
-
+
@@ -114,6 +112,10 @@
Online
@@ -139,6 +141,10 @@
-
+
+
+
Upload Too Large
+
Uploads are limited to 50 MB per file.
+
+
+
+
+