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 87b1268..c76ae86 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -11,6 +11,51 @@ 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 + run: | + export WINEARCH=win32 + export WINEPREFIX="$HOME/.wine32" + wineboot -u + npm run dist:win + - name: Copy installers into static folder + run: | + mkdir -p static/installers + find static/installers -mindepth 1 -maxdepth 1 -type f -delete + copy_first() { + pattern="$1" + out="$2" + file="$(find dist -maxdepth 1 -type f -name "$pattern" | head -n 1)" + if [ -n "$file" ]; then + 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: @@ -40,4 +85,4 @@ jobs: mkdir -p ~/discord/static/uploads find ~/discord/static -mindepth 1 -maxdepth 1 ! -name 'uploads' -exec rm -rf {} + cp -r static/* ~/discord/static/ - systemctl --user start discord \ No newline at end of file + systemctl --user start discord diff --git a/.gitignore b/.gitignore index 2b240d4..a2feb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .env -node_modules \ No newline at end of file +node_modules +dist 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 bc67687..e0b9fa7 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -1,1596 +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, - 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 --- -const urlParams = new URLSearchParams(window.location.search); -const API_BASE_URL = urlParams.get('backend') || ''; // Absolute URL to the remote server - -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"), - - // 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)}
-
-
- ${escapeHtml(displayName)} - ${formatDate(m.created_at)} -
-
${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 }), - }); - const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; - try { - await navigator.clipboard.writeText(link); - alert(`Invite link copied:\n${link}`); - } catch { - prompt("Copy invite link:", link); - } - } catch (err) { - alert(err.message); - } -} - -// --- 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.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; -} - -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); - - // Metering/Speaking detection - const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; - source.connect(analyser); - - const dataArray = new Uint8Array(analyser.frequencyBinCount); - let localIsSpeaking = false; - let speakCounter = 0; - - const checkVolume = () => { - if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - analyser.getByteFrequencyData(dataArray); - let sum = 0; - for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; - const avg = sum / dataArray.length; - - const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech - - if (isCurrentlySpeaking) { - speakCounter = Math.min(speakCounter + 1, 5); - } else { - speakCounter = Math.max(speakCounter - 1, 0); - } - - const newSpeakingState = speakCounter >= 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, 100); - }; - checkVolume(); - - const destination = ctx.createMediaStreamDestination(); - source.connect(destination); - return destination.stream; -} - -async function createLocalVoiceStream() { - const constraints = { - audio: { - channelCount: 1, - sampleRate: 48000, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: false, - }, - video: false, - }; - const rawStream = await navigator.mediaDevices.getUserMedia(constraints); - const localStream = await buildAudioPipeline(rawStream); - 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("microphone denied", 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 response = await fetch(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { - 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) { - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url })); - } - // Also play locally immediately - const audio = new Audio(url); - 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 response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, { - 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 GIPHY_API_KEY = "dc6zaTOxFJmzC"; // Public beta key - - el.gifBtn.onclick = () => { - el.gifPicker.classList.remove("hidden"); - searchGifs(""); // Initial trending 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 endpoint = query - ? `https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=20` - : `https://api.giphy.com/v1/gifs/trending?api_key=${GIPHY_API_KEY}&limit=20`; - - try { - const res = await fetch(endpoint); - const data = await res.json(); - renderGifs(data.data); - } 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 => { - const imgUrl = gif.images.fixed_height.url; - const item = document.createElement("div"); - item.className = "gif-item"; - item.innerHTML = ``; - item.onclick = () => { - sendGif(imgUrl); - 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"); - } -} - -init(); +import "../static/shared/app-core.js"; diff --git a/desktop/index.html b/desktop/index.html index c0b54c6..1684077 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -1,3 +1,4 @@ + @@ -5,12 +6,9 @@ Chattz - - - - + - + @@ -40,6 +38,7 @@