feat: Implement SearchPanel, various mobile UI screens, and foundational shared components across applications.
All checks were successful
Build and Release / build-and-release (push) Successful in 15m35s

This commit is contained in:
Bryan1029384756
2026-02-23 11:27:01 -06:00
parent 90cf99f7ab
commit a6af4dda00
22 changed files with 3462 additions and 2396 deletions

View File

@@ -17,4 +17,3 @@
# Android # Android
- On android when i minimize the app it shows im idle. Can we make it so it shows im offline?

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27 versionCode 27
versionName "1.0.37" versionName "1.0.38"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/android", "name": "@discord-clone/android",
"private": true, "private": true,
"version": "1.0.36", "version": "1.0.38",
"type": "module", "type": "module",
"scripts": { "scripts": {
"cap:sync": "npx cap sync", "cap:sync": "npx cap sync",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"private": true, "private": true,
"version": "1.0.37", "version": "1.0.38",
"description": "Brycord - Electron app", "description": "Brycord - Electron app",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/web", "name": "@discord-clone/web",
"private": true, "private": true,
"version": "1.0.37", "version": "1.0.38",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

File diff suppressed because it is too large Load Diff

View File

@@ -1,611 +0,0 @@
2026-02-22T18:24:19.1151666Z e0b0e1a10f48(version:v0.2.13) received task 97 of job build-and-release, be triggered by event: push
2026-02-22T18:24:19.1154469Z workflow prepared
2026-02-22T18:24:19.1154948Z evaluating expression 'success()'
2026-02-22T18:24:19.1155512Z expression 'success()' evaluated to 'true'
2026-02-22T18:24:19.1155614Z 🚀 Start image=moyettes/eb
2026-02-22T18:24:19.1199380Z 🐳 docker pull image=moyettes/eb platform= username= forcePull=false
2026-02-22T18:24:19.1199576Z 🐳 docker pull moyettes/eb
2026-02-22T18:24:19.1206004Z Image exists? true
2026-02-22T18:24:19.1285918Z Cleaning up network for job build-and-release, and network name is: GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network
2026-02-22T18:24:20.6844953Z 🐳 docker create image=moyettes/eb platform= entrypoint=["/bin/sleep" "10800"] cmd=[] network="GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network"
2026-02-22T18:24:20.6919431Z Custom container.Config from options ==> &{Hostname: Domainname: User: AttachStdin:false AttachStdout:true AttachStderr:true ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[] Cmd:[] Healthcheck:<nil> ArgsEscaped:false Image: Volumes:map[] WorkingDir: Entrypoint:[] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:<nil> Shell:[]}
2026-02-22T18:24:20.6919707Z Merged container.Config ==> &{Hostname: Domainname: User: AttachStdin:false AttachStdout:true AttachStderr:true ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TEMP=/tmp LANG=C.UTF-8] Cmd:[] Healthcheck:<nil> ArgsEscaped:false Image:moyettes/eb Volumes:map[] WorkingDir:/workspace/Moyettes/DiscordClone Entrypoint:[/bin/sleep 10800] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:<nil> Shell:[]}
2026-02-22T18:24:20.6919931Z Custom container.HostConfig from options ==> &{Binds:[] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network PortBindings:map[] RestartPolicy:{Name:no MaximumRetryCount:0} AutoRemove:false VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:0xc0002ec948 OomKillDisable:0xc0002ec843 PidsLimit:0xc0002ec9a8 Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[] MaskedPaths:[] ReadonlyPaths:[] Init:<nil>}
2026-02-22T18:24:20.6920169Z --network and --net in the options will be ignored.
2026-02-22T18:24:20.6920322Z Merged container.HostConfig ==> &{Binds:[/var/run/docker.sock:/var/run/docker.sock] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network PortBindings:map[] RestartPolicy:{Name:no MaximumRetryCount:0} AutoRemove:true VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:0xc0002ec948 OomKillDisable:0xc0002ec843 PidsLimit:0xc0002ec9a8 Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[{Type:volume Source:act-toolcache Target:/opt/hostedtoolcache ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>} {Type:volume Source:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-env Target:/var/run/act ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>} {Type:volume Source:GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release Target:/workspace/Moyettes/DiscordClone ReadOnly:false Consistency: BindOptions:<nil> VolumeOptions:<nil> TmpfsOptions:<nil> ClusterOptions:<nil>}] MaskedPaths:[] ReadonlyPaths:[] Init:<nil>}
2026-02-22T18:24:23.0180031Z Created container name=GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release id=476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e from image moyettes/eb (platform: )
2026-02-22T18:24:23.0180328Z ENV ==> [RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TEMP=/tmp LANG=C.UTF-8]
2026-02-22T18:24:23.0180410Z 🐳 docker run image=moyettes/eb platform= entrypoint=["/bin/sleep" "10800"] cmd=[] network="GITEA-ACTIONS-TASK-97_WORKFLOW-Build-and-Release_JOB-build-and-release-build-and-release-network"
2026-02-22T18:24:23.0180491Z Starting container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
2026-02-22T18:24:39.2493696Z Started container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
2026-02-22T18:24:39.3080025Z Writing entry to tarball workflow/event.json len:5124
2026-02-22T18:24:39.3080335Z Writing entry to tarball workflow/envs.txt len:0
2026-02-22T18:24:39.3080410Z Extracting content to '/var/run/act/'
2026-02-22T18:24:39.3144112Z ☁ git clone 'https://github.com/actions/checkout' # ref=v4
2026-02-22T18:24:39.3144257Z cloning https://github.com/actions/checkout to /root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
2026-02-22T18:24:39.6397485Z Non-terminating error while running 'git clone': some refs were not updated
2026-02-22T18:24:39.6437468Z ☁ git clone 'https://github.com/actions/cache' # ref=v4
2026-02-22T18:24:39.6437622Z cloning https://github.com/actions/cache to /root/.cache/act/6b4e4eb40e21c1bd02cb00a273f4d79af7c42205c1390e4e65c594ecd7a3696e
2026-02-22T18:24:39.7994895Z Non-terminating error while running 'git clone': some refs were not updated
2026-02-22T18:24:39.8106683Z evaluating expression ''
2026-02-22T18:24:39.8107099Z expression '' evaluated to 'true'
2026-02-22T18:24:39.8107195Z ⭐ Run Main Checkout repository
2026-02-22T18:24:39.8107327Z Writing entry to tarball workflow/outputcmd.txt len:0
2026-02-22T18:24:39.8107460Z Writing entry to tarball workflow/statecmd.txt len:0
2026-02-22T18:24:39.8107544Z Writing entry to tarball workflow/pathcmd.txt len:0
2026-02-22T18:24:39.8107630Z Writing entry to tarball workflow/envs.txt len:0
2026-02-22T18:24:39.8107761Z Writing entry to tarball workflow/SUMMARY.md len:0
2026-02-22T18:24:39.8107852Z Extracting content to '/var/run/act'
2026-02-22T18:24:39.8148668Z expression '${{ github.token }}' rewritten to 'format('{0}', github.token)'
2026-02-22T18:24:39.8148813Z evaluating expression 'format('{0}', github.token)'
2026-02-22T18:24:39.8149058Z expression 'format('{0}', github.token)' evaluated to '%!t(string=***)'
2026-02-22T18:24:39.8149363Z expression '${{ github.repository }}' rewritten to 'format('{0}', github.repository)'
2026-02-22T18:24:39.8149445Z evaluating expression 'format('{0}', github.repository)'
2026-02-22T18:24:39.8149608Z expression 'format('{0}', github.repository)' evaluated to '%!t(string=Moyettes/DiscordClone)'
2026-02-22T18:24:39.8149736Z type=remote-action actionDir=/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab actionPath= workdir=/workspace/Moyettes/DiscordClone actionCacheDir=/root/.cache/act actionName=c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab containerActionDir=/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
2026-02-22T18:24:39.8149871Z /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab
2026-02-22T18:24:39.8150013Z 🐳 docker cp src=/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/ dst=/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
2026-02-22T18:24:39.8151040Z Writing tarball /tmp/act144515510 from /root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
2026-02-22T18:24:39.8151159Z Stripping prefix:/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/ src:/root/.cache/act/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/
2026-02-22T18:24:39.8524368Z Extracting content from '/tmp/act144515510' to '/var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/'
2026-02-22T18:24:39.8921095Z executing remote job container: [node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]
2026-02-22T18:24:39.8921429Z 🐳 docker exec cmd=[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js] user= workdir=
2026-02-22T18:24:39.8921512Z Exec command '[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]'
2026-02-22T18:24:39.8921727Z Working directory '/workspace/Moyettes/DiscordClone'
2026-02-22T18:24:39.9825906Z ::add-matcher::/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/problem-matcher.json
2026-02-22T18:24:39.9825992Z ::add-matcher::/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/problem-matcher.json
2026-02-22T18:24:39.9827546Z Syncing repository: Moyettes/DiscordClone
2026-02-22T18:24:39.9828977Z ::group::Getting Git version info
2026-02-22T18:24:39.9829179Z Working directory is '/workspace/Moyettes/DiscordClone'
2026-02-22T18:24:39.9843627Z [command]/usr/bin/git version
2026-02-22T18:24:39.9876792Z git version 2.34.1
2026-02-22T18:24:39.9893034Z ::endgroup::
2026-02-22T18:24:39.9900583Z Copying '/root/.gitconfig' to '/tmp/13a3563f-2afc-454a-be82-84e8c97f13b5/.gitconfig'
2026-02-22T18:24:39.9906566Z Temporarily overriding HOME='/tmp/13a3563f-2afc-454a-be82-84e8c97f13b5' before making global git config changes
2026-02-22T18:24:39.9906722Z Adding repository directory to the temporary git global config as a safe directory
2026-02-22T18:24:39.9910486Z [command]/usr/bin/git config --global --add safe.directory /workspace/Moyettes/DiscordClone
2026-02-22T18:24:39.9930061Z Deleting the contents of '/workspace/Moyettes/DiscordClone'
2026-02-22T18:24:39.9931500Z ::group::Initializing the repository
2026-02-22T18:24:39.9932961Z [command]/usr/bin/git init /workspace/Moyettes/DiscordClone
2026-02-22T18:24:39.9951790Z hint: Using 'master' as the name for the initial branch. This default branch name
2026-02-22T18:24:39.9951851Z hint: is subject to change. To configure the initial branch name to use in all
2026-02-22T18:24:39.9951902Z hint: of your new repositories, which will suppress this warning, call:
2026-02-22T18:24:39.9951956Z hint:
2026-02-22T18:24:39.9951999Z hint: git config --global init.defaultBranch <name>
2026-02-22T18:24:39.9952045Z hint:
2026-02-22T18:24:39.9952086Z hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
2026-02-22T18:24:39.9952134Z hint: 'development'. The just-created branch can be renamed via this command:
2026-02-22T18:24:39.9952193Z hint:
2026-02-22T18:24:39.9952254Z hint: git branch -m <name>
2026-02-22T18:24:39.9953884Z Initialized empty Git repository in /workspace/Moyettes/DiscordClone/.git/
2026-02-22T18:24:39.9958929Z [command]/usr/bin/git remote add origin http://192.168.125.15:4000/Moyettes/DiscordClone
2026-02-22T18:24:39.9974658Z ::endgroup::
2026-02-22T18:24:39.9974751Z ::group::Disabling automatic garbage collection
2026-02-22T18:24:39.9976776Z [command]/usr/bin/git config --local gc.auto 0
2026-02-22T18:24:39.9990144Z ::endgroup::
2026-02-22T18:24:39.9990226Z ::group::Setting up auth
2026-02-22T18:24:39.9993042Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
2026-02-22T18:24:40.0008870Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
2026-02-22T18:24:40.0175272Z [command]/usr/bin/git config --local --name-only --get-regexp http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader
2026-02-22T18:24:40.0190112Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader' && git config --local --unset-all 'http.http://192.168.125.15:4000/.extraheader' || :"
2026-02-22T18:24:40.0332525Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
2026-02-22T18:24:40.0346462Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
2026-02-22T18:24:40.0492696Z [command]/usr/bin/git config --local http.http://192.168.125.15:4000/.extraheader AUTHORIZATION: basic ***
2026-02-22T18:24:40.0508589Z ::endgroup::
2026-02-22T18:24:40.0508709Z ::group::Fetching the repository
2026-02-22T18:24:40.0511771Z [command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +fee34f02364d5b90ed3ecfbfb65db744e61cff35:refs/remotes/origin/main
2026-02-22T18:24:41.1339161Z From http://192.168.125.15:4000/Moyettes/DiscordClone
2026-02-22T18:24:41.1339550Z * [new ref] fee34f02364d5b90ed3ecfbfb65db744e61cff35 -> origin/main
2026-02-22T18:24:41.1351696Z ::endgroup::
2026-02-22T18:24:41.1351803Z ::group::Determining the checkout info
2026-02-22T18:24:41.1351925Z ::endgroup::
2026-02-22T18:24:41.1359598Z [command]/usr/bin/git sparse-checkout disable
2026-02-22T18:24:41.1379913Z [command]/usr/bin/git config --local --unset-all extensions.worktreeConfig
2026-02-22T18:24:41.1393889Z ::group::Checking out the ref
2026-02-22T18:24:41.1395930Z [command]/usr/bin/git checkout --progress --force -B main refs/remotes/origin/main
2026-02-22T18:24:41.2311099Z Switched to a new branch 'main'
2026-02-22T18:24:41.2311372Z Branch 'main' set up to track remote branch 'main' from 'origin'.
2026-02-22T18:24:41.2317056Z ::endgroup::
2026-02-22T18:24:41.2344857Z [command]/usr/bin/git log -1 --format=%H
2026-02-22T18:24:41.2360982Z fee34f02364d5b90ed3ecfbfb65db744e61cff35
2026-02-22T18:24:41.2368952Z ::remove-matcher owner=checkout-git::
2026-02-22T18:24:41.6092721Z (node:169) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
2026-02-22T18:24:41.6092918Z (Use `node --trace-deprecation ...` to show where the warning was created)
2026-02-22T18:25:01.6665289Z ::warning::Failed to restore: getCacheEntry failed: Request timeout: /_apis/artifactcache/cache?keys=npm-electron-134bff4a9275926d53e3bf1904c23c6d759a3e9f2e4078e45534b44b35a3bf70%252Cnpm-electron-&version=f2531268ab9c19c75ce7b3eb23cc11c7f69fd3cf796834d4881591e430a373ff
2026-02-22T18:25:01.6666495Z Cache not found for input keys: npm-electron-134bff4a9275926d53e3bf1904c23c6d759a3e9f2e4078e45534b44b35a3bf70, npm-electron-
2026-02-22T18:25:04.7732694Z npm warn deprecated tar@6.2.1: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:04.8375071Z npm warn deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
2026-02-22T18:25:05.1190751Z npm warn deprecated npmlog@6.0.2: This package is no longer supported.
2026-02-22T18:25:05.3333621Z npm warn deprecated lodash.isequal@4.5.0: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
2026-02-22T18:25:05.4953489Z npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
2026-02-22T18:25:05.7402389Z npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:05.7562188Z npm warn deprecated gauge@4.0.4: This package is no longer supported.
2026-02-22T18:25:05.9472737Z npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
2026-02-22T18:25:05.9889854Z npm warn deprecated are-we-there-yet@3.0.1: This package is no longer supported.
2026-02-22T18:25:06.4312265Z npm warn deprecated @npmcli/move-file@2.0.1: This functionality has been moved to @npmcli/fs
2026-02-22T18:25:07.5674864Z npm warn deprecated source-map@0.8.0-beta.0: The work that was done in this beta branch won't be included in future versions
2026-02-22T18:25:07.6125546Z npm warn deprecated glob@11.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:07.6628274Z npm warn deprecated glob@9.3.5: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:07.6926905Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
2026-02-22T18:25:07.8119261Z npm warn deprecated glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:07.8308730Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
2026-02-22T18:25:07.8573021Z npm warn deprecated glob@8.1.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:07.8663811Z npm warn deprecated glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
2026-02-22T18:25:07.8757391Z npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
2026-02-22T18:25:10.1036978Z
2026-02-22T18:25:10.1037318Z added 923 packages, and audited 929 packages in 8s
2026-02-22T18:25:10.1037551Z
2026-02-22T18:25:10.1037625Z 259 packages are looking for funding
2026-02-22T18:25:10.1037816Z run `npm fund` for details
2026-02-22T18:25:10.1265827Z
2026-02-22T18:25:10.1266279Z 25 vulnerabilities (2 moderate, 23 high)
2026-02-22T18:25:10.1266395Z
2026-02-22T18:25:10.1266467Z To address issues that do not require attention, run:
2026-02-22T18:25:10.1266587Z npm audit fix
2026-02-22T18:25:10.1266656Z
2026-02-22T18:25:10.1266722Z To address all issues (including breaking changes), run:
2026-02-22T18:25:10.1266796Z npm audit fix --force
2026-02-22T18:25:10.1266867Z
2026-02-22T18:25:10.1266957Z Run `npm audit` for details.
2026-02-22T18:25:10.1464890Z --- app-builder diagnostics ---
2026-02-22T18:25:10.1679924Z -rwxr-xr-x 1 root root 18116608 Feb 22 18:25 node_modules/app-builder-bin/linux/x64/app-builder
2026-02-22T18:25:10.2788131Z not a dynamic executable
2026-02-22T18:25:10.3000273Z 3.5.10
2026-02-22T18:25:10.3431920Z 3.5.10
2026-02-22T18:25:10.5573080Z
2026-02-22T18:25:10.5573589Z > @***/electron@1.0.37 build
2026-02-22T18:25:10.5573668Z > vite build
2026-02-22T18:25:10.5573716Z
2026-02-22T18:25:10.7182083Z vite v7.3.1 building client environment for production...
2026-02-22T18:25:10.7514080Z transforming...
2026-02-22T18:25:11.1579107Z The glob option "as" has been deprecated in favour of "query". Please update `as: 'url'` to `query: '?url', import: 'default'`.
2026-02-22T18:25:11.1682512Z [plugin vite:esbuild] ../../packages/shared/src/components/ScreenShareModal.jsx: Duplicate key "width" in object literal
2026-02-22T18:25:11.1682810Z 104 | }}
2026-02-22T18:25:11.1682901Z 105 | >
2026-02-22T18:25:11.1682948Z 106 | ...le={{ position: 'relative', width: '100%', height: '250px', width: '450px', borderRadius: '8px', overflow: 'hidden...
2026-02-22T18:25:11.1683023Z | ^
2026-02-22T18:25:11.1683065Z 107 | {/* Thumbnail/Placeholder */}
2026-02-22T18:25:11.1683129Z 108 | {item.thumbnail ? (
2026-02-22T18:25:11.1683176Z 
2026-02-22T18:25:13.1140514Z ✓ 4668 modules transformed.
2026-02-22T18:25:13.6896511Z rendering chunks...
2026-02-22T18:25:13.7330742Z computing gzip size...
2026-02-22T18:25:13.8835603Z dist-react/index.html  0.63 kB │ gzip: 0.39 kB
2026-02-22T18:25:13.8835922Z dist-react/assets/woman_teacher-CXwESYU3.svg  4.10 kB │ gzip: 1.60 kB
2026-02-22T18:25:13.8836000Z dist-react/assets/woman_teacher_tone2-DWJ6rjnf.svg  4.10 kB │ gzip: 1.60 kB
2026-02-22T18:25:13.8836057Z dist-react/assets/woman_teacher_tone3-BvnMOsM7.svg  4.10 kB │ gzip: 1.60 kB
2026-02-22T18:25:13.8836204Z dist-react/assets/woman_teacher_tone4-C9bkU449.svg  4.10 kB │ gzip: 1.60 kB
2026-02-22T18:25:13.8836307Z dist-react/assets/bubble_tea-Cy1d5egt.svg  4.10 kB │ gzip: 1.92 kB
2026-02-22T18:25:13.8836358Z dist-react/assets/flag_gq-B3TFx5qI.svg  4.11 kB │ gzip: 1.68 kB
2026-02-22T18:25:13.8836416Z dist-react/assets/person_in_lotus_position_tone1-MK18jaNb.svg  4.11 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8836466Z dist-react/assets/person_in_lotus_position_tone2-Dzm_xqT2.svg  4.11 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8836517Z dist-react/assets/person_in_lotus_position_tone3-DUxbd8tQ.svg  4.11 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8836575Z dist-react/assets/person_in_lotus_position_tone5-CinKf4VL.svg  4.11 kB │ gzip: 1.72 kB
2026-02-22T18:25:13.8836623Z dist-react/assets/person_in_lotus_position-9VFgclqE.svg  4.11 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8836670Z dist-react/assets/person_in_lotus_position_tone4-BtjpxNGo.svg  4.11 kB │ gzip: 1.72 kB
2026-02-22T18:25:13.8836722Z dist-react/assets/man_feeding_baby_tone5-DOWKsCGX.svg  4.11 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8836777Z dist-react/assets/man_feeding_baby-BLUtslbF.svg  4.13 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8836875Z dist-react/assets/man_feeding_baby_tone1-lg4dBAV2.svg  4.13 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8836925Z dist-react/assets/man_feeding_baby_tone2-BuF25R9x.svg  4.13 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8836978Z dist-react/assets/man_feeding_baby_tone3-DEYxzqY-.svg  4.13 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8837026Z dist-react/assets/man_feeding_baby_tone4-IRS8MZPe.svg  4.13 kB │ gzip: 1.83 kB
2026-02-22T18:25:13.8837091Z dist-react/assets/woman_zombie-Cn4gQ0af.svg  4.15 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8837140Z dist-react/assets/hiking_boot-CPXD60gE.svg  4.17 kB │ gzip: 1.76 kB
2026-02-22T18:25:13.8837196Z dist-react/assets/japanese_ogre-BsqNvmIl.svg  4.22 kB │ gzip: 1.85 kB
2026-02-22T18:25:13.8837245Z dist-react/assets/woman_police_officer_tone5-CuQMgf5h.svg  4.23 kB │ gzip: 1.72 kB
2026-02-22T18:25:13.8837299Z dist-react/assets/woman_police_officer-D6jKOTyC.svg  4.25 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8837348Z dist-react/assets/woman_police_officer_tone1-A8sdhmvt.svg  4.25 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8837398Z dist-react/assets/woman_police_officer_tone2-DaHNn5-D.svg  4.25 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8837448Z dist-react/assets/woman_police_officer_tone3-DXZ2OdUI.svg  4.25 kB │ gzip: 1.73 kB
2026-02-22T18:25:13.8837497Z dist-react/assets/woman_police_officer_tone4--Oe8w2XD.svg  4.25 kB │ gzip: 1.72 kB
2026-02-22T18:25:13.8837559Z dist-react/assets/ferris_wheel-DvW0t9g3.svg  4.25 kB │ gzip: 1.19 kB
2026-02-22T18:25:13.8837608Z dist-react/assets/man_teacher_tone5-Bk9uZHaS.svg  4.26 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8837748Z dist-react/assets/man_teacher-BRypTuYs.svg  4.27 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8837808Z dist-react/assets/man_teacher_tone1-jNO2AiRD.svg  4.27 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8837872Z dist-react/assets/man_teacher_tone2-rQoIFfFz.svg  4.27 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8837924Z dist-react/assets/man_teacher_tone3-BEE8k6p5.svg  4.27 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8837982Z dist-react/assets/man_teacher_tone4-C4j4RIq3.svg  4.27 kB │ gzip: 1.74 kB
2026-02-22T18:25:13.8838043Z dist-react/assets/sari-BSRA0_R3.svg  4.27 kB │ gzip: 1.77 kB
2026-02-22T18:25:13.8838102Z dist-react/assets/gloves-BcY_RgAR.svg  4.31 kB │ gzip: 1.62 kB
2026-02-22T18:25:13.8838160Z dist-react/assets/moon_cake-BQr_VKRq.svg  4.31 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8838220Z dist-react/assets/man_zombie-D5T1AZ12.svg  4.34 kB │ gzip: 1.78 kB
2026-02-22T18:25:13.8838396Z dist-react/assets/woman_surfing_tone1-Mj4OdRWf.svg  4.35 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8838458Z dist-react/assets/woman_surfing-22i7hQgf.svg  4.38 kB │ gzip: 1.95 kB
2026-02-22T18:25:13.8838519Z dist-react/assets/woman_surfing_tone2-BcJfdMyX.svg  4.38 kB │ gzip: 1.95 kB
2026-02-22T18:25:13.8838578Z dist-react/assets/woman_surfing_tone3-D1NUtDA8.svg  4.38 kB │ gzip: 1.95 kB
2026-02-22T18:25:13.8838634Z dist-react/assets/woman_surfing_tone4-ByYdFJZp.svg  4.38 kB │ gzip: 1.95 kB
2026-02-22T18:25:13.8838702Z dist-react/assets/woman_surfing_tone5-CrN9a9WS.svg  4.38 kB │ gzip: 1.95 kB
2026-02-22T18:25:13.8838760Z dist-react/assets/person_with_probing_cane-DRcmbgmz.svg  4.38 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8838819Z dist-react/assets/person_with_probing_cane_tone1-zKjrapc7.svg  4.38 kB │ gzip: 1.90 kB
2026-02-22T18:25:13.8838880Z dist-react/assets/person_with_probing_cane_tone2-CY2wYkQb.svg  4.38 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8838937Z dist-react/assets/person_with_probing_cane_tone3-CdJAKQXv.svg  4.38 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8838994Z dist-react/assets/person_with_probing_cane_tone5-DFAMgo57.svg  4.38 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8839052Z dist-react/assets/person_with_probing_cane_tone4-DXYc5Dlc.svg  4.38 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8839110Z dist-react/assets/skier-BTSq18N5.svg  4.39 kB │ gzip: 1.75 kB
2026-02-22T18:25:13.8839169Z dist-react/assets/woman_singer_tone5-Co-5wXNK.svg  4.40 kB │ gzip: 1.84 kB
2026-02-22T18:25:13.8839229Z dist-react/assets/woman_singer_tone1-DbW2lM_k.svg  4.42 kB │ gzip: 1.85 kB
2026-02-22T18:25:13.8839286Z dist-react/assets/man_with_probing_cane-BarJlRlV.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839342Z dist-react/assets/man_with_probing_cane_tone1-D50RTI5B.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839403Z dist-react/assets/man_with_probing_cane_tone2-CIeZuoUa.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839467Z dist-react/assets/man_with_probing_cane_tone3-GUZ14VpU.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839527Z dist-react/assets/man_with_probing_cane_tone4-Bd6iA8-d.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839586Z dist-react/assets/man_with_probing_cane_tone5-BCpRNGU4.svg  4.45 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8839644Z dist-react/assets/woman_singer-skPaDBsj.svg  4.45 kB │ gzip: 1.84 kB
2026-02-22T18:25:13.8839706Z dist-react/assets/woman_singer_tone2-Bc-xqa4S.svg  4.45 kB │ gzip: 1.85 kB
2026-02-22T18:25:13.8839763Z dist-react/assets/woman_singer_tone3-CNRJeino.svg  4.45 kB │ gzip: 1.85 kB
2026-02-22T18:25:13.8839836Z dist-react/assets/woman_singer_tone4-CEfcWjkD.svg  4.45 kB │ gzip: 1.84 kB
2026-02-22T18:25:13.8839896Z dist-react/assets/flag_sz-CsAySmAn.svg  4.46 kB │ gzip: 1.66 kB
2026-02-22T18:25:13.8839958Z dist-react/assets/snowman2-CeWFCRvE.svg  4.48 kB │ gzip: 1.15 kB
2026-02-22T18:25:13.8840013Z dist-react/assets/man_surfing-fqnQ3hm1.svg  4.49 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8840070Z dist-react/assets/man_surfing_tone2-CDUKGUjg.svg  4.49 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8840123Z dist-react/assets/man_surfing_tone1-BeC3CjNB.svg  4.49 kB │ gzip: 1.96 kB
2026-02-22T18:25:13.8840184Z dist-react/assets/man_surfing_tone3-Dt-HUBR5.svg  4.49 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8840241Z dist-react/assets/man_surfing_tone4-PI8ASA2j.svg  4.49 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8840302Z dist-react/assets/man_surfing_tone5-CyvTB2HT.svg  4.49 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8840359Z dist-react/assets/motorized_wheelchair-DYMoavTH.svg  4.50 kB │ gzip: 1.63 kB
2026-02-22T18:25:13.8840422Z dist-react/assets/woman_feeding_baby_tone5-B6CmkSrw.svg  4.54 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840597Z dist-react/assets/flag_ht-nORDdDQL.svg  4.54 kB │ gzip: 1.88 kB
2026-02-22T18:25:13.8840655Z dist-react/assets/woman_feeding_baby-p-8aPRtV.svg  4.56 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840706Z dist-react/assets/woman_feeding_baby_tone1-BezQI8D_.svg  4.56 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840763Z dist-react/assets/woman_feeding_baby_tone2-CwnQLRQK.svg  4.56 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840826Z dist-react/assets/woman_feeding_baby_tone3-DlgTa1f-.svg  4.56 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840885Z dist-react/assets/woman_feeding_baby_tone4-DVay5Top.svg  4.56 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8840943Z dist-react/assets/flag_ad-CYOJPtjR.svg  4.59 kB │ gzip: 1.79 kB
2026-02-22T18:25:13.8840999Z dist-react/assets/woman_with_probing_cane-DyJEphms.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841059Z dist-react/assets/woman_with_probing_cane_tone2-GEDDmrTN.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841160Z dist-react/assets/woman_with_probing_cane_tone1-DuB7wHWP.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841219Z dist-react/assets/woman_with_probing_cane_tone3-CeM4gv4f.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841281Z dist-react/assets/woman_with_probing_cane_tone4-BaEnBpNy.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841337Z dist-react/assets/woman_with_probing_cane_tone5-DimRSWot.svg  4.60 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8841394Z dist-react/assets/person_surfing_tone1-B_kCGm1b.svg  4.62 kB │ gzip: 2.10 kB
2026-02-22T18:25:13.8841445Z dist-react/assets/person_surfing-DEOz-TJs.svg  4.62 kB │ gzip: 2.10 kB
2026-02-22T18:25:13.8841505Z dist-react/assets/person_surfing_tone2-D35jmuhN.svg  4.62 kB │ gzip: 2.11 kB
2026-02-22T18:25:13.8841565Z dist-react/assets/person_surfing_tone3-C60a5Aj1.svg  4.62 kB │ gzip: 2.10 kB
2026-02-22T18:25:13.8841624Z dist-react/assets/person_surfing_tone5-CXXi5x8s.svg  4.62 kB │ gzip: 2.10 kB
2026-02-22T18:25:13.8841671Z dist-react/assets/person_surfing_tone4-nIpCJUpE.svg  4.62 kB │ gzip: 2.10 kB
2026-02-22T18:25:13.8841725Z dist-react/assets/flag_tc-Dn_lC0KY.svg  4.72 kB │ gzip: 1.94 kB
2026-02-22T18:25:13.8841772Z dist-react/assets/person_golfing_tone2-BbPQ5nEE.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8841819Z dist-react/assets/person_golfing-Mc5PuEC7.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8841865Z dist-react/assets/person_golfing_tone4-DPEV2yNK.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8841912Z dist-react/assets/person_golfing_tone1-DhUQwNf7.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8841967Z dist-react/assets/person_golfing_tone5-Bgq3Ud_d.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8842017Z dist-react/assets/person_golfing_tone3-CKRqu9yJ.svg  4.72 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8842067Z dist-react/assets/flag_ki-Ccc3Xi24.svg  4.77 kB │ gzip: 1.66 kB
2026-02-22T18:25:13.8842116Z dist-react/assets/flag_mo-PAf1BQIO.svg  4.77 kB │ gzip: 2.24 kB
2026-02-22T18:25:13.8842162Z dist-react/assets/flag_sh-CT89bJZi.svg  4.80 kB │ gzip: 1.67 kB
2026-02-22T18:25:13.8842218Z dist-react/assets/crab-D6qU1zIW.svg  4.87 kB │ gzip: 1.97 kB
2026-02-22T18:25:13.8842274Z dist-react/assets/couple-KSrP6fk0.svg  4.90 kB │ gzip: 2.04 kB
2026-02-22T18:25:13.8842333Z dist-react/assets/kiwi-BWXr7Vjo.svg  4.91 kB │ gzip: 2.27 kB
2026-02-22T18:25:13.8842390Z dist-react/assets/hedgehog-CMNxZzfp.svg  4.92 kB │ gzip: 1.82 kB
2026-02-22T18:25:13.8842453Z dist-react/assets/money_with_wings-BnGoAiwj.svg  4.95 kB │ gzip: 2.07 kB
2026-02-22T18:25:13.8842510Z dist-react/assets/flag_kg-D_P2G_Do.svg  4.98 kB │ gzip: 2.14 kB
2026-02-22T18:25:13.8842557Z dist-react/assets/maracas-kQiWhg0J.svg  4.99 kB │ gzip: 1.76 kB
2026-02-22T18:25:13.8842615Z dist-react/assets/x_ray-CWrdKTDm.svg  4.99 kB │ gzip: 2.37 kB
2026-02-22T18:25:13.8842675Z dist-react/assets/people_holding_hands-BRZihiu5.svg  5.03 kB │ gzip: 1.68 kB
2026-02-22T18:25:13.8842736Z dist-react/assets/butterfly-AxzpD-Pg.svg  5.04 kB │ gzip: 1.86 kB
2026-02-22T18:25:13.8842794Z dist-react/assets/flag_xk-D1vfCqOd.svg  5.08 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8842848Z dist-react/assets/flag_tm-_4vioey7.svg  5.13 kB │ gzip: 1.54 kB
2026-02-22T18:25:13.8842902Z dist-react/assets/two_men_holding_hands-BKJxHZb_.svg  5.15 kB │ gzip: 1.50 kB
2026-02-22T18:25:13.8842960Z dist-react/assets/seal-Djs0F0U5.svg  5.16 kB │ gzip: 2.18 kB
2026-02-22T18:25:13.8843016Z dist-react/assets/speak_no_evil-EoRZCJhS.svg  5.20 kB │ gzip: 2.27 kB
2026-02-22T18:25:13.8843136Z dist-react/assets/man_dancing_tone1-XI7g5maV.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843196Z dist-react/assets/man_dancing-Dg-6O6t7.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843251Z dist-react/assets/man_dancing_tone2-DBvANdsI.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843309Z dist-react/assets/man_dancing_tone3-BK7ka3J3.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843459Z dist-react/assets/man_dancing_tone5-CQh9niVO.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843516Z dist-react/assets/man_dancing_tone4-D9FZRxV5.svg  5.22 kB │ gzip: 2.00 kB
2026-02-22T18:25:13.8843572Z dist-react/assets/flag_pf-OA_PTTaZ.svg  5.22 kB │ gzip: 1.81 kB
2026-02-22T18:25:13.8843628Z dist-react/assets/pie-DZ6nmSau.svg  5.24 kB │ gzip: 1.98 kB
2026-02-22T18:25:13.8843685Z dist-react/assets/two_women_holding_hands-CnKtKnaZ.svg  5.29 kB │ gzip: 1.71 kB
2026-02-22T18:25:13.8843734Z dist-react/assets/brain-Czvux5Q4.svg  5.32 kB │ gzip: 2.48 kB
2026-02-22T18:25:13.8843779Z dist-react/assets/lacrosse-DK95k1kF.svg  5.33 kB │ gzip: 2.22 kB
2026-02-22T18:25:13.8843833Z dist-react/assets/see_no_evil-DnuksPIR.svg  5.42 kB │ gzip: 2.38 kB
2026-02-22T18:25:13.8843887Z dist-react/assets/flag_vi-vzZjsoBi.svg  5.43 kB │ gzip: 2.34 kB
2026-02-22T18:25:13.8843950Z dist-react/assets/dodo-CoZFlciJ.svg  5.44 kB │ gzip: 2.31 kB
2026-02-22T18:25:13.8844004Z dist-react/assets/flag_hk-CzNuCBPg.svg  5.46 kB │ gzip: 2.52 kB
2026-02-22T18:25:13.8844061Z dist-react/assets/spider_web-DPcv-q20.svg  5.50 kB │ gzip: 2.43 kB
2026-02-22T18:25:13.8844115Z dist-react/assets/flag_bl-BoaeaHPp.svg  5.58 kB │ gzip: 1.90 kB
2026-02-22T18:25:13.8844173Z dist-react/assets/flag_as-B43i20pO.svg  5.61 kB │ gzip: 2.39 kB
2026-02-22T18:25:13.8844229Z dist-react/assets/flag_gp-DW1UVBGw.svg  5.63 kB │ gzip: 2.05 kB
2026-02-22T18:25:13.8844293Z dist-react/assets/flag_ic-BrB5Xakj.svg  5.63 kB │ gzip: 2.24 kB
2026-02-22T18:25:13.8844348Z dist-react/assets/man_golfing-DhSLN6KQ.svg  5.64 kB │ gzip: 2.61 kB
2026-02-22T18:25:13.8844404Z dist-react/assets/man_golfing_tone1-DBE1f7b1.svg  5.64 kB │ gzip: 2.61 kB
2026-02-22T18:25:13.8844461Z dist-react/assets/man_golfing_tone2-CNmTGsfk.svg  5.64 kB │ gzip: 2.61 kB
2026-02-22T18:25:13.8844515Z dist-react/assets/man_golfing_tone3-vxj0o6sI.svg  5.64 kB │ gzip: 2.61 kB
2026-02-22T18:25:13.8844579Z dist-react/assets/man_golfing_tone4-CsGYmisz.svg  5.64 kB │ gzip: 2.61 kB
2026-02-22T18:25:13.8844633Z dist-react/assets/man_golfing_tone5-Cf_z4uyg.svg  5.64 kB │ gzip: 2.60 kB
2026-02-22T18:25:13.8844694Z dist-react/assets/flag_bm-CzSakp_Z.svg  5.67 kB │ gzip: 2.37 kB
2026-02-22T18:25:13.8844760Z dist-react/assets/map-BGXvLkiw.svg  5.68 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8844814Z dist-react/assets/people_wrestling-DjCLlDDS.svg  5.71 kB │ gzip: 2.48 kB
2026-02-22T18:25:13.8844869Z dist-react/assets/tamale-2biJGrAo.svg  5.74 kB │ gzip: 2.53 kB
2026-02-22T18:25:13.8844929Z dist-react/assets/men_wrestling-BNuLmHCV.svg  5.74 kB │ gzip: 2.47 kB
2026-02-22T18:25:13.8844987Z dist-react/assets/empty_nest-DGy7reBo.svg  5.79 kB │ gzip: 2.71 kB
2026-02-22T18:25:13.8845041Z dist-react/assets/worm-CxRJMG1n.svg  5.86 kB │ gzip: 2.77 kB
2026-02-22T18:25:13.8845099Z dist-react/assets/flag_bo-B7hNQ755.svg  5.91 kB │ gzip: 2.22 kB
2026-02-22T18:25:13.8845146Z dist-react/assets/face_in_clouds-DBzCKo8S.svg  5.94 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8845195Z dist-react/assets/women_wrestling-CARP3ZvF.svg  5.96 kB │ gzip: 2.59 kB
2026-02-22T18:25:13.8845248Z dist-react/assets/man_lifting_weights_tone5-cnCH-jDP.svg  6.00 kB │ gzip: 2.06 kB
2026-02-22T18:25:13.8845300Z dist-react/assets/man_lifting_weights_tone1-DGilOf2d.svg  6.03 kB │ gzip: 2.06 kB
2026-02-22T18:25:13.8845350Z dist-react/assets/man_lifting_weights-DkiBT0IO.svg  6.03 kB │ gzip: 2.07 kB
2026-02-22T18:25:13.8845398Z dist-react/assets/man_lifting_weights_tone3-DhF3q93u.svg  6.03 kB │ gzip: 2.07 kB
2026-02-22T18:25:13.8845444Z dist-react/assets/man_lifting_weights_tone4-MLQqpJKZ.svg  6.03 kB │ gzip: 2.07 kB
2026-02-22T18:25:13.8845494Z dist-react/assets/man_lifting_weights_tone2-CXUv2fBp.svg  6.03 kB │ gzip: 2.07 kB
2026-02-22T18:25:13.8845541Z dist-react/assets/flag_fj-B2-D6gPQ.svg  6.04 kB │ gzip: 2.64 kB
2026-02-22T18:25:13.8845589Z dist-react/assets/flag_pn-Bde7vecB.svg  6.05 kB │ gzip: 2.68 kB
2026-02-22T18:25:13.8845637Z dist-react/assets/flag_bt-COHVTZ6I.svg  6.06 kB │ gzip: 2.52 kB
2026-02-22T18:25:13.8845691Z dist-react/assets/person_doing_cartwheel-B6e7BEW_.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8845742Z dist-react/assets/person_doing_cartwheel_tone1-TBt_b-Oj.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8845798Z dist-react/assets/person_doing_cartwheel_tone3-BzmNF0vv.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8845846Z dist-react/assets/person_doing_cartwheel_tone2-BR4ztGzg.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8845900Z dist-react/assets/person_doing_cartwheel_tone4-j074vq-9.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8845955Z dist-react/assets/person_doing_cartwheel_tone5-BzNEt2oA.svg  6.07 kB │ gzip: 2.65 kB
2026-02-22T18:25:13.8846003Z dist-react/assets/accordion-BPueGNgN.svg  6.07 kB │ gzip: 1.20 kB
2026-02-22T18:25:13.8846058Z dist-react/assets/lobster-Cfls8jg_.svg  6.07 kB │ gzip: 2.32 kB
2026-02-22T18:25:13.8846111Z dist-react/assets/volcano-Bh_Lqk9r.svg  6.14 kB │ gzip: 2.70 kB
2026-02-22T18:25:13.8846274Z dist-react/assets/man_cartwheeling-NFQt9ZB9.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846330Z dist-react/assets/man_cartwheeling_tone1-B3S_eUE1.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846395Z dist-react/assets/man_cartwheeling_tone2-CYBBI2iM.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846444Z dist-react/assets/man_cartwheeling_tone4-B96D58fZ.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846491Z dist-react/assets/man_cartwheeling_tone5-PFLWmq7Q.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846542Z dist-react/assets/man_cartwheeling_tone3-D2kqEChS.svg  6.17 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8846589Z dist-react/assets/flag_lb-DHr4ylgr.svg  6.19 kB │ gzip: 2.75 kB
2026-02-22T18:25:13.8846638Z dist-react/assets/man_playing_handball-C_yN7fGQ.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846687Z dist-react/assets/man_playing_handball_tone1-22QBgB92.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846735Z dist-react/assets/man_playing_handball_tone2-Bs8PtV12.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846797Z dist-react/assets/man_playing_handball_tone3-q-BDso_I.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846855Z dist-react/assets/man_playing_handball_tone4-BUH96fLA.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846912Z dist-react/assets/man_playing_handball_tone5-DK-UJ5SH.svg  6.21 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8846959Z dist-react/assets/flag_fk-1KKBtSFw.svg  6.22 kB │ gzip: 2.54 kB
2026-02-22T18:25:13.8847013Z dist-react/assets/flag_rs-CmpxaRIS.svg  6.23 kB │ gzip: 2.35 kB
2026-02-22T18:25:13.8847062Z dist-react/assets/cucumber-oVkPYVB9.svg  6.24 kB │ gzip: 2.01 kB
2026-02-22T18:25:13.8847118Z dist-react/assets/woman_lifting_weights_tone5-BJQrRdVE.svg  6.25 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8847174Z dist-react/assets/woman_lifting_weights-CsixMYFL.svg  6.28 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8847225Z dist-react/assets/woman_lifting_weights_tone1-BpRsBk7z.svg  6.28 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8847288Z dist-react/assets/woman_lifting_weights_tone3-C0gnGp49.svg  6.28 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8847335Z dist-react/assets/woman_lifting_weights_tone4-CQZmiYUl.svg  6.28 kB │ gzip: 2.16 kB
2026-02-22T18:25:13.8847390Z dist-react/assets/woman_lifting_weights_tone2-P18Nfbuz.svg  6.28 kB │ gzip: 2.15 kB
2026-02-22T18:25:13.8847438Z dist-react/assets/person_lifting_weights_tone5-DEciUSJH.svg  6.29 kB │ gzip: 2.20 kB
2026-02-22T18:25:13.8847494Z dist-react/assets/person_lifting_weights_tone1-CXfKAA0L.svg  6.30 kB │ gzip: 2.19 kB
2026-02-22T18:25:13.8847542Z dist-react/assets/person_lifting_weights-Cn0dQ6qY.svg  6.30 kB │ gzip: 2.20 kB
2026-02-22T18:25:13.8847592Z dist-react/assets/person_lifting_weights_tone2-Dkw3-09P.svg  6.30 kB │ gzip: 2.20 kB
2026-02-22T18:25:13.8847639Z dist-react/assets/person_lifting_weights_tone3-3OqiHF7e.svg  6.30 kB │ gzip: 2.20 kB
2026-02-22T18:25:13.8847715Z dist-react/assets/person_lifting_weights_tone4-C62SuN24.svg  6.30 kB │ gzip: 2.20 kB
2026-02-22T18:25:13.8847762Z dist-react/assets/women_with_bunny_ears_partying-CKr9TLic.svg  6.30 kB │ gzip: 1.68 kB
2026-02-22T18:25:13.8847821Z dist-react/assets/flag_sm-BYO1ASeM.svg  6.31 kB │ gzip: 2.30 kB
2026-02-22T18:25:13.8847870Z dist-react/assets/coat-Cbu3wnI6.svg  6.35 kB │ gzip: 2.52 kB
2026-02-22T18:25:13.8847927Z dist-react/assets/woman_cartwheeling_tone1-fJFXi2hD.svg  6.37 kB │ gzip: 2.78 kB
2026-02-22T18:25:13.8847975Z dist-react/assets/woman_cartwheeling-tGvm940R.svg  6.37 kB │ gzip: 2.78 kB
2026-02-22T18:25:13.8848032Z dist-react/assets/woman_cartwheeling_tone2-C5lE2K9g.svg  6.37 kB │ gzip: 2.77 kB
2026-02-22T18:25:13.8848082Z dist-react/assets/woman_cartwheeling_tone4-CjyM2w54.svg  6.37 kB │ gzip: 2.77 kB
2026-02-22T18:25:13.8848138Z dist-react/assets/woman_cartwheeling_tone5-D-eW47Ua.svg  6.37 kB │ gzip: 2.77 kB
2026-02-22T18:25:13.8848186Z dist-react/assets/woman_cartwheeling_tone3-BourpL3A.svg  6.37 kB │ gzip: 2.77 kB
2026-02-22T18:25:13.8848232Z dist-react/assets/man_running_tone1-BbRoQah0.svg  6.37 kB │ gzip: 2.89 kB
2026-02-22T18:25:13.8848294Z dist-react/assets/man_running-Bp7fZpx0.svg  6.37 kB │ gzip: 2.89 kB
2026-02-22T18:25:13.8848342Z dist-react/assets/man_running_tone2-gBe1A9EP.svg  6.37 kB │ gzip: 2.89 kB
2026-02-22T18:25:13.8848397Z dist-react/assets/man_running_tone3-DfAx9qZO.svg  6.37 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8848444Z dist-react/assets/man_running_tone5-Do-aIXEX.svg  6.37 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8848499Z dist-react/assets/man_running_tone4-CeeXJkX_.svg  6.37 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8848553Z dist-react/assets/woman_playing_handball-fiyPmBDz.svg  6.42 kB │ gzip: 2.73 kB
2026-02-22T18:25:13.8848614Z dist-react/assets/woman_playing_handball_tone2-BtTxnxhZ.svg  6.42 kB │ gzip: 2.73 kB
2026-02-22T18:25:13.8848668Z dist-react/assets/woman_playing_handball_tone1-B_P42W0r.svg  6.42 kB │ gzip: 2.73 kB
2026-02-22T18:25:13.8848723Z dist-react/assets/woman_playing_handball_tone3-C7TXAAWV.svg  6.42 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8848773Z dist-react/assets/woman_playing_handball_tone4-CtCwRGCv.svg  6.42 kB │ gzip: 2.73 kB
2026-02-22T18:25:13.8848830Z dist-react/assets/woman_playing_handball_tone5-CmZlugee.svg  6.42 kB │ gzip: 2.73 kB
2026-02-22T18:25:13.8848881Z dist-react/assets/microbe-DHWlm4x3.svg  6.48 kB │ gzip: 2.82 kB
2026-02-22T18:25:13.8849001Z dist-react/assets/horse_racing-Cd5KXigQ.svg  6.50 kB │ gzip: 2.87 kB
2026-02-22T18:25:13.8849061Z dist-react/assets/horse_racing_tone1-BPFu29EM.svg  6.50 kB │ gzip: 2.87 kB
2026-02-22T18:25:13.8849112Z dist-react/assets/horse_racing_tone2-kHM6lt0G.svg  6.50 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8849172Z dist-react/assets/horse_racing_tone5-DoKtvypB.svg  6.50 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8849222Z dist-react/assets/horse_racing_tone3-1prjoMK9.svg  6.50 kB │ gzip: 2.88 kB
2026-02-22T18:25:13.8849277Z dist-react/assets/horse_racing_tone4-DZVx5-VD.svg  6.50 kB │ gzip: 2.87 kB
2026-02-22T18:25:13.8849323Z dist-react/assets/person_in_manual_wheelchair-B2ofcHYu.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849390Z dist-react/assets/person_in_manual_wheelchair_tone1-BrR0l2XR.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849441Z dist-react/assets/person_in_manual_wheelchair_tone2-DmJ1Zffk.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849623Z dist-react/assets/person_in_manual_wheelchair_tone3-Bt_5AaRy.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849673Z dist-react/assets/person_in_manual_wheelchair_tone4-TZTDWyKD.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849732Z dist-react/assets/person_in_manual_wheelchair_tone5-DrOKlCDl.svg  6.50 kB │ gzip: 2.57 kB
2026-02-22T18:25:13.8849788Z dist-react/assets/burrito-B4L0kbwK.svg  6.52 kB │ gzip: 2.58 kB
2026-02-22T18:25:13.8849844Z dist-react/assets/person_running-DNDUEkxU.svg  6.52 kB │ gzip: 2.94 kB
2026-02-22T18:25:13.8849906Z dist-react/assets/person_running_tone1-B8sLRwke.svg  6.52 kB │ gzip: 2.94 kB
2026-02-22T18:25:13.8849954Z dist-react/assets/person_running_tone2-DNzEDUb0.svg  6.52 kB │ gzip: 2.94 kB
2026-02-22T18:25:13.8850011Z dist-react/assets/person_running_tone3-Dist2leS.svg  6.52 kB │ gzip: 2.94 kB
2026-02-22T18:25:13.8850058Z dist-react/assets/person_running_tone4-DVBWC3-p.svg  6.52 kB │ gzip: 2.94 kB
2026-02-22T18:25:13.8850113Z dist-react/assets/person_running_tone5-DEOJVy8u.svg  6.52 kB │ gzip: 2.93 kB
2026-02-22T18:25:13.8850159Z dist-react/assets/man_in_manual_wheelchair_tone1-Da2hybrT.svg  6.57 kB │ gzip: 2.62 kB
2026-02-22T18:25:13.8850219Z dist-react/assets/man_in_manual_wheelchair-cGfKOLRc.svg  6.60 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8850274Z dist-react/assets/man_in_manual_wheelchair_tone2-BPBmkRcs.svg  6.60 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8850331Z dist-react/assets/man_in_manual_wheelchair_tone3-H5kpv3q_.svg  6.60 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8850380Z dist-react/assets/man_in_manual_wheelchair_tone4-BvKWPBcq.svg  6.60 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8850438Z dist-react/assets/man_in_manual_wheelchair_tone5-YZQTD5Nr.svg  6.60 kB │ gzip: 2.63 kB
2026-02-22T18:25:13.8850493Z dist-react/assets/person_playing_handball_tone1-CbOONp_g.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850554Z dist-react/assets/person_playing_handball_tone2-jeC51_-P.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850616Z dist-react/assets/person_playing_handball-CH3hWpQR.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850671Z dist-react/assets/person_playing_handball_tone5-D_rmeJiN.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850727Z dist-react/assets/person_playing_handball_tone4-BsA09Avm.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850783Z dist-react/assets/person_playing_handball_tone3-BGgWTsuS.svg  6.62 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8850832Z dist-react/assets/men_with_bunny_ears_partying-DabknRQ1.svg  6.64 kB │ gzip: 1.78 kB
2026-02-22T18:25:13.8850889Z dist-react/assets/man_bouncing_ball_tone1-BrCW39oq.svg  6.67 kB │ gzip: 3.05 kB
2026-02-22T18:25:13.8850946Z dist-react/assets/man_bouncing_ball-BCtAjpGP.svg  6.67 kB │ gzip: 3.04 kB
2026-02-22T18:25:13.8851051Z dist-react/assets/man_bouncing_ball_tone2-pU3f7Oqo.svg  6.67 kB │ gzip: 3.05 kB
2026-02-22T18:25:13.8851110Z dist-react/assets/man_bouncing_ball_tone4-BonEB_V5.svg  6.67 kB │ gzip: 3.05 kB
2026-02-22T18:25:13.8851162Z dist-react/assets/man_bouncing_ball_tone3-CMYhYDFZ.svg  6.67 kB │ gzip: 3.05 kB
2026-02-22T18:25:13.8851219Z dist-react/assets/man_bouncing_ball_tone5-mVU7qtFm.svg  6.67 kB │ gzip: 3.05 kB
2026-02-22T18:25:13.8851266Z dist-react/assets/woman_running-_mwbLWM0.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851326Z dist-react/assets/woman_running_tone1-Dfqdg043.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851374Z dist-react/assets/woman_running_tone2-rXRqTMa0.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851430Z dist-react/assets/woman_running_tone3-BmRDPwCM.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851478Z dist-react/assets/woman_running_tone4-DmFzAsxD.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851533Z dist-react/assets/woman_running_tone5-C66GYSAh.svg  6.74 kB │ gzip: 3.03 kB
2026-02-22T18:25:13.8851584Z dist-react/assets/woman_in_manual_wheelchair-Ba72kfnU.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851640Z dist-react/assets/woman_in_manual_wheelchair_tone1-Ce9x88Rf.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851689Z dist-react/assets/woman_in_manual_wheelchair_tone2-CAKIPnJE.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851749Z dist-react/assets/woman_in_manual_wheelchair_tone3-D4YsEoBp.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851797Z dist-react/assets/woman_in_manual_wheelchair_tone4-BD3k04p2.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851857Z dist-react/assets/woman_in_manual_wheelchair_tone5-BmBeJ4-f.svg  6.75 kB │ gzip: 2.66 kB
2026-02-22T18:25:13.8851904Z dist-react/assets/person_in_motorized_wheelchair-DxhhvjYe.svg  6.80 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8851964Z dist-react/assets/person_in_motorized_wheelchair_tone1-Dcta4qUb.svg  6.80 kB │ gzip: 2.68 kB
2026-02-22T18:25:13.8852017Z dist-react/assets/person_in_motorized_wheelchair_tone2-C8UQYonN.svg  6.80 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8852077Z dist-react/assets/person_in_motorized_wheelchair_tone3-BRD_Obbg.svg  6.80 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8852134Z dist-react/assets/person_in_motorized_wheelchair_tone4-DLSO0rlF.svg  6.80 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8852190Z dist-react/assets/person_in_motorized_wheelchair_tone5-SnULyxgF.svg  6.80 kB │ gzip: 2.69 kB
2026-02-22T18:25:13.8852236Z dist-react/assets/person_bouncing_ball_tone1-BIhBY2_P.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852293Z dist-react/assets/person_bouncing_ball_tone2-9V5mlEG0.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852349Z dist-react/assets/person_bouncing_ball-H1IsbPT2.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852409Z dist-react/assets/person_bouncing_ball_tone3-DSpJYpZ1.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852458Z dist-react/assets/person_bouncing_ball_tone4-BycyNnMy.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852513Z dist-react/assets/person_bouncing_ball_tone5-C9pS5gcg.svg  6.82 kB │ gzip: 3.10 kB
2026-02-22T18:25:13.8852560Z dist-react/assets/man_in_motorized_wheelchair_tone1-B-J_H3TB.svg  6.85 kB │ gzip: 2.71 kB
2026-02-22T18:25:13.8852618Z dist-react/assets/flag_ms-BKjfidu-.svg  6.86 kB │ gzip: 3.00 kB
2026-02-22T18:25:13.8852673Z dist-react/assets/man_in_motorized_wheelchair-CiMQlH-Z.svg  6.87 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8852729Z dist-react/assets/man_in_motorized_wheelchair_tone2-DQy0C3Cx.svg  6.87 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8852784Z dist-react/assets/man_in_motorized_wheelchair_tone4-CoEn9n-F.svg  6.87 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8852844Z dist-react/assets/man_in_motorized_wheelchair_tone3-DuduwQoe.svg  6.87 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8852893Z dist-react/assets/man_in_motorized_wheelchair_tone5-CgvQDAuT.svg  6.87 kB │ gzip: 2.72 kB
2026-02-22T18:25:13.8852950Z dist-react/assets/flag_ky-E8sT-Yzf.svg  6.99 kB │ gzip: 2.92 kB
2026-02-22T18:25:13.8852998Z dist-react/assets/anatomical_heart-DbQDqK_8.svg  7.00 kB │ gzip: 3.14 kB
2026-02-22T18:25:13.8853057Z dist-react/assets/wales-ll0ySOk-.svg  7.01 kB │ gzip: 2.89 kB
2026-02-22T18:25:13.8853107Z dist-react/assets/woman_in_motorized_wheelchair-CIaEP3y5.svg  7.03 kB │ gzip: 2.79 kB
2026-02-22T18:25:13.8853171Z dist-react/assets/woman_in_motorized_wheelchair_tone2-uhLYilhF.svg  7.03 kB │ gzip: 2.79 kB
2026-02-22T18:25:13.8853221Z dist-react/assets/woman_in_motorized_wheelchair_tone1-1BibIgKr.svg  7.03 kB │ gzip: 2.78 kB
2026-02-22T18:25:13.8853278Z dist-react/assets/woman_in_motorized_wheelchair_tone3-B51r71l0.svg  7.03 kB │ gzip: 2.79 kB
2026-02-22T18:25:13.8853345Z dist-react/assets/woman_in_motorized_wheelchair_tone4-oIvpxZcp.svg  7.03 kB │ gzip: 2.79 kB
2026-02-22T18:25:13.8853404Z dist-react/assets/woman_in_motorized_wheelchair_tone5-_fFN26h0.svg  7.03 kB │ gzip: 2.79 kB
2026-02-22T18:25:13.8853451Z dist-react/assets/woman_bouncing_ball-B4V8jGG-.svg  7.09 kB │ gzip: 3.20 kB
2026-02-22T18:25:13.8853513Z dist-react/assets/woman_bouncing_ball_tone2-BPyPsinZ.svg  7.09 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8853661Z dist-react/assets/woman_bouncing_ball_tone3-UqVs8gxM.svg  7.09 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8853719Z dist-react/assets/woman_bouncing_ball_tone1-I7gUQpbX.svg  7.09 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8853778Z dist-react/assets/woman_bouncing_ball_tone4-CtQI59zT.svg  7.09 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8853832Z dist-react/assets/woman_bouncing_ball_tone5-BgHu12i2.svg  7.09 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8853879Z dist-react/assets/flag_va-BB2uDrB0.svg  7.21 kB │ gzip: 2.49 kB
2026-02-22T18:25:13.8853934Z dist-react/assets/mammoth-Diaisynz.svg  7.29 kB │ gzip: 3.06 kB
2026-02-22T18:25:13.8853989Z dist-react/assets/nest_with_eggs-C5ulh3Rz.svg  7.36 kB │ gzip: 3.39 kB
2026-02-22T18:25:13.8854036Z dist-react/assets/phoenix-QKXqSCuH.svg  7.57 kB │ gzip: 2.91 kB
2026-02-22T18:25:13.8854092Z dist-react/assets/flag_cy-JKjUtxO9.svg  7.60 kB │ gzip: 3.32 kB
2026-02-22T18:25:13.8854141Z dist-react/assets/people_with_bunny_ears_partying-BVR6SBwD.svg  7.63 kB │ gzip: 1.93 kB
2026-02-22T18:25:13.8854198Z dist-react/assets/flag_gu-CyZZwWUz.svg  7.68 kB │ gzip: 3.24 kB
2026-02-22T18:25:13.8854257Z dist-react/assets/t_rex-BYG-fgI4.svg  8.00 kB │ gzip: 3.35 kB
2026-02-22T18:25:13.8854315Z dist-react/assets/flag_vg-DWuAWiyw.svg  8.21 kB │ gzip: 1.91 kB
2026-02-22T18:25:13.8854377Z dist-react/assets/flag_yt-BfOxXbO5.svg  8.42 kB │ gzip: 2.95 kB
2026-02-22T18:25:13.8854431Z dist-react/assets/piñata-CQK6iMPe.svg  8.47 kB │ gzip: 3.21 kB
2026-02-22T18:25:13.8854492Z dist-react/assets/mirror_ball-R_criUm_.svg  8.55 kB │ gzip: 3.31 kB
2026-02-22T18:25:13.8854549Z dist-react/assets/ping-LfakLpwb.mp3  8.58 kB
2026-02-22T18:25:13.8854610Z dist-react/assets/flag_gs-DhFNtBGF.svg  8.86 kB │ gzip: 3.67 kB
2026-02-22T18:25:13.8854679Z dist-react/assets/knot-CpRGiIMe.svg  8.92 kB │ gzip: 3.89 kB
2026-02-22T18:25:13.8854736Z dist-react/assets/flag_dg-DwJEN7pv.svg  9.06 kB │ gzip: 2.87 kB
2026-02-22T18:25:13.8854787Z dist-react/assets/flag_gt-CietPgvg.svg  9.11 kB │ gzip: 3.86 kB
2026-02-22T18:25:13.8854842Z dist-react/assets/flag_mx-g-aNhK9D.svg  9.66 kB │ gzip: 3.72 kB
2026-02-22T18:25:13.8854891Z dist-react/assets/flag_ta-Q6DTxsoW.svg  10.30 kB │ gzip: 4.05 kB
2026-02-22T18:25:13.8854945Z dist-react/assets/flag_je-CGBxZBdT.svg  10.35 kB │ gzip: 4.17 kB
2026-02-22T18:25:13.8854993Z dist-react/assets/flag_do-sBcfT32z.svg  11.37 kB │ gzip: 4.67 kB
2026-02-22T18:25:13.8855050Z dist-react/assets/flag_sa-B3EC8eCD.svg  12.29 kB │ gzip: 5.12 kB
2026-02-22T18:25:13.8855097Z dist-react/assets/flag_al-D439po3l.svg  12.43 kB │ gzip: 5.32 kB
2026-02-22T18:25:13.8855154Z dist-react/assets/flag_bz-B34xZjVJ.svg  13.64 kB │ gzip: 5.44 kB
2026-02-22T18:25:13.8855209Z dist-react/assets/flag_pm-C-C2d-w4.svg  13.65 kB │ gzip: 3.80 kB
2026-02-22T18:25:13.8855259Z dist-react/assets/flag_nf-BjOIhoMF.svg  14.69 kB │ gzip: 5.98 kB
2026-02-22T18:25:13.8855314Z dist-react/assets/flag_ac-Dr8n8VBW.svg  16.66 kB │ gzip: 5.62 kB
2026-02-22T18:25:13.8855371Z dist-react/assets/screenshare_stop-DhppajDk.mp3  18.43 kB
2026-02-22T18:25:13.8855419Z dist-react/assets/potted_plant-BHg6K0D8.svg  21.00 kB │ gzip: 8.96 kB
2026-02-22T18:25:13.8855479Z dist-react/assets/mute-BoS1FmYK.mp3  22.10 kB
2026-02-22T18:25:13.8855526Z dist-react/assets/unmute-BaZvvXS7.mp3  22.10 kB
2026-02-22T18:25:13.8855579Z dist-react/assets/flag_mp-Bs0Xr_ND.svg  24.06 kB │ gzip: 9.57 kB
2026-02-22T18:25:13.8855630Z dist-react/assets/flag_af-CN78RMpg.svg  24.13 kB │ gzip: 9.18 kB
2026-02-22T18:25:13.8855684Z dist-react/assets/flag_kz-D77IkgDL.svg  26.58 kB │ gzip: 9.02 kB
2026-02-22T18:25:13.8855733Z dist-react/assets/united_nations-BC9awctQ.svg  26.58 kB │ gzip: 10.32 kB
2026-02-22T18:25:13.8855793Z dist-react/assets/a_dcfe10bac4a782ffb5eefef7a8003115-BrkAovaO.png  29.98 kB
2026-02-22T18:25:13.8855847Z dist-react/assets/deafen-CRezb6LQ.mp3  32.55 kB
2026-02-22T18:25:13.8855913Z dist-react/assets/undeafen-DI8u8nRW.mp3  35.69 kB
2026-02-22T18:25:13.8855960Z dist-react/assets/gg sans Regular-Bd8GJPVd.woff  39.09 kB
2026-02-22T18:25:13.8856019Z dist-react/assets/emojies_greyscale-CtRIvx0g.png  39.11 kB
2026-02-22T18:25:13.8856067Z dist-react/assets/gg sans Bold-BGlwbW8t.woff  40.13 kB
2026-02-22T18:25:13.8856121Z dist-react/assets/gg sans Medium-BMWm4JFW.woff  40.32 kB
2026-02-22T18:25:13.8856168Z dist-react/assets/gg sans Semibold-xAGa8zYH.woff  40.57 kB
2026-02-22T18:25:13.8856222Z dist-react/assets/leave_call-BZHqChzH.mp3  41.95 kB
2026-02-22T18:25:13.8856276Z dist-react/assets/emojies_colored-Cxo2u_zo.png  45.89 kB
2026-02-22T18:25:13.8856332Z dist-react/assets/join_call-DlUYaXyD.mp3  47.18 kB
2026-02-22T18:25:13.8856377Z dist-react/assets/screenshare_viewer_leave-BoDMhfvJ.mp3  67.54 kB
2026-02-22T18:25:13.8856433Z dist-react/assets/screenshare_viewer_join-BOPrADSV.mp3  67.54 kB
2026-02-22T18:25:13.8856491Z dist-react/assets/screenshare_start-Bpje2BJB.mp3  72.83 kB
2026-02-22T18:25:13.8856541Z dist-react/assets/default_call_sound-DTYq-Lur.mp3  90.63 kB
2026-02-22T18:25:13.8856595Z dist-react/assets/sql-wasm-CbWyWKgW.wasm  659.73 kB │ gzip: 323.01 kB
2026-02-22T18:25:13.8856645Z dist-react/assets/a_dcfe10bac4a782ffb5eefef7a8003115-DDM1tbIM.png 1,083.11 kB
2026-02-22T18:25:13.8856704Z dist-react/assets/index-CAoTlF3l.css  93.68 kB │ gzip: 16.45 kB
2026-02-22T18:25:13.8856767Z dist-react/assets/index-C_rgzTeB.js 8,672.01 kB │ gzip: 1,616.66 kB
2026-02-22T18:25:13.8856827Z 
2026-02-22T18:25:13.8856869Z (!) Some chunks are larger than 1000 kB after minification. Consider:
2026-02-22T18:25:13.8856917Z - Using dynamic import() to code-split the application
2026-02-22T18:25:13.8856961Z - Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
2026-02-22T18:25:13.8857016Z - Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
2026-02-22T18:25:13.8857088Z ✓ built in 3.15s
2026-02-22T18:25:14.3025566Z • electron-builder version=25.1.8 os=6.12.54-Unraid
2026-02-22T18:25:14.3027203Z • artifacts will be published if draft release exists reason=CI detected
2026-02-22T18:25:14.3044803Z • loaded configuration file=package.json ("build" field)
2026-02-22T18:25:14.3754964Z  Invalid configuration object. electron-builder 25.1.8 has been initialized using a configuration object that does not match the API schema.
2026-02-22T18:25:14.3755344Z  - configuration.nsis has an unknown property 'compression'. These properties are valid:
2026-02-22T18:25:14.3755432Z  object { allowElevation?, allowToChangeInstallationDirectory?, artifactName?, createDesktopShortcut?, createStartMenuShortcut?, customNsisBinary?, deleteAppDataOnUninstall?, differentialPackage?, displayLanguageSelector?, guid?, include?, installerHeader?, installerHeaderIcon?, installerIcon?, installerLanguages?, installerSidebar?, language?, license?, menuCategory?, multiLanguageInstaller?, oneClick?, packElevateHelper?, perMachine?, preCompressedFileExtensions?, publish?, removeDefaultUninstallWelcomePage?, runAfterFinish?, script?, selectPerMachineByDefault?, shortcutName?, unicode?, uninstallDisplayName?, uninstallerIcon?, uninstallerSidebar?, useZip?, warningsAsErrors? }
2026-02-22T18:25:14.3755577Z  How to fix:
2026-02-22T18:25:14.3755627Z  1. Open https://www.electron.build/nsis
2026-02-22T18:25:14.3755683Z  2. Search the option name on the page (or type in into Search to find across the docs).
2026-02-22T18:25:14.3755740Z  * Not found? The option was deprecated or not exists (check spelling).
2026-02-22T18:25:14.3755793Z  * Found? Check that the option in the appropriate place. e.g. "title" only in the "dmg", not in the root.
2026-02-22T18:25:14.3755925Z  failedTask=build stackTrace=ValidationError: Invalid configuration object. electron-builder 25.1.8 has been initialized using a configuration object that does not match the API schema.
2026-02-22T18:25:14.3755995Z - configuration.nsis has an unknown property 'compression'. These properties are valid:
2026-02-22T18:25:14.3756044Z object { allowElevation?, allowToChangeInstallationDirectory?, artifactName?, createDesktopShortcut?, createStartMenuShortcut?, customNsisBinary?, deleteAppDataOnUninstall?, differentialPackage?, displayLanguageSelector?, guid?, include?, installerHeader?, installerHeaderIcon?, installerIcon?, installerLanguages?, installerSidebar?, language?, license?, menuCategory?, multiLanguageInstaller?, oneClick?, packElevateHelper?, perMachine?, preCompressedFileExtensions?, publish?, removeDefaultUninstallWelcomePage?, runAfterFinish?, script?, selectPerMachineByDefault?, shortcutName?, unicode?, uninstallDisplayName?, uninstallerIcon?, uninstallerSidebar?, useZip?, warningsAsErrors? }
2026-02-22T18:25:14.3756156Z How to fix:
2026-02-22T18:25:14.3756266Z 1. Open https://www.electron.build/nsis
2026-02-22T18:25:14.3756310Z 2. Search the option name on the page (or type in into Search to find across the docs).
2026-02-22T18:25:14.3756360Z * Not found? The option was deprecated or not exists (check spelling).
2026-02-22T18:25:14.3756405Z * Found? Check that the option in the appropriate place. e.g. "title" only in the "dmg", not in the root.
2026-02-22T18:25:14.3756503Z at validate (/workspace/Moyettes/DiscordClone/node_modules/@develar/schema-utils/dist/validate.js:86:11)
2026-02-22T18:25:14.3756640Z at validateConfiguration (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/util/config/config.ts:238:3)
2026-02-22T18:25:14.3756702Z at Packager.validateConfig (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/packager.ts:354:5)
2026-02-22T18:25:14.3756751Z at Packager.build (/workspace/Moyettes/DiscordClone/node_modules/app-builder-lib/src/packager.ts:362:5)
2026-02-22T18:25:14.3756798Z at executeFinally (/workspace/Moyettes/DiscordClone/node_modules/builder-util/src/promise.ts:12:14)
2026-02-22T18:25:14.3952981Z ❌ Failure - Main Build Electron app
2026-02-22T18:25:14.3976393Z exitcode '1': failure
2026-02-22T18:25:14.4823938Z expression 'npm-electron-${{ hashFiles('package-lock.json') }}' rewritten to 'format('npm-electron-{0}', hashFiles('package-lock.json'))'
2026-02-22T18:25:14.4824153Z evaluating expression 'format('npm-electron-{0}', hashFiles('package-lock.json'))'
2026-02-22T18:25:14.4824385Z Writing entry to tarball workflow/hashfiles/index.js len:168437
2026-02-22T18:25:14.4826023Z Extracting content to '/var/run/act'
2026-02-22T18:25:14.4836053Z 🐳 docker exec cmd=[node /var/run/act/workflow/hashfiles/index.js] user= workdir=
2026-02-22T18:25:14.4836201Z Exec command '[node /var/run/act/workflow/hashfiles/index.js]'
2026-02-22T18:25:14.4836285Z Working directory '/workspace/Moyettes/DiscordClone'
2026-02-22T18:25:14.5510887Z expression 'format('npm-electron-{0}', hashFiles('package-lock.json'))' evaluated to '%!t(string=npm-electron-2487a9d0dd01ab81f1c0a0ae11d988df70638852a8778c5593e6a6a43f537060)'
2026-02-22T18:25:14.5547093Z evaluating expression 'success()'
2026-02-22T18:25:14.5547372Z expression 'success()' evaluated to 'false'
2026-02-22T18:25:14.5547454Z Skipping step 'Cache npm and Electron' due to 'success()'
2026-02-22T18:25:14.5630579Z evaluating expression 'always()'
2026-02-22T18:25:14.5630849Z expression 'always()' evaluated to 'true'
2026-02-22T18:25:14.5630951Z ⭐ Run Post Checkout repository
2026-02-22T18:25:14.5631090Z Writing entry to tarball workflow/outputcmd.txt len:0
2026-02-22T18:25:14.5631231Z Writing entry to tarball workflow/statecmd.txt len:0
2026-02-22T18:25:14.5631336Z Writing entry to tarball workflow/pathcmd.txt len:0
2026-02-22T18:25:14.5631426Z Writing entry to tarball workflow/envs.txt len:0
2026-02-22T18:25:14.5631501Z Writing entry to tarball workflow/SUMMARY.md len:0
2026-02-22T18:25:14.5631586Z Extracting content to '/var/run/act'
2026-02-22T18:25:14.5641701Z run post step for 'Checkout repository'
2026-02-22T18:25:14.5642142Z executing remote job container: [node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]
2026-02-22T18:25:14.5642288Z 🐳 docker exec cmd=[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js] user= workdir=
2026-02-22T18:25:14.5642402Z Exec command '[node /var/run/act/actions/c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab/dist/index.js]'
2026-02-22T18:25:14.5642637Z Working directory '/workspace/Moyettes/DiscordClone'
2026-02-22T18:25:14.6451217Z [command]/usr/bin/git version
2026-02-22T18:25:14.6473698Z git version 2.34.1
2026-02-22T18:25:14.6492584Z ***
2026-02-22T18:25:14.6499445Z Copying '/root/.gitconfig' to '/tmp/5f2eb5e2-7129-42c6-9748-38fd1038c4a4/.gitconfig'
2026-02-22T18:25:14.6507937Z Temporarily overriding HOME='/tmp/5f2eb5e2-7129-42c6-9748-38fd1038c4a4' before making global git config changes
2026-02-22T18:25:14.6509556Z Adding repository directory to the temporary git global config as a safe directory
2026-02-22T18:25:14.6513122Z [command]/usr/bin/git config --global --add safe.directory /workspace/Moyettes/DiscordClone
2026-02-22T18:25:14.6536297Z [command]/usr/bin/git config --local --name-only --get-regexp core\.sshCommand
2026-02-22T18:25:14.6566221Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'core\.sshCommand' && git config --local --unset-all 'core.sshCommand' || :"
2026-02-22T18:25:14.6731038Z [command]/usr/bin/git config --local --name-only --get-regexp http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader
2026-02-22T18:25:14.6743418Z http.http://192.168.125.15:4000/.extraheader
2026-02-22T18:25:14.6746856Z [command]/usr/bin/git config --local --unset-all http.http://192.168.125.15:4000/.extraheader
2026-02-22T18:25:14.6762942Z [command]/usr/bin/git submodule foreach --recursive sh -c "git config --local --name-only --get-regexp 'http\.http\:\/\/192\.168\.125\.15\:4000\/\.extraheader' && git config --local --unset-all 'http.http://192.168.125.15:4000/.extraheader' || :"
2026-02-22T18:25:14.6910862Z [command]/usr/bin/git config --local --name-only --get-regexp ^includeIf\.gitdir:
2026-02-22T18:25:14.6926044Z [command]/usr/bin/git submodule foreach --recursive git config --local --show-origin --name-only --get-regexp remote.origin.url
2026-02-22T18:25:14.7127748Z ✅ Success - Post Checkout repository
2026-02-22T18:25:14.7150122Z Cleaning up container for job build-and-release
2026-02-22T18:26:14.7155198Z failed to remove container: Delete "http://%2Fvar%2Frun%2Fdocker.sock/v1.44/containers/476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e?force=1&v=1": context deadline exceeded
2026-02-22T18:26:14.7155530Z Removed container: 476c8d61de5006daf47cd044eca820c0f99b08acfc7b846a97d8d1560814378e
2026-02-22T18:26:14.7155595Z Error while stop job container: context deadline exceeded
2026-02-22T18:26:14.7155649Z 🏁 Job failed
2026-02-22T18:26:14.7196884Z Error occurred running finally: Error occurred running finally: context deadline exceeded (original error: <nil>) (original error: <nil>)

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.0.37", "version": "1.0.38",
"type": "module", "type": "module",
"main": "src/App.jsx", "main": "src/App.jsx",
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,126 @@
import React, { useState, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import ColoredIcon from './ColoredIcon';
import settingsIcon from '../assets/icons/settings.svg';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const MobileChannelDrawer = ({ channel, isUnread, onMarkAsRead, onEditChannel, onClose }) => {
const [closing, setClosing] = useState(false);
const drawerRef = useRef(null);
const dragStartY = useRef(null);
const dragCurrentY = useRef(null);
const dragStartTime = useRef(null);
const dismiss = useCallback(() => {
setClosing(true);
setTimeout(onClose, 200);
}, [onClose]);
const handleAction = useCallback((cb) => {
dismiss();
setTimeout(cb, 220);
}, [dismiss]);
// Swipe-to-dismiss
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY;
dragCurrentY.current = e.touches[0].clientY;
dragStartTime.current = Date.now();
if (drawerRef.current) {
drawerRef.current.style.transition = 'none';
}
}, []);
const handleTouchMove = useCallback((e) => {
if (dragStartY.current === null) return;
dragCurrentY.current = e.touches[0].clientY;
const dy = dragCurrentY.current - dragStartY.current;
if (dy > 0 && drawerRef.current) {
drawerRef.current.style.transform = `translateY(${dy}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (dragStartY.current === null || !drawerRef.current) return;
const dy = dragCurrentY.current - dragStartY.current;
const dt = (Date.now() - dragStartTime.current) / 1000;
const velocity = dt > 0 ? dy / dt : 0;
const drawerHeight = drawerRef.current.offsetHeight;
const threshold = drawerHeight * 0.3;
if (dy > threshold || velocity > 500) {
dismiss();
} else {
drawerRef.current.style.transition = 'transform 0.2s ease-out';
drawerRef.current.style.transform = 'translateY(0)';
}
dragStartY.current = null;
}, [dismiss]);
const isVoice = channel?.type === 'voice';
return ReactDOM.createPortal(
<>
<div className="mobile-drawer-overlay" onClick={dismiss} />
<div
ref={drawerRef}
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="mobile-drawer-handle">
<div className="mobile-drawer-handle-bar" />
</div>
{/* Channel header */}
<div style={{
padding: '4px 16px 12px',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
{isVoice ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--interactive-normal)">
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
</svg>
) : (
<span style={{ color: 'var(--interactive-normal)', fontSize: 20, fontWeight: 500 }}>#</span>
)}
<span style={{
color: 'var(--text-normal)',
fontSize: 16,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{channel?.name}
</span>
</div>
{/* Actions */}
<div className="mobile-drawer-card">
<button
className={`mobile-drawer-action${!isUnread ? ' mobile-drawer-action-disabled' : ''}`}
onClick={isUnread ? () => handleAction(onMarkAsRead) : undefined}
disabled={!isUnread}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill={isUnread ? ICON_COLOR_DEFAULT : 'var(--text-muted)'}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span>Mark As Read</span>
</button>
<button className="mobile-drawer-action" onClick={() => handleAction(onEditChannel)}>
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Edit Channel</span>
</button>
</div>
</div>
</>,
document.body
);
};
export default MobileChannelDrawer;

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const MobileChannelSettingsScreen = ({ channel, categories, onClose, onDelete }) => {
const [visible, setVisible] = useState(false);
const [channelName, setChannelName] = useState(channel.name);
const [channelTopic, setChannelTopic] = useState(channel.topic || '');
const [selectedCategoryId, setSelectedCategoryId] = useState(channel.categoryId || null);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [saving, setSaving] = useState(false);
const convex = useConvex();
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
const handleClose = () => {
setVisible(false);
setTimeout(onClose, 250);
};
const handleNameChange = (e) => {
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
};
const hasChanges =
channelName.trim() !== channel.name ||
channelTopic.trim() !== (channel.topic || '') ||
selectedCategoryId !== (channel.categoryId || null);
const handleSave = async () => {
if (!hasChanges || saving) return;
setSaving(true);
try {
const trimmedName = channelName.trim();
if (trimmedName && trimmedName !== channel.name) {
await convex.mutation(api.channels.rename, { id: channel._id, name: trimmedName });
}
const trimmedTopic = channelTopic.trim();
if (trimmedTopic !== (channel.topic || '')) {
await convex.mutation(api.channels.updateTopic, { id: channel._id, topic: trimmedTopic });
}
if (selectedCategoryId !== (channel.categoryId || null)) {
await convex.mutation(api.channels.moveChannel, {
id: channel._id,
categoryId: selectedCategoryId || undefined,
position: 0,
});
}
handleClose();
} catch (err) {
console.error('Failed to save channel settings:', err);
alert('Failed to save: ' + err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await convex.mutation(api.channels.remove, { id: channel._id });
if (onDelete) onDelete(channel._id);
handleClose();
} catch (err) {
console.error('Failed to delete channel:', err);
alert('Failed to delete: ' + err.message);
}
};
const currentCategoryName = selectedCategoryId
? (categories || []).find(c => c._id === selectedCategoryId)?.name || 'Unknown'
: 'None';
return ReactDOM.createPortal(
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
{/* Header */}
<div className="mobile-create-header">
<button className="mobile-create-close-btn" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<span className="mobile-create-title">Channel Settings</span>
<button
className={`mobile-create-submit-btn${!hasChanges || saving ? ' disabled' : ''}`}
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
{/* Body */}
<div className="mobile-create-body">
{/* Channel Name */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">Channel Name</label>
<div className="mobile-create-input-wrapper">
<span className="mobile-create-input-prefix">
{channel.type === 'voice' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
</svg>
) : '#'}
</span>
<input
className="mobile-create-input"
type="text"
placeholder="channel-name"
value={channelName}
onChange={handleNameChange}
/>
</div>
</div>
{/* Channel Topic */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">
Channel Topic
<span style={{ float: 'right', fontWeight: 400, textTransform: 'none' }}>
{channelTopic.length}/1024
</span>
</label>
<div className="mobile-create-input-wrapper" style={{ alignItems: 'flex-start' }}>
<textarea
className="mobile-create-input"
placeholder="Set a topic for this channel"
value={channelTopic}
onChange={(e) => {
if (e.target.value.length <= 1024) setChannelTopic(e.target.value);
}}
rows={3}
style={{
resize: 'none',
fontFamily: 'inherit',
lineHeight: '1.4',
}}
/>
</div>
</div>
{/* Category */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">Category</label>
<div
className="mobile-create-input-wrapper"
style={{ cursor: 'pointer' }}
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
>
<span className="mobile-create-input" style={{ cursor: 'pointer', userSelect: 'none' }}>
{currentCategoryName}
</span>
<svg
width="20" height="20" viewBox="0 0 24 24"
fill="var(--interactive-normal)"
style={{
marginRight: 8,
flexShrink: 0,
transform: showCategoryPicker ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s',
}}
>
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
</svg>
</div>
{showCategoryPicker && (
<div className="mobile-channel-settings-category-list">
<div
className={`mobile-channel-settings-category-option${!selectedCategoryId ? ' selected' : ''}`}
onClick={() => { setSelectedCategoryId(null); setShowCategoryPicker(false); }}
>
None
</div>
{(categories || []).map(cat => (
<div
key={cat._id}
className={`mobile-channel-settings-category-option${selectedCategoryId === cat._id ? ' selected' : ''}`}
onClick={() => { setSelectedCategoryId(cat._id); setShowCategoryPicker(false); }}
>
{cat.name}
</div>
))}
</div>
)}
</div>
{/* Delete Channel */}
<div className="mobile-create-section" style={{ marginTop: 16 }}>
{!confirmDelete ? (
<button
className="mobile-channel-settings-delete-btn"
onClick={() => setConfirmDelete(true)}
>
Delete Channel
</button>
) : (
<div className="mobile-channel-settings-delete-confirm">
<p style={{ color: '#ed4245', fontSize: 14, margin: '0 0 12px' }}>
Are you sure you want to delete <strong>#{channel.name}</strong>? This cannot be undone.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="mobile-channel-settings-cancel-btn"
onClick={() => setConfirmDelete(false)}
>
Cancel
</button>
<button
className="mobile-channel-settings-delete-btn"
onClick={handleDelete}
>
Delete
</button>
</div>
</div>
)}
</div>
</div>
</div>,
document.body
);
};
export default MobileChannelSettingsScreen;

View File

@@ -0,0 +1,82 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const MobileCreateCategoryScreen = ({ onClose, onSubmit }) => {
const [visible, setVisible] = useState(false);
const [categoryName, setCategoryName] = useState('');
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
const handleClose = () => {
setVisible(false);
setTimeout(onClose, 250);
};
const handleCreate = () => {
if (!categoryName.trim()) return;
onSubmit(categoryName.trim());
handleClose();
};
return ReactDOM.createPortal(
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
{/* Header */}
<div className="mobile-create-header">
<button className="mobile-create-close-btn" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
<span className="mobile-create-title">Create Category</span>
<button
className={`mobile-create-submit-btn${!categoryName.trim() ? ' disabled' : ''}`}
onClick={handleCreate}
disabled={!categoryName.trim()}
>
Create
</button>
</div>
{/* Body */}
<div className="mobile-create-body">
{/* Category Name */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">Category Name</label>
<div className="mobile-create-input-wrapper">
<input
className="mobile-create-input"
type="text"
placeholder="New Category"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
autoFocus
/>
</div>
</div>
{/* Private Category Toggle */}
<div className="mobile-create-section">
<p className="mobile-create-private-desc">
By making a category private, only selected members and roles will be able to view this category.
</p>
<div className="mobile-create-toggle-row">
<div className="mobile-create-toggle-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
</svg>
<span className="mobile-create-toggle-label">Private Category</span>
</div>
<div className="category-toggle-switch">
<div className="category-toggle-knob" />
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default MobileCreateCategoryScreen;

View File

@@ -0,0 +1,128 @@
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const MobileCreateChannelScreen = ({ onClose, onSubmit, categoryId }) => {
const [visible, setVisible] = useState(false);
const [channelName, setChannelName] = useState('');
const [channelType, setChannelType] = useState('text');
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
const handleClose = () => {
setVisible(false);
setTimeout(onClose, 250);
};
const handleCreate = () => {
if (!channelName.trim()) return;
onSubmit(channelName.trim(), channelType, categoryId);
handleClose();
};
const handleNameChange = (e) => {
setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'));
};
return ReactDOM.createPortal(
<div className={`mobile-create-screen${visible ? ' visible' : ''}`}>
{/* Header */}
<div className="mobile-create-header">
<button className="mobile-create-close-btn" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
<span className="mobile-create-title">Create Channel</span>
<button
className={`mobile-create-submit-btn${!channelName.trim() ? ' disabled' : ''}`}
onClick={handleCreate}
disabled={!channelName.trim()}
>
Create
</button>
</div>
{/* Body */}
<div className="mobile-create-body">
{/* Channel Name */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">Channel Name</label>
<div className="mobile-create-input-wrapper">
<span className="mobile-create-input-prefix">
{channelType === 'text' ? '#' : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
</svg>
)}
</span>
<input
className="mobile-create-input"
type="text"
placeholder="new-channel"
value={channelName}
onChange={handleNameChange}
autoFocus
/>
</div>
</div>
{/* Channel Type */}
<div className="mobile-create-section">
<label className="mobile-create-section-label">Channel Type</label>
<div className="mobile-create-type-list">
<div
className={`mobile-create-type-option${channelType === 'text' ? ' selected' : ''}`}
onClick={() => setChannelType('text')}
>
<span className="mobile-create-type-icon">#</span>
<div className="mobile-create-type-info">
<div className="mobile-create-type-name">Text</div>
<div className="mobile-create-type-desc">Send messages, images, GIFs, emoji, opinions, and puns</div>
</div>
<div className={`mobile-create-radio${channelType === 'text' ? ' selected' : ''}`}>
{channelType === 'text' && <div className="mobile-create-radio-dot" />}
</div>
</div>
<div
className={`mobile-create-type-option${channelType === 'voice' ? ' selected' : ''}`}
onClick={() => setChannelType('voice')}
>
<span className="mobile-create-type-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
</svg>
</span>
<div className="mobile-create-type-info">
<div className="mobile-create-type-name">Voice</div>
<div className="mobile-create-type-desc">Hang out together with voice, video, and screen share</div>
</div>
<div className={`mobile-create-radio${channelType === 'voice' ? ' selected' : ''}`}>
{channelType === 'voice' && <div className="mobile-create-radio-dot" />}
</div>
</div>
</div>
</div>
{/* Private Channel Toggle */}
<div className="mobile-create-section">
<div className="mobile-create-toggle-row">
<div className="mobile-create-toggle-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ color: 'var(--interactive-normal)' }}>
<path d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
</svg>
<span className="mobile-create-toggle-label">Private Channel</span>
</div>
<div className="category-toggle-switch">
<div className="category-toggle-knob" />
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default MobileCreateChannelScreen;

View File

@@ -0,0 +1,464 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useOnlineUsers } from '../contexts/PresenceContext';
import { useSearch } from '../contexts/SearchContext';
import { usePlatform } from '../platform';
import { LinkPreview } from './ChatArea';
import { extractUrls } from './MessageItem';
import Avatar from './Avatar';
import {
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
SearchResultImage, SearchResultVideo, SearchResultFile
} from '../utils/searchRendering';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const BROWSE_TABS = ['Recent', 'Members', 'Channels'];
const SEARCH_TABS = ['Messages', 'Media', 'Links', 'Files'];
function formatTimeAgo(timestamp) {
if (!timestamp) return '';
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Active just now';
if (minutes < 60) return `Active ${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `Active ${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'Active 1d ago';
return `Active ${days}d ago`;
}
const MobileSearchScreen = ({ channels, allMembers, serverName, onClose, onSelectChannel, onJumpToMessage }) => {
const [activeTab, setActiveTab] = useState('Recent');
const [visible, setVisible] = useState(false);
const [searchText, setSearchText] = useState('');
const { resolveStatus } = useOnlineUsers();
const { search, isReady } = useSearch() || {};
const { links } = usePlatform();
const customEmojis = useQuery(api.customEmojis.list) || [];
// Search result state
const [messageResults, setMessageResults] = useState([]);
const [mediaResults, setMediaResults] = useState([]);
const [linkResults, setLinkResults] = useState([]);
const [fileResults, setFileResults] = useState([]);
const [searching, setSearching] = useState(false);
const searchTimerRef = useRef(null);
const channelIds = useMemo(() => channels.map(c => c._id), [channels]);
const latestTimestampsRaw = useQuery(
api.readState.getLatestMessageTimestamps,
channelIds.length > 0 ? { channelIds } : "skip"
) || [];
const latestTimestamps = useMemo(() => {
const map = {};
for (const item of latestTimestampsRaw) {
map[item.channelId] = item.latestTimestamp;
}
return map;
}, [latestTimestampsRaw]);
const serverChannelIds = useMemo(() => new Set(channels.map(c => c._id)), [channels]);
const channelMap = useMemo(() => {
const map = {};
for (const c of channels) map[c._id] = c.name;
return map;
}, [channels]);
// Determine mode based on search text
const hasQuery = searchText.trim().length > 0;
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
// Debounced search execution
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!hasQuery || !search || !isReady) {
setMessageResults([]);
setMediaResults([]);
setLinkResults([]);
setFileResults([]);
setSearching(false);
return;
}
setSearching(true);
searchTimerRef.current = setTimeout(() => {
const q = searchText.trim();
const filterToServer = (results) => results.filter(r => serverChannelIds.has(r.channel_id));
// Messages search
const msgs = filterToServer(search({ query: q, limit: 50 }));
msgs.sort((a, b) => b.created_at - a.created_at);
setMessageResults(msgs);
// Media search (images + videos, deduped)
const images = filterToServer(search({ query: q, hasImage: true, limit: 50 }));
const videos = filterToServer(search({ query: q, hasVideo: true, limit: 50 }));
const mediaMap = new Map();
for (const r of [...images, ...videos]) mediaMap.set(r.id, r);
const media = Array.from(mediaMap.values());
media.sort((a, b) => b.created_at - a.created_at);
setMediaResults(media);
// Links search
const lnks = filterToServer(search({ query: q, hasLink: true, limit: 50 }));
lnks.sort((a, b) => b.created_at - a.created_at);
setLinkResults(lnks);
// Files search
const files = filterToServer(search({ query: q, hasFile: true, limit: 50 }));
files.sort((a, b) => b.created_at - a.created_at);
setFileResults(files);
setSearching(false);
}, 300);
return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); };
}, [searchText, hasQuery, search, isReady, serverChannelIds]);
// Reset to first search tab when entering search mode
useEffect(() => {
if (hasQuery) {
setActiveTab('Messages');
} else {
setActiveTab('Recent');
}
}, [hasQuery]);
const handleClose = () => {
setVisible(false);
setTimeout(onClose, 250);
};
const handleSelectChannel = (channelId) => {
setVisible(false);
setTimeout(() => {
onSelectChannel(channelId);
onClose();
}, 250);
};
const handleResultClick = useCallback((result) => {
if (onJumpToMessage) {
setVisible(false);
setTimeout(() => {
onJumpToMessage(result.channel_id, result.id);
}, 250);
}
}, [onJumpToMessage]);
const query = searchText.toLowerCase().trim();
// Browse mode data
const recentChannels = useMemo(() => {
const textChannels = channels.filter(c => c.type === 'text');
return textChannels
.map(c => ({ ...c, lastActivity: latestTimestamps[c._id] || 0 }))
.sort((a, b) => b.lastActivity - a.lastActivity)
.filter(c => !query || c.name.toLowerCase().includes(query));
}, [channels, latestTimestamps, query]);
const filteredMembers = useMemo(() => {
if (!query) return allMembers;
return allMembers.filter(m =>
m.username.toLowerCase().includes(query) ||
(m.displayName && m.displayName.toLowerCase().includes(query))
);
}, [allMembers, query]);
const filteredChannels = useMemo(() => {
if (!query) return channels;
return channels.filter(c => c.name.toLowerCase().includes(query));
}, [channels, query]);
// Group results by channel
const groupByChannel = useCallback((results) => {
const grouped = {};
for (const r of results) {
const chName = channelMap[r.channel_id] || 'Unknown';
if (!grouped[chName]) grouped[chName] = [];
grouped[chName].push(r);
}
return grouped;
}, [channelMap]);
const renderSearchResult = useCallback((r) => (
<div
key={r.id}
className="mobile-search-result-item"
onClick={() => handleResultClick(r)}
>
<div
className="mobile-search-result-avatar"
style={{ backgroundColor: getAvatarColor(r.username) }}
>
{r.username?.[0]?.toUpperCase()}
</div>
<div className="mobile-search-result-body">
<div className="mobile-search-result-header">
<span className="mobile-search-result-username" style={{ color: getAvatarColor(r.username) }}>
{r.username}
</span>
<span className="mobile-search-result-time">{formatTime(r.created_at)}</span>
</div>
{!(r.has_attachment && r.attachment_meta) && (
<div
className="mobile-search-result-content"
dangerouslySetInnerHTML={{ __html: formatEmojisHtml(linkifyHtml(r.snippet || escapeHtml(r.content)), customEmojis) }}
onClick={(e) => {
if (e.target.tagName === 'A' && e.target.href) {
e.preventDefault();
e.stopPropagation();
links.openExternal(e.target.href);
}
}}
/>
)}
{r.has_attachment && r.attachment_meta ? (() => {
try {
const meta = JSON.parse(r.attachment_meta);
if (r.attachment_type?.startsWith('image/')) return <SearchResultImage metadata={meta} />;
if (r.attachment_type?.startsWith('video/')) return <SearchResultVideo metadata={meta} />;
return <SearchResultFile metadata={meta} />;
} catch { return <span className="search-result-badge">File</span>; }
})() : r.has_attachment ? <span className="search-result-badge">File</span> : null}
{r.has_link && r.content && (() => {
const urls = extractUrls(r.content);
return urls.map((url, i) => <LinkPreview key={i} url={url} />);
})()}
</div>
</div>
), [handleResultClick, customEmojis, links]);
const renderGroupedResults = useCallback((results) => {
if (searching) {
return <div className="mobile-search-empty">Searching...</div>;
}
if (!isReady) {
return <div className="mobile-search-empty">Search database is loading...</div>;
}
if (results.length === 0) {
return (
<div className="mobile-search-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.3, marginBottom: 8 }}>
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<div>No results found</div>
</div>
);
}
const grouped = groupByChannel(results);
return (
<div className="mobile-search-results">
{Object.entries(grouped).map(([chName, msgs]) => (
<div key={chName} className="mobile-search-channel-group">
<div className="mobile-search-channel-group-header">#{chName}</div>
{msgs.map(renderSearchResult)}
</div>
))}
</div>
);
}, [searching, isReady, groupByChannel, renderSearchResult]);
const renderContent = () => {
// Search mode
if (hasQuery) {
switch (activeTab) {
case 'Messages': return renderGroupedResults(messageResults);
case 'Media': return renderGroupedResults(mediaResults);
case 'Links': return renderGroupedResults(linkResults);
case 'Files': return renderGroupedResults(fileResults);
default: return renderGroupedResults(messageResults);
}
}
// Browse mode
switch (activeTab) {
case 'Recent':
return (
<div className="mobile-search-section">
<div className="mobile-search-section-title">Suggested</div>
{recentChannels.length === 0 ? (
<div className="mobile-search-empty">No channels found</div>
) : (
recentChannels.map(channel => (
<button
key={channel._id}
className="mobile-search-channel-item"
onClick={() => handleSelectChannel(channel._id)}
>
<span className="mobile-search-channel-hash">#</span>
<div className="mobile-search-channel-info">
<span className="mobile-search-channel-name">{channel.name}</span>
<span className="mobile-search-channel-activity">
{formatTimeAgo(channel.lastActivity)}
</span>
</div>
</button>
))
)}
</div>
);
case 'Members':
return (
<div className="mobile-search-section">
{filteredMembers.length === 0 ? (
<div className="mobile-search-empty">No members found</div>
) : (
filteredMembers.map(member => {
const effectiveStatus = resolveStatus(member.status, member.id);
return (
<div key={member.id} className="mobile-search-member-item">
<div className="member-avatar-wrapper">
{member.avatarUrl ? (
<img
className="member-avatar"
src={member.avatarUrl}
alt={member.username}
style={{ objectFit: 'cover' }}
/>
) : (
<div
className="member-avatar"
style={{ backgroundColor: getUserColor(member.username) }}
>
{(member.displayName || member.username).substring(0, 1).toUpperCase()}
</div>
)}
<div
className="member-status-dot"
style={{ backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline }}
/>
</div>
<span className="mobile-search-member-name">
{member.displayName || member.username}
</span>
</div>
);
})
)}
</div>
);
case 'Channels':
return (
<div className="mobile-search-section">
{filteredChannels.length === 0 ? (
<div className="mobile-search-empty">No channels found</div>
) : (
filteredChannels.map(channel => (
<button
key={channel._id}
className="mobile-search-channel-item"
onClick={() => handleSelectChannel(channel._id)}
>
<span className="mobile-search-channel-hash">
{channel.type === 'voice' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1z" />
</svg>
) : '#'}
</span>
<span className="mobile-search-channel-name">{channel.name}</span>
</button>
))
)}
</div>
);
default:
return (
<div className="mobile-search-empty">
{activeTab} coming soon
</div>
);
}
};
const currentTabs = hasQuery ? SEARCH_TABS : BROWSE_TABS;
const getTabLabel = (tab) => {
if (!hasQuery) return tab;
switch (tab) {
case 'Messages': return `Messages${!searching ? ` (${messageResults.length})` : ''}`;
case 'Media': return `Media${!searching ? ` (${mediaResults.length})` : ''}`;
case 'Links': return `Links${!searching ? ` (${linkResults.length})` : ''}`;
case 'Files': return `Files${!searching ? ` (${fileResults.length})` : ''}`;
default: return tab;
}
};
return ReactDOM.createPortal(
<div className={`mobile-search-screen${visible ? ' visible' : ''}`}>
<div className="mobile-search-header">
<button className="mobile-search-back" onClick={handleClose}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<div className="mobile-search-input-wrapper">
<svg className="mobile-search-input-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
<input
className="mobile-search-input"
type="text"
placeholder="Search"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
autoFocus
/>
{searchText && (
<button className="mobile-search-clear" onClick={() => setSearchText('')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
</div>
<div className="mobile-search-tabs">
{currentTabs.map(tab => (
<button
key={tab}
className={`mobile-search-tab${activeTab === tab ? ' active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{getTabLabel(tab)}
</button>
))}
</div>
<div className="mobile-search-content">
{renderContent()}
</div>
</div>,
document.body
);
};
export default MobileSearchScreen;

View File

@@ -0,0 +1,124 @@
import React, { useState, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import ColoredIcon from './ColoredIcon';
import inviteUserIcon from '../assets/icons/invite_user.svg';
import settingsIcon from '../assets/icons/settings.svg';
import createIcon from '../assets/icons/create.svg';
import createCategoryIcon from '../assets/icons/create_category.svg';
const ICON_COLOR_DEFAULT = 'hsl(240, 4.294%, 68.039%)';
const MobileServerDrawer = ({ serverName, serverIconUrl, memberCount, onInvite, onSettings, onCreateChannel, onCreateCategory, onClose }) => {
const [closing, setClosing] = useState(false);
const drawerRef = useRef(null);
const dragStartY = useRef(null);
const dragCurrentY = useRef(null);
const dragStartTime = useRef(null);
const dismiss = useCallback(() => {
setClosing(true);
setTimeout(onClose, 200);
}, [onClose]);
const handleAction = useCallback((cb) => {
dismiss();
setTimeout(cb, 220);
}, [dismiss]);
// Swipe-to-dismiss
const handleTouchStart = useCallback((e) => {
dragStartY.current = e.touches[0].clientY;
dragCurrentY.current = e.touches[0].clientY;
dragStartTime.current = Date.now();
if (drawerRef.current) {
drawerRef.current.style.transition = 'none';
}
}, []);
const handleTouchMove = useCallback((e) => {
if (dragStartY.current === null) return;
dragCurrentY.current = e.touches[0].clientY;
const dy = dragCurrentY.current - dragStartY.current;
if (dy > 0 && drawerRef.current) {
drawerRef.current.style.transform = `translateY(${dy}px)`;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (dragStartY.current === null || !drawerRef.current) return;
const dy = dragCurrentY.current - dragStartY.current;
const dt = (Date.now() - dragStartTime.current) / 1000;
const velocity = dt > 0 ? dy / dt : 0;
const drawerHeight = drawerRef.current.offsetHeight;
const threshold = drawerHeight * 0.3;
if (dy > threshold || velocity > 500) {
dismiss();
} else {
drawerRef.current.style.transition = 'transform 0.2s ease-out';
drawerRef.current.style.transform = 'translateY(0)';
}
dragStartY.current = null;
}, [dismiss]);
const initials = serverName ? serverName.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase() : '?';
return ReactDOM.createPortal(
<>
<div className="mobile-drawer-overlay" onClick={dismiss} />
<div
ref={drawerRef}
className={`mobile-drawer${closing ? ' mobile-drawer-closing' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="mobile-drawer-handle">
<div className="mobile-drawer-handle-bar" />
</div>
{/* Server header */}
<div className="mobile-server-drawer-header">
<div className="mobile-server-drawer-icon">
{serverIconUrl ? (
<img src={serverIconUrl} alt={serverName} />
) : (
<span>{initials}</span>
)}
</div>
<div>
<div className="mobile-server-drawer-name">{serverName}</div>
<div className="mobile-server-drawer-members">{memberCount} {memberCount === 1 ? 'member' : 'members'}</div>
</div>
</div>
{/* Actions card 1 */}
<div className="mobile-drawer-card">
<button className="mobile-drawer-action" onClick={() => handleAction(onInvite)}>
<ColoredIcon src={inviteUserIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Invite People</span>
</button>
<button className="mobile-drawer-action" onClick={() => handleAction(onSettings)}>
<ColoredIcon src={settingsIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Server Settings</span>
</button>
</div>
{/* Actions card 2 */}
<div className="mobile-drawer-card">
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateChannel)}>
<ColoredIcon src={createIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Create Channel</span>
</button>
<button className="mobile-drawer-action" onClick={() => handleAction(onCreateCategory)}>
<ColoredIcon src={createCategoryIcon} color={ICON_COLOR_DEFAULT} size="20px" />
<span>Create Category</span>
</button>
</div>
</div>
</>,
document.body
);
};
export default MobileServerDrawer;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import { useSearch } from '../contexts/SearchContext'; import { useSearch } from '../contexts/SearchContext';
@@ -6,216 +6,10 @@ import { parseFilters } from '../utils/searchUtils';
import { usePlatform } from '../platform'; import { usePlatform } from '../platform';
import { LinkPreview } from './ChatArea'; import { LinkPreview } from './ChatArea';
import { extractUrls } from './MessageItem'; import { extractUrls } from './MessageItem';
import { AllEmojis } from '../assets/emojis'; import {
formatTime, escapeHtml, linkifyHtml, formatEmojisHtml, getAvatarColor,
function formatTime(ts) { SearchResultImage, SearchResultVideo, SearchResultFile
const d = new Date(ts); } from '../utils/searchRendering';
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function linkifyHtml(html) {
if (!html) return '';
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
}
function formatEmojisHtml(html, customEmojis = []) {
if (!html) return '';
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
const custom = customEmojis.find(e => e.name === name);
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
const emoji = AllEmojis.find(e => e.name === name);
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
return match;
});
}
function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
const rewriteStorageUrl = (url) => {
try {
const u = new URL(url);
const pub = new URL(CONVEX_PUBLIC_URL);
u.hostname = pub.hostname;
u.port = pub.port;
u.protocol = pub.protocol;
return u.toString();
} catch { return url; }
};
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const searchImageCache = new Map();
const SearchResultImage = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search image decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
if (error) return null;
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
};
const SearchResultVideo = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search video decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
if (error) return null;
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}></div>}
</div>
);
};
const SearchResultFile = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) return;
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search file decrypt error:', err);
if (isMounted) setLoading(false);
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
<span style={{ marginRight: 8, fontSize: 20 }}>📄</span>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
</div>
</div>
);
};
const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => { const SearchPanel = ({ visible, onClose, channels, isDM, dmChannelId, onJumpToMessage, query, sortOrder, onSortChange }) => {
const { search, isReady } = useSearch() || {}; const { search, isReady } = useSearch() || {};

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { useQuery, useConvex } from 'convex/react'; import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import { AllEmojis } from '../assets/emojis'; import { AllEmojis } from '../assets/emojis';
import AvatarCropModal from './AvatarCropModal'; import AvatarCropModal from './AvatarCropModal';
import Cropper from 'react-easy-crop'; import Cropper from 'react-easy-crop';
import { useIsMobile } from '../hooks/useIsMobile';
function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) { function getCroppedEmojiImg(imageSrc, pixelCrop, rotation, flipH, flipV) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -95,19 +97,44 @@ const ServerSettingsModal = ({ onClose }) => {
const [savingIcon, setSavingIcon] = useState(false); const [savingIcon, setSavingIcon] = useState(false);
const iconInputRef = useRef(null); const iconInputRef = useRef(null);
// Mobile state
const isMobile = useIsMobile();
const [mobileScreen, setMobileScreen] = useState('menu');
const mobileGoBack = () => {
if (mobileScreen === 'role-edit') setMobileScreen('roles');
else setMobileScreen('menu');
};
const mobileSelectRole = (role) => {
setSelectedRole(role);
setMobileScreen('role-edit');
};
// Auto-navigate to overview on icon crop (so user sees save button)
useEffect(() => {
if (isMobile && iconDirty && mobileScreen === 'menu') {
setMobileScreen('overview');
}
}, [isMobile, iconDirty, mobileScreen]);
useEffect(() => { useEffect(() => {
const handleKey = (e) => { const handleKey = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (showEmojiModal) { if (showEmojiModal) {
handleEmojiModalClose(); handleEmojiModalClose();
} else if (!showIconCropModal) { } else if (!showIconCropModal) {
if (isMobile && mobileScreen !== 'menu') {
mobileGoBack();
} else {
onClose(); onClose();
} }
} }
}
}; };
window.addEventListener('keydown', handleKey); window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
}, [onClose, showEmojiModal, showIconCropModal]); }, [onClose, showEmojiModal, showIconCropModal, isMobile, mobileScreen]);
React.useEffect(() => { React.useEffect(() => {
if (serverSettings) { if (serverSettings) {
@@ -745,6 +772,506 @@ const ServerSettingsModal = ({ onClose }) => {
} }
}; };
// ─── Mobile render functions ───
const roleMemberCounts = React.useMemo(() => {
const counts = {};
for (const m of members) {
for (const r of (m.roles || [])) {
counts[r._id] = (counts[r._id] || 0) + 1;
}
}
return counts;
}, [members]);
const renderMobileHeader = (title, onBack, rightAction) => (
<div className="msm-header">
<button className="msm-back-btn" onClick={onBack}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<div className="msm-header-title">{title}</div>
{rightAction || <div style={{ width: 32 }} />}
</div>
);
const renderMobileMenu = () => (
<div className="msm-screen">
<div className="msm-header">
<div style={{ flex: 1 }} />
<button className="msm-back-btn" onClick={onClose}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div className="msm-content">
{/* Server icon + name */}
<div className="msm-icon-section">
<div className="msm-icon-wrapper" onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
{currentIconUrl ? (
<img src={currentIconUrl} alt="Server Icon" className="msm-icon-img" />
) : (
<div className="msm-icon-placeholder">{serverName.substring(0, 2)}</div>
)}
{myPermissions.manage_channels && (
<div className="msm-icon-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="#fff"><path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/></svg>
</div>
)}
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
</div>
<div className="msm-icon-name">{serverName}</div>
</div>
{/* Settings menu */}
<div className="msm-section-label">Settings</div>
<div className="msm-card">
{[
{ key: 'overview', label: 'Overview', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg> },
{ key: 'emoji', label: 'Emoji', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg> },
{ key: 'roles', label: 'Roles', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg> },
{ key: 'members', label: 'Members', icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg> },
].map(item => (
<div key={item.key} className="msm-card-item" onClick={() => setMobileScreen(item.key)}>
<span className="msm-card-item-icon">{item.icon}</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>{item.label}</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
))}
</div>
</div>
</div>
);
const renderMobileOverview = () => (
<div className="msm-screen">
{renderMobileHeader('Overview', mobileGoBack)}
<div className="msm-content">
{/* Server icon (small) */}
<div className="msm-section-label">Server Icon</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div className="msm-icon-wrapper" style={{ width: 56, height: 56 }} onClick={() => myPermissions.manage_channels && iconInputRef.current?.click()}>
{currentIconUrl ? (
<img src={currentIconUrl} alt="Icon" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 16 }} />
) : (
<div className="msm-icon-placeholder" style={{ width: 56, height: 56, fontSize: 18, borderRadius: 16 }}>{serverName.substring(0, 2)}</div>
)}
<input ref={iconInputRef} type="file" accept="image/*" onChange={handleIconFileChange} style={{ display: 'none' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{iconDirty && myPermissions.manage_channels && (
<button className="msm-btn-primary" onClick={handleSaveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '6px 14px' }}>
{savingIcon ? 'Saving...' : 'Save Icon'}
</button>
)}
{currentIconUrl && !iconDirty && myPermissions.manage_channels && (
<button className="msm-btn-danger-outline" onClick={handleRemoveIcon} disabled={savingIcon} style={{ fontSize: 13, padding: '5px 12px' }}>
Remove
</button>
)}
</div>
</div>
{/* Server name */}
<div className="msm-section-label">Server Name</div>
<input
className="msm-input"
value={serverName}
onChange={(e) => { setServerName(e.target.value); setServerNameDirty(true); }}
disabled={!myPermissions.manage_channels}
maxLength={100}
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
/>
{serverNameDirty && myPermissions.manage_channels && (
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveServerName} disabled={!serverName.trim()} style={{ marginTop: 8 }}>
Save Changes
</button>
)}
{/* AFK settings */}
<div className="msm-section-label">Inactive Channel</div>
<select
className="msm-select"
value={afkChannelId}
onChange={(e) => { setAfkChannelId(e.target.value); setAfkDirty(true); }}
disabled={!myPermissions.manage_channels}
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
>
<option value="">No Inactive Channel</option>
{voiceChannels.map(ch => (
<option key={ch._id} value={ch._id}>{ch.name}</option>
))}
</select>
<div className="msm-section-label">Inactive Timeout</div>
<select
className="msm-select"
value={afkTimeout}
onChange={(e) => { setAfkTimeout(Number(e.target.value)); setAfkDirty(true); }}
disabled={!myPermissions.manage_channels}
style={{ opacity: myPermissions.manage_channels ? 1 : 0.5 }}
>
{TIMEOUT_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{afkDirty && myPermissions.manage_channels && (
<button className="msm-btn-primary msm-btn-full" onClick={handleSaveAfkSettings} style={{ marginTop: 8 }}>
Save Changes
</button>
)}
</div>
</div>
);
const renderMobileEmoji = () => (
<div className="msm-screen">
{renderMobileHeader('Emoji', mobileGoBack)}
<div className="msm-content">
{myPermissions.manage_channels && (
<>
<button className="msm-btn-primary msm-btn-full" onClick={() => emojiFileInputRef.current?.click()} style={{ marginBottom: 12 }}>
Upload Emoji
</button>
<input ref={emojiFileInputRef} type="file" accept="image/*,.gif" onChange={handleEmojiFileSelect} style={{ display: 'none' }} />
</>
)}
<p className="msm-description">Add custom emoji that anyone can use in this server.</p>
{customEmojis.length === 0 ? (
<div className="msm-empty">No custom emojis yet</div>
) : (
<div className="msm-card">
{customEmojis.map(emoji => (
<div key={emoji._id} className="msm-emoji-row">
<img src={emoji.src} alt={emoji.name} className="msm-emoji-img" />
<div className="msm-emoji-info">
<span className="msm-emoji-name">:{emoji.name}:</span>
<span className="msm-emoji-uploader">{emoji.uploadedByUsername}</span>
</div>
{myPermissions.manage_channels && (
<button className="msm-emoji-delete" onClick={() => handleEmojiDelete(emoji._id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
const renderMobileRoles = () => (
<div className="msm-screen">
{renderMobileHeader('Roles', mobileGoBack, canManageRoles ? (
<button className="msm-header-action" onClick={handleCreateRole}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
) : null)}
<div className="msm-content">
<p className="msm-description">Roles let you organize members and customize permissions.</p>
{/* @everyone */}
<div className="msm-card" style={{ marginBottom: 16 }}>
<div className="msm-card-item" onClick={() => mobileSelectRole(editableRoles.find(r => r.name === '@everyone') || editableRoles[0])}>
<span className="msm-role-dot" style={{ backgroundColor: '#99aab5' }} />
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>@everyone</span>
<span className="msm-role-count">{members.length}</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
</div>
{/* Other roles */}
{editableRoles.filter(r => r.name !== '@everyone').length > 0 && (
<>
<div className="msm-section-label">Roles - {editableRoles.filter(r => r.name !== '@everyone').length}</div>
<div className="msm-card">
{editableRoles.filter(r => r.name !== '@everyone').map(r => (
<div key={r._id} className="msm-card-item" onClick={() => mobileSelectRole(r)}>
<span className="msm-role-dot" style={{ backgroundColor: r.color || '#99aab5' }} />
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>{r.name}</span>
<span className="msm-role-count">{roleMemberCounts[r._id] || 0}</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
))}
</div>
</>
)}
</div>
</div>
);
const renderMobileRoleEdit = () => {
if (!selectedRole) return renderMobileRoles();
const permList = ['manage_channels', 'manage_roles', 'manage_nicknames', 'create_invite', 'embed_links', 'attach_files', 'move_members', 'mute_members'];
return (
<div className="msm-screen">
{renderMobileHeader(`Edit Role`, () => setMobileScreen('roles'))}
<div className="msm-content">
{/* Role name */}
<div className="msm-section-label">Role Name</div>
<input
className="msm-input"
value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
disabled={!canManageRoles}
style={{ opacity: canManageRoles ? 1 : 0.5 }}
/>
{/* Role color */}
<div className="msm-section-label">Role Color</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<input
type="color"
className="msm-color-input"
value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
disabled={!canManageRoles}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{selectedRole.color}</span>
</div>
{/* Display separately toggle (isHoist) */}
<div className="msm-section-label">Display</div>
<div className="msm-card">
<div className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { isHoist: !selectedRole.isHoist })}>
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15 }}>Display separately</span>
<div className={`msm-toggle ${selectedRole.isHoist ? 'msm-toggle-on' : ''}`}>
<div className="msm-toggle-knob" />
</div>
</div>
</div>
{/* Permissions */}
<div className="msm-section-label">Permissions</div>
<div className="msm-card">
{permList.map(perm => (
<div key={perm} className="msm-card-item" onClick={() => canManageRoles && handleUpdateRole(selectedRole._id, { permissions: { ...selectedRole.permissions, [perm]: !selectedRole.permissions?.[perm] } })}>
<span style={{ flex: 1, color: 'var(--header-primary)', fontSize: 15, textTransform: 'capitalize' }}>{perm.replace(/_/g, ' ')}</span>
<div className={`msm-toggle ${selectedRole.permissions?.[perm] ? 'msm-toggle-on' : ''}`}>
<div className="msm-toggle-knob" />
</div>
</div>
))}
</div>
{/* Delete */}
{canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
<div className="msm-card" style={{ marginTop: 24 }}>
<div className="msm-card-item msm-card-item-danger" onClick={() => handleDeleteRole(selectedRole._id)}>
Delete Role
</div>
</div>
)}
</div>
</div>
);
};
const renderMobileMembers = () => (
<div className="msm-screen">
{renderMobileHeader('Members', mobileGoBack)}
<div className="msm-content">
{members.length === 0 ? (
<div className="msm-empty">No members found</div>
) : (
<div className="msm-card">
{members.map(m => (
<div key={m.id} className="msm-member-row">
<div className="msm-member-avatar">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" style={{ width: 36, height: 36, borderRadius: '50%' }} />
) : (
m.username[0].toUpperCase()
)}
</div>
<div className="msm-member-info">
<div className="msm-member-name">{m.username}</div>
<div className="msm-member-roles">
{m.roles?.map(r => (
<span key={r._id} className="msm-member-role-pill" style={{ backgroundColor: r.color + '33', color: r.color, borderColor: r.color + '66' }}>
{r.name}
</span>
))}
</div>
</div>
{canManageRoles && (
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
{editableRoles.map(r => {
const hasRole = m.roles?.some(ur => ur._id === r._id);
return (
<button
key={r._id}
className="msm-member-role-toggle"
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
style={{
borderColor: r.color,
backgroundColor: hasRole ? r.color : 'transparent',
}}
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
/>
);
})}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
const renderMobileContent = () => {
switch (mobileScreen) {
case 'overview': return renderMobileOverview();
case 'emoji': return renderMobileEmoji();
case 'roles': return renderMobileRoles();
case 'role-edit': return renderMobileRoleEdit();
case 'members': return renderMobileMembers();
default: return renderMobileMenu();
}
};
// ─── Shared modals ───
const sharedModals = (
<>
{showIconCropModal && rawIconUrl && (
<AvatarCropModal
imageUrl={rawIconUrl}
onApply={handleIconCropApply}
onCancel={handleIconCropCancel}
cropShape="rect"
/>
)}
{showEmojiModal && emojiPreviewUrl && (
<div
onClick={handleEmojiModalClose}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
width: 580, maxWidth: '90vw', overflow: 'hidden',
}}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 16px 0',
}}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
<button
onClick={handleEmojiModalClose}
style={{
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
}}
>
</button>
</div>
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
width: 240, height: 240, position: 'relative',
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
overflow: 'hidden',
}}>
<Cropper
image={emojiPreviewUrl}
crop={emojiCrop}
zoom={emojiZoom}
rotation={emojiRotation}
aspect={1}
cropShape="rect"
showGrid={false}
onCropChange={setEmojiCrop}
onZoomChange={setEmojiZoom}
onCropComplete={onEmojiCropComplete}
style={{
containerStyle: { width: 240, height: 240 },
mediaStyle: {
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
},
}}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
<button onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)} title="Rotate left" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button onClick={() => setEmojiRotation((r) => (r + 90) % 360)} title="Rotate right" style={{ width: 36, height: 36, borderRadius: 4, background: 'var(--bg-tertiary)', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button onClick={() => setEmojiFlipH((f) => !f)} title="Flip horizontal" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipH ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
<button onClick={() => setEmojiFlipV((f) => !f)} title="Flip vertical" style={{ width: 36, height: 36, borderRadius: 4, background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)', border: emojiFlipV ? '1px solid #5865F2' : 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
</div>
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
<input type="range" min={1} max={3} step={0.01} value={emojiZoom} onChange={(e) => setEmojiZoom(Number(e.target.value))} className="avatar-crop-slider" />
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)"><path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/></svg>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: 16 }}>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>Preview</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, backgroundColor: 'rgba(88, 101, 242, 0.15)', border: '1px solid var(--brand-experiment, #5865F2)', borderRadius: 8, padding: '2px 6px', cursor: 'default' }}>
<img src={emojiPreviewUrl} alt="" style={{ width: 16, height: 16, objectFit: 'contain', transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined }} />
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
Emoji name <span style={{ color: '#ed4245' }}>*</span>
</label>
<div style={{ position: 'relative' }}>
<input type="text" value={emojiName} onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }} maxLength={32} style={{ width: '100%', padding: '10px 32px 10px 10px', background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', fontSize: 14, boxSizing: 'border-box' }} />
{emojiName && (
<button onClick={() => setEmojiName('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer', fontSize: 14, padding: '2px 4px' }}></button>
)}
</div>
{emojiError && <div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>}
</div>
<div style={{ flex: 1 }} />
<button onClick={handleEmojiUpload} disabled={emojiUploading || !emojiName.trim()} style={{ backgroundColor: '#5865F2', color: '#fff', border: 'none', borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: 14, width: '100%', opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1 }}>
{emojiUploading ? 'Uploading...' : 'Finish'}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
// ─── Main return ───
if (isMobile) {
return ReactDOM.createPortal(
<div className="msm-root">
{renderMobileContent()}
{sharedModals}
</div>,
document.body
);
}
return ( return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}> <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
{renderSidebar()} {renderSidebar()}
@@ -772,231 +1299,7 @@ const ServerSettingsModal = ({ onClose }) => {
</div> </div>
<div style={{ flex: '0.5' }} /> <div style={{ flex: '0.5' }} />
</div> </div>
{showIconCropModal && rawIconUrl && ( {sharedModals}
<AvatarCropModal
imageUrl={rawIconUrl}
onApply={handleIconCropApply}
onCancel={handleIconCropCancel}
cropShape="rect"
/>
)}
{showEmojiModal && emojiPreviewUrl && (
<div
onClick={handleEmojiModalClose}
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 2000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'var(--bg-secondary)', borderRadius: 8,
width: 580, maxWidth: '90vw', overflow: 'hidden',
}}
>
{/* Modal header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 16px 0',
}}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, fontSize: 20, fontWeight: 600 }}>Add Emoji</h2>
<button
onClick={handleEmojiModalClose}
style={{
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 20, padding: '4px 8px',
}}
>
</button>
</div>
{/* Modal body */}
<div style={{ display: 'flex', padding: '20px 16px 16px', gap: 24 }}>
{/* Left: Cropper + toolbar + zoom */}
<div style={{ width: 240, minWidth: 240, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{
width: 240, height: 240, position: 'relative',
backgroundColor: 'var(--bg-tertiary)', borderRadius: 8,
overflow: 'hidden',
}}>
<Cropper
image={emojiPreviewUrl}
crop={emojiCrop}
zoom={emojiZoom}
rotation={emojiRotation}
aspect={1}
cropShape="rect"
showGrid={false}
onCropChange={setEmojiCrop}
onZoomChange={setEmojiZoom}
onCropComplete={onEmojiCropComplete}
style={{
containerStyle: { width: 240, height: 240 },
mediaStyle: {
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
},
}}
/>
</div>
{/* Toolbar: rotate + flip */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 4 }}>
<button
onClick={() => setEmojiRotation((r) => (r - 90 + 360) % 360)}
title="Rotate left"
style={{
width: 36, height: 36, borderRadius: 4,
background: 'var(--bg-tertiary)', border: 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button
onClick={() => setEmojiRotation((r) => (r + 90) % 360)}
title="Rotate right"
style={{
width: 36, height: 36, borderRadius: 4,
background: 'var(--bg-tertiary)', border: 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'scaleX(-1)' }}><path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/></svg>
</button>
<button
onClick={() => setEmojiFlipH((f) => !f)}
title="Flip horizontal"
style={{
width: 36, height: 36, borderRadius: 4,
background: emojiFlipH ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
border: emojiFlipH ? '1px solid #5865F2' : 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
<button
onClick={() => setEmojiFlipV((f) => !f)}
title="Flip vertical"
style={{
width: 36, height: 36, borderRadius: 4,
background: emojiFlipV ? 'rgba(88, 101, 242, 0.3)' : 'var(--bg-tertiary)',
border: emojiFlipV ? '1px solid #5865F2' : 'none',
color: 'var(--header-secondary)', cursor: 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(90deg)' }}><path d="M15 21h2v-2h-2v2zm4-12h2V7h-2v2zM3 5v14c0 1.1.9 2 2 2h4v-2H5V5h4V3H5c-1.1 0-2 .9-2 2zm16-2v2h2c0-1.1-.9-2-2-2zm-8-2h2v24h-2V1zm8 8h2v2h-2v-2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-4h2v-2h-2v2zm-4 8h2v-2h-2v2zm4-16h2v-2h-2v2z"/></svg>
</button>
</div>
{/* Zoom slider */}
<div className="avatar-crop-slider-row" style={{ padding: 0, margin: 0 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
<input
type="range"
min={1}
max={3}
step={0.01}
value={emojiZoom}
onChange={(e) => setEmojiZoom(Number(e.target.value))}
className="avatar-crop-slider"
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
</div>
</div>
{/* Right: Reaction preview + Name + Finish */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Reaction pill preview */}
<div style={{ marginBottom: 16 }}>
<span style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
Preview
</span>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
backgroundColor: 'rgba(88, 101, 242, 0.15)',
border: '1px solid var(--brand-experiment, #5865F2)',
borderRadius: 8, padding: '2px 6px', cursor: 'default',
}}>
<img
src={emojiPreviewUrl}
alt=""
style={{
width: 16, height: 16, objectFit: 'contain',
transform: `${emojiFlipH ? 'scaleX(-1)' : ''} ${emojiFlipV ? 'scaleY(-1)' : ''}`.trim() || undefined,
}}
/>
<span style={{ color: 'var(--text-normal)', fontSize: 14, marginLeft: 2 }}>1</span>
</div>
</div>
{/* Emoji name input */}
<div style={{ marginBottom: 16 }}>
<label style={{ color: 'var(--header-secondary)', fontSize: 12, fontWeight: 700, textTransform: 'uppercase', display: 'block', marginBottom: 8 }}>
Emoji name <span style={{ color: '#ed4245' }}>*</span>
</label>
<div style={{ position: 'relative' }}>
<input
type="text"
value={emojiName}
onChange={(e) => { setEmojiName(e.target.value); setEmojiError(''); }}
maxLength={32}
style={{
width: '100%', padding: '10px 32px 10px 10px',
background: 'var(--bg-tertiary)', border: 'none',
borderRadius: 4, color: 'var(--header-primary)', fontSize: 14,
boxSizing: 'border-box',
}}
/>
{emojiName && (
<button
onClick={() => setEmojiName('')}
style={{
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
background: 'transparent', border: 'none', color: 'var(--header-secondary)',
cursor: 'pointer', fontSize: 14, padding: '2px 4px',
}}
>
</button>
)}
</div>
{emojiError && (
<div style={{ color: '#ed4245', fontSize: 13, marginTop: 6 }}>{emojiError}</div>
)}
</div>
<div style={{ flex: 1 }} />
{/* Finish button */}
<button
onClick={handleEmojiUpload}
disabled={emojiUploading || !emojiName.trim()}
style={{
backgroundColor: '#5865F2', color: '#fff', border: 'none',
borderRadius: 3, padding: '10px 0', cursor: emojiUploading ? 'not-allowed' : 'pointer',
fontWeight: 600, fontSize: 14, width: '100%',
opacity: (emojiUploading || !emojiName.trim()) ? 0.5 : 1,
}}
>
{emojiUploading ? 'Uploading...' : 'Finish'}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -7,6 +7,11 @@ import { useVoice } from '../contexts/VoiceContext';
import ChannelSettingsModal from './ChannelSettingsModal'; import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal'; import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal'; import ScreenShareModal from './ScreenShareModal';
import MobileServerDrawer from './MobileServerDrawer';
import MobileCreateChannelScreen from './MobileCreateChannelScreen';
import MobileCreateCategoryScreen from './MobileCreateCategoryScreen';
import MobileChannelDrawer from './MobileChannelDrawer';
import MobileChannelSettingsScreen from './MobileChannelSettingsScreen';
import DMList from './DMList'; import DMList from './DMList';
import Avatar from './Avatar'; import Avatar from './Avatar';
import UserSettings from './UserSettings'; import UserSettings from './UserSettings';
@@ -808,7 +813,7 @@ const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
); );
}; };
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser }) => { const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId, serverName = 'Secure Chat', serverIconUrl, isMobile, onStartCallWithUser, onOpenMobileSearch }) => {
const { crypto, settings } = usePlatform(); const { crypto, settings } = usePlatform();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -835,6 +840,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const [activeDragItem, setActiveDragItem] = useState(null); const [activeDragItem, setActiveDragItem] = useState(null);
const [dragOverChannelId, setDragOverChannelId] = useState(null); const [dragOverChannelId, setDragOverChannelId] = useState(null);
const [voiceNicknameModal, setVoiceNicknameModal] = useState(null); const [voiceNicknameModal, setVoiceNicknameModal] = useState(null);
const [showMobileServerDrawer, setShowMobileServerDrawer] = useState(false);
const [showMobileCreateChannel, setShowMobileCreateChannel] = useState(false);
const [showMobileCreateCategory, setShowMobileCreateCategory] = useState(false);
const [mobileChannelDrawer, setMobileChannelDrawer] = useState(null);
const [showMobileChannelSettings, setShowMobileChannelSettings] = useState(null);
const convex = useConvex(); const convex = useConvex();
@@ -844,6 +854,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
userId ? { userId } : "skip" userId ? { userId } : "skip"
) || {}; ) || {};
// Member count for mobile server drawer
const allUsersForDrawer = useQuery(api.auth.getPublicKeys) || [];
// DnD sensors // DnD sensors
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: isMobile ? Infinity : 5 } })
@@ -1099,6 +1112,52 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
} }
}; };
// Long-press handler factory for mobile channel items
const createLongPressHandlers = (callback) => {
let timer = null;
let startX = 0;
let startY = 0;
let triggered = false;
return {
onTouchStart: (e) => {
triggered = false;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
timer = setTimeout(() => {
triggered = true;
if (navigator.vibrate) navigator.vibrate(50);
callback();
}, 500);
},
onTouchMove: (e) => {
if (!timer) return;
const dx = e.touches[0].clientX - startX;
const dy = e.touches[0].clientY - startY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
clearTimeout(timer);
timer = null;
}
},
onTouchEnd: (e) => {
if (timer) { clearTimeout(timer); timer = null; }
if (triggered) { e.preventDefault(); triggered = false; }
},
};
};
const handleMarkAsRead = async (channelId) => {
if (!userId) return;
try {
await convex.mutation(api.readState.markRead, {
userId,
channelId,
lastReadTimestamp: Date.now(),
});
} catch (e) {
console.error('Failed to mark as read:', e);
}
};
const renderDMView = () => ( const renderDMView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}> <div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<DMList <DMList
@@ -1393,12 +1452,34 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const renderServerView = () => ( const renderServerView = () => (
<div className="channel-panel" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}> <div className="channel-panel" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
<div className="server-header" style={{ borderBottom: '1px solid var(--app-frame-border)' }}> <div className="server-header" style={{ borderBottom: isMobile ? 'none' : '1px solid var(--app-frame-border)' }}>
<span className="server-header-name" onClick={() => setIsServerSettingsOpen(true)}>{serverName}</span> <span className="server-header-name" onClick={() => isMobile ? setShowMobileServerDrawer(true) : setIsServerSettingsOpen(true)}>
{serverName}
{isMobile && (
<svg className="mobile-server-chevron" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.42 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.71 6.7a1 1 0 0 0-1.42 0Z"/>
</svg>
)}
</span>
{!isMobile && (
<button className="server-header-invite" onClick={handleCreateInvite} title="Invite People"> <button className="server-header-invite" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" /> <img src={inviteUserIcon} alt="Invite" />
</button> </button>
)}
</div> </div>
{isMobile && (
<div className="mobile-search-invite-row">
<button className="mobile-search-bar-btn" onClick={onOpenMobileSearch}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
Search
</button>
<button className="mobile-search-invite-btn" onClick={handleCreateInvite} title="Invite People">
<img src={inviteUserIcon} alt="Invite" />
</button>
</div>
)}
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={isMobile ? undefined : (e) => { <div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={isMobile ? undefined : (e) => {
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) { if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
@@ -1497,6 +1578,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`} className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
onClick={() => handleChannelClick(channel)} onClick={() => handleChannelClick(channel)}
{...channelDragListeners} {...channelDragListeners}
{...(isMobile ? createLongPressHandlers(() => setMobileChannelDrawer(channel)) : {})}
style={{ style={{
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@@ -1523,6 +1605,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
</span> </span>
</div> </div>
{!isMobile && (
<button <button
className="channel-settings-icon" className="channel-settings-icon"
onClick={(e) => { onClick={(e) => {
@@ -1539,6 +1622,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
> >
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" /> <ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button> </button>
)}
</div>} </div>}
{isCollapsed {isCollapsed
? renderCollapsedVoiceUsers(channel) ? renderCollapsedVoiceUsers(channel)
@@ -1712,7 +1796,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<UserControlPanel username={username} userId={userId} /> <UserControlPanel username={username} userId={userId} />
{editingChannel && ( {editingChannel && !isMobile && (
<ChannelSettingsModal <ChannelSettingsModal
channel={editingChannel} channel={editingChannel}
onClose={() => setEditingChannel(null)} onClose={() => setEditingChannel(null)}
@@ -1723,6 +1807,18 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
{isServerSettingsOpen && ( {isServerSettingsOpen && (
<ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} /> <ServerSettingsModal onClose={() => setIsServerSettingsOpen(false)} />
)} )}
{showMobileServerDrawer && (
<MobileServerDrawer
serverName={serverName}
serverIconUrl={serverIconUrl}
memberCount={allUsersForDrawer.length}
onInvite={handleCreateInvite}
onSettings={() => setIsServerSettingsOpen(true)}
onCreateChannel={() => { setCreateChannelCategoryId(null); setShowMobileCreateChannel(true); }}
onCreateCategory={() => setShowMobileCreateCategory(true)}
onClose={() => setShowMobileServerDrawer(false)}
/>
)}
{isScreenShareModalOpen && ( {isScreenShareModalOpen && (
<ScreenShareModal <ScreenShareModal
onClose={() => setIsScreenShareModalOpen(false)} onClose={() => setIsScreenShareModalOpen(false)}
@@ -1828,6 +1924,54 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}} }}
/> />
)} )}
{showMobileCreateChannel && (
<MobileCreateChannelScreen
categoryId={createChannelCategoryId}
onClose={() => setShowMobileCreateChannel(false)}
onSubmit={async (name, type, catId) => {
const userId = localStorage.getItem('userId');
if (!userId) { alert("Please login first."); return; }
try {
const createArgs = { name, type };
if (catId) createArgs.categoryId = catId;
const { id: channelId } = await convex.mutation(api.channels.create, createArgs);
const keyHex = randomHex(32);
try { await encryptKeyForUsers(convex, channelId, keyHex, crypto); }
catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); }
} catch (err) { console.error(err); alert("Failed to create channel: " + err.message); }
}}
/>
)}
{showMobileCreateCategory && (
<MobileCreateCategoryScreen
onClose={() => setShowMobileCreateCategory(false)}
onSubmit={async (name) => {
try {
await convex.mutation(api.categories.create, { name });
} catch (err) {
console.error(err);
alert("Failed to create category: " + err.message);
}
}}
/>
)}
{mobileChannelDrawer && (
<MobileChannelDrawer
channel={mobileChannelDrawer}
isUnread={unreadChannels.has(mobileChannelDrawer._id)}
onMarkAsRead={() => handleMarkAsRead(mobileChannelDrawer._id)}
onEditChannel={() => setShowMobileChannelSettings(mobileChannelDrawer)}
onClose={() => setMobileChannelDrawer(null)}
/>
)}
{showMobileChannelSettings && (
<MobileChannelSettingsScreen
channel={showMobileChannelSettings}
categories={categories}
onClose={() => setShowMobileChannelSettings(null)}
onDelete={onDeleteChannel}
/>
)}
</div> </div>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { useQuery, useConvex } from 'convex/react'; import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar'; import Avatar from './Avatar';
@@ -7,6 +8,7 @@ import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import { useSearch } from '../contexts/SearchContext'; import { useSearch } from '../contexts/SearchContext';
import { usePlatform } from '../platform'; import { usePlatform } from '../platform';
import { useIsMobile } from '../hooks/useIsMobile';
const THEME_PREVIEWS = { const THEME_PREVIEWS = {
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' }, [THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
@@ -26,14 +28,22 @@ const TABS = [
const UserSettings = ({ onClose, userId, username, onLogout }) => { const UserSettings = ({ onClose, userId, username, onLogout }) => {
const [activeTab, setActiveTab] = useState('account'); const [activeTab, setActiveTab] = useState('account');
const isMobile = useIsMobile();
const [mobileScreen, setMobileScreen] = useState('menu');
useEffect(() => { useEffect(() => {
const handleKey = (e) => { const handleKey = (e) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') {
if (isMobile && mobileScreen !== 'menu') {
setMobileScreen('menu');
} else {
onClose();
}
}
}; };
window.addEventListener('keydown', handleKey); window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
}, [onClose]); }, [onClose, isMobile, mobileScreen]);
const renderSidebar = () => { const renderSidebar = () => {
let lastSection = null; let lastSection = null;
@@ -91,6 +101,174 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
return items; return items;
}; };
// ─── Mobile render functions ───
const renderMobileHeader = (title, onBack) => (
<div className="msm-header">
<button className="msm-back-btn" onClick={onBack}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
<div className="msm-header-title">{title}</div>
<div style={{ width: 32 }} />
</div>
);
const renderMobileMenu = () => (
<div className="msm-screen">
<div className="msm-header">
<div style={{ flex: 1 }} />
<button className="msm-back-btn" onClick={onClose}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div className="msm-content">
<div className="msm-section-label">User Settings</div>
<div className="msm-card">
<div className="msm-card-item" onClick={() => setMobileScreen('account')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Account</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
<div className="msm-card-item" onClick={() => setMobileScreen('security')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Security</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
</div>
<div className="msm-section-label">App Settings</div>
<div className="msm-card">
<div className="msm-card-item" onClick={() => setMobileScreen('appearance')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Appearance</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
<div className="msm-card-item" onClick={() => setMobileScreen('voice')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Voice & Video</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
<div className="msm-card-item" onClick={() => setMobileScreen('keybinds')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Keybinds</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
<div className="msm-card-item" onClick={() => setMobileScreen('search')}>
<span className="msm-card-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
</span>
<span style={{ flex: 1, fontSize: 15, color: 'var(--header-primary)' }}>Search</span>
<span className="msm-card-item-chevron">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
</div>
{/* Log Out */}
<div className="msm-card" style={{ marginTop: 24 }}>
<div className="msm-card-item msm-card-item-danger" onClick={onLogout}>
<span className="msm-card-item-icon" style={{ color: '#ed4245' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 17L21 12L16 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M21 12H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
Log Out
</div>
</div>
</div>
</div>
);
const renderMobileContent = () => {
switch (mobileScreen) {
case 'account':
return (
<div className="msm-screen">
{renderMobileHeader('Account', () => setMobileScreen('menu'))}
<div className="msm-content">
<MyAccountTab userId={userId} username={username} />
</div>
</div>
);
case 'security':
return (
<div className="msm-screen">
{renderMobileHeader('Security', () => setMobileScreen('menu'))}
<div className="msm-content">
<SecurityTab />
</div>
</div>
);
case 'appearance':
return (
<div className="msm-screen">
{renderMobileHeader('Appearance', () => setMobileScreen('menu'))}
<div className="msm-content">
<AppearanceTab />
</div>
</div>
);
case 'voice':
return (
<div className="msm-screen">
{renderMobileHeader('Voice & Video', () => setMobileScreen('menu'))}
<div className="msm-content">
<VoiceVideoTab />
</div>
</div>
);
case 'keybinds':
return (
<div className="msm-screen">
{renderMobileHeader('Keybinds', () => setMobileScreen('menu'))}
<div className="msm-content">
<KeybindsTab />
</div>
</div>
);
case 'search':
return (
<div className="msm-screen">
{renderMobileHeader('Search', () => setMobileScreen('menu'))}
<div className="msm-content">
<SearchTab userId={userId} />
</div>
</div>
);
default:
return renderMobileMenu();
}
};
if (isMobile) {
return ReactDOM.createPortal(
<div className="msm-root">{renderMobileContent()}</div>,
document.body
);
}
return ( return (
<div style={{ <div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView'; import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList'; import MembersList from '../components/MembersList';
import MobileMembersScreen from '../components/MobileMembersScreen'; import MobileMembersScreen from '../components/MobileMembersScreen';
import MobileSearchScreen from '../components/MobileSearchScreen';
import ChatHeader from '../components/ChatHeader'; import ChatHeader from '../components/ChatHeader';
import SearchPanel from '../components/SearchPanel'; import SearchPanel from '../components/SearchPanel';
import SearchDropdown from '../components/SearchDropdown'; import SearchDropdown from '../components/SearchDropdown';
@@ -40,6 +41,7 @@ const Chat = () => {
const [showMembers, setShowMembers] = useState(true); const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false); const [showPinned, setShowPinned] = useState(false);
const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false); const [showMobileMembersScreen, setShowMobileMembersScreen] = useState(false);
const [showMobileSearchScreen, setShowMobileSearchScreen] = useState(false);
const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps } = const { activeView: mobileView, goToChat, goToSidebar, trayRef, trayStyle, isSwiping, swipeBindProps } =
useSwipeNavigation({ useSwipeNavigation({
@@ -497,7 +499,8 @@ const Chat = () => {
setShowSearchResults(false); setShowSearchResults(false);
setSearchQuery(''); setSearchQuery('');
setJumpToMessageId(messageId); setJumpToMessageId(messageId);
}, [dmChannels]); if (isMobile) goToChat();
}, [dmChannels, isMobile, goToChat]);
// Shared search props for ChatHeader // Shared search props for ChatHeader
const searchProps = { const searchProps = {
@@ -617,7 +620,7 @@ const Chat = () => {
return ( return (
<div className="chat-container"> <div className="chat-container">
{isMobile && ( {isMobile && (
<div className="chat-header"> <div className="chat-header voice-header">
<div className="chat-header-left"> <div className="chat-header-left">
<button className="mobile-back-btn" onClick={handleMobileBack}> <button className="mobile-back-btn" onClick={handleMobileBack}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
@@ -736,6 +739,7 @@ const Chat = () => {
serverIconUrl={serverIconUrl} serverIconUrl={serverIconUrl}
isMobile={isMobile} isMobile={isMobile}
onStartCallWithUser={handleStartCallWithUser} onStartCallWithUser={handleStartCallWithUser}
onOpenMobileSearch={() => setShowMobileSearchScreen(true)}
/> />
); );
@@ -786,6 +790,22 @@ const Chat = () => {
onClose={() => setShowMobileMembersScreen(false)} onClose={() => setShowMobileMembersScreen(false)}
/> />
)} )}
{showMobileSearchScreen && isMobile && (
<MobileSearchScreen
channels={channels}
allMembers={allMembers}
serverName={serverName}
onClose={() => setShowMobileSearchScreen(false)}
onSelectChannel={(channelId) => {
handleSelectChannel(channelId);
setShowMobileSearchScreen(false);
}}
onJumpToMessage={(channelId, messageId) => {
handleJumpToMessage(channelId, messageId);
setShowMobileSearchScreen(false);
}}
/>
)}
</div> </div>
</PresenceProvider> </PresenceProvider>
); );

View File

@@ -398,16 +398,21 @@
/* Chat area/header use var(--bg-primary), not --bg-tertiary, so override explicitly */ /* Chat area/header use var(--bg-primary), not --bg-tertiary, so override explicitly */
.theme-dark .is-mobile .chat-container, .theme-dark .is-mobile .chat-container,
.theme-dark .is-mobile .chat-area, .theme-dark .is-mobile .chat-area,
.theme-dark .is-mobile .chat-header,
.theme-dark .is-mobile .chat-input-form, .theme-dark .is-mobile .chat-input-form,
.theme-dark .mobile-members-screen { .theme-dark .mobile-members-screen,
.theme-dark .mobile-search-screen {
background-color: #1C1D22; background-color: #1C1D22;
} }
.theme-dark .is-mobile .chat-header { .theme-dark .is-mobile .chat-header {
background-color: #1C1D22;
border-bottom: 1px solid var(--app-frame-border); border-bottom: 1px solid var(--app-frame-border);
} }
.theme-dark .is-mobile .chat-header.voice-header {
background-color: #000;
}
.theme-dark .is-mobile .chat-input-wrapper { .theme-dark .is-mobile .chat-input-wrapper {
background-color: #26262E; background-color: #26262E;
} }

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect, useRef } from 'react';
import { usePlatform } from '../platform';
import { AllEmojis } from '../assets/emojis';
export function formatTime(ts) {
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
export function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function linkifyHtml(html) {
if (!html) return '';
return html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="search-result-link">$1</a>');
}
export function formatEmojisHtml(html, customEmojis = []) {
if (!html) return '';
return html.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
const custom = customEmojis.find(e => e.name === name);
if (custom) return `<img src="${custom.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
const emoji = AllEmojis.find(e => e.name === name);
if (emoji) return `<img src="${emoji.src}" alt="${match}" style="width:48px;height:48px;vertical-align:bottom;margin:0 1px;display:inline" />`;
return match;
});
}
export function getAvatarColor(name) {
const colors = ['#5865F2', '#57F287', '#FEE75C', '#EB459E', '#ED4245', '#F47B67', '#E67E22', '#3498DB'];
let hash = 0;
for (let i = 0; i < (name || '').length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
export const CONVEX_PUBLIC_URL = 'https://api.brycord.com';
export const rewriteStorageUrl = (url) => {
try {
const u = new URL(url);
const pub = new URL(CONVEX_PUBLIC_URL);
u.hostname = pub.hostname;
u.port = pub.port;
u.protocol = pub.protocol;
return u.toString();
} catch { return url; }
};
export const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
export const searchImageCache = new Map();
export const SearchResultImage = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search image decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading image...</div>;
if (error) return null;
return <img src={url} alt={metadata.filename} style={{ width: '100%', height: 'auto', borderRadius: 4, marginTop: 4, display: 'block' }} />;
};
export const SearchResultVideo = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
const [error, setError] = useState(null);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) throw new Error('Invalid file data');
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search video decrypt error:', err);
if (isMounted) { setError('Failed to load'); setLoading(false); }
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '11px', marginTop: 4 }}>Loading video...</div>;
if (error) return null;
const handlePlayClick = () => { setShowControls(true); if (videoRef.current) videoRef.current.play(); };
return (
<div style={{ marginTop: 4, position: 'relative', display: 'inline-block', maxWidth: '100%' }}>
<video ref={videoRef} src={url} controls={showControls} style={{ width: '100%', maxHeight: 200, borderRadius: 4, display: 'block', backgroundColor: 'black' }} />
{!showControls && <div className="play-icon" onClick={handlePlayClick} style={{ cursor: 'pointer' }}>&#9654;</div>}
</div>
);
};
export const SearchResultFile = ({ metadata }) => {
const { crypto } = usePlatform();
const fetchUrl = rewriteStorageUrl(metadata.url);
const [url, setUrl] = useState(searchImageCache.get(fetchUrl) || null);
const [loading, setLoading] = useState(!searchImageCache.has(fetchUrl));
useEffect(() => {
if (searchImageCache.has(fetchUrl)) {
setUrl(searchImageCache.get(fetchUrl));
setLoading(false);
return;
}
let isMounted = true;
const decrypt = async () => {
try {
const res = await fetch(fetchUrl);
const blob = await res.blob();
const arrayBuffer = await blob.arrayBuffer();
const hexInput = toHexString(new Uint8Array(arrayBuffer));
if (hexInput.length < 32) return;
const TAG_HEX_LEN = 32;
const contentHex = hexInput.slice(0, -TAG_HEX_LEN);
const tagHex = hexInput.slice(-TAG_HEX_LEN);
const decrypted = await crypto.decryptData(contentHex, metadata.key, metadata.iv, tagHex, { encoding: 'buffer' });
const decryptedBlob = new Blob([decrypted], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(decryptedBlob);
if (isMounted) {
searchImageCache.set(fetchUrl, objectUrl);
setUrl(objectUrl);
setLoading(false);
}
} catch (err) {
console.error('Search file decrypt error:', err);
if (isMounted) setLoading(false);
}
};
decrypt();
return () => { isMounted = false; };
}, [fetchUrl, metadata, crypto]);
const sizeStr = metadata.size ? `${(metadata.size / 1024).toFixed(1)} KB` : '';
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '8px 10px', borderRadius: 4, marginTop: 4, maxWidth: '100%' }}>
<span style={{ marginRight: 8, fontSize: 20 }}>&#128196;</span>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 13 }}>{metadata.filename}</div>
{sizeStr && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>{sizeStr}</div>}
{url && <a href={url} download={metadata.filename} onClick={e => e.stopPropagation()} style={{ color: 'var(--header-secondary)', fontSize: 11, textDecoration: 'underline' }}>Download</a>}
{loading && <div style={{ color: 'var(--header-secondary)', fontSize: 11 }}>Decrypting...</div>}
</div>
</div>
);
};