diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ff56e21fe..d1d2a1705 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -317,6 +317,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + packages: platform-tools - name: Install NDK run: sdkmanager "ndk;27.0.11902837" @@ -426,6 +428,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + packages: platform-tools - name: Install NDK run: sdkmanager "ndk;27.0.11902837" @@ -596,7 +600,7 @@ jobs: - name: Start Tauri iOS build with WS server run: | npm run tauri ios build -- --open & - ADDR_FILE="${TMPDIR%/}/com.obsidianirc.dev-server-addr" + ADDR_FILE="src-tauri/gen/apple/.tauri/cli-options-server.json" echo "Waiting for $ADDR_FILE…" for i in $(seq 1 120); do [ -f "$ADDR_FILE" ] && { echo "Addr file ready after ${i}s"; break; } diff --git a/index.html b/index.html index a97c4c701..f6e8f17c4 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 41fd1cbe7..d745da755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", - "@tauri-apps/api": "^2.11.1", + "@tauri-apps/api": "^2.12.1", "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-geolocation": "^2.3.3", "@tauri-apps/plugin-haptics": "^2.3.2", @@ -56,7 +56,7 @@ "@lingui/babel-plugin-lingui-macro": "^5.9.5", "@lingui/cli": "^5.9.5", "@lingui/format-po": "^5.9.5", - "@tauri-apps/cli": "^2.11.4", + "@tauri-apps/cli": "^2.12.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -3132,9 +3132,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.1.tgz", + "integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -3142,9 +3142,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz", + "integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -3158,23 +3158,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + "@tauri-apps/cli-darwin-arm64": "2.12.1", + "@tauri-apps/cli-darwin-x64": "2.12.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", + "@tauri-apps/cli-linux-arm64-musl": "2.12.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-gnu": "2.12.1", + "@tauri-apps/cli-linux-x64-musl": "2.12.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", + "@tauri-apps/cli-win32-x64-msvc": "2.12.1" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz", + "integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==", "cpu": [ "arm64" ], @@ -3189,9 +3189,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz", + "integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==", "cpu": [ "x64" ], @@ -3206,9 +3206,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz", + "integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==", "cpu": [ "arm" ], @@ -3223,9 +3223,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz", + "integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==", "cpu": [ "arm64" ], @@ -3243,9 +3243,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz", + "integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==", "cpu": [ "arm64" ], @@ -3263,9 +3263,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz", + "integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==", "cpu": [ "riscv64" ], @@ -3283,9 +3283,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz", + "integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==", "cpu": [ "x64" ], @@ -3303,9 +3303,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz", + "integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==", "cpu": [ "x64" ], @@ -3323,9 +3323,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz", + "integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==", "cpu": [ "arm64" ], @@ -3340,9 +3340,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz", + "integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==", "cpu": [ "ia32" ], @@ -3357,9 +3357,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz", + "integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index b3c41effc..401a11ac5 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", - "@tauri-apps/api": "^2.11.1", + "@tauri-apps/api": "^2.12.1", "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-geolocation": "^2.3.3", "@tauri-apps/plugin-haptics": "^2.3.2", @@ -93,7 +93,7 @@ "@lingui/babel-plugin-lingui-macro": "^5.9.5", "@lingui/cli": "^5.9.5", "@lingui/format-po": "^5.9.5", - "@tauri-apps/cli": "^2.11.4", + "@tauri-apps/cli": "^2.12.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 22255eb3b..ae20b481b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -69,15 +69,15 @@ dependencies = [ [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -429,9 +429,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -557,12 +557,13 @@ dependencies = [ [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -583,13 +584,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -795,7 +796,7 @@ version = "0.29.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.6.1", "dtoa-short", "itoa", "matches", @@ -808,11 +809,11 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.7.1", "dtoa-short", "itoa", "phf 0.13.1", @@ -830,20 +831,20 @@ dependencies = [ ] [[package]] -name = "ctor" -version = "0.8.0" +name = "cssparser-macros" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ - "ctor-proc-macro", - "dtor", + "quote", + "syn 2.0.117", ] [[package]] -name = "ctor-proc-macro" -version = "0.0.7" +name = "ctor" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "darling" @@ -946,9 +947,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -962,7 +963,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1031,16 +1032,16 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser 0.37.0", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever 0.39.0", "precomputed-hash", - "selectors 0.36.1", + "selectors 0.38.0", "tendril 0.5.0", ] @@ -1074,21 +1075,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1188,7 +1174,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1826,12 +1812,12 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever 0.39.0", ] [[package]] @@ -2122,9 +2108,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -2251,21 +2237,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -2273,13 +2259,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ "bitflags 2.11.0", "serde", - "unicode-segmentation", ] [[package]] @@ -2438,9 +2423,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril 0.5.0", @@ -2508,9 +2493,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -2524,7 +2509,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3948,7 +3933,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4128,12 +4113,12 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags 2.11.0", - "cssparser 0.36.0", + "cssparser 0.37.0", "derive_more 2.1.1", "log", "new_debug_unreachable", @@ -4404,7 +4389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4524,9 +4509,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" dependencies = [ "base64 0.21.7", "serde", @@ -4599,9 +4584,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.2" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ "bitflags 2.11.0", "block2", @@ -4619,29 +4604,29 @@ dependencies = [ "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -4662,9 +4647,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9" dependencies = [ "anyhow", "bytes", @@ -4708,14 +4693,14 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", @@ -4734,16 +4719,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png 0.18.1", "proc-macro2", "quote", "semver", @@ -4761,9 +4746,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4998,9 +4983,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" dependencies = [ "cookie", "dpi", @@ -5018,14 +5003,14 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" dependencies = [ "gtk", "http", @@ -5043,15 +5028,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows 0.62.2", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" dependencies = [ "anyhow", "brotli", @@ -5120,7 +5105,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5510,9 +5495,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", "dirs", @@ -5527,7 +5512,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5556,48 +5541,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", + "windows-sys 0.60.2", ] [[package]] @@ -5645,13 +5589,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -6036,16 +5980,14 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -6061,13 +6003,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -6092,7 +6034,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6103,16 +6045,17 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "windows-version", ] @@ -6712,9 +6655,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ "base64 0.22.1", "block2", @@ -6748,8 +6691,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 3daf33a30..0ebaa7059 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -6,5 +6,9 @@ Obby uses the microphone for voice calls. NSCameraUsageDescription Obby uses the camera for video calls. + UIBackgroundModes + + audio + diff --git a/src-tauri/capabilities/ios.json b/src-tauri/capabilities/ios.json new file mode 100644 index 000000000..87bd6cf3a --- /dev/null +++ b/src-tauri/capabilities/ios.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "ios", + "description": "iOS-only permissions", + "windows": [ + "main" + ], + "platforms": ["iOS"], + "permissions": [ + "ios-keyboard:allow-listen" + ] +} diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore index b24820317..9e3ae68cf 100644 --- a/src-tauri/gen/android/.gitignore +++ b/src-tauri/gen/android/.gitignore @@ -12,6 +12,7 @@ build /captures .externalNativeBuild .cxx +.kotlin local.properties key.properties diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 0cd4e1341..d5c67faff 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,9 @@ android:label="@string/main_activity_title" android:name=".MainActivity" android:exported="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:supportsPictureInPicture="true" + android:resizeableActivity="true"> @@ -72,7 +74,7 @@ - + diff --git a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt index fe6886434..c595ed211 100644 --- a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt @@ -5,19 +5,85 @@ import android.annotation.SuppressLint import android.view.ViewTreeObserver import android.view.View import android.graphics.Rect +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.os.Build +import android.util.Rational +import android.webkit.JavascriptInterface +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat class MainActivity : TauriActivity() { private lateinit var wv: WebView private var isKeyboardOpen = false - + @Volatile private var isInCall = false + override fun onWebViewCreate(webView: WebView) { wv = webView setupKeyboardDetection() + webView.addJavascriptInterface(CallBridge(), "ObbyNative") + } + + // The web app tells us when a call is live, so leaving the app keeps the call + // visible in a picture-in-picture window. + private inner class CallBridge { + @JavascriptInterface + fun setInCall(active: Boolean) { + isInCall = active + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + runOnUiThread { setPictureInPictureParams(pipParams(active)) } + } + } + } + + private fun pipParams(autoEnter: Boolean): PictureInPictureParams { + val builder = PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoEnter).setSeamlessResizeEnabled(true) + } + return builder.build() + } + + // Android 12+ enters picture-in-picture on its own through setAutoEnterEnabled. + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (isInCall && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + enterPictureInPictureMode(pipParams(false)) + } + } + + // WryActivity pauses the WebView on onPause, which Android also sends when the + // activity moves into picture-in-picture, so we keep it drawing while pinned. + override fun onPause() { + super.onPause() + if (::wv.isInitialized && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + isInPictureInPictureMode) { + wv.onResume() + } + } + + override fun onPictureInPictureModeChanged(isInPip: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPip, newConfig) + if (::wv.isInitialized) { + if (isInPip) wv.onResume() + wv.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('obby-native-pip', { detail: $isInPip }));", + null, + ) + } } private fun setupKeyboardDetection() { val rootView = findViewById(android.R.id.content) + // Edge-to-edge (forced from Android 15) ignores adjustResize, so we shrink + // the WebView by the keyboard height ourselves; otherwise Android only pans it. + ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + view.setPadding(0, 0, 0, imeBottom) + insets + } val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { val rect = Rect() rootView.getWindowVisibleDisplayFrame(rect) diff --git a/src-tauri/gen/apple/Obby_iOS/Info.plist b/src-tauri/gen/apple/Obby_iOS/Info.plist index 2c13c3d34..86db88206 100644 --- a/src-tauri/gen/apple/Obby_iOS/Info.plist +++ b/src-tauri/gen/apple/Obby_iOS/Info.plist @@ -65,5 +65,9 @@ https + UIBackgroundModes + + audio + \ No newline at end of file diff --git a/src-tauri/plugins/ios-keyboard/ios/Sources/AppLifecycle.swift b/src-tauri/plugins/ios-keyboard/ios/Sources/AppLifecycle.swift new file mode 100644 index 000000000..7d654e974 --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/ios/Sources/AppLifecycle.swift @@ -0,0 +1,23 @@ +import UIKit +import WebKit + +/// Tells the page when the app is back in the foreground, so it can restart +/// call videos that iOS paused while the app was away. +class AppLifecycleObserver { + private weak var webview: WKWebView? + + init(webview: WKWebView) { + self.webview = webview + NotificationCenter.default.addObserver( + self, selector: #selector(didBecomeActive), + name: UIApplication.didBecomeActiveNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func didBecomeActive() { + webview?.evaluateJavaScript("window.ObbyApp?.didBecomeActive()") + } +} diff --git a/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift b/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift index c543568ce..adb4d1536 100644 --- a/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift +++ b/src-tauri/plugins/ios-keyboard/ios/Sources/KeyboardPlugin.swift @@ -4,15 +4,21 @@ import WebKit class KeyboardPlugin: Plugin { private var keyboardObserver: KeyboardObserver? + private var appLifecycleObserver: AppLifecycleObserver? override public func load(webview: WKWebView) { super.load(webview: webview) + // The page pads itself with env(safe-area-inset-*), so we keep UIKit from + // insetting the scroll view as well, which on some devices shrinks the + // page by both insets and leaves a bare strip under it. + webview.scrollView.contentInsetAdjustmentBehavior = .never keyboardObserver = KeyboardObserver(webview: webview) { [weak self] event in self?.sendKeyboardEvent(event) } keyboardObserver?.startObserving() + appLifecycleObserver = AppLifecycleObserver(webview: webview) } deinit { diff --git a/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md index 103541be8..3469a0a94 100644 --- a/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md +++ b/src-tauri/plugins/ios-keyboard/permissions/autogenerated/reference.md @@ -30,6 +30,19 @@ Enables the ping command without any pre-configured scope. Denies the ping command without any pre-configured scope. + + + + + + +`ios-keyboard:allow-listen` + + + + +Lets the app subscribe to keyboard show and hide events. + diff --git a/src-tauri/plugins/ios-keyboard/permissions/listen.toml b/src-tauri/plugins/ios-keyboard/permissions/listen.toml new file mode 100644 index 000000000..50ae6a74c --- /dev/null +++ b/src-tauri/plugins/ios-keyboard/permissions/listen.toml @@ -0,0 +1,6 @@ +"$schema" = "schemas/schema.json" + +[[permission]] +identifier = "allow-listen" +description = "Lets the app subscribe to keyboard show and hide events." +commands.allow = ["registerListener", "register_listener", "removeListener", "remove_listener"] diff --git a/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json b/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json index 1e335fe28..4cf3d6bfb 100644 --- a/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json +++ b/src-tauri/plugins/ios-keyboard/permissions/schemas/schema.json @@ -305,6 +305,12 @@ "type": "string", "const": "deny-ping", "markdownDescription": "Denies the ping command without any pre-configured scope." + }, + { + "description": "Lets the app subscribe to keyboard show and hide events.", + "type": "string", + "const": "allow-listen", + "markdownDescription": "Lets the app subscribe to keyboard show and hide events." } ] } diff --git a/src-tauri/plugins/ios-keyboard/src/lib.rs b/src-tauri/plugins/ios-keyboard/src/lib.rs index e52fa1600..06e9fa27f 100644 --- a/src-tauri/plugins/ios-keyboard/src/lib.rs +++ b/src-tauri/plugins/ios-keyboard/src/lib.rs @@ -32,7 +32,7 @@ impl> crate::KeyboardExt for T { } pub fn init() -> TauriPlugin { - Builder::new("keyboard") + Builder::new("ios-keyboard") .invoke_handler(tauri::generate_handler![commands::ping]) .setup(|app, api| { #[cfg(mobile)] diff --git a/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Invoke.swift b/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Invoke.swift index cdc17d107..6f810868f 100644 --- a/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Invoke.swift +++ b/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Invoke.swift @@ -31,22 +31,10 @@ import UIKit } public func getArgs() throws -> JSObject { - guard let jsonData = self.data.data(using: .utf8) else { - throw NSError(domain: "Tauri.Invoke", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invoke payload could not be encoded as UTF-8", - ]) - } - let payload = try JSONSerialization.jsonObject(with: jsonData, options: []) - guard - let dictionary = payload as? NSDictionary, - let args = JSTypes.coerceDictionaryToJSObject( - dictionary, formattingDatesAsStrings: true) - else { - throw NSError(domain: "Tauri.Invoke", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Invoke payload must be a JSON object", - ]) - } - return args + let jsonData = self.data.data(using: .utf8)! + let data = try JSONSerialization.jsonObject(with: jsonData, options: []) + return JSTypes.coerceDictionaryToJSObject( + (data as! NSDictionary), formattingDatesAsStrings: true)! } public func parseArgs(_ type: T.Type) throws -> T { diff --git a/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Tauri.swift b/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Tauri.swift index 9e4209694..82818c6ee 100644 --- a/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Tauri.swift +++ b/src-tauri/plugins/share-sheet/.tauri/tauri-api/Sources/Tauri/Tauri.swift @@ -19,7 +19,7 @@ class PluginHandle { public class PluginManager { static let shared: PluginManager = PluginManager() - public weak var viewController: UIViewController? + public var viewController: UIViewController? var plugins: [String: PluginHandle] = [:] var ipcDispatchQueue = DispatchQueue(label: "ipc") public var isSimEnvironment: Bool { @@ -77,17 +77,16 @@ public class PluginManager { fn(plugin.instance, selectorWithCompletionHandler, invoke, blockObj) } else if plugin.instance.responds(to: selectorWithThrows) { var error: NSError? = nil - withUnsafeMutablePointer(to: &error) { errorPtr in + withUnsafeMutablePointer(to: &error) { let methodIMP: IMP! = plugin.instance.method(for: selectorWithThrows) - // NSError** is __autoreleasing — AutoreleasingUnsafeMutablePointer preserves - // ARC autorelease semantics so the error object is managed correctly. unsafeBitCast( - methodIMP, - to: (@convention(c) (Any?, Selector, Invoke, AutoreleasingUnsafeMutablePointer) -> Void).self - )(plugin.instance, selectorWithThrows, invoke, AutoreleasingUnsafeMutablePointer(errorPtr)) + methodIMP, to: (@convention(c) (Any?, Selector, Invoke, OpaquePointer) -> Void).self)( + plugin.instance, selectorWithThrows, invoke, OpaquePointer($0)) } if let error = error { invoke.reject("\(error)") + // TODO: app crashes without this leak + let _ = Unmanaged.passRetained(error) } } else { let selector = Selector(("\(invoke.command):")) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0abad9055..5fd298980 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use std::{collections::HashMap, sync::Arc}; +use tauri::{webview::PageLoadEvent, Manager}; use tokio::sync::Mutex; mod overlay; @@ -248,6 +249,13 @@ pub fn run() { Ok(()) }) .manage(SocketState(Arc::new(Mutex::new(HashMap::new())))) + .on_page_load(|webview, payload| { + if webview.label() != "main" || payload.event() != PageLoadEvent::Started { + return; + } + let state = webview.state::().inner().clone(); + tauri::async_runtime::spawn(async move { socket::disconnect_all(&state).await }); + }) .manage(screen_record::RecorderState::default()) .invoke_handler(tauri::generate_handler![ connect, diff --git a/src-tauri/src/screen_record.rs b/src-tauri/src/screen_record.rs index 41a584270..8086d50a8 100644 --- a/src-tauri/src/screen_record.rs +++ b/src-tauri/src/screen_record.rs @@ -65,8 +65,9 @@ pub fn record_command(rect: Rect, path: &str, audio: bool) -> (String, Vec bool { /// system, so there is nothing there that could be missing. #[tauri::command] pub fn region_recording_supported() -> bool { + if cfg!(mobile) { + return false; + } if cfg!(target_os = "windows") { return true; } diff --git a/src-tauri/src/socket.rs b/src-tauri/src/socket.rs index 2c67f2a76..a3c6fce39 100644 --- a/src-tauri/src/socket.rs +++ b/src-tauri/src/socket.rs @@ -34,6 +34,7 @@ pub struct ConnectionHandle { } /// Socket state to manage multiple connections +#[derive(Clone)] pub struct SocketState(pub(crate) Arc>>); /// Payload we send back to TS whenever we receive data @@ -393,6 +394,15 @@ pub async fn disconnect(client_id: String, state: State<'_, SocketState>) -> Res } } +/// Closes every connection, since a page that reloads loses the ids it would close them by. +pub async fn disconnect_all(state: &SocketState) { + for (_, mut handle) in state.0.lock().await.drain() { + if let Some(shutdown_tx) = handle.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + /// Start listening for messages from all active connections #[tauri::command] pub async fn listen( diff --git a/src/App.tsx b/src/App.tsx index 3c1aa70d5..8ce0a160b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,12 +91,13 @@ const initializeEnvSettings = ( joinChannel: (serverId: string, channelName: string) => void, ) => { if (loadSavedServers().length > 0) return; - const host = __DEFAULT_IRC_SERVER__ - ? __DEFAULT_IRC_SERVER__.split(":")[1].replace(/^\/\//, "") - : undefined; - const port = __DEFAULT_IRC_SERVER__ - ? __DEFAULT_IRC_SERVER__.split(":")[2] - : undefined; + const [, scheme, hostname, port] = + __DEFAULT_IRC_SERVER__?.match(/^(\w+):\/\/([^:/]+):(\d+)/) ?? []; + // The client reads TLS from an irc:// or ircs:// host, so we keep that scheme. + const host = + scheme === "irc" || scheme === "ircs" + ? `${scheme}://${hostname}:${port}` + : hostname; if (!host || !port) { return; } @@ -379,7 +380,7 @@ const App: React.FC = () => { }, []); return ( -
+
} /> {

) : null}
@@ -689,144 +715,115 @@ export const ChatHeader: React.FC = ({ {isNarrowView ? : } )} -
- {(() => { - const selectedServer = servers.find( - (s) => s.id === selectedServerId, - ); - const shouldShowAvatar = - canShowAvatarUrl( - privateChatAvatar, - serverFilehosts(selectedServer), - mediaSettings, - ) && !avatarLoadFailed; - - return shouldShowAvatar ? ( - {selectedPrivateChat.username} setAvatarLoadFailed(true)} - /> - ) : ( -
- -
- ); - })()} - -
-
-

+

- {(() => { - const userMetadata = getUserMetadata( - selectedPrivateChat.username, - ); - const displayName = userMetadata?.["display-name"]?.value; - const user = getUserFromChannels(selectedPrivateChat.username); - - if (displayName) { - return ( -
- - {selectedPrivateChat.username} + + + + + {(() => { + const userMetadata = getUserMetadata( + selectedPrivateChat.username, + ); + const displayName = userMetadata?.["display-name"]?.value; + const user = getUserFromChannels( + selectedPrivateChat.username, + ); + return ( + <> + {displayName || selectedPrivateChat.username} {renderUserBadges( selectedPrivateChat.username, selectedPrivateChat, user, + !displayName, )} - -
+ + ); + })()} + + {(() => { + const userMetadata = getUserMetadata( + selectedPrivateChat.username, + ); + const displayName = userMetadata?.["display-name"]?.value; + const user = getUserFromChannels( + selectedPrivateChat.username, ); - } - return privateChatUserMetadata?.status?.value ? ( - - {privateChatUserMetadata.status.value} - - ) : null; - })()} -
- {selectedServerId && ( - - )} - {selectedServerId && ( - + + return privateChatUserMetadata?.status?.value ? ( + + {privateChatUserMetadata.status.value} + + ) : null; + })()} + + + {selectedServerId && ( + + + )}
@@ -867,29 +864,6 @@ export const ChatHeader: React.FC = ({ )} - {selectedServerId && selectedPrivateChat && ( - - )} - {selectedServerId && ( = ({ - {!privateChat.isPinned && ( - - )} - + {!leading && !isTouch && ( + )} diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 24bf0557d..c98482665 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -21,6 +21,9 @@ import ServerBottomSheet from "../mobile/ServerBottomSheet"; import { BouncerServerGroup } from "./BouncerServerGroup"; import "./obby-readable-sidebar.css"; +const CONNECTION_BADGE_CLASS = + "absolute -bottom-1 -right-1 z-10 w-5 h-5 rounded-full bg-discord-dark-500 ring-2 ring-discord-dark-300 flex items-center justify-center"; + interface ServerIconProps { server: Server; isSelected: boolean; @@ -119,18 +122,24 @@ const ServerIcon: React.FC = ({ {(server.connectionState === "connecting" || server.connectionState === "reconnecting") && ( - + + + )} {server.connectionState === "disconnected" && ( - { e.stopPropagation(); onReconnect(); }} title={t`Reconnect to server`} - /> + aria-label={t`Reconnect to server`} + > + + )}
{ }, []); return ( -
+
{/* Home button */}
void; } @@ -30,6 +34,10 @@ export const UserPanel: React.FC = ({ avatarUrl, status, awayMessage, + displayName, + customStatus, + pronouns, + networkName, isIrcOp, onOpenSettings, }) => { @@ -41,12 +49,19 @@ export const UserPanel: React.FC = ({ const [menuAnchor, setMenuAnchor] = useState(null); const triggerRef = useRef(null); - const statusLabel = + const presenceLabel = status === "online" ? t`Online` : status === "away" ? awayMessage || t`Away` : t`Offline`; + const statusLabel = + status === "online" && customStatus ? customStatus : presenceLabel; + const name = displayName || username; + const wideMeta = [ + name !== username && username, + pronouns || networkName, + ].filter((meta): meta is string => !!meta); const avatar = ( @@ -99,7 +114,7 @@ export const UserPanel: React.FC = ({ ]; return ( -
+
= ({ {avatar} - {username} + {name} {statusLabel} diff --git a/src/components/layout/VoiceChannelView.tsx b/src/components/layout/VoiceChannelView.tsx index c8e72b5b1..e67374c4d 100644 --- a/src/components/layout/VoiceChannelView.tsx +++ b/src/components/layout/VoiceChannelView.tsx @@ -17,11 +17,15 @@ import { useIsTouchInput } from "../../hooks/useIsTouchInput"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useVoiceCall } from "../../hooks/useVoiceCall"; import { releaseVoiceClient, type VoiceMember } from "../../lib/voice"; -import { useSpotlight } from "../../lib/voiceSpotlight"; +import { describeVoiceError } from "../../lib/voiceErrors"; +import { useSpotlight, useTheater } from "../../lib/voiceSpotlight"; import useStore, { resolveCallReturnTarget } from "../../store"; +import BottomSheet from "../mobile/BottomSheet"; import { LoadingSpinner } from "../ui/LoadingSpinner"; +import { VoiceAvatarBar } from "../voice/VoiceAvatarBar"; import { VoiceDock } from "../voice/VoiceDock"; import { VoiceStage } from "../voice/VoiceStage"; +import { VoiceVideoBand } from "../voice/VoiceVideoBand"; interface Props { serverId: string; @@ -66,7 +70,8 @@ export const VoiceChannelView: React.FC = ({ // / grid -- see VoiceStage). Shared with the pop-out via // voiceSpotlight.ts so the pick follows the user across both views. const [focusNick, setFocusNick] = useSpotlight(serverId, channelName); - const [theater, setTheater] = useState(false); + const [theater, setTheater] = useTheater(serverId, channelName); + const [isViewerSheetOpen, setIsViewerSheetOpen] = useState(false); // Push-to-talk is purely a client-side toggle (not part of VoiceState), // so it's the one control mirrored into local state -- seeded from the // client whenever it changes so a remount picks up the real value. @@ -74,6 +79,15 @@ export const VoiceChannelView: React.FC = ({ const isNarrow = useMediaQuery(); const isTouch = useIsTouchInput(); + const isLandscape = useMediaQuery("(orientation: landscape)"); + const hasStreamVideo = streamerMembers.some((m) => m.videoTrack); + + // Turning a phone sideways while a stream plays is how people ask to watch + // it full screen, and turning it back is how they leave. + // biome-ignore lint/correctness/useExhaustiveDependencies: we react to orientation changes only + useEffect(() => { + if (isTouch && isStreamRoom && hasStreamVideo) setTheater(isLandscape); + }, [isLandscape]); useEffect(() => { if (client) setPttOn(client.isPushToTalk()); @@ -109,7 +123,7 @@ export const VoiceChannelView: React.FC = ({ }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [theater]); + }, [theater, setTheater]); // Auto-clear an explicit spotlight when that member leaves the call. useEffect(() => { @@ -189,9 +203,187 @@ export const VoiceChannelView: React.FC = ({ ? "bg-discord-red" : "bg-yellow-400 animate-pulse"; + // A phone shows a call above its chat, and full screen it shows only the + // video, since there is no room left for chat beside it. + const isFullscreenVideo = + theater && isTouch && hasStreamVideo && state.phase === "connected"; + const isCompact = isNarrow && isTouch && !isFullscreenVideo; + const rootClass = theater ? "fixed inset-0 z-[9990] flex flex-col bg-discord-dark-400" - : "w-full flex flex-col bg-discord-dark-200"; + : "w-full min-h-0 flex flex-col bg-discord-dark-200"; + const safeAreaPadding = { + paddingTop: "var(--safe-area-inset-top, 0px)", + paddingBottom: "var(--safe-area-inset-bottom, 0px)", + paddingLeft: "var(--safe-area-inset-left, 0px)", + paddingRight: "var(--safe-area-inset-right, 0px)", + }; + + const dock = ( + + ); + + const errorBanner = error && ( +
+ {describeVoiceError(error)} + +
+ ); + + const viewerSheet = isStreamRoom && ( + setIsViewerSheetOpen(false)} + > + + + ); + + const statusPill = ( + + + ); + const viewersButton = isStreamRoom && state.phase === "connected" && ( + + ); + const videoBand = ( + setTheater(!theater)} + /> + ); + + if (isFullscreenVideo) { + return createPortal( +
+ {videoBand} + {errorBanner && ( +
+ {errorBanner} +
+ )} + {viewerSheet} +
, + document.body, + ); + } + + if (isCompact) { + return ( +
+ {state.phase !== "connected" && state.phase !== "failed" && ( +
+ +
+ )} + {state.phase === "connected" && hasStreamVideo ? ( + <> +
+ {videoBand} +
+ + + ) : ( + (state.phase === "connected" || state.phase === "failed") && ( + <> + {(isStreamRoom || state.phase === "failed") && ( +
+ {statusPill} + + {viewersButton} +
+ )} + + + ) + )} + {errorBanner &&
{errorBanner}
} + {viewerSheet} +
+ ); + } // Theater renders through a portal to document.body so its `fixed` // container is positioned against the real viewport -- nested in place, @@ -199,19 +391,7 @@ export const VoiceChannelView: React.FC = ({ // AppLayout) would otherwise become its containing block and confine // it to that ancestor's box instead of the window. const content = ( -
+
= ({ className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDotClass}`} aria-hidden="true" /> - - {statusLabel} - {state.phase === "failed" && `: ${state.error}`} - + {statusLabel} {isStreamRoom && state.phase === "connected" && ( <> @@ -255,14 +432,14 @@ export const VoiceChannelView: React.FC = ({ className={ theater ? "relative flex-1 min-h-0 flex flex-col bg-discord-dark-400" - : "relative bg-discord-dark-400 min-h-[16rem]" + : "relative min-h-0 flex flex-col bg-discord-dark-400" } >
{state.phase !== "connected" && state.phase !== "failed" && ( @@ -283,7 +460,7 @@ export const VoiceChannelView: React.FC = ({ isHost={isHost} onDemote={onDemote} theater={theater} - onToggleTheater={() => setTheater((v) => !v)} + onToggleTheater={() => setTheater(!theater)} /> )} {state.phase === "connected" && @@ -295,7 +472,7 @@ export const VoiceChannelView: React.FC = ({

)} - {isStreamRoom && state.phase === "connected" && ( + {isStreamRoom && !theater && state.phase === "connected" && ( = ({ )}
-
- {error && ( -
- {error} - -
- )} -
- -
+
+ {errorBanner} +
{dock}
diff --git a/src/components/message/EncryptedMediaPreview.tsx b/src/components/message/EncryptedMediaPreview.tsx index 8efceafc1..c43d93025 100644 --- a/src/components/message/EncryptedMediaPreview.tsx +++ b/src/components/message/EncryptedMediaPreview.tsx @@ -94,7 +94,7 @@ export const EncryptedMediaPreview: React.FC<{ {safeName} diff --git a/src/components/message/MediaPreview.tsx b/src/components/message/MediaPreview.tsx index a23d120fc..2d74fad24 100644 --- a/src/components/message/MediaPreview.tsx +++ b/src/components/message/MediaPreview.tsx @@ -372,7 +372,10 @@ const VideoPlayer: React.FC<{ msgid?: string; serverId?: string; channelId?: string; -}> = ({ url, msgid, serverId, channelId }) => { + onAudioOnly: () => void; +}> = ({ url, msgid, serverId, channelId, onAudioOnly }) => { + const onAudioOnlyRef = useRef(onAudioOnly); + onAudioOnlyRef.current = onAudioOnly; const openMedia = useStore((state) => state.openMedia); const containerRef = useRef(null); const videoRef = useRef(null); @@ -446,6 +449,11 @@ const VideoPlayer: React.FC<{ }; const onLoadedMetadata = () => { + // Voice messages share the .webm extension with videos. + if (video.videoWidth === 0) { + onAudioOnlyRef.current(); + return; + } const d = video.duration ?? 0; setDuration(d); if (seekRef.current) seekRef.current.max = String(d); @@ -688,6 +696,17 @@ const VideoPreview: React.FC<{ serverId?: string; channelId?: string; }> = ({ url, msgid, serverId, channelId }) => { + const [isAudioOnly, setIsAudioOnly] = useState(false); + if (isAudioOnly) { + return ( + + ); + } // canPlayVideoUrl() is a synchronous HTMLVideoElement.canPlayType() probe — no // network request. Skipping the player entirely prevents the zombie mini-player // (play → store active → codec error → mini-bar playing nothing). @@ -707,6 +726,7 @@ const VideoPreview: React.FC<{ msgid={msgid} serverId={serverId} channelId={channelId} + onAudioOnly={() => setIsAudioOnly(true)} /> ); }; diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index 3d83cbfef..5f12baead 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -36,6 +36,7 @@ import { mediaLevelToSettings, } from "../../lib/mediaUtils"; import { stripIrcFormatting } from "../../lib/messageFormatter"; +import { isWhisper } from "../../lib/messageGrouping"; import useStore, { loadSavedMetadata } from "../../store"; import type { MessageType, PrivateChat, User } from "../../types"; import MessageBottomSheet from "../mobile/MessageBottomSheet"; @@ -571,13 +572,7 @@ export const MessageItem = memo((props: MessageItemProps) => { return ; } - // Handle whisper messages (channel-context tag, ratified or draft variant) - if ( - message.tags?.["+channel-context"] || - message.tags?.["channel-context"] || - message.tags?.["+draft/channel-context"] || - message.tags?.["draft/channel-context"] - ) { + if (isWhisper(message)) { return ( <> {showDate && ( @@ -1018,7 +1013,7 @@ export const MessageItem = memo((props: MessageItemProps) => { ))}
- {workflow.prompt && ( + {workflow.prompt && !isSheet && ( {workflow.prompt} )}
@@ -257,6 +257,9 @@ export const BotToolsCard: React.FC = ({
+ {workflow.prompt && isSheet && ( +

{workflow.prompt}

+ )} {secondsLeft !== null && (
@@ -311,14 +314,6 @@ export const BotToolsCard: React.FC = ({
diff --git a/src/components/ui/CameraCaptureModal.tsx b/src/components/ui/CameraCaptureModal.tsx index f7fe92f7a..608319975 100644 --- a/src/components/ui/CameraCaptureModal.tsx +++ b/src/components/ui/CameraCaptureModal.tsx @@ -9,7 +9,9 @@ import { startLimitedRecording, } from "../../lib/limitedRecording"; import { + audioConstraints, describeMediaError, + listDevices, mirrorsCamera, resolveDeviceId, videoConstraints, @@ -27,7 +29,13 @@ import { useDeviceAccess } from "./settings/useDeviceAccess"; const VIDEO_FILE_PREFIX = "video-message"; type Mode = "photo" | "video"; -type Status = "starting" | "ready" | "recording" | "review" | "error"; +type Status = + | "starting" + | "ready" + | "arming" + | "recording" + | "review" + | "error"; interface Capture { file: File; @@ -40,6 +48,33 @@ function formatElapsed(ms: number): string { return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, "0")}`; } +// We open the microphone only while a video records, so taking a photo never +// lights the mic indicator. A denied mic records a silent video. +async function addMicrophone(stream: MediaStream): Promise { + const settings = useStore.getState().globalSettings; + const deviceId = resolveDeviceId( + settings.callAudioInput, + await listDevices("audioinput"), + ); + try { + const mic = await navigator.mediaDevices.getUserMedia({ + audio: audioConstraints(settings, deviceId), + }); + for (const track of mic.getAudioTracks()) stream.addTrack(track); + return true; + } catch (err) { + if (!(err instanceof DOMException)) throw err; + return false; + } +} + +function removeMicrophone(stream: MediaStream): void { + for (const track of stream.getAudioTracks()) { + track.stop(); + stream.removeTrack(track); + } +} + /** The camera, opened as its own page on a phone: a live viewfinder first, * then a photo or a recording taken from it, then a look at the result before * anything is sent. */ @@ -59,6 +94,8 @@ export function CameraCaptureModal({ const globalSettings = useStore((s) => s.globalSettings); const updateGlobalSettings = useStore((s) => s.updateGlobalSettings); const videoAccess = useDeviceAccess("videoinput"); + // Touch devices take the default camera, so getUserMedia asks for access itself. + const needsDeviceAccess = !isTouch && videoAccess.needsPermission; const [mode, setMode] = useState("photo"); const [status, setStatus] = useState("starting"); @@ -69,6 +106,7 @@ export function CameraCaptureModal({ const [elapsedMs, setElapsedMs] = useState(0); const [sizeBytes, setSizeBytes] = useState(0); const [stoppedBy, setStoppedBy] = useState(null); + const [isSilent, setIsSilent] = useState(false); const [capture, setCapture] = useState(null); const videoRef = useRef(null); @@ -103,7 +141,6 @@ export function CameraCaptureModal({ try { stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints(settings, deviceId, isTouch), - audio: true, }); } catch (err) { if (generation === genRef.current && mountedRef.current) { @@ -132,14 +169,14 @@ export function CameraCaptureModal({ // biome-ignore lint/correctness/useExhaustiveDependencies: openCamera and release are stable; reopening on every device-list change would restart the viewfinder under the person using it useEffect(() => { - if (!isOpen || videoAccess.needsPermission) return; + if (!isOpen || needsDeviceAccess) return; setMode("photo"); setCapture(null); void openCamera(); return () => { release(); }; - }, [isOpen, videoAccess.needsPermission]); + }, [isOpen, needsDeviceAccess]); useEffect(() => { if (!capture) return; @@ -159,7 +196,7 @@ export function CameraCaptureModal({ keepCapture(file, false); }; - const startRecording = () => { + const startRecording = async () => { const stream = streamRef.current; const format = pickVideoRecordingFormat(); if (!stream || !format) { @@ -167,6 +204,13 @@ export function CameraCaptureModal({ setError(t`This browser cannot record video.`); return; } + setStatus("arming"); + const hasMic = await addMicrophone(stream); + if (stream !== streamRef.current || !mountedRef.current) { + removeMicrophone(stream); + return; + } + setIsSilent(!hasMic); setElapsedMs(0); setSizeBytes(0); setStoppedBy(null); @@ -181,6 +225,7 @@ export function CameraCaptureModal({ }, onFile: (file, reason) => { recordingRef.current = null; + removeMicrophone(stream); if (!mountedRef.current) return; setStoppedBy(reason); keepCapture(file, true); @@ -196,7 +241,7 @@ export function CameraCaptureModal({ return; } if (mode === "photo") void takePhoto(); - else startRecording(); + else void startRecording(); }; const handleRetake = () => { @@ -206,7 +251,7 @@ export function CameraCaptureModal({ if (!streamRef.current) void openCamera(); }; - const handleSend = () => { + const handleAttach = () => { if (!capture) return; onCaptured(capture.file); release(); @@ -244,16 +289,14 @@ export function CameraCaptureModal({ maxWidth="lg" > - {videoAccess.needsPermission ? ( + {needsDeviceAccess ? ( void videoAccess.requestAccess()} message={ - - Device names are hidden until you allow camera access. - + Allow camera access to take a photo or video. } buttonLabel={Allow camera access} /> @@ -309,6 +352,11 @@ export function CameraCaptureModal({
)}
+ {recording && isSilent && ( +

+ Allow microphone access to record sound. +

+ )} {stopReasonMessage && (

{stopReasonMessage} @@ -329,7 +377,7 @@ export function CameraCaptureModal({ )} diff --git a/src/components/ui/ChangePasswordModal.tsx b/src/components/ui/ChangePasswordModal.tsx index d1ab7b0c0..397362d1c 100644 --- a/src/components/ui/ChangePasswordModal.tsx +++ b/src/components/ui/ChangePasswordModal.tsx @@ -4,6 +4,7 @@ // code -- the server accepts SETPASS on its own when the connection // holds a SASL session for the target account. +import { Trans, useLingui } from "@lingui/react/macro"; import type React from "react"; import { useEffect, useState } from "react"; import ircClient from "../../lib/ircClient"; @@ -14,6 +15,7 @@ interface Props { } export const ChangePasswordModal: React.FC = ({ serverId, onClose }) => { + const { t } = useLingui(); const [newPass, setNewPass] = useState(""); const [confirmPass, setConfirmPass] = useState(""); const [busy, setBusy] = useState(false); @@ -55,11 +57,11 @@ export const ChangePasswordModal: React.FC = ({ serverId, onClose }) => { e.preventDefault(); setErr(null); if (newPass.length < 3) { - setErr("Pick a password (3 characters or more)."); + setErr(t`Pick a password (3 characters or more).`); return; } if (newPass !== confirmPass) { - setErr("Passwords don't match."); + setErr(t`Passwords don't match.`); return; } setBusy(true); @@ -74,7 +76,9 @@ export const ChangePasswordModal: React.FC = ({ serverId, onClose }) => { >

-

Change password

+

+ Change password +

) : ( @@ -120,9 +126,9 @@ export const ChangePasswordModal: React.FC = ({ serverId, onClose }) => {
)} diff --git a/src/components/ui/ChannelListModal.tsx b/src/components/ui/ChannelListModal.tsx index 1eef66f88..820364b58 100644 --- a/src/components/ui/ChannelListModal.tsx +++ b/src/components/ui/ChannelListModal.tsx @@ -21,17 +21,17 @@ function channelTypeMeta(name: string): { } { switch (name[0]) { case "^": - return { Icon: FaVolumeUp, label: "Voice", color: "text-discord-green" }; + return { Icon: FaVolumeUp, label: t`Voice`, color: "text-discord-green" }; case "$": return { Icon: FaDesktop, - label: "Stream", - color: "text-discord-blue", + label: t`Stream`, + color: "text-discord-primary", }; default: return { Icon: FaHashtag, - label: "Text", + label: t`Text`, color: "text-discord-text-muted", }; } @@ -493,7 +493,7 @@ const ChannelListModal: React.FC = () => { diff --git a/src/components/ui/E2EERequestBanner.tsx b/src/components/ui/E2EERequestBanner.tsx index 1a4267ef4..901267cb4 100644 --- a/src/components/ui/E2EERequestBanner.tsx +++ b/src/components/ui/E2EERequestBanner.tsx @@ -75,7 +75,7 @@ export const E2EERequestBanner: React.FC<{
@@ -396,7 +396,7 @@ export const EditServerModal: React.FC = ({ id="operOnConnect" checked={operOnConnect} onChange={() => setOperOnConnect(!operOnConnect)} - className="accent-discord-accent rounded" + className="accent-discord-primary rounded" />
diff --git a/src/components/ui/FloodSettingsModal.tsx b/src/components/ui/FloodSettingsModal.tsx index 2a93644eb..6a7eea1fc 100644 --- a/src/components/ui/FloodSettingsModal.tsx +++ b/src/components/ui/FloodSettingsModal.tsx @@ -312,7 +312,7 @@ const FloodSettingsModal: React.FC = ({ href="https://www.unrealircd.org/docs/Channel_anti-flood_settings#flood-profiles" target="_blank" rel="noopener noreferrer" - className="text-discord-blue hover:underline ml-1" + className="text-discord-primary hover:underline ml-1" > Learn more about profiles → @@ -326,7 +326,7 @@ const FloodSettingsModal: React.FC = ({ - {/* Input — same structure as main ChatArea input. - keyboard-aware-layout: CSS rule zeroes padding-bottom when keyboard is visible - (using !important to beat the inline safe-area style below). */} -
+
)} @@ -191,10 +195,12 @@ export const PasswordRecoveryModal: React.FC = ({

{info}

- Note: the server returns the same response whether or not the - account has a verified email, so a typo will not be flagged here. - If no code arrives within a couple of minutes, double-check the - account name. + + Note: the server returns the same response whether or not the + account has a verified email, so a typo will not be flagged + here. If no code arrives within a couple of minutes, + double-check the account name. +

= ({ }} className="px-3 py-2 rounded bg-discord-dark-100 text-white text-sm" > - Back + Back
@@ -233,13 +239,13 @@ export const PasswordRecoveryModal: React.FC = ({

{info}

- You have a few minutes to choose a new password. + You have a few minutes to choose a new password.

setNewPass(e.target.value)} - placeholder="New password" + placeholder={t`New password`} className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" autoFocus /> @@ -247,7 +253,7 @@ export const PasswordRecoveryModal: React.FC = ({ type="password" value={confirmPass} onChange={(e) => setConfirmPass(e.target.value)} - placeholder="Confirm new password" + placeholder={t`Confirm new password`} className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" /> )} @@ -264,17 +270,19 @@ export const PasswordRecoveryModal: React.FC = ({

{info}

- For security, all existing sessions for this account were signed - out and account-scoped server state was purged. If two-factor - authentication was enabled, it remains enabled — the next login - will still require it. + + For security, all existing sessions for this account were signed + out and account-scoped server state was purged. If two-factor + authentication was enabled, it remains enabled, so the next + login will still require it. +

)} diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx index 44c3e9ce1..dd906d6fe 100644 --- a/src/components/ui/Popover.tsx +++ b/src/components/ui/Popover.tsx @@ -176,7 +176,6 @@ export const Popover: React.FC = ({ {titleBar}
{children}
diff --git a/src/components/ui/QuickActions/uiActionConfig.tsx b/src/components/ui/QuickActions/uiActionConfig.tsx index 945ce87ac..3f28b099f 100644 --- a/src/components/ui/QuickActions/uiActionConfig.tsx +++ b/src/components/ui/QuickActions/uiActionConfig.tsx @@ -60,7 +60,7 @@ export function getUIActions(): UIActionConfig[] { { id: "open-channel-settings", type: "ui-modal", - title: t`Channel Settings`, + title: t`Edit Channel`, description: t`Open channel configuration settings`, keywords: ["channel", "settings", "config", "configure"], score: 85, @@ -121,7 +121,7 @@ export function getUIActions(): UIActionConfig[] { { id: "open-server-channels", type: "ui-modal", - title: t`Server Channels`, + title: t`Browse Channels`, description: t`Browse all channels on the server`, keywords: ["server", "channels", "browse", "list"], score: 80, diff --git a/src/components/ui/RecordingControlsOverlay.tsx b/src/components/ui/RecordingControlsOverlay.tsx index ba4373531..e45f1dd06 100644 --- a/src/components/ui/RecordingControlsOverlay.tsx +++ b/src/components/ui/RecordingControlsOverlay.tsx @@ -59,7 +59,7 @@ export function RecordingControlsOverlay() { diff --git a/src/components/ui/TextInput.tsx b/src/components/ui/TextInput.tsx index c5b0d5235..9d425d692 100644 --- a/src/components/ui/TextInput.tsx +++ b/src/components/ui/TextInput.tsx @@ -10,7 +10,9 @@ import { const handleMobileDone = (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - (e.target as HTMLInputElement).blur(); + const input = e.target as HTMLInputElement; + input.blur(); + input.form?.requestSubmit(); } }; diff --git a/src/components/ui/TicTacToeModal.tsx b/src/components/ui/TicTacToeModal.tsx index 49a528854..e5c31a8ed 100644 --- a/src/components/ui/TicTacToeModal.tsx +++ b/src/components/ui/TicTacToeModal.tsx @@ -1,9 +1,11 @@ +import { Trans, useLingui } from "@lingui/react/macro"; import type React from "react"; import { useMemo } from "react"; import { isLocalTurn, isWinningCell } from "../../lib/games/tictactoe"; import useStore from "../../store"; export const TicTacToeModal: React.FC = () => { + const { t } = useLingui(); const open = useStore((s) => s.tictactoe?.open ?? null); const games = useStore((s) => s.tictactoe?.games ?? {}); const accept = useStore((s) => s.tictactoeAccept); @@ -34,13 +36,13 @@ export const TicTacToeModal: React.FC = () => {

- Tic-Tac-Toe vs {open.opponent} + Tic-Tac-Toe vs {open.opponent}

@@ -49,7 +51,7 @@ export const TicTacToeModal: React.FC = () => { {game.inviteIncoming && (

- {open.opponent} has invited you to play. + {open.opponent} has invited you to play.

@@ -72,7 +74,7 @@ export const TicTacToeModal: React.FC = () => { {game.invitePending && !game.active && (
- Waiting for {open.opponent} to accept… + Waiting for {open.opponent} to accept…
)} @@ -132,9 +134,9 @@ export const TicTacToeModal: React.FC = () => {
)} @@ -146,7 +148,7 @@ export const TicTacToeModal: React.FC = () => { onClick={onClose} className="px-3 py-2 rounded bg-discord-red text-white text-sm" > - Forfeit + Forfeit
)} diff --git a/src/components/ui/TopicModal.tsx b/src/components/ui/TopicModal.tsx index d13a642f1..c5fe6a717 100644 --- a/src/components/ui/TopicModal.tsx +++ b/src/components/ui/TopicModal.tsx @@ -101,7 +101,7 @@ export const TopicModal: React.FC = ({
{channel.topic ? ( renderTopicWithLinks(channel.topic) diff --git a/src/components/ui/TotpStepUpModal.tsx b/src/components/ui/TotpStepUpModal.tsx index 8ee637794..e47f33a9f 100644 --- a/src/components/ui/TotpStepUpModal.tsx +++ b/src/components/ui/TotpStepUpModal.tsx @@ -1,8 +1,10 @@ +import { Trans, useLingui } from "@lingui/react/macro"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import useStore from "../../store"; export const TotpStepUpModal: React.FC = () => { + const { t } = useLingui(); const pending = useStore((s) => s.pendingTotpStepUp); const submitTotpStepUp = useStore((s) => s.submitTotpStepUp); const cancelTotpStepUp = useStore((s) => s.cancelTotpStepUp); @@ -21,12 +23,13 @@ export const TotpStepUpModal: React.FC = () => { }, [pending]); if (!pending) return null; + const account = pending.account; const onSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = code.trim(); if (!/^\d{6}$/.test(trimmed)) { - setError("Enter the 6-digit code from your authenticator app."); + setError(t`Enter the 6-digit code from your authenticator app.`); return; } submitTotpStepUp(pending.serverId, trimmed); @@ -40,12 +43,20 @@ export const TotpStepUpModal: React.FC = () => { >

- Two-factor authentication required + Two-factor authentication required

- Enter the 6-digit code from your authenticator app to finish signing - in - {pending.account ? ` as ${pending.account}` : ""}. + {account ? ( + + Enter the 6-digit code from your authenticator app to finish + signing in as {account}. + + ) : ( + + Enter the 6-digit code from your authenticator app to finish + signing in. + + )}

{ setCode(e.target.value.replace(/\D/g, "")); setError(null); }} - className="w-full px-3 py-2 rounded bg-discord-dark-300 text-white tracking-[0.4em] text-center text-xl font-mono focus:outline-none focus:ring-2 focus:ring-discord-blue" + className="w-full px-3 py-2 rounded bg-discord-dark-300 text-white tracking-[0.4em] text-center text-xl font-mono focus:outline-none focus:ring-2 focus:ring-discord-primary" placeholder="000000" /> {error &&

{error}

} @@ -70,13 +81,13 @@ export const TotpStepUpModal: React.FC = () => { onClick={() => cancelTotpStepUp(pending.serverId)} className="px-3 py-2 rounded text-sm text-discord-text-muted hover:text-white" > - Cancel + Cancel
diff --git a/src/components/ui/TwoFactorSettingsModal.tsx b/src/components/ui/TwoFactorSettingsModal.tsx index f49affd03..7ed38399f 100644 --- a/src/components/ui/TwoFactorSettingsModal.tsx +++ b/src/components/ui/TwoFactorSettingsModal.tsx @@ -1,3 +1,4 @@ +import { Trans, useLingui } from "@lingui/react/macro"; import QRCode from "qrcode"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; @@ -14,13 +15,6 @@ import { } from "../../lib/sasl/webauthn"; import useStore, { loadSavedServers } from "../../store"; -const TYPE_LABELS: Record = { - totp: "Authenticator app (TOTP)", - webauthn: "Biometric / security key", - oauth: "OAuth identity", - external: "TLS certificate fingerprint", -}; - function decodeChallenge(blob: string): unknown { try { return JSON.parse(new TextDecoder().decode(b64StdDecode(blob))); @@ -38,6 +32,13 @@ export const TwoFactorSettingsModal: React.FC = ({ serverId, onClose, }) => { + const { t } = useLingui(); + const typeLabels: Record = { + totp: t`Authenticator app (TOTP)`, + webauthn: t`Biometric / security key`, + oauth: t`OAuth identity`, + external: t`TLS certificate fingerprint`, + }; const status = useStore((s) => s.twofaStatus[serverId] ?? "unknown") as | "enabled" | "disabled" @@ -162,7 +163,7 @@ export const TwoFactorSettingsModal: React.FC = ({ const bearer = isOpaque ? result.accessToken : (result.idToken ?? result.accessToken); - if (!bearer) throw new Error("Provider returned no bearer token."); + if (!bearer) throw new Error(t`Provider returned no bearer token.`); const serverProvider = provider.serverProvider || // Fall back to the preset id (e.g. "github") so the obbyircd @@ -182,7 +183,7 @@ export const TwoFactorSettingsModal: React.FC = ({ const onAddWebAuthn = async () => { if (!isWebAuthnAvailable()) { - setEnrollError("WebAuthn is not available in this environment."); + setEnrollError(t`WebAuthn is not available in this environment.`); return; } setEnrollError(null); @@ -201,7 +202,7 @@ export const TwoFactorSettingsModal: React.FC = ({ return; const decoded = decodeChallenge(challenge.blob); if (!decoded || typeof decoded !== "object") { - setEnrollError("Server returned an invalid WebAuthn challenge."); + setEnrollError(t`Server returned an invalid WebAuthn challenge.`); return; } const name = enrollName.trim() || `Device-${Date.now()}`; @@ -225,11 +226,11 @@ export const TwoFactorSettingsModal: React.FC = ({ setEnrollError(null); const name = enrollName.trim(); if (!name || /\s/.test(name)) { - setEnrollError("Pick a single-word name (no spaces)."); + setEnrollError(t`Pick a single-word name (no spaces).`); return; } if (!/^\d{6}$/.test(enrollCode.trim())) { - setEnrollError("Enter the 6-digit code from your authenticator app."); + setEnrollError(t`Enter the 6-digit code from your authenticator app.`); return; } twofaAdd(serverId, "totp", name, enrollCode.trim()); @@ -241,7 +242,7 @@ export const TwoFactorSettingsModal: React.FC = ({ setEnrollError(null); if (!/^\d{6}$/.test(disableCode.trim())) { setEnrollError( - "Enter a 6-digit code from any of your authenticator apps to disable 2FA.", + t`Enter a 6-digit code from any of your authenticator apps to disable 2FA.`, ); return; } @@ -264,7 +265,7 @@ export const TwoFactorSettingsModal: React.FC = ({

- Two-factor authentication + Two-factor authentication

)}
{isPendingRemoval && isLastWhileEnabled && (

- Can't remove your last 2FA credential + Can't remove your last 2FA credential

- 2FA is currently enforced on this account. Removing the - only registered credential would lock you out of login. - Add another credential first, or disable 2FA below. + + 2FA is currently enforced on this account. Removing + the only registered credential would lock you out of + login. Add another credential first, or disable 2FA + below. +

)} {isPendingRemoval && !isLastWhileEnabled && (

- Remove "{c.name}"? You will no longer be able to use it - as a second factor. + + Remove "{c.name}"? You will no longer be able to use + it as a second factor. +

@@ -392,9 +403,9 @@ export const TwoFactorSettingsModal: React.FC = ({
{oauthProviders.length > 0 && (
- Link an OAuth identity + Link an OAuth identity

- Sign in with the provider, then we'll bind the resulting identity - to your account so SASL OAUTHBEARER / IRCV3BEARER works on - subsequent connections. + + Sign in with the provider, then we'll bind the resulting + identity to your account so SASL OAUTHBEARER / IRCV3BEARER works + on subsequent connections. +

{oauthProviders.map((p) => { const tag = p.serverProvider || p.providerLabel; @@ -439,8 +452,8 @@ export const TwoFactorSettingsModal: React.FC = ({ }`} > {busy - ? "Waiting for sign-in..." - : `Link with ${p.providerLabel}`} + ? t`Waiting for sign-in…` + : t`Link with ${p.providerLabel}`} ); })} @@ -456,8 +469,10 @@ export const TwoFactorSettingsModal: React.FC = ({ className="mb-4 p-3 rounded bg-discord-dark-300" >

- Scan this QR code with your authenticator app, then confirm with - the 6-digit code it shows. + + Scan this QR code with your authenticator app, then confirm with + the 6-digit code it shows. +

= ({
setEnrollName(e.target.value)} className="w-full px-3 py-2 rounded bg-discord-dark-200 text-white" @@ -477,7 +492,7 @@ export const TwoFactorSettingsModal: React.FC = ({ inputMode="numeric" maxLength={6} pattern="\d{6}" - placeholder="6-digit code" + placeholder={t`6-digit code`} value={enrollCode} onChange={(e) => setEnrollCode(e.target.value.replace(/\D/g, "")) @@ -488,7 +503,7 @@ export const TwoFactorSettingsModal: React.FC = ({ type="submit" className="px-4 py-2 rounded bg-discord-green text-white text-sm font-medium" > - Confirm enrolment + Confirm enrolment
@@ -499,7 +514,7 @@ export const TwoFactorSettingsModal: React.FC = ({ )} {enrollBusy && (

- Waiting for biometric prompt… + Waiting for biometric prompt…

)} @@ -508,8 +523,10 @@ export const TwoFactorSettingsModal: React.FC = ({ {status === "enabled" ? (

- Disable 2FA. Enter a 6-digit code from any of your registered - authenticator apps to confirm. + + Disable 2FA. Enter a 6-digit code from any of your registered + authenticator apps to confirm. +

= ({ onClick={onDisable} className="px-3 py-2 rounded bg-discord-red text-white text-sm font-medium" > - Disable + Disable
@@ -541,8 +558,8 @@ export const TwoFactorSettingsModal: React.FC = ({ className="w-full px-3 py-2 rounded bg-discord-green text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" > {credentials.length === 0 - ? "Add a credential to enable 2FA" - : "Enable 2FA"} + ? t`Add a credential to enable 2FA` + : t`Enable 2FA`} )}
diff --git a/src/components/ui/UploadProgressOverlay.tsx b/src/components/ui/UploadProgressOverlay.tsx index a75cb5a0a..908576db8 100644 --- a/src/components/ui/UploadProgressOverlay.tsx +++ b/src/components/ui/UploadProgressOverlay.tsx @@ -3,7 +3,7 @@ // by XHR's upload-progress event, and a "done" / "failed" mark when // the upload settles. -import { Trans } from "@lingui/react/macro"; +import { Plural, Trans, useLingui } from "@lingui/react/macro"; import type React from "react"; import { FaCheck, @@ -36,14 +36,33 @@ interface Props { } export const UploadProgressOverlay: React.FC = ({ jobs, onCancel }) => { + const { t } = useLingui(); if (jobs.length === 0) return null; + const activeCount = jobs.filter( + (job) => job.status === "pending" || job.status === "uploading", + ).length; + const failedCount = jobs.filter((job) => job.status === "failed").length; + const header = + activeCount > 0 ? ( + + ) : failedCount > 0 ? ( + + ) : ( + Uploaded + ); return (
- {jobs.length === 1 - ? "Uploading 1 file" - : `Uploading ${jobs.length} files`} + {header}
{jobs.map((job) => { const pct = @@ -56,25 +75,27 @@ export const UploadProgressOverlay: React.FC = ({ jobs, onCancel }) => { ) : job.status === "failed" ? ( ) : ( - + )} {job.file.name} - {job.status === "failed" - ? "failed" - : job.status === "done" - ? "done" - : `${formatFileSize(job.loaded)} / ${formatFileSize(job.total || job.file.size)}`} + {job.status === "failed" ? ( + failed + ) : job.status === "done" ? ( + done + ) : ( + `${formatFileSize(job.loaded)} / ${formatFileSize(job.total || job.file.size)}` + )} {onCancel && job.status !== "done" && ( @@ -111,7 +132,7 @@ export const UploadProgressOverlay: React.FC = ({ jobs, onCancel }) => { className={`h-full transition-[width] duration-150 ${ job.status === "failed" ? "bg-discord-red" - : "bg-discord-blue" + : "bg-discord-primary" }`} style={{ width: `${pct}%` }} /> diff --git a/src/components/ui/UserProfileModal.tsx b/src/components/ui/UserProfileModal.tsx index a35b6f73a..6bf8cebdb 100644 --- a/src/components/ui/UserProfileModal.tsx +++ b/src/components/ui/UserProfileModal.tsx @@ -356,7 +356,7 @@ const UserProfileModal: React.FC = ({ /> ) : null}
{displayName[0].toUpperCase()} @@ -468,7 +468,7 @@ const UserProfileModal: React.FC = ({ {/* WHOIS Information */}
- +

User Information

@@ -476,7 +476,7 @@ const UserProfileModal: React.FC = ({ {isLoadingWhois && (
-
+
Loading WHOIS data...
)} @@ -616,7 +616,7 @@ const UserProfileModal: React.FC = ({ type="button" key={channel} onClick={() => handleChannelClick(channel)} - className="flex items-center gap-2 bg-discord-dark-100 rounded px-3 py-2 hover:bg-discord-blurple transition-colors cursor-pointer text-left group" + className="flex items-center gap-2 bg-discord-dark-100 rounded px-3 py-2 hover:bg-discord-primary transition-colors cursor-pointer text-left group" title={t`Click to join ${channel}`} > {badge && ( @@ -688,7 +688,7 @@ const UserProfileModal: React.FC = ({ key={msg} className="text-white text-sm flex items-start gap-2" > - + • {msg} @@ -773,7 +773,7 @@ const UserProfileModal: React.FC = ({ )} diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index 20cab1a83..3ff88f060 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -984,7 +984,7 @@ export const UserSettings: React.FC = React.memo(() => { {" "} support@obby.world @@ -998,7 +998,7 @@ export const UserSettings: React.FC = React.memo(() => { href="https://github.com/obbyworld/obby" target="_blank" rel="noopener noreferrer" - className="text-discord-primary hover:text-discord-primary-light" + className="text-discord-primary hover:text-discord-text-link" > github.com/obbyworld/obby @@ -1385,7 +1385,7 @@ export const UserSettings: React.FC = React.memo(() => { if (!currentServer || !serverConfig) { return (
- Connect to a server to manage operator settings. + Connect to a server to manage operator settings.
); } @@ -1403,11 +1403,13 @@ export const UserSettings: React.FC = React.memo(() => { {supportsTwoFactor && (

- Two-Factor Authentication + Two-factor authentication

- Add an authenticator app or biometric / security key to require a - second factor when logging in. + + Add an authenticator app or biometric / security key to require + a second factor when logging in. +

)} @@ -1471,7 +1473,7 @@ export const UserSettings: React.FC = React.memo(() => { htmlFor="operOnConnect" className="text-discord-text-normal text-sm" > - Authenticate on connect + Authenticate on connect
@@ -1481,7 +1483,7 @@ export const UserSettings: React.FC = React.memo(() => { disabled={!operName.trim() || !operPassword.trim()} className="w-full rounded bg-discord-button-success-default px-4 py-2 text-white hover:bg-discord-button-success-hover disabled:opacity-50 disabled:cursor-not-allowed" > - Authenticate Now + Authenticate now
@@ -1490,18 +1492,20 @@ export const UserSettings: React.FC = React.memo(() => { {currentUser?.isIrcOp && currentServer?.emojiPackUrl && (

- Custom emoji packs + Custom emoji packs

- Manage the network-wide and channel-scoped emoji packs that this - server publishes via draft/EMOJI. + + Manage the network-wide and channel-scoped emoji packs that this + server publishes via draft/EMOJI. +

)} @@ -1529,7 +1533,7 @@ export const UserSettings: React.FC = React.memo(() => { {/* Header */}

- User Settings + User Settings

diff --git a/src/components/ui/settings/CallDeviceShared.tsx b/src/components/ui/settings/CallDeviceShared.tsx index e72355d81..eefcd97a0 100644 --- a/src/components/ui/settings/CallDeviceShared.tsx +++ b/src/components/ui/settings/CallDeviceShared.tsx @@ -36,7 +36,7 @@ export const DeviceAccessGate: React.FC = ({ type="button" onClick={onRequestAccess} disabled={requesting || disabled} - className="min-h-11 rounded bg-discord-primary px-4 py-2 text-white hover:opacity-90 disabled:opacity-50" + className="min-h-11 rounded button-primary px-4 py-2" > {requesting ? : buttonLabel} diff --git a/src/components/ui/settings/CustomMentionsField.tsx b/src/components/ui/settings/CustomMentionsField.tsx index c77ed629f..d7cf92f9c 100644 --- a/src/components/ui/settings/CustomMentionsField.tsx +++ b/src/components/ui/settings/CustomMentionsField.tsx @@ -44,7 +44,7 @@ export const CustomMentionsField: React.FC = ({ onKeyPress={handleKeyPress} disabled={disabled} placeholder={t`Add a word or phrase...`} - className="flex-1 rounded border border-discord-button-secondary-default bg-discord-input-bg px-3 py-2 text-discord-text-normal placeholder-discord-text-muted focus:border-discord-text-link focus:outline-none disabled:opacity-50" + className="flex-1 rounded border border-discord-button-secondary-default bg-discord-dark-400 px-3 py-2 text-discord-text-normal placeholder-discord-text-muted focus:border-discord-text-link focus:outline-none disabled:opacity-50" /> diff --git a/src/components/voice/ParticipantTile.tsx b/src/components/voice/ParticipantTile.tsx index f7040cf18..8c1c6c10d 100644 --- a/src/components/voice/ParticipantTile.tsx +++ b/src/components/voice/ParticipantTile.tsx @@ -36,6 +36,9 @@ export interface ParticipantTileProps { * as opposed to a small grid or filmstrip thumbnail -- letterboxes * its video instead of cropping it. */ mainTile?: boolean; + /** The phone band draws its own controls over the tile, so the tile shows + * only the video. */ + bare?: boolean; } const CONTROL_BUTTON = @@ -57,6 +60,7 @@ export function ParticipantTile({ theater, onToggleTheater, mainTile, + bare, }: ParticipantTileProps) { const videoRef = useRef(null); const nickColor = useUserColor(serverId, member.nick); @@ -89,10 +93,14 @@ export function ParticipantTile({ return (
+ {!compact && ( + + onSetVolume(member.nick, Number(e.target.value)) + } + aria-label={t`Volume for ${member.nick}`} + className={`h-1 rounded-full accent-white cursor-pointer overflow-hidden transition-[width] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${sliderWidthClass}`} + /> + )} + + )} + {!isSelf && canDemote && onDemote && ( + + )} + {showPip && ( - {!compact && ( - - onSetVolume(member.nick, Number(e.target.value)) - } - aria-label={t`Volume for ${member.nick}`} - className={`h-1 rounded-full accent-white cursor-pointer overflow-hidden transition-[width] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${sliderWidthClass}`} - /> - )} - - )} - {!isSelf && canDemote && onDemote && ( - - )} - {showPip && ( - - )} - {onToggleTheater && ( - - )} -
+ )} + {onToggleTheater && ( + + )} +
-
- - {member.nick} - - {!member.micOn && ( - - )} - {member.deafened && ( - - )} +
+ + {member.nick} + + {!member.micOn && ( + + )} + {member.deafened && ( + + )} +
-
+ )}
); } diff --git a/src/components/voice/VideoTrackView.tsx b/src/components/voice/VideoTrackView.tsx index d2ddf17cf..deb61aff7 100644 --- a/src/components/voice/VideoTrackView.tsx +++ b/src/components/voice/VideoTrackView.tsx @@ -1,4 +1,22 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +// WebKit's own presentation API, which iOS web views keep when they turn the +// standard Picture-in-Picture API off. +type WebkitPresentationMode = "inline" | "picture-in-picture" | "fullscreen"; + +declare global { + interface HTMLVideoElement { + webkitSupportsPresentationMode?: (mode: WebkitPresentationMode) => boolean; + webkitSetPresentationMode?: (mode: WebkitPresentationMode) => void; + webkitPresentationMode?: WebkitPresentationMode; + } +} interface VideoTrackViewProps { track: MediaStreamTrack | undefined; @@ -35,6 +53,7 @@ export const VideoTrackView = forwardRef( return (