Compare commits

..
Author SHA1 Message Date
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
242 changed files with 3131 additions and 10011 deletions
-4
View File
@@ -1,6 +1,2 @@
node_modules/
.git/
dist/
experiment/
*.md
!README.md
+41 -196
View File
@@ -30,13 +30,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -57,69 +52,56 @@ jobs:
sleep $((attempt * 15))
done
# ── #99 — lockfile.yml (GitHub-only, deleted) commented on lockfile
# diffs after the fact; this is the CI-native equivalent, ported as a
# hard gate. `npm ci` already refuses to run on an out-of-range
# mismatch, but it can silently normalize lesser lockfile drift (e.g.
# metadata/resolved fields behind a stale-but-satisfiable range)
# without failing — so assert zero diff afterward, which is the
# cheapest way to also catch that class of drift.
- name: Verify lockfile is in sync
run: git diff --exit-code package-lock.json
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
# error fails in seconds instead of after the ~minutes-long build. All are
# hard gates — any failure fails the job and blocks the deploy. The tree is
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
# deploy without CI; a later normal push surfaces any introduced issue here
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
# eslint gates on errors, plus a warning ratchet (Gitea #97): `check:eslint`
# runs with `--max-warnings 74`, the exact warning count on this tree at
# the time the ratchet was added. New warnings push the count over that
# ceiling and fail the build; fixing an existing warning is free to do
# and should lower the ceiling in the same PR so the count can only go
# down over time, never back up.
- name: Prettier
run: npm run check:prettier
- name: ESLint
run: npm run check:eslint
- name: TypeScript
run: npm run typecheck
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
# ── Critical gate — if this fails, nothing deploys ──────────────────
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# ── Boot check — actually loads the built dist/, not just builds it ──
- name: Boot check
run: node scripts/boot-check.mjs
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Security — hard gate. #24 cleared the outstanding advisories (0
# vulnerabilities on this tree, verified with `npm audit --omit=dev`), so
# there is nothing left this should be soft against. Hard on both
# `push` and `pull_request`: a new high/critical advisory should block
# the deploy just as much as it should block the PR.
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
# formatted), so these gate real regressions instead of relying on local
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- name: TypeScript
run: npm run typecheck
- name: ESLint
run: npm run check:eslint
- name: Prettier
run: npm run check:prettier
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
continue-on-error: true
# ── Bundle size budget — hard gate on pull_request, warning on push (a
# push has already merged; failing it can only delay deploying an
# otherwise-good commit, not prevent the regression, so pull_request is
# where this should be caught). Budgets live in scripts/bundle-budget.json.
- name: Check bundle size budget
continue-on-error: ${{ github.event_name == 'push' }}
run: node scripts/check-bundle-size.mjs ${{ github.event_name }}
# ── Bundle size report (informational — never blocks a deploy) ───────
- name: Report bundle sizes
continue-on-error: true
run: |
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
name=$(basename "$f")
size=$(du -sh "$f" | cut -f1)
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
done
# ── Desktop build trigger ──────────────────────────────────────────────
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
@@ -152,140 +134,3 @@ jobs:
git push origin main
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
fi
# ── #95 — secret scanning ────────────────────────────────────────────────
# zricethezav/gitleaks-action is GitHub-Actions-only; on the Gitea act_runner
# we can't assume the host has a Docker daemon reachable from job containers
# (see the `docker` job below), so this downloads the pinned linux/amd64
# binary release directly instead. `--no-git` scans the checked-out tree as
# plain files (a point-in-time content scan) rather than walking history,
# since this runs on both push and pull_request and a PR's shallow checkout
# doesn't carry full history anyway.
gitleaks:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install gitleaks 8.30.1
run: |
curl -fsSL -o gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
- name: Scan for secrets
run: ./gitleaks detect --no-git -v --redact --source . --config .gitleaks.toml
# ── #93 — the image was never actually built in CI, so a Dockerfile break
# (or a header regression, once #95's nginx CSP shipped) could sit unnoticed
# until a manual `docker build` on deploy infra caught it. This builds the
# real image, boots it, and asserts both a 200 and the security headers
# added to docker-nginx.conf for #95.
#
# Gated on the repo/org Actions VARIABLE `CI_HAS_DOCKER` == "true": run #1880
# proved the shared act_runner has no `docker` binary in job containers, and
# Gitea does not honour job-level continue-on-error for the run conclusion,
# so an unconditional job just paints every run red. Set the variable once a
# Docker-capable runner (or DinD) is attached; until then the job is skipped.
docker:
name: Docker image build & smoke test
needs: build
if: ${{ vars.CI_HAS_DOCKER == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build image
run: docker build -t cinny-ci .
- name: Run container
run: docker run -d --name cinny-ci -p 8095:80 cinny-ci
- name: Wait for container to be ready
run: |
for i in $(seq 1 30); do
curl -fsS -o /dev/null http://localhost:8095/ && exit 0
sleep 1
done
echo "container never became ready" >&2
exit 1
- name: Check response and security headers
run: |
headers="$(curl -fsSI http://localhost:8095/)"
echo "$headers"
echo "$headers" | grep -qi '^HTTP/[0-9.]* 200' || { echo "expected HTTP 200"; exit 1; }
echo "$headers" | grep -qi '^content-security-policy:' || { echo "missing Content-Security-Policy header"; exit 1; }
echo "$headers" | grep -qi "frame-ancestors 'none'" || { echo "CSP missing frame-ancestors 'none'"; exit 1; }
echo "$headers" | grep -qi '^referrer-policy: *no-referrer' || { echo "missing Referrer-Policy header"; exit 1; }
echo "$headers" | grep -qi '^x-content-type-options: *nosniff' || { echo "missing X-Content-Type-Options header"; exit 1; }
- name: Stop container
if: always()
run: docker rm -f cinny-ci || true
# ── #90 — Playwright smoke test ──────────────────────────────────────────
# Boots the built client in a real (headless) Chromium and drives it. Two
# tiers live under e2e/ (see LOTUS_TESTING.md → "Playwright smoke test"):
# boot tier — always runs: login page renders with no console/page
# errors, sw.js is served + registers, bundled Element Call
# mounts in a frame.
# E2EE tier — password login, create a private encrypted room, send text
# + a compressed image, assert every `PUT …/send/*` went out
# as m.room.encrypted. Skips itself unless the E2E_* secrets
# below are set (create them under repo → Settings → Actions
# → Secrets; they are empty until then).
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
# on this Gitea runner (see LOTUS_REFERENCE.md → CI/CD), so `needs: build` only gates on the
# main job having passed, not on its artifact.
# Hard gate: proven green on the runner in run #1880 (chromium + deps
# install fine there). The E2EE tier self-skips without the E2E_* secrets.
e2e:
name: Playwright smoke (e2e)
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
- name: Install dependencies
run: |
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-timeout 600000
for attempt in 1 2 3; do
echo "npm ci attempt $attempt…"
npm ci && break
if [ "$attempt" = "3" ]; then
echo "npm ci failed after 3 attempts" >&2
exit 1
fi
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
sleep $((attempt * 15))
done
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=6144'
VITE_APP_VERSION: ${{ github.sha }}
- name: Playwright smoke test
run: npm run test:e2e
env:
CI: 'true'
E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }}
E2E_USER: ${{ secrets.E2E_USER }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
-33
View File
@@ -1,33 +0,0 @@
name: Renovate
# Gitea #94 — no dependency update automation existed at all. Runs the
# official renovate/renovate Docker image against this Gitea instance.
#
# Requires a `RENOVATE_TOKEN` repo/org secret: a Gitea access token with
# read/write on LotusGuild/cinny and LotusGuild/element-call, created by a
# maintainer — this workflow does not (and cannot) create one for you.
# Note: Gitea reserves the `GITEA_` secret-name prefix, so the token cannot
# be named e.g. `GITEA_TOKEN` — hence `RENOVATE_TOKEN`.
on:
schedule:
- cron: '0 4 * * 1' # weekly, Monday 04:00 UTC
workflow_dispatch: {}
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
# Gated on the Actions VARIABLE `RENOVATE_ENABLED` == "true" (set it together
# with the RENOVATE_TOKEN secret). Gitea ignores job-level continue-on-error
# for the run conclusion, so without the gate every weekly run would be red
# until the token exists. Also needs a Docker-capable runner (uses the
# renovate/renovate image) — see CI_HAS_DOCKER in ci.yml.
if: ${{ vars.RENOVATE_ENABLED == 'true' }}
steps:
- name: Run Renovate
uses: docker://renovate/renovate:44
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://code.lotusguild.org/api/v1
RENOVATE_REPOSITORIES: LotusGuild/cinny,LotusGuild/element-call
+1 -1
View File
@@ -57,7 +57,7 @@ body:
required: true
- type: textarea
attributes:
label: Environment
label: Environement
description: |
Please provide information about your environment. Include the following:
- OS:
-27
View File
@@ -1,27 +0,0 @@
---
name: Bug Report
about: Report something that isn't working in Lotus Chat
title: ''
labels: bug
---
**Describe the bug**
A clear and concise description of what went wrong.
**Steps to reproduce**
1. Go to '...'
2. Click on '...'
3. See error
**Expected behavior**
What you expected to happen instead.
**Client info**
- Lotus Chat version (Settings → Help & About):
- Platform: Web / Desktop (Windows / macOS / Linux)
- Browser + version (if web):
**Screenshots / logs**
If applicable, add screenshots or the browser devtools console output.
+5 -1
View File
@@ -1 +1,5 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
-15
View File
@@ -1,15 +0,0 @@
---
name: Feature Request
about: Suggest an idea or improvement for Lotus Chat
title: ''
labels: enhancement
---
**What would you like?**
A clear and concise description of the feature or change.
**Why / use case**
What problem does it solve, or what does it make better?
**Alternatives considered**
Any workarounds or other approaches you've thought about.
+9
View File
@@ -0,0 +1,9 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
-9
View File
@@ -1,9 +0,0 @@
### Description
<!-- Please include a summary of the change. -->
Fixes #
### Checklist:
- [ ] I have read and understood the [Contribution policy](https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md).
+40
View File
@@ -0,0 +1,40 @@
name: Build pull request
on:
pull_request:
types: ['opened', 'synchronize']
jobs:
build-pull-request:
name: Build pull request
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview
path: dist
retention-days: 1
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr
path: ./pr.txt
retention-days: 1
+36
View File
@@ -0,0 +1,36 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: 'signatures.json'
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'main'
allowlist: ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: cinnyapp
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
+63
View File
@@ -0,0 +1,63 @@
name: Deploy PR to Netlify
run-name: 'Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})'
on:
workflow_run:
workflows: ['Build pull request']
types: [completed]
jobs:
deploy-pull-request:
name: Deploy pull request
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Validate and output pr number
id: pr
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
- name: Download artifact
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: preview
path: dist
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: 'Deploy PR ${{ steps.pr.outputs.id }}'
alias: ${{ steps.pr.outputs.id }}
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b #v3.0.1
env:
github-token: ${{ secrets.GITHUB_TOKEN }}
with:
pr-number: ${{ steps.pr.outputs.id }}
comment-tag: ${{ steps.pr.outputs.id }}
message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️
+63
View File
@@ -0,0 +1,63 @@
name: 'Docker check'
on:
pull_request:
paths:
- 'Dockerfile'
- '.github/workflows/docker-pr.yml'
- '.github/workflows/prod-deploy.yml'
jobs:
docker-build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
continue-on-error: true
- name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
ajbura/cinny
ghcr.io/${{ github.repository }}
- name: Build Docker image (no push)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Show Docker images
run: docker images
+26
View File
@@ -0,0 +1,26 @@
name: NPM Lockfile Changes
on:
pull_request:
paths:
- 'package-lock.json'
jobs:
lockfile_changes:
runs-on: ubuntu-latest
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: NPM Lockfile Changes
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values.
collapsibleThreshold: 25
failOnDowngrade: false
path: package-lock.json
updateComment: true
+39
View File
@@ -0,0 +1,39 @@
name: Deploy to Netlify (dev)
on:
push:
branches:
- dev
jobs:
deploy-to-netlify:
name: Deploy to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: nightly
github-deployment-description: 'Nightly deployment on each commit to dev branch'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
timeout-minutes: 1
+15
View File
@@ -0,0 +1,15 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- edited
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+99
View File
@@ -0,0 +1,99 @@
name: Production deploy
on:
release:
types: [published]
jobs:
deploy-and-tarball:
name: Netlify deploy and tarball
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: stable
github-deployment-description: 'Stable deployment on each release'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
timeout-minutes: 1
- name: Get version from tag
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
run: |
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
# Sadly a few lines in the private key match a few lines in the public key,
# As a result just --export --armor gives us a few lines replaced with ***
# making it useless for importing the signing key. Instead, we dump it as
# non-armored and hex-encode it so that its printable.
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-8
View File
@@ -6,11 +6,3 @@ devAssets
.DS_Store
.ideapackage-lock.json
public/decorations/
# Playwright (npm run test:e2e)
playwright-report/
test-results/
# local dev homeserver (scripts/dev-homeserver.sh)
.dev-homeserver/
__pycache__/
-36
View File
@@ -1,36 +0,0 @@
title = "gitleaks config for Lotus Chat (cinny fork)"
# Gitea #95 — secret scanning was entirely absent. Extend gitleaks' built-in
# ruleset (don't replace it) and allowlist the known-public infrastructure
# URLs that show up in tracked config, which are hostnames, not secrets.
[extend]
useDefault = true
[allowlist]
description = "Known-public Lotus/Matrix homeserver + npm registry URLs — not secrets"
regexes = [
'''https?://matrix\.lotusguild\.org''',
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
'''matrix\.lotusguild\.org''',
]
paths = [
'''config\.json''',
'''\.npmrc''',
# Build output and vendored bundles are not source — CI scans a fresh
# checkout, but a local run after `npm run build` would trip on minified
# matrix-js-sdk crypto identifiers (claimedEd25519Key etc.).
'''^dist/''',
'''^node_modules/''',
'''^public/element-call/''',
]
# localStorage / IndexedDB key NAMES (e.g. `STORAGE_KEY = 'cinny_recent_gifs_v1'`)
# match generic-api-key purely because the variable is called *_KEY. They are
# namespaced identifiers, not credentials.
[[rules]]
id = "generic-api-key"
[rules.allowlist]
regexTarget = "line"
regexes = [
'''(STORAGE|CACHE|IDB|DB|LS)_KEY\s*=\s*['"](cinny|lotus)[-_][a-z0-9_-]+['"]''',
]
+3 -1
View File
@@ -1 +1,3 @@
npx lint-staged
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
-1
View File
@@ -1 +0,0 @@
24.13.1
+2 -1
View File
@@ -2,4 +2,5 @@ dist
node_modules
package.json
package-lock.json
LICENSE
LICENSE
README.md
+1 -1
View File
@@ -11,7 +11,7 @@ RUN npm run build
## App
FROM nginx:1.31.5-alpine
FROM nginx:1.29.8-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
-60
View File
@@ -1,60 +0,0 @@
# Developing Cinny
> [!TIP]
> We recommend using a version manager as versions change very quickly.
> You will likely need to switch between multiple Node.js versions based
> on the needs of different projects you're working on. [NVM-windows]
> on Windows and [nvm] on Linux/macOS are pretty good choices. Recommended
> nodejs version is Krypton LTS (v24.13.1).
[nvm-windows]: https://github.com/coreybutler/nvm-windows#installation--upgrades
[nvm]: https://github.com/nvm-sh/nvm
Execute the following commands to start a development server:
```sh
npm ci # Installs all dependencies
npm start # Serve a development version
```
To build the app:
```sh
npm run build # Compiles the app into the dist/ directory
```
To commit changes:
```sh
npm run commit
```
## Running with Docker
This repository includes a Dockerfile, which builds the application from
source and serves it with Nginx on port 80. To use this locally, you can
build the container like so:
```
docker build -t cinny:latest .
```
You can then run the container you've built with a command similar to this:
```
docker run -p 8080:80 cinny:latest
```
This will forward your `localhost` port 8080 to the container's port 80.
You can visit the app in your browser by navigating to `http://localhost:8080`.
## Code formatting
We use [ESLint](https://eslint.org/) for clean and stylistically
consistent code syntax, so make sure your pull request follow it.
## Helpful links
- [BEM methodology](http://getbem.com/introduction/)
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
+24 -65
View File
@@ -26,9 +26,8 @@ Last updated: July 2026.
17. [Notifications](#notifications)
18. [Server Integration](#server-integration)
19. [Infrastructure](#infrastructure)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [Key Custom Files](#key-custom-files)
20. [Desktop App Features](#desktop-app-features)
21. [Key Custom Files](#key-custom-files)
---
@@ -155,13 +154,13 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
### Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion. Each background drives its own single drift/scroll keyframe (no secondary glow or blink layers):
All five animated backgrounds were rewritten for smoother, more organic motion:
- **Digital Rain** — column scroll keyframe; stripe opacity increased for better visibility
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`130 px`, `190 px`, `260 px`), eliminating the visible seam on loop
- **Grid Pulse** — size breathe keyframe (4 s)
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
- **Fireflies** — position drift keyframe with per-firefly duration/delay so motion stays unsynchronised
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
### Files
@@ -176,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes
| Theme | Window | Effect |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | Synthwave CRT: neon perspective grid framing the timeline (faded through the chat column), broken horizon line, rolling scanlines, pixel sparkles, bottom-right "1UP / INSERT COIN" HUD |
| 🚀 Deep Space Week | Oct 410 | Violet void with drifting magenta/cyan nebula clouds, two-depth parallax starfield (~60 twinkling stars + 6 hero gleams), slow galaxy spiral, occasional comet streaks |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
| Theme | Window | Effect |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
@@ -332,7 +331,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
> pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
> work list are in
> the Element Call fork reference in [`LOTUS_REFERENCE.md`](./LOTUS_REFERENCE.md).
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
@@ -743,7 +742,7 @@ never leaves it.
### Message Search Date Range
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in)
@@ -1171,26 +1170,6 @@ Persists via the `homeRoomSort` setting.
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
### Tracking-Parameter Stripping (Gitea #103)
Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `utm_*`, `fbclid`, `gclid`, YouTube `si=`, Amazon `ref=`/`tag=`, X `s=`/`t=`, TikTok `_r`/`_t`, and ~40 more, plus host-scoped rules so e.g. `si` is only removed on YouTube/Spotify. Runs entirely on the device (`src/app/utils/urlTracking.ts`, unit-tested). Wired at paste (re-inserted through Slate so multi-line pastes still split into paragraphs), at send/schedule/edit on both `body` and `formatted_body`, and at render (linkify + explicit `<a href>` in formatted HTML), so links from other clients are cleaned locally too. `matrix.to` and non-http(s) schemes are never touched; Amazon's `th`/`psc` variant selectors are kept. Toggle in **Settings → Privacy → Strip Tracking Parameters from Links** (default on).
### Settings Sync Across Devices (Gitea #104)
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
### Forwarded messages show their provenance
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
### Copy Lotus Link — direct permalinks (Gitea #130)
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
### PWA App-Icon Badge (Gitea #154)
When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the app icon carries a numeric badge via the Badging API (`navigator.setAppBadge`). The number is the same highlight count (mentions/DMs, leaf rooms only) that the tab title shows, so the two can never disagree; it clears when the count reaches zero. Lives in `FaviconUpdater` (`ClientNonUIFeatures.tsx`) next to the title/favicon logic. No-op in a plain browser tab (the API is absent) and under Tauri, where the native `set_badge_count` already owns the badge.
### Media Gallery
`MediaGallery.tsx` — a right-side drawer for browsing room media.
@@ -1201,7 +1180,7 @@ When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- **Auto-pagination** — an `IntersectionObserver` sentinel pulls older media as you scroll (manual retry on error). Since Gitea #163 this pages through the gallery's **own timeline set** (`useRoomMediaTimeline``utils/detachedTimeline.ts`), never the room's live timeline: unencrypted rooms use a server-side `contains_url` filter (a page is 100 media events, not 100 events), encrypted rooms page raw history into a private set and filter after decrypting. The message list behind the drawer no longer jumps into the past; the Activity log and history Export use the same helper.
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
### Knock-to-Join
@@ -1226,7 +1205,7 @@ Hook: `src/app/hooks/usePendingKnocks.ts`
### Room Emoji Prefix
An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
### Configurable Composer Toolbar (P3-6)
@@ -1425,23 +1404,7 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
### Crypto Diagnostics (E2EE investigation kit)
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_REFERENCE.md`](./LOTUS_REFERENCE.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
---
## Localization
Lotus Chat is **English-only for now**, by explicit decision (Sept 2026 audit, #53). The i18next
mechanism (`i18next-browser-languagedetector` + `public/locales/{en,de}.json`, wired in
`src/app/i18n.ts`) is real and still used by the ~11 upstream-inherited files that call
`useTranslation()`, but none of the Lotus-added UI (presence picker, calls/soundboard, avatar
decorations, seasonal settings, keyboard shortcuts help, toasts, etc.) is routed through it. Letting
the language detector pick a non-English browser locale therefore produced a UI that was only
partially translated. `src/app/i18n.ts` now sets `supportedLngs: ['en']` so the whole app renders
consistently in English regardless of browser locale, while leaving the detector/backend/`de.json`
in place. Re-enabling another language requires two things: (1) route Lotus strings through
`useTranslation()`/`public/locales/<lng>.json` like the existing localized files, then (2) drop (or
extend) `supportedLngs` in `src/app/i18n.ts` — a one-line change.
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
---
@@ -1490,10 +1453,6 @@ Rounds out the native app beyond Windows (macOS out of scope):
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
### System-Wide Voice Hotkeys (cinny-desktop #2)
Push to Talk and Push to Deafen keep working while a game or any other app has focus. The desktop does **not** register a global shortcut (that would swallow the key from every app — a bare `Space` PTT would stop other apps typing spaces); instead, only while a call is joined, a native thread polls `GetAsyncKeyState` for the two configured keys every ~8 ms and emits a `lotus-global-hotkey` DOM event on each press/release transition (`src-tauri/src/native/hotkeys.rs`). `useCallHotkeys` ignores those events while the Lotus window itself has focus (the DOM handlers own that case with their editable-field checks), so nothing double-fires. Windows only — Linux/Wayland has no non-consuming path; `global_hotkeys_supported` reports it and the toggle is hidden outside Tauri. Toggle: **Settings → Calls → Hotkeys Work Outside the Window** (device-local, default on).
### Custom Window Chrome (P5-47)
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome``native/chrome.rs`.
-115
View File
@@ -1,115 +0,0 @@
# Lotus Chat — Engineering Reference
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> **There is no backlog in this file.** All open work lives in Gitea issues — [cinny](https://code.lotusguild.org/LotusGuild/cinny/issues), [element-call](https://code.lotusguild.org/LotusGuild/element-call/issues), [cinny-desktop](https://code.lotusguild.org/LotusGuild/cinny-desktop/issues), [matrix](https://code.lotusguild.org/LotusGuild/matrix/issues) (infra/CI) — grouped by milestone (`Features 2026-Q4`, `Desktop 2026-Q4`, `Manual QA backlog`, `Desktop QA backlog`, the `Audit 2026-09 · *` set). Shipped features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md); how to run the automated tests is in [LOTUS_TESTING.md](./LOTUS_TESTING.md). The former `LOTUS_TODO.md` backlog was migrated to issues on 2026-09-17 (full history in git).
This file keeps only what a contributor needs to have open while working: the two design laws, decisions already made, what the server blocks, and the operational reference.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
>
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
---
## Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
---
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
## 📖 Reference
### Server Capabilities (as of 2026-07)
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
### Key File Reference
| What | File | Lines |
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power levels | `hooks/usePowerLevels.ts` | whole |
| Slash commands | `hooks/useCommands.ts` | 140-537 |
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
| Knock support check | `utils/matrix.ts` | 376-391 |
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.3`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.3`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
| Action | Dir | Purpose | Module |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
### CI/CD + per-feature checklist
```
edit → commit → git push origin lotus
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
(a push that lands while a deploy is mid-build is queued and deployed right after — matrix@b6ea4a3; before that it was dropped)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x] **Concurrency**`cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
**CI follow-ups** are tracked in the `matrix` repo: [#8](https://code.lotusguild.org/LotusGuild/matrix/issues/8) dedicated `desktop-linux` runner, [#9](https://code.lotusguild.org/LotusGuild/matrix/issues/9) debounce the desktop trigger, [#10](https://code.lotusguild.org/LotusGuild/matrix/issues/10) verify Gitea `concurrency`. Build-once/deploy-the-artifact was considered and deferred (noted on #8).
+788 -27
View File
@@ -1,31 +1,38 @@
# Lotus Chat — Testing
# Lotus Chat — Manual Testing Guide
> **Manual QA checklists no longer live here.** Every shipped-but-unverified behaviour is a Gitea issue labelled **`qa`** — cinny milestone [Manual QA backlog](https://code.lotusguild.org/LotusGuild/cinny/milestones), cinny-desktop milestone [Desktop QA backlog](https://code.lotusguild.org/LotusGuild/cinny-desktop/milestones). Each issue carries the full steps + expected results; tick items as they pass, comment on FAIL (what you saw vs expected, browser/OS, web vs desktop, theme, console errors), close when green. Migrated from this file on 2026-09-17 (full text in git history).
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
This file keeps what a contributor needs to run and extend the **automated** coverage, plus the ops tip at the bottom.
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
## Environment notes
- Test the **web** build (`chat.lotusguild.org`, LXC 106) first; re-run call + poll items on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
- Several call checks need a **second participant** (marked **👥 2 people** in the issues); a couple need a third room/call in parallel (**👥👥**).
- You push from your own machine; these commits are local on `lotus` until you do.
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
---
## Local dev environment — drive the real UI against a throwaway homeserver
## Commits covered
```
scripts/dev-homeserver.sh start # Synapse in .dev-homeserver/ (venv, SQLite), open registration, no rate limits, :8008
python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an image every 10th
npm start # Vite on :5173
```
| Commit | Area |
| :--------- | :--------------------------------------------------------------------------- |
| `caf6318a` | Poll vote buttons → folds tokens (N4) |
| `c67aed01` | In-call incoming-call banner (#4b) |
| `4a875884` | Selectable ringtone (#4a) |
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct |
| `b7e1f89c` | Timeline/composer/emoji perf memoization |
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep) |
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
---
## Automated coverage map — what the unit tests already pin (2026-07)
**Read this before working a `qa` issue.** Much of the _logic_ the manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
| QA item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
@@ -41,30 +48,784 @@ Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts``resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already verified live were dropped when the checklists moved to issues.
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
---
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
## A. Calls — new ringtone + notification work (highest priority)
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Two tiers:
### A1. Ringtone selection — preview in Settings
| Tier | File | When it runs | What it proves |
| :------------------------ | :-------------------------- | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
**Steps**
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
1. Open **Settings → General**, scroll to the **Calls** section.
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
- `E2E_USER` / `E2E_PASSWORD` — a **throwaway** account: each run logs in as a new device and creates a new `e2e-smoke-<timestamp>` room. Prune devices/rooms occasionally.
**Expected**
The `e2e` job is `continue-on-error: true` for now because `playwright install --with-deps` needs `apt` on the runner image — promote it to a hard gate once it is green on the runner. Locally: `npm run test:e2e` (boot tier only), or `E2E_HOMESERVER=… E2E_USER=… E2E_PASSWORD=… npm run test:e2e` for both; on failure look in `test-results/` (screenshot + trace) and `playwright-report/`.
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
- **Silent** plays nothing.
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
- No console errors.
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
### A2. Ringtone selection persists
1. Set Ringtone to **Retro**, reload the app.
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
### A3. Incoming call uses the selected ringtone — 👥 2 people
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
1. As A, pick a non-silent ringtone (e.g. **Chime**).
2. From B, **start a call** in that DM/room. Do **not** answer on A.
**Expected on A**
- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
**Expected on A**
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
- The banner auto-dismisses if the caller hangs up / the call times out.
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
### A5. Camera focus during screenshare (#1) — 👥 2 people
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
2. In the menu, click **"Focus camera"**.
**Expected**
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
1. Join a call with that participant.
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
**Expected**
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
---
## B. Polls (N4) — render correctly on non-TDS themes
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
### B1. Poll renders on a default theme — ✅ PASS
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
**Expected**
- Each option is a clearly **bordered** button with visible rounded corners.
- A **radio circle** indicator is visible on the left of each option.
- Text, and (after votes) the percentage, are legible.
### B2. Voting + selected/progress state
1. **Vote** on an option.
**Expected**
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
- The percentage and total vote count update.
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
### B3. Multiple-choice poll
1. Create a poll allowing **multiple selections**.
**Expected**
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
- You can select **several** options; each shows its own progress fill.
### B4. Lotus Terminal theme regression — ✅ PASS
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
---
### Green CI but the fix isn't live?
## C. Robustness / background behavior
`curl -s https://chat.lotusguild.org/index.html | grep -o 'assets/index-[^"]*\.js'` gives the deployed entry chunk; grep it for a string unique to your change (`curl -s https://chat.lotusguild.org/<that path> | grep -c <string>`). If it's 0 after ~15 min, the deploy trigger was lost — push again (any commit) to re-fire the `lotus-deploy` webhook. The deploy log lives at `/var/log/lotus-deploy.log` on LXC 106.
### C1. Presence updates on tab close
1. Open the app, then **close the tab** (or quit the browser).
2. From another session/device, check your **presence** shortly after.
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
### C2. Upload retry on flaky network (best-effort)
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
**Expected**
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
### C3. General timeline/composer perf (no functional regression)
The memoization changes are invisible if correct. Just confirm **nothing broke**:
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
---
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
- [ ] **Mic** mute/unmute (icon + actual audio)
- [ ] **Camera** on/off
- [ ] **Deafen / Sound** toggle (your deafen key too)
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
- [ ] **Screenshare audio** mute toggle
- [ ] **Fullscreen** toggle
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
- [ ] **End** call leaves cleanly
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
---
## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`
> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
> the fork features won't be present, so don't test D2 yet.
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.
### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)
Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
shim is **removed**). This is the single change with the widest blast radius — test deliberately.
- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
regresses, mic dies on every reconnect.)_
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
`restart()` path as reconnect).
- [ ] **Mute → unmute** a few times: audio returns each time.
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet`
each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
injected alongside the in-source engine).
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).
### D2-2. Speaking + mute indicators from widget **events** (#2)
Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.
- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.
### D2-3. Focus camera **during a screenshare** (#4 / A5)
Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.
- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
spotlighted **alongside/over** the shared screen (not ignored).
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).
### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**
Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
tiles out of scope — that's now in scope.
- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.
### D2-5. Native transparent background (#5)
Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
see-through, or layout breakage (also covered loosely by §D2 "looks right").
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
General → Voice → Call Quality Caps**.
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
still shares. ❌ tell us if any setting kills audio/screenshare.
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
not just our client hiding a button.
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
server-blocked for all clients; **microphones still work**.
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
on stock Element. ✅ good if the share drops within ~35 s; ❌ tell us if it keeps going.
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
---
# Backlog of previously-fixed-but-unverified items
> Sections AD above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way AD are; do them as you have the right device handy.
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
### E1. Composer toolbar touch targets (#7)
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
### E2. Room Settings — no horizontal overflow (#8)
On a narrow phone screen, open **Room Settings**.
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
### E3. Modals go fullscreen on mobile (#9)
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
On a phone, **inside a room**, open the room header **··· More Options** menu.
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
---
## F. Visual / theming
### F1. Animated chat background — no flicker (#2)
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
In Settings → Appearance:
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
### F3. Background / seasonal picker grid layout (N81)
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
---
## G. Calls — additional unverified (👥 2 people)
### G1. PiP mute badges point at the right person (#12)
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
### G2. Full-screen camera broadcasts
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
### G3. PTT badge renders on all themes (N53)
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
---
## H. Media / performance (needs a room with many images)
### H1. Lazy image decryption (P5-5 / MediaGallery)
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
### H2. Thumbnail framing (P5-6)
Look at **tall portrait** images in the timeline and in the media gallery.
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
---
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
- [ ] **Edit history** button announces "View edit history".
- [ ] **Thread indicator** announces "View thread".
- [ ] **Reply** (jump to original) announces "Jump to original message".
---
## J. Desktop / Tauri build only
### J1. Proactive update notifications (P5-40)
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
### J2. DTLN noise suppression sanity
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
---
## K. Features — end-to-end unverified
### K1. Remind Me Later
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
### K2. Advanced search filters (P4-9)
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
**Expected:** each narrows results correctly and reflects in the search.
### K3. Notification content + click target (P5-20 partial)
Trigger a desktop/browser notification for a new message.
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
---
## L. Fixed — verify
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
**To verify:**
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
3. **Unmute** → the indicator should re-appear (capture re-acquired).
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
### L2. Maskable PWA icon (N108) — Android install
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
2. Look at the **home-screen icon**.
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
---
## M. New features (this round)
### M1. Search: `has:image` / `has:file` / `has:video` filters
1. Open message search (in a room with shared images/files/videos in history).
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
**Expected:**
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
### M2. Search: recent searches
1. Run a few different searches, then **clear the search box** and focus it.
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
**Expected:**
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
- **Reset** clears it back to the theme default.
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
- Reload → the chosen accent **persists**.
---
### M4. Search: "Pinned only" filter
In message search, toggle the **Pinned** chip.
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
Settings → Appearance → theme picker → try each of the 5 new themes.
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
---
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
delta + a `config.json` override) OR against **mozilla.org** with a real account.
### N1. OIDC login flow (the core test) — needs a MAS homeserver
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
### N2. Session persists across reload (token storage)
After N1, hard-refresh the page.
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
### N3. Token refresh (long-lived session)
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
### N4. Logout revokes at the issuer
Log out from Settings.
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
### N5. Account-management deep-link
Settings → Account.
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
### N6. Non-OIDC regression — password login unchanged
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
---
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
3. Reply to a reply _inside_ the panel.
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
### O3. Math / LaTeX (P4-4)
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
### O4. Encrypted search cache (P4-8) — opt-in
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
### O5. Session hardening (N97a) — cross-tab
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
### O6. Audit-wave correctness fixes (AW-1)
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
---
## P. Accessibility (P3-4) — needs a browser + a screen reader
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
### P1. Keyboard-only golden path (no mouse)
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
### P2. `?` shortcuts dialog
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
### P3. Screen-reader: reading messages
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
### P4. Screen-reader: live announcements
- **New message** arrives while you're reading → announced (polite).
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
- **Editing a message** → the edit box announces "Editing message from X".
### P5. Focus return from dialogs
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
### P6. axe / Lighthouse scan
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
---
## Q. Inline Media Embeds — video / audio / post players (needs the web deploy live)
The whole feature is behind **Settings → General → "Inline Media Players"** (default **on**). Everything loads from the homeserver's cached thumbnail first; the third-party player only mounts on **Play**. Test on the **web** build first, then re-check the video ones on **desktop (Tauri)** since the CSP differs. On any failure, grab the **browser console** (F12) — a blocked embed shows as a CSP `frame-src` violation naming the host.
### Q1. Facade + one of each kind plays in place
Paste each of these into a room and confirm a media tile (not a plain link) with a thumbnail + play button, and that clicking Play mounts the player **inline**:
- **16:9 video:** a YouTube `watch` link, a Vimeo link, a Dailymotion link, a Streamable link, a Twitch VOD/clip, a Loom `share` link.
- **9:16 portrait:** a YouTube **Shorts** link (renders tall, not letterboxed).
- **Audio player:** a Spotify track, a SoundCloud track, an Apple Music album, a Tidal album/track.
- **Post embed:** an X/Twitter post, an Instagram post, a Reddit post.
**Expected:** ✅ tile shows the thumbnail; **no** request to the third party until you press Play (check DevTools → Network); the player then plays inline. ❌ tell me any that stay a plain link, show a blank frame, or hit the network before you click.
### Q2. TikTok (the tricky one) + portrait fill
1. Paste a **full** TikTok URL and a **short** copy-link (`vm.tiktok.com/…` or `tiktok.com/t/…`).
2. Press Play on each.
**Expected:** both resolve to a clean **9:16** player that **fills the box** (no big empty band on the right). The short link shows a brief spinner while it resolves via oEmbed, then plays. ❌ tell me if a short link shows only the TikTok logo/♫ and never a play button, or if the player has dead space beside it.
### Q3. Post self-resize + Close / Fullscreen controls
1. Play a **Reddit**, **Instagram**, and **X/Twitter** post embed.
2. Watch the card height as the embed loads.
**Expected:** the card **grows to fit** the post (no clipped/scrollbarless content, no giant empty box). A **Close** button (✕) collapses the player back to the thumbnail; video players also show a **⛶ Fullscreen** control that works. Keyboard: Tab to the play button → it shows a visible **focus ring**.
### Q4. New providers (unverified) + the toggle + the cap
- **Bluesky / Loom / Kick** — these are freshly added and unverified live. Paste a `bsky.app/profile/…/post/…`, a `loom.com/share/…`, and a live `kick.com/{channel}` link. ✅ good if each plays/renders inline; ❌ if any is a broken frame (for **Bluesky** especially, note whether a **handle** URL resolves or only a DID one does — grab the console).
- **Toggle off:** Settings → General → **Inline Media Players** off → every media link reverts to a plain link tile (no player).
- **Cap:** paste a message with **8+** media links → at most **6** preview cards render (the rest are suppressed), and the page stays responsive.
---
## R. Discovery-pass fixes (DP1DP18, 2026-07)
Agent-surveyed + TPVR-verified low/med issues, now fixed (commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`). Behavioral items have concrete checks; the refactors just need a "still works" regression pass.
### Correctness
- [ ] **DP1 — slash-command errors are visible.** Run a slash command that must fail — e.g. `/kick @nobody:server` in a room where you lack permission, or `/join` a bad alias. **Expected:** an error toast appears (not a silent no-op); a successful command still clears the composer normally.
- [ ] **DP2 — no invite re-notify on reload.** With ≥1 pending invite, hard-reload (Ctrl+F5). **Expected:** NO "you have N new invitation" toast/sound on load. Then have someone invite you while the app is open → you DO get one notification for the new invite. (👥 2 accounts)
- [ ] **DP3 — status clear syncs across devices.** Set a status message on device A, confirm it shows on B; clear it on A. **Expected:** B clears too and does NOT re-publish the old status on its next presence heartbeat. Toggling Invisible must not wipe a real saved status. (👥 2 sessions)
- [ ] **DP4 — tag-toggle failure surfaced once.** With the network offline, toggle a room's Favourite/Low-priority. **Expected:** a single error toast (not two) on failure; on success the tag updates as before.
- [ ] **DP5 — soundboard packs update on room switch.** Open a soundboard in room A, then switch to room B (different pack) in the same mounted view. **Expected:** B's packs show without needing an unrelated event.
- [ ] **DP6 — declining a call still dismisses.** Decline an incoming call. **Expected:** the ringing UI dismisses even if the decline send fails (best-effort). (👥 2 accounts)
### a11y / UX (needs a screen reader + a narrow viewport)
- [ ] **DP7 / DP8 — call-control buttons announce correctly.** In a call with a screen reader: the deafen button announces "Deafen" when sound is on (not "Undeafen"); Sound / Video / Screenshare announce a consistent pressed/unpressed state like Mic.
- [ ] **DP9 — GIF picker focus + width.** Open the GIF picker, close it (Esc / click-out) → focus returns to the GIF button. On a ~320px viewport the picker doesn't overflow the page.
- [ ] **DP10 — search-filter clears by keyboard.** In message search, toggle a filter chip (Has link / msg-type / pinned) off with Enter; the date-range clears via its menu's Clear. No mouse-only clear needed.
- [ ] **DP11 — voice recorder fits + announces.** On a ~360px viewport, start a voice message → the recorder row doesn't overflow the composer; a screen reader can query the duration (role="timer") without being spammed.
- [ ] **DP12 — live-call count announced.** With a screen reader, when someone joins/leaves an active call, the "{n} Live" change is announced (polite status region).
### TDS colors (Lotus Terminal theme)
- [ ] **DP14 — send-status + receipt colors follow the theme.** In **TDS light** mode: the message send-status "failed" icon and the read-receipt pill use the theme's darker red/blue (from `--lt-*` tokens), NOT bright dark-mode cyan/red. TDS dark still looks right; non-TDS themes unchanged.
### Refactor regression pass (no behavior change intended)
- [ ] **DP13 — bookmarks / reminders / notes still work** (they now share one store engine). Add/remove a bookmark, set/clear a reminder, write/clear a user note; each persists across reload; rapid consecutive writes don't clobber each other.
- [ ] **DP15 / DP16 — state-event + account-data writes still work.** Edit room name/topic/avatar, join-rules, power levels, an emoji/soundboard pack (state events); toggle a setting stored in account data. All save + reflect correctly.
- [ ] **DP17 — link previews render.** Open a TikTok / Spotify / Steam / Reddit link preview; provider icons render (folds icons, not raw glyphs) and brand colors look right.
- [ ] **DP18 — member avatars/names live-update.** Read receipts + the "seen by" reader list show correct avatars/names and update live when a member changes their avatar or display name (no reload).
---
## Priority if you're short on time
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
5. **D** (EC control sweep) — guards against the fork breaking calls.
6. Everything else.
---
## Outstanding verification backlog
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
---
+412
View File
@@ -0,0 +1,412 @@
# Lotus Chat — Work Backlog
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
> Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). Manual test steps live in [LOTUS_TESTING.md](./LOTUS_TESTING.md). This file is **open work only** — resolved audit findings and shipped-feature write-ups were removed 2026-07 (full history in git).
Status legend: `[ ]` pending · `[~]` in progress / shipped-awaiting-QA · `[x]` done · `[BLOCKED]` server/upstream-gated · `[DEFERRED]`/`[DROPPED]`/`[WON'T FIX]` decided.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
>
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
---
## ✅ Audit (2026-07) — closed out
A three-wave feature bug-hunt (~15 parallel agents, each batch independently reviewed) plus a low-tail cleanup. All confirmed 🔴/🟠 and the clean 🟡 tail are **fixed, reviewed, and gate-green**; details in git history + LOTUS_FEATURES. Only the minor items below remain open.
**Still open (low tail — all 🟡 minor):**
-**Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs``expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
- **EC fork (EC1EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
---
## ✅ Shipped — Awaiting Live Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then graduate to LOTUS_FEATURES.md. Includes the **desktop/native Tier A/B stack** (P5-35/36/41/42/43/44/46/47/48/49/55/56/57, P6-1 Linux parity) — all **CI-compile-verified, runtime-verify on Windows/Linux** — plus:
| Area | Test guide |
| :-------------------------------------------------------------------------- | :-------------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters + virtualized infinite scroll | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS) · 5 Color Theme Presets | M3 / M5 |
| Intersection lazy media loading · context-aware thumbnails | H1 / H2 |
| Thread Panel (side drawer) + per-thread notification modes (P4-1) | (thread QA) |
| Encrypted message search indexing/caching (opt-in, default OFF) | search backlog |
| Remind Me Later · Mobile Bookmarks access | K1 / E5 |
| In-Call Soundboard (P5-15) · Quality Controls (P5-31) · Permissions (P5-31) | D2-7 / D2-8 / D2-9 |
| Desktop proactive update notifications (P5-40) | J1 |
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
| Inline media embeds (16 providers: video/audio/post + click-to-play facade) | Q1 / Q2 / Q3 / Q4 |
---
## 🔴 Open — Actionable
### ✅ Discovery pass (2026-07) — DP1DP18 DONE
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
### ✅ Discovery pass 2 (2026-07) — perf / security / correctness — DONE
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
- [x] **PERF-4 — message-search room filter re-sorted every render**`useMemo`. `4708a179`.
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout**`clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
- [x] **SEC-3 — `window.open(_blank)` without `noopener`**`noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew**`Math.abs(...)` (±20s). `ab01d27a`.
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
- [x] **COR-5 — upload cancel ignored during retry back-off**`AbortSignal` threaded into the retry loop. `ab01d27a`.
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
**Deferred / decided (not built):**
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient``requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
**Embeds / URL previews**
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
**Voice / video calls**
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
**Theming / visuals**
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
**Threads / presence / UX**
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
**Rooms / moderation / notifications / infra / desktop**
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag``${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
**Fix:** `roomToUnread.ts``handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
- **KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).** `POST /keys/upload` returns `400 M_UNKNOWN: One time key … already exists` continuously — the rust-crypto store and Synapse have **diverged OTK state** (upstream `matrix-rust-sdk#5200`, OPEN: on the 400 the SDK never marks the request sent → re-uploads forever; **not** fixed in 41.7.0). Leading web trigger: cinny never calls **`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable while the `localStorage` session survives → device resurrects with a blank store. **Buildable preventive fix (no call needed):** request persistent storage on login (+ optional multi-tab guard + a 400-loop→recovery prompt). Healing an already-diverged device still needs a clean logout+login.
- **KE-2 — EC media keys not arriving/decrypting → audio/video cut out (CRITICAL).** `MissingKey … for participant`, unexpected encrypted to-device `io.element.call.encryption_keys`. Almost certainly downstream of KE-1 (broken Olm sessions). This is the "friend's audio cuts out" symptom.
- **KE-3 — Timeline decrypt error: missing `algorithm` field (HIGH).** rust-crypto can't parse a malformed/legacy encrypted event — capture the offending event id + raw content.
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH).** `Restart delayed event timed out`, repeated `msc4157.update_delayed_event` — may be partly HS responsiveness; correlate with synapse latency. Same planning session (shares the call-reliability surface).
### Security & Privacy
- **N97 — Access token + device id in plaintext `localStorage`** (`state/sessions.ts`), XSS-exposed. Architectural — needs a token-protection / session-storage redesign.
- **Persisted PII without encryption:** user status message + expiry (`Profile.tsx`), unsent composer drafts (`RoomInput.tsx`). Leak risk on shared devices.
### PWA / Offline / Web Push
- **N107 — Web Push is non-functional:** `src/sw.ts` has no `push` handler. Needs a `push` listener + Matrix push-gateway integration. **The one substantive remaining feature** (session/crypto groundwork it waited on has landed).
- **No app-asset caching strategy** in `src/sw.ts` — no offline capability.
### Dependencies / Build / Hygiene
- Build-time: `lotusDenoise` does heavy sequential `fs` in `closeBundle`; `viteStaticCopy` has redundant renames — could be streamlined.
- `patch-folds.mjs` edits `node_modules` directly (robust today; `patch-package` considered but more brittle to folds restructuring — WON'T-DO unless it breaks).
- `types/matrix/` mirrors SDK types instead of importing them — drift risk; spot-fix highest-risk only.
- `contrib/nginx`/`contrib/caddy` examples: headers + `try_files` already synced with prod; the prod nginx `add_header` isn't inherited by cache `location` blocks (pre-existing; SPA entry `/` still gets all headers).
- `as any` casts across `src/` — gradual typing cleanup. Keep commits scoped (bisect-friendly). Keep README fork-sync version/logo current.
---
## 🌐 Matrix Protocol Gaps
Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first.
**Phase A ✅ (2026-07, gate-green 683 tests):**
- [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`.
- [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging.
**Phase B ✅ (2026-07, gate-green 688 tests):**
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
**Phase C (Room Widgets ✅ 2026-07; Sliding Sync ❌ evaluated — parked):**
- [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
- **[PARKED] Sliding Sync — MSC3575 / simplified MSC4186** (evaluated 2026-07, 3 research passes). Server side is GA (`simplified_msc3575`), but the **client** side is not viable for a safe rollout: matrix-js-sdk's `SlidingSync`/`SlidingSyncSdk` are `_internal_`/`@experimental` (Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); **presence isn't delivered over sliding sync** (regresses Lotus presence badges/rings/status); **no upstream Cinny impl** to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the **full local room set** (`allRoomsAtom``mx.getRooms()`), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). **Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended.** Full assessment: git plan history.
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf`**`nginx -s reload`**) or external widgets are blocked.
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
Findings:
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy``Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
### Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases AC the client spec is ~complete. What's left, flagged by **what unblocks it**:
**✅ Buildable NOW (client-only, no server/infra change):**
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
- Reaction / relation redaction (**MSC3892** — `false`)
- ~~Room preview before joining (MSC3266)~~ — **DONE** (client was always built; unstable `im.nheko.summary` endpoint returns 200 — verified on 1.156)
- Thread subscriptions (**MSC4306** — `false`)
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
---
## 📋 Open Feature Backlog
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
### [x] P4-4 · Math / LaTeX Rendering — DONE
Rendering shipped (KaTeX, `$…$`/`$$…$$` + spec `data-mx-maths`, lazy-loaded,
`<pre>/<code>`-guarded) — see LOTUS_FEATURES.md. **Outgoing cross-client interop
added (2026-07):** the composer now emits spec `data-mx-maths` HTML on send
(`editor/output.ts`, reusing `splitMathSegments`), so math a Lotus user types
renders on Element and every other client, not just Lotus. Deferred: multi-line
block `$$…$$` (spans editor paragraph nodes) still renders on Lotus via the
plain-body `$…$` path only.
### [~] P5-20 · Quick Reply from Browser Notification (partial)
Done: notifications show the real body, click navigates to the specific event + focuses the tab. **Remaining:** inline reply via Notification Actions API needs the SW `push`+`notificationclick` pipeline (switch `new Notification()``serviceWorkerRegistration.showNotification()` so the SW receives `notificationclick`; on `event.action==='reply'` POST `m.room.message` with the stored `{roomId, threadId}`). Ties into N107.
### [~] P5-30 · Advanced ML Noise Suppression — open verification
Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex; AEC on, AGC off for ML tier; never-silent watchdog). **Open:** real-call by-ear **A/B** — model choice, `lotusDenoiseFloor`, AGC on/off (LOTUS_TESTING §D2-1 / J2). **GTCRN (deferred):** tiny MIT 16 kHz model beating RNNoise, but no drop-in browser package — needs `onnxruntime-web` in a Web Worker behind a custom AudioWorklet ring-buffer (ORT can't run in an AudioWorklet, issue #13072); ~1-week build. Revisit only if low-power quality proves insufficient. HW-gated (FRCRN/Maxine) = desktop-Rust-only future.
### [~] P6-2 · Element Call fork — retire remaining DOM hacks (Phase 2 needs publish)
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1``lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1M6 + N1N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
**NOT done — needs a real device / product decisions (open):**
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
- **Vimeo `event/{id}` (live events) + `ondemand/…`** — event embed host is `vimeo.com` (**not** `player.vimeo.com`, so it needs a new CSP `frame-src` host); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case if `vimeo.com` is widened for another reason.
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
### Deferred / dropped (decided — kept for context)
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
---
## 🚫 Blocked Features (server / upstream gated)
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate``RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
---
## 📖 Reference
### Server Capabilities (as of 2026-07)
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
### Key File Reference
| What | File | Lines |
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
| Power levels | `hooks/usePowerLevels.ts` | whole |
| Slash commands | `hooks/useCommands.ts` | 140-537 |
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
| Knock support check | `utils/matrix.ts` | 376-391 |
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
| Action | Dir | Purpose | Module |
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
### CI/CD + per-feature checklist
```
edit → commit → git push origin lotus
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
```
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
**CI hardening (2026-07, reviewed):**
- [x] **Concurrency**`cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
**CI follow-ups (open):**
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
- [ ] **Debounce the desktop trigger**`trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
+7 -35
View File
@@ -2,7 +2,7 @@
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.7
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
---
@@ -22,7 +22,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms)
- Schedule messages to send at a specific time
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
@@ -73,6 +73,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Toggle to pause background animations
- Glassmorphism sidebar — frosted glass effect that lets the background show through
- Night Light / blue light filter with an adjustable intensity slider
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
- Rename any room for yourself only — other members see the original name
- Emoji picker on all room name inputs
@@ -119,7 +120,6 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
- Homeserver support contact displayed in Help & About (MSC1929)
- Server notice rooms are visually distinct from regular DMs
- Known limitation: the UI is English-only for now — Lotus-added surfaces aren't yet localized, so language selection is restricted to English rather than showing a partially-translated UI (see [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#localization))
---
@@ -129,16 +129,7 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
| Operating System | Download |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe) |
| Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage) |
| Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb) |
| Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst` |
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
### SmartScreen Warning (Windows)
@@ -174,29 +165,12 @@ Beyond the web client, the desktop app adds native OS integration (Windows-focus
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_REFERENCE.md](LOTUS_REFERENCE.md) for the design laws and operational reference (open work is in [Gitea issues](https://code.lotusguild.org/LotusGuild/cinny/issues)).
### Local Development
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
```bash
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
npm start # Vite dev server → http://localhost:8080
```
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0``matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.3`, upstream base v0.25.0, source at
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
@@ -209,7 +183,7 @@ avatar decorations on EC video tiles, and a native transparent background.
(`io.lotus.set_quality`).
The fork's `io.lotus.*` action catalog + the publish procedure are in
**[`LOTUS_REFERENCE.md`](LOTUS_REFERENCE.md)** ("Element Call fork — operational reference");
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
Search the docs for the **`[EC-FORK]`** tag to find every related note.
@@ -230,5 +204,3 @@ NODE_OPTIONS=--max_old_space_size=6144 npm run build
```
edit → commit → git push → ~11 min → live at chat.lotusguild.org
```
CI (`.gitea/workflows/ci.yml`) also runs a gitleaks secret scan, builds and smoke-tests the Docker image (`docker build`, boot + security-header checks against `docker-nginx.conf`), and dependency updates are proposed weekly by Renovate (`.gitea/workflows/renovate.yml`, config in `renovate.json`).
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
"rooms": ["#jellyfin:matrix.org"],
"servers": ["matrixrooms.info"]
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
-1
View File
@@ -3,7 +3,6 @@
## Insert wasm type into nginx mime.types file so they load correctly.
`/etc/nginx/mime.types`:
```
types {
..
+9 -13
View File
@@ -39,7 +39,6 @@ services:
```
### 1a. MAS `config.yaml` — the parts that matter
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
```yaml
@@ -49,19 +48,18 @@ http:
database:
uri: postgresql://synapse:pw@postgres/synapse
matrix:
homeserver: localhost # the server_name
homeserver: localhost # the server_name
endpoint: http://synapse:8008/
secret: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
clients:
- client_id: '0000000000000000000SYNAPSE'
- client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
passwords: # so you can create a local test account in the MAS UI
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
passwords: # so you can create a local test account in the MAS UI
enabled: true
```
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
See `synapse-msc3861.yaml` in this folder; the key block is:
```yaml
@@ -69,18 +67,17 @@ experimental_features:
msc3861:
enabled: true
issuer: http://localhost:8090/
client_id: '0000000000000000000SYNAPSE'
client_id: "0000000000000000000SYNAPSE"
client_auth_method: client_secret_basic
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET' # == MAS clients[].client_secret
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN' # == MAS matrix.secret
account_management_url: 'http://localhost:8090/account'
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
account_management_url: "http://localhost:8090/account"
```
Create a test user via the MAS UI (`http://localhost:8090/`) or
`docker compose exec mas mas-cli manage register-user`.
Sanity check discovery (the client relies on this):
```bash
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
@@ -111,6 +108,5 @@ See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
revocation, account-management link, and the non-OIDC-regression check).
## Files here
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
- `config.local.json` — the Lotus `public/config.json` override.
-36
View File
@@ -2,42 +2,6 @@ server {
listen 80;
listen [::]:80;
# ── Gitea #95 / #44 — shipped image had no security headers at all.
# `always` so these are sent on error responses too, not just 200s.
#
# Content-Security-Policy, directive by directive:
# default-src 'self' baseline: same-origin unless a directive below opens it up
# script-src 'self' 'wasm-unsafe-eval'
# app code is same-origin only; 'wasm-unsafe-eval' is required
# for the wasm modules used for E2EE crypto and audio denoise
# style-src 'self' 'unsafe-inline'
# vanilla-extract (the app's CSS-in-JS) emits inline <style>,
# so 'unsafe-inline' is required — no remote stylesheets needed
# img-src * data: blob: avatars/media/previews come from whichever homeserver or
# media repo the user points the client at — not knowable
# ahead of time — plus data: URIs and blob: for local previews
# media-src * blob: same reasoning as img-src, for audio/video attachments
# connect-src * the Matrix homeserver is user-chosen at runtime, so this
# can't be pinned to a fixed origin
# worker-src 'self' blob: service worker + blob: web workers (crypto/denoise) are
# same-origin or created from in-memory blobs, never remote
# frame-src ... the rich link-preview embeds in
# src/app/utils/videoEmbed.ts, one entry per provider:
# YouTube, Vimeo, Dailymotion, Streamable, Twitch, Spotify,
# SoundCloud, Apple Music, Tidal, Mixcloud, Deezer,
# Instagram, Reddit, Bluesky, Loom, Kick, TikTok, Steam —
# plus 'self' (no first-party iframes today, cheap to allow)
# object-src 'none' no <object>/<embed> plugin content is used anywhere
# base-uri 'self' blocks a <base> tag injection from redirecting relative URLs
# frame-ancestors 'none' this app must never be framed by another site (clickjacking)
#
# Shipped config — verify against chat.lotusguild.org's live headers before
# enabling this in the production nginx config; this file is currently only
# exercised by the CI `docker` smoke-test job, not by the live deploy path.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; media-src * blob:; connect-src *; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://player.vimeo.com https://geo.dailymotion.com https://streamable.com https://clips.twitch.tv https://player.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://embed.tidal.com https://www.mixcloud.com https://widget.deezer.com https://www.instagram.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.tiktok.com https://store.steampowered.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
root /usr/share/nginx/html;
-101
View File
@@ -1,101 +0,0 @@
import { test, expect } from '@playwright/test';
import { collectConsole } from './helpers';
// Tier 1 — boot smoke (Gitea #90). Runs against the built dist/ served by
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
test.describe('boot', () => {
test('client boots to the login screen without errors', async ({ page }) => {
const consoleLog = collectConsole(page);
await page.goto('/');
// The auth page is what an unauthenticated visitor lands on.
await expect(page).toHaveURL(/\/login\//);
await expect(page.getByLabel('Username or email')).toBeVisible();
await expect(page.getByLabel('Password', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
// The React root rendered something (a blank #root is the classic
// "bundle built but doesn't run" failure).
const rootChildren = await page.locator('#root > *').count();
expect(rootChildren, '#root should have rendered children').toBeGreaterThan(0);
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
});
test('service worker script is served and registers', async ({ page }) => {
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status(), 'GET /sw.js').toBe(200);
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
await page.goto('/');
await expect(page.getByLabel('Username or email')).toBeVisible();
// src/index.tsx registers sw.js on load; wait for the registration to
// exist (localhost counts as a secure context so this works in CI).
const registered = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return 'unsupported';
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
// eslint-disable-next-line no-await-in-loop
const reg = await navigator.serviceWorker.getRegistration();
if (reg) return 'registered';
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 250);
});
}
return 'timeout';
});
expect(registered).toBe('registered');
});
test('bundled Element Call loads in a frame', async ({ page }) => {
const consoleLog = collectConsole(page);
// Any EC asset that fails to come back (wrong base path, missing chunk)
// is the regression this test exists to catch.
const failedEcRequests: string[] = [];
page.on('response', (res) => {
if (res.url().includes('/public/element-call/') && res.status() >= 400) {
failedEcRequests.push(`${res.status()} ${res.url()}`);
}
});
page.on('requestfailed', (req) => {
if (req.url().includes('/public/element-call/')) {
failedEcRequests.push(`${req.failure()?.errorText ?? 'failed'} ${req.url()}`);
}
});
// Same-origin host page so the iframe is served exactly as the client
// embeds it.
await page.goto('/');
await expect(page.getByLabel('Username or email')).toBeVisible();
const ecResponse = await page.request.get('/public/element-call/index.html');
expect(ecResponse.status(), 'GET /public/element-call/index.html').toBe(200);
await page.evaluate(() => {
const frame = document.createElement('iframe');
frame.id = 'e2e-ec-frame';
frame.src = '/public/element-call/index.html';
frame.style.width = '800px';
frame.style.height = '600px';
document.body.appendChild(frame);
});
const frame = page.frameLocator('#e2e-ec-frame');
// EC mounts into its own #root; rendering anything at all proves the
// bundle resolved its assets from the /public/element-call/ base.
await expect(frame.locator('#root > *').first()).toBeAttached({ timeout: 30_000 });
// Let EC finish its initial render/requests before inspecting the logs.
await page.waitForTimeout(2_000);
expect(failedEcRequests, 'Element Call asset requests that failed').toEqual([]);
// Loaded bare (no widget params / no homeserver) EC runs in standalone
// mode and logs a caught React error about its missing config — that is
// console noise, not a broken bundle. Uncaught page errors are still
// fatal, and so is anything the boot test would reject on the host page.
expect(consoleLog.pageErrors, 'uncaught page errors while loading Element Call').toEqual([]);
});
});
-201
View File
@@ -1,201 +0,0 @@
import { test, expect, Page, Request } from '@playwright/test';
import { collectConsole, generateJpeg } from './helpers';
// Tier 2 — E2EE composer smoke (Gitea #90). Needs a real homeserver account
// that supports `m.login.password`, supplied via env (CI secrets, see
// LOTUS_TESTING.md). Skips cleanly when unset so the boot tier still gates CI.
//
// E2E_HOMESERVER server name as typed in the login page, e.g. matrix.example.org
// E2E_USER localpart or full MXID
// E2E_PASSWORD password
//
// Every run logs in as a fresh device (fresh browser context), so the account
// accumulates one device per run — use a throwaway test account.
const HOMESERVER = process.env.E2E_HOMESERVER;
const USER = process.env.E2E_USER;
const PASSWORD = process.env.E2E_PASSWORD;
const HAS_CREDENTIALS = Boolean(HOMESERVER && USER && PASSWORD);
type SentEvent = { url: string; body: Record<string, unknown> };
/** Records every `PUT .../send/<type>/<txn>` the client makes. */
function recordSentEvents(page: Page): SentEvent[] {
const sent: SentEvent[] = [];
page.on('request', (req: Request) => {
if (
req.method() !== 'PUT' ||
!/\/_matrix\/client\/[^/]+\/rooms\/[^/]+\/send\//.test(req.url())
) {
return;
}
let body: Record<string, unknown> = {};
try {
body = JSON.parse(req.postData() ?? '{}');
} catch {
// leave empty; the assertion below will surface it
}
sent.push({ url: req.url(), body });
});
return sent;
}
const eventTypeOf = (url: string): string =>
decodeURIComponent(url.match(/\/send\/([^/]+)\//)?.[1] ?? '');
test.describe('E2EE composer', () => {
test.skip(!HAS_CREDENTIALS, 'needs E2E_HOMESERVER / E2E_USER / E2E_PASSWORD');
// The three scenarios build on one another (login → room → messages), so
// share a single page and run them in order.
test.describe.configure({ mode: 'serial' });
test.setTimeout(120_000);
let page: Page;
let sentEvents: SentEvent[];
let consoleLog: ReturnType<typeof collectConsole>;
let roomUrl: string;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
consoleLog = collectConsole(page);
sentEvents = recordSentEvents(page);
});
test.afterAll(async () => {
await page?.close();
});
test('logs in with a password and reaches the client', async () => {
await page.goto(`/login/${encodeURIComponent(HOMESERVER as string)}/`);
await page.getByLabel('Username or email').fill(USER as string);
await page.getByLabel('Password', { exact: true }).fill(PASSWORD as string);
await page.getByRole('button', { name: 'Login' }).click();
// Leaving /login/ means the session was stored and the client mounted.
await expect(page).not.toHaveURL(/\/login\//, { timeout: 60_000 });
// The client shell mounts at /home/ (or the last-visited space) once the
// session is restored and initial sync starts.
await expect(page).toHaveURL(/\/(home|direct|explore|inbox|!|#)/, { timeout: 60_000 });
await expect(page.locator('#root > *').first()).toBeAttached();
expect(consoleLog.pageErrors, 'uncaught page errors during login').toEqual([]);
});
test('creates a private encrypted room and sends a text message', async () => {
const roomName = `e2e-smoke-${Date.now()}`;
const createRoomRequest = page.waitForRequest(
(req) => req.method() === 'POST' && /\/_matrix\/client\/[^/]+\/createRoom/.test(req.url()),
);
await page.goto('/home/create/');
const form = page.locator('form').filter({ has: page.locator('input[name="nameInput"]') });
await expect(form).toBeVisible();
await form.locator('input[name="nameInput"]').fill(roomName);
// Default access is Private (or Restricted, which also allows E2EE); the
// encryption switch lives in the "End-to-End Encryption" setting tile.
const encryptionSwitch = form
.getByText('End-to-End Encryption', { exact: true })
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
.getByRole('switch');
await expect(encryptionSwitch).toBeVisible();
if ((await encryptionSwitch.getAttribute('aria-checked')) !== 'true') {
await encryptionSwitch.click();
}
await expect(encryptionSwitch).toHaveAttribute('aria-checked', 'true');
await form.getByRole('button', { name: 'Create' }).click();
// The createRoom request itself must ask for encryption up front.
const createBody = JSON.parse((await createRoomRequest).postData() ?? '{}') as {
initial_state?: { type: string; content?: { algorithm?: string } }[];
};
const encryptionState = createBody.initial_state?.find((s) => s.type === 'm.room.encryption');
expect(encryptionState?.content?.algorithm, 'createRoom initial_state m.room.encryption').toBe(
'm.megolm.v1.aes-sha2',
);
// Landed in the new room.
await expect(page).toHaveURL(/\/home\/!/, { timeout: 30_000 });
roomUrl = page.url();
await expect(page.getByText(roomName, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const text = `hello from playwright ${Date.now()}`;
const composer = page.getByRole('textbox', { name: 'Send a message...' });
await expect(composer).toBeVisible();
await composer.click();
await composer.fill(text);
await composer.press('Enter');
await expect(page.getByText(text, { exact: true })).toBeVisible({ timeout: 30_000 });
const messageSends = sentEvents.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
expect(messageSends.length, 'at least one room event sent').toBeGreaterThan(0);
for (const e of messageSends) {
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
expect(e.body).toHaveProperty('ciphertext');
expect(e.body).not.toHaveProperty('body');
expect(JSON.stringify(e.body)).not.toContain(text);
}
expect(consoleLog.pageErrors, 'uncaught page errors while sending text').toEqual([]);
});
test('attaches a compressed image and it is sent encrypted', async () => {
await expect(page).toHaveURL(roomUrl);
const fileName = `lotus-e2e-${Date.now()}.jpg`;
const jpeg = await generateJpeg(page);
const sentBefore = sentEvents.length;
// The composer opens a detached <input type=file> via selectFile(); the
// file chooser event is the hook Playwright gives us for that.
const fileChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Attach file' }).click();
await (await fileChooser).setFiles({ name: fileName, mimeType: 'image/jpeg', buffer: jpeg });
// Upload board: tick "Compress image before uploading", then Send.
const compressSwitch = page
.getByText('Compress image before uploading', { exact: true })
.locator('xpath=ancestor::div[.//*[@role="switch"]][1]')
.getByRole('switch');
await expect(compressSwitch).toBeVisible({ timeout: 30_000 });
if ((await compressSwitch.getAttribute('aria-checked')) !== 'true') {
await compressSwitch.click();
}
await expect(compressSwitch).toHaveAttribute('aria-checked', 'true');
// compressImage() runs asynchronously once ticked; wait for it to settle
// so the Send picks up the compressed result.
await expect(page.getByText('compressing…')).toHaveCount(0, { timeout: 30_000 });
await page.getByRole('button', { name: 'Send', exact: true }).click();
// The timeline shows the image (alt/title = file body; compression
// renames to .jpg which our name already is).
const image = page.locator(`img[alt="${fileName}"]`);
const viewButton = page.getByRole('button', { name: 'View', exact: true });
await expect(image.or(viewButton).first()).toBeVisible({ timeout: 60_000 });
if (!(await image.count())) {
// Media auto-load disabled — click through and wait for the image.
await viewButton.first().click();
}
await expect(image.first()).toBeVisible({ timeout: 60_000 });
// Every room event sent for the image was encrypted: no plaintext
// m.room.message with a `url`/`file`/`body`.
const newSends = sentEvents
.slice(sentBefore)
.filter((e) => eventTypeOf(e.url).startsWith('m.room.'));
expect(newSends.length, 'image produced at least one room event').toBeGreaterThan(0);
for (const e of newSends) {
expect(eventTypeOf(e.url), `event type for ${e.url}`).toBe('m.room.encrypted');
expect(e.body).toHaveProperty('ciphertext');
expect(e.body).not.toHaveProperty('url');
expect(e.body).not.toHaveProperty('file');
expect(e.body).not.toHaveProperty('body');
expect(JSON.stringify(e.body)).not.toContain('mxc://');
}
expect(consoleLog.pageErrors, 'uncaught page errors while sending image').toEqual([]);
});
});
-74
View File
@@ -1,74 +0,0 @@
import { Page } from '@playwright/test';
// Console noise that is expected on a clean boot and must not fail the smoke
// test. Keep this list short and specific — every entry should name a known,
// understood source.
const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
// README: after login you may see a 404 for a missing avatar thumbnail —
// "not a login failure". Also covers the generic resource-404 console line.
/_matrix\/(client|media)\/v\d+\/(media\/)?thumbnail/i,
/Failed to load resource: the server responded with a status of 404/i,
// The login page probes `POST /_matrix/client/v3/register` to learn whether
// registration is open; the homeserver answers 401 + UIA flows by design.
/Failed to load resource: the server responded with a status of 401/i,
// Homeserver discovery pings can fail on a runner with no outbound network.
/\/\.well-known\/matrix\/client/i,
/Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED/i,
// React devtools hint in production bundles.
/Download the React DevTools/i,
];
export type ConsoleCollector = {
errors: string[];
pageErrors: string[];
/** Errors not matched by the benign allowlist. */
unexpected: () => string[];
};
/**
* Records console.error lines and uncaught page errors for the given page.
* Attach BEFORE navigating so nothing emitted during boot is missed.
*/
export function collectConsole(page: Page): ConsoleCollector {
const errors: string[] = [];
const pageErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
return {
errors,
pageErrors,
unexpected: () => [
...pageErrors.map((m) => `pageerror: ${m}`),
...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))),
],
};
}
/**
* Generates a small JPEG in the browser (canvas.toBlob) and returns its bytes.
* JPEG rather than PNG so the composer's "Compress image" path actually
* re-encodes (compressImage() deliberately skips PNG to preserve alpha).
*/
export async function generateJpeg(page: Page, size = 96): Promise<Buffer> {
const dataUrl = await page.evaluate((px) => {
const canvas = document.createElement('canvas');
canvas.width = px;
canvas.height = px;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d context unavailable');
const grad = ctx.createLinearGradient(0, 0, px, px);
grad.addColorStop(0, '#7c3aed');
grad.addColorStop(1, '#f59e0b');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, px, px);
ctx.fillStyle = '#fff';
ctx.font = `${Math.floor(px / 4)}px sans-serif`;
ctx.fillText('e2e', px / 8, px / 2);
return canvas.toDataURL('image/jpeg', 0.95);
}, size);
return Buffer.from(dataUrl.split(',')[1], 'base64');
}
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["./**/*.ts", "../playwright.config.ts"]
}
+107 -259
View File
@@ -1,12 +1,12 @@
{
"name": "lotus-chat",
"version": "4.12.7-lotus",
"version": "4.12.3-lotus",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lotus-chat",
"version": "4.12.7-lotus",
"version": "4.12.3-lotus",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
@@ -50,13 +50,13 @@
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.18.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
@@ -69,8 +69,8 @@
"react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.18.3",
"sanitize-html": "2.17.7",
"react-router-dom": "7.15.1",
"sanitize-html": "2.17.4",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -80,8 +80,7 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
"@playwright/test": "1.63.0",
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -121,7 +120,7 @@
"vite-plugin-static-copy": "4.1.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=16.0.0"
}
},
"node_modules/@apideck/better-ajv-errors": {
@@ -2494,6 +2493,20 @@
"uuid": "^9.0.0"
}
},
"node_modules/@giphy/js-util/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@giphy/react-components": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.2.tgz",
@@ -2681,9 +2694,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.3",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.3/element-call-embedded-0.25.0-lotus.3.tgz",
"integrity": "sha512-UjOi8DXosVjBeVb5SiPNFApJSI9gggpU67xAUn0EWevD3oj9wQFhtjdMkxGbhnQ7B9r6QEUivDGWsBpsfKe0/w==",
"version": "0.20.1-lotus.1",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
@@ -2696,9 +2709,9 @@
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
@@ -2712,23 +2725,23 @@
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
@@ -2746,9 +2759,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
@@ -2766,9 +2779,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
@@ -2786,9 +2799,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
@@ -2806,9 +2819,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
@@ -2826,9 +2839,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
@@ -2846,9 +2859,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
@@ -2866,9 +2879,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
@@ -2886,9 +2899,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
@@ -2906,9 +2919,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
@@ -2926,9 +2939,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
@@ -2974,22 +2987,6 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
@@ -5011,9 +5008,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -6164,7 +6161,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
@@ -6178,7 +6174,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -6194,7 +6189,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -6237,7 +6231,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
@@ -6251,7 +6244,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -6335,7 +6327,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
@@ -8048,7 +8039,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -8068,7 +8058,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -8084,7 +8073,6 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -9017,19 +9005,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -9147,9 +9125,9 @@
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"version": "0.16.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
@@ -10045,9 +10023,9 @@
}
},
"node_modules/matrix-widget-api": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz",
"integrity": "sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew==",
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
@@ -10224,9 +10202,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -10684,15 +10662,15 @@
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.3.289",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
"version": "5.7.284",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
"integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
"@napi-rs/canvas": "^0.1.100"
}
},
"node_modules/picocolors": {
@@ -10725,35 +10703,6 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -10773,9 +10722,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
@@ -10792,7 +10741,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -11164,9 +11113,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -11186,12 +11135,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
"react-router": "7.15.1"
},
"engines": {
"node": ">=20.0.0"
@@ -11697,106 +11646,18 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"version": "2.17.4",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
"integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/scheduler": {
@@ -13077,19 +12938,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+10 -14
View File
@@ -1,24 +1,22 @@
{
"name": "lotus-chat",
"version": "4.12.7-lotus",
"version": "4.12.3-lotus",
"description": "Lotus Chat — Matrix client for Lotus Guild",
"main": "index.js",
"type": "module",
"engines": {
"node": ">=20.0.0"
"node": ">=16.0.0"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -77,13 +75,13 @@
"is-hotkey": "0.2.0",
"jotai": "2.20.0",
"jsqr": "1.4.0",
"katex": "0.16.47",
"katex": "0.16.11",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.18.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
"pdfjs-dist": "6.3.289",
"pdfjs-dist": "5.7.284",
"prismjs": "1.30.0",
"qrcode": "1.5.4",
"qrcode.react": "4.2.0",
@@ -96,8 +94,8 @@
"react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.18.3",
"sanitize-html": "2.17.7",
"react-router-dom": "7.15.1",
"sanitize-html": "2.17.4",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -107,8 +105,7 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
"@playwright/test": "1.63.0",
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -149,8 +146,7 @@
},
"overrides": {
"@giphy/js-util": {
"dompurify": ">=3.3.4",
"uuid": ">=11.1.1"
"dompurify": ">=3.3.4"
},
"js-cookie": ">=3.0.6"
}
-35
View File
@@ -1,35 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
// Playwright smoke tests (Gitea #90). Two tiers live under e2e/:
// - boot.spec.ts always runs; serves the built dist/ via `vite preview`
// and checks the client actually boots in a real browser.
// - e2ee-composer.spec.ts skips itself unless E2E_HOMESERVER/E2E_USER/E2E_PASSWORD
// are set (CI secrets — see LOTUS_TESTING.md).
// `npm run build` must have produced dist/ before `npm run test:e2e`.
const PORT = 4173;
const BASE_URL = `http://localhost:${PORT}/`;
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 15_000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
outputDir: 'test-results',
use: {
baseURL: BASE_URL,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
-25
View File
@@ -1,25 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["before 6am on monday"],
"packageRules": [
{
"matchPackagePatterns": ["^matrix-js-sdk"],
"groupName": "matrix-js-sdk"
},
{
"matchPackagePatterns": ["^@lotusguild/"],
"groupName": "@lotusguild packages"
},
{
"matchPackageNames": ["@lotusguild/element-call-embedded"],
"matchUpdateTypes": ["major"],
"enabled": false
},
{
"matchCategories": ["security"],
"groupName": "security updates",
"automerge": false
}
]
}
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env node
// Boot-check (Gitea #92): after `npm run build`, nothing actually loaded the
// built dist/ — a build that produces a broken bundle (bad base path, a 500
// from an asset, a malformed config.json) still went green. This script
// serves dist/ the same way production does (vite preview) and makes a
// handful of real HTTP requests against it, so a broken bundle fails CI
// instead of surfacing after deploy.
//
// No Playwright here: playwright-core is not a project dependency (only the
// browser binary caches happen to be present on this machine), so we don't
// depend on it being installed. Plain fetch is enough to catch the class of
// bug this check exists for — a page/asset/config that doesn't come back.
import { spawn } from 'node:child_process';
const PORT = 4173;
const HOST = '127.0.0.1';
const BASE_URL = `http://${HOST}:${PORT}`;
const BOOT_TIMEOUT_MS = 30_000;
function log(msg) {
console.log(`[boot-check] ${msg}`);
}
function waitForPort(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
const attempt = async () => {
try {
const res = await fetch(url, { method: 'GET' });
return res;
} catch {
return null;
}
};
return new Promise((resolve, reject) => {
const poll = async () => {
const res = await attempt();
if (res) {
resolve();
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${url} to come up`));
return;
}
setTimeout(poll, 300);
};
poll();
});
}
async function assert(condition, message) {
if (!condition) {
throw new Error(`Assertion failed: ${message}`);
}
log(`ok: ${message}`);
}
async function main() {
const preview = spawn(
'npx',
['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', HOST],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let previewOutput = '';
preview.stdout.on('data', (d) => (previewOutput += d.toString()));
preview.stderr.on('data', (d) => (previewOutput += d.toString()));
const cleanup = () => {
if (!preview.killed) {
preview.kill('SIGTERM');
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(1);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(1);
});
try {
await waitForPort(BASE_URL, BOOT_TIMEOUT_MS);
// 1. index page loads and contains the SPA mount point.
const indexRes = await fetch(`${BASE_URL}/`);
await assert(indexRes.status === 200, `GET / returns 200 (got ${indexRes.status})`);
const indexHtml = await indexRes.text();
await assert(indexHtml.includes('<div id="root"'), 'GET / contains <div id="root"');
// 2. runtime config is valid JSON.
const configRes = await fetch(`${BASE_URL}/config.json`);
await assert(
configRes.status === 200,
`GET /config.json returns 200 (got ${configRes.status})`,
);
const configText = await configRes.text();
let configJson;
try {
configJson = JSON.parse(configText);
} catch (err) {
throw new Error(`GET /config.json is not valid JSON: ${err.message}`);
}
await assert(
typeof configJson === 'object' && configJson !== null,
'/config.json parses to an object',
);
// 3. the main JS entry referenced from index.html actually loads.
const scriptMatch = indexHtml.match(/<script[^>]+type="module"[^>]+src="([^"]+)"/);
await assert(!!scriptMatch, 'index.html references a module script entry');
const mainScriptUrl = new URL(scriptMatch[1], BASE_URL).toString();
const scriptRes = await fetch(mainScriptUrl);
await assert(
scriptRes.status === 200,
`GET ${scriptMatch[1]} returns 200 (got ${scriptRes.status})`,
);
const scriptContentType = scriptRes.headers.get('content-type') || '';
await assert(
/javascript/.test(scriptContentType),
`GET ${scriptMatch[1]} has a JS content-type (got "${scriptContentType}")`,
);
// 4. Element Call widget bundle is present.
const callRes = await fetch(`${BASE_URL}/public/element-call/index.html`);
await assert(
callRes.status === 200,
`GET /public/element-call/index.html returns 200 (got ${callRes.status})`,
);
log('all checks passed');
} finally {
cleanup();
}
}
main()
.catch((err) => {
console.error(`[boot-check] FAILED: ${err.message}`);
process.exitCode = 1;
})
.finally(() => {
// The killed preview server's stdio pipes can keep the event loop alive
// briefly; force the process down promptly with whatever exit code was set.
process.exit(process.exitCode ?? 0);
});
-5
View File
@@ -1,5 +0,0 @@
{
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
"totalGzipBytes": 1731120,
"largestChunkGzipBytes": 358317
}
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env node
// Bundle size budget (Gitea #96). The CI "Report bundle sizes" step printed
// numbers with nothing to compare them against, so a bundle could balloon
// silently. This script compares dist/assets gzip sizes against a small
// checked-in budget (scripts/bundle-budget.json) and prints the same report.
//
// Mode is passed via argv: `pull_request` fails the build over budget,
// anything else (e.g. `push`) only warns — a push has already merged, so
// blocking it can't prevent the regression, only delay the deploy of an
// otherwise-good commit; the pull_request gate is where this should be caught.
import { appendFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mode = process.argv[2] || 'push';
const distAssetsDir = path.join(__dirname, '..', 'dist', 'assets');
const budgetPath = path.join(__dirname, 'bundle-budget.json');
function formatKb(bytes) {
return `${(bytes / 1024).toFixed(1)} kB`;
}
function main() {
const budget = JSON.parse(readFileSync(budgetPath, 'utf-8'));
const jsFiles = readdirSync(distAssetsDir)
.filter((f) => f.endsWith('.js') && !f.endsWith('.map'))
.sort();
if (jsFiles.length === 0) {
console.error(
`[check-bundle-size] No .js files found in ${distAssetsDir} — did the build run?`,
);
process.exitCode = 1;
return;
}
const rows = jsFiles.map((name) => {
const filePath = path.join(distAssetsDir, name);
const size = statSync(filePath).size;
const gzipSize = gzipSync(readFileSync(filePath)).length;
return { name, size, gzipSize };
});
const totalGzipBytes = rows.reduce((sum, r) => sum + r.gzipSize, 0);
const largest = rows.reduce((max, r) => (r.gzipSize > max.gzipSize ? r : max), rows[0]);
const summaryLines = [
'### Bundle sizes',
'',
'| File | Size | Gzip |',
'|------|------|------|',
...rows.map((r) => `| ${r.name} | ${formatKb(r.size)} | ${formatKb(r.gzipSize)} |`),
'',
`**Total gzip:** ${formatKb(totalGzipBytes)} (budget ${formatKb(budget.totalGzipBytes)})`,
`**Largest chunk gzip:** ${largest.name}${formatKb(largest.gzipSize)} (budget ${formatKb(
budget.largestChunkGzipBytes,
)})`,
];
const summaryText = summaryLines.join('\n');
console.log(summaryText);
if (process.env.GITHUB_STEP_SUMMARY) {
// Gitea Actions/act_runner honors the same GITHUB_STEP_SUMMARY convention.
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summaryText}\n`);
}
const overBudget = [];
if (totalGzipBytes > budget.totalGzipBytes) {
overBudget.push(
`total gzip ${formatKb(totalGzipBytes)} exceeds budget ${formatKb(budget.totalGzipBytes)}`,
);
}
if (largest.gzipSize > budget.largestChunkGzipBytes) {
overBudget.push(
`largest chunk (${largest.name}) gzip ${formatKb(largest.gzipSize)} exceeds budget ${formatKb(
budget.largestChunkGzipBytes,
)}`,
);
}
if (overBudget.length > 0) {
const message = `[check-bundle-size] Over budget: ${overBudget.join('; ')}`;
if (mode === 'pull_request') {
console.error(message);
process.exitCode = 1;
} else {
console.warn(`${message} (warning only on "${mode}")`);
}
} else {
console.log('[check-bundle-size] Within budget.');
}
}
main();
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
# Local throwaway Synapse for driving the real UI (Playwright / a browser)
# against a homeserver you control — open registration, no rate limits,
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
#
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
# scripts/dev-homeserver.sh stop
# scripts/dev-homeserver.sh reset # wipe the database and media
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images
#
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
# as alice / password123.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIR="$ROOT/.dev-homeserver"
VENV="$DIR/venv"
CFG="$DIR/homeserver.yaml"
PIDFILE="$DIR/synapse.pid"
install() {
mkdir -p "$DIR"
if [ ! -x "$VENV/bin/python" ]; then
python3 -m venv --without-pip "$VENV"
curl -sS https://bootstrap.pypa.io/get-pip.py -o "$DIR/get-pip.py"
"$VENV/bin/python" "$DIR/get-pip.py" -q
"$VENV/bin/pip" install -q "matrix-synapse[url-preview]"
fi
if [ ! -f "$CFG" ]; then
(cd "$DIR" && "$VENV/bin/python" -m synapse.app.homeserver \
--server-name localhost --config-path homeserver.yaml --generate-config --report-stats=no >/dev/null)
# the generated listener only serves `client`; add media + open the door
python3 - "$CFG" <<'PY'
import sys, re
p = sys.argv[1]; s = open(p).read()
s = s.replace(" - client\n", " - client\n - media\n", 1)
s += """
enable_registration: true
enable_registration_without_verification: true
rc_message: { per_second: 1000, burst_count: 10000 }
rc_registration: { per_second: 1000, burst_count: 10000 }
rc_login: { address: { per_second: 1000, burst_count: 10000 }, account: { per_second: 1000, burst_count: 10000 }, failed_attempts: { per_second: 1000, burst_count: 10000 } }
rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_second: 1000, burst_count: 10000 } }
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
max_upload_size: 50M
suppress_key_server_warning: true
# URL previews (embed facades use the homeserver's cached thumbnail)
url_preview_enabled: true
url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7']
# scheduled messages (MSC4140 delayed events)
max_event_delay_duration: 24h
experimental_features:
msc4140_enabled: true
"""
open(p, "w").write(s)
PY
fi
}
start() {
install
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "already running (pid $(cat "$PIDFILE"))"; return
fi
(cd "$DIR" && setsid nohup "$VENV/bin/python" -m synapse.app.homeserver --config-path homeserver.yaml \
> synapse.log 2>&1 < /dev/null & echo $! > "$PIDFILE")
for _ in $(seq 1 40); do
sleep 1
curl -sf http://127.0.0.1:8008/_matrix/client/versions >/dev/null && { echo "synapse up on http://localhost:8008"; return; }
done
echo "synapse did not come up — see $DIR/synapse.log" >&2; exit 1
}
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
esac
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image
every 10th message. Idempotent for the users; every run creates a new room.
python3 scripts/dev-seed.py [N=400]
"""
import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
HS = "http://127.0.0.1:8008"
PASSWORD = "password123"
def req(method, path, data=None, token=None, raw=None, ctype="application/json"):
headers = {"Content-Type": ctype}
if token:
headers["Authorization"] = f"Bearer {token}"
body = raw if raw is not None else (json.dumps(data).encode() if data is not None else None)
r = urllib.request.Request(HS + path, data=body, headers=headers, method=method)
return json.load(urllib.request.urlopen(r))
def register_or_login(user):
try:
return req("POST", "/_matrix/client/v3/register",
{"username": user, "password": PASSWORD, "auth": {"type": "m.login.dummy"}})
except urllib.error.HTTPError:
return req("POST", "/_matrix/client/v3/login",
{"type": "m.login.password", "identifier": {"type": "m.id.user", "user": user}, "password": PASSWORD})
def png(w, h, rgb):
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 400
alice, bob = register_or_login("alice"), register_or_login("bob")
ta, tb = alice["access_token"], bob["access_token"]
room = req("POST", "/_matrix/client/v3/createRoom",
{"name": "Busy Room", "preset": "public_chat", "visibility": "public"}, ta)["room_id"]
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(room)}", {}, tb)
txn = int(time.time() * 1000)
for i in range(n):
tok = ta if i % 2 == 0 else tb
if i % 10 == 0:
data = png(64, 48, ((i * 37) % 256, (i * 91) % 256, (i * 17) % 256))
up = req("POST", f"/_matrix/media/v3/upload?filename=img{i}.png", token=tok, raw=data, ctype="image/png")
content = {"msgtype": "m.image", "body": f"img{i}.png", "url": up["content_uri"],
"info": {"mimetype": "image/png", "w": 64, "h": 48, "size": len(data)}}
else:
content = {"msgtype": "m.text", "body": f"message #{i}"}
txn += 1
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
if __name__ == "__main__":
main()
+13 -41
View File
@@ -4,56 +4,28 @@ import { join, dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js');
const foldsPkgPath = join(__dirname, '../node_modules/folds/package.json');
// Context lines around the target, not just the single `children: src(filled)`
// expression, so a coincidental match elsewhere in the bundle (e.g. some other
// `src(filled)` call) can't be mistaken for the Icon component we're patching.
// This is still string matching, not an AST edit, but the extra context makes
// an accidental match far less likely (Gitea #55).
const original = [' ...props,', ' ref,', ' children: src(filled)', ' }'].join(
'\n',
);
const patched = [
' ...props,',
' ref,',
' children: typeof src === "function" ? src(filled) : null',
' }',
].join('\n');
function foldsVersion() {
try {
return JSON.parse(readFileSync(foldsPkgPath, 'utf8')).version ?? 'unknown';
} catch {
return 'unknown';
}
}
try {
const content = readFileSync(foldsPath, 'utf8');
let content = readFileSync(foldsPath, 'utf8');
// Defensive guard: if src is not a function, render null instead of crashing
const original = 'children: src(filled)';
const patched = 'children: typeof src === "function" ? src(filled) : null';
if (content.includes(patched)) {
// Already patched (e.g. re-running postinstall, or a fresh checkout that
// already has a patched node_modules cache) — no-op, exit 0.
console.log('folds patch already applied.');
} else if (content.includes(original)) {
writeFileSync(foldsPath, content.replace(original, patched), 'utf8');
content = content.replace(original, patched);
writeFileSync(foldsPath, content, 'utf8');
console.log('Applied defensive Icon src guard to folds.');
} else {
// Genuine "patch could not be applied" case: neither the original nor the
// patched form was found, meaning folds changed the Icon implementation.
// Fail loudly so the postinstall hook / CI breaks instead of silently
// shipping an unpatched folds (which crashes at render with "src is not a
// function"). See cinny #210 (build hygiene notes) for
// context on why this is a direct node_modules patch rather than
// patch-package.
console.error('ERROR: folds Icon patch target not found.');
console.error(` folds version installed: ${foldsVersion()}`);
console.error(` Expected to find (surrounding context):\n${original}`);
// Genuine "patch could not be applied" case: the target string is gone
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
// so the postinstall hook / CI breaks loudly instead of silently shipping
// an unpatched folds (which crashes at render with "src is not a function").
console.error(
' folds likely changed its Icon implementation. Update the patch target ' +
'in scripts/patch-folds.mjs (see cinny #210 ' +
'-> patch-folds.mjs entry) before building.',
'ERROR: folds Icon patch target not found - folds may have updated. ' +
'Update the patch target string in scripts/patch-folds.mjs before building.',
);
process.exit(1);
}
+4 -33
View File
@@ -103,38 +103,9 @@ missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
const missingSet = new Set(missing.map((r) => r.slug));
// Remove individual entries for missing slugs
const removedSlugs = new Set();
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) => {
if (!missingSet.has(slug)) return match;
removedSlugs.add(slug);
return '';
});
// Regex-based removal is brittle: if the catalog is reformatted (different
// indentation, line-wrapped entries, etc.) the pattern above can silently
// match zero entries while HTTP probing still reports slugs missing. Verify
// every slug we intended to remove actually got matched — otherwise abort
// without writing, so a formatting change fails loudly instead of leaving
// stale/dead entries in the catalog (see Gitea #88).
const unmatched = [...missingSet].filter((slug) => !removedSlugs.has(slug));
if (unmatched.length > 0) {
console.error(
`Aborting: expected to remove ${missingSet.size} entr${missingSet.size === 1 ? 'y' : 'ies'} ` +
`but only matched ${removedSlugs.size}. The catalog's formatting may have changed and the ` +
`parser in scripts/syncDecorations.mjs needs updating. Refusing to write a partial result.`,
);
console.error(` Unmatched slugs: ${unmatched.join(', ')}`);
process.exit(1);
}
if (removedSlugs.size === 0) {
// We already exited above when `missing.length === 0`, so reaching here
// with zero removals despite `missing.length > 0` means the diff between
// "expected" and "actual" itself is broken — fail rather than proceed.
console.error(
'Aborting: no entries were matched for removal despite missing slugs. Refusing to write.',
);
process.exit(1);
}
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
missingSet.has(slug) ? '' : match,
);
// Drop category blocks that now have an empty decorations array
updated = updated.replace(
@@ -147,6 +118,6 @@ updated = updated.replace(/\n{3,}/g, '\n\n');
writeFileSync(catalogPath, updated, 'utf8');
console.log(
`\nDone. Removed ${removedSlugs.size} entr${removedSlugs.size === 1 ? 'y' : 'ies'} from the catalog.`,
`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`,
);
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
+14 -47
View File
@@ -45,8 +45,6 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
@@ -60,7 +58,6 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
@@ -68,7 +65,6 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { zIndices } from '../styles/zIndex';
@@ -115,10 +111,6 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
// Gitea #28 — don't ring during quiet hours / DND / Focus Assist / snooze. The
// call can still be answered from this overlay; only the audible ring is
// skipped.
const quiet = useNotificationsQuiet();
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
@@ -140,10 +132,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
);
useEffect(() => {
if (info.notificationType !== 'ring' || quiet) return undefined;
if (info.notificationType !== 'ring') return undefined;
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
return stop;
}, [info.notificationType, ringtoneId, ringtoneVolume, quiet]);
}, [info.notificationType, ringtoneId, ringtoneVolume]);
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
@@ -280,9 +272,6 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
// Gitea #28 — no ping during quiet hours / DND / Focus Assist / snooze; the
// banner itself still shows so the call can be answered.
const quiet = useNotificationsQuiet();
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
@@ -309,11 +298,11 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
// ringtone settings while the banner is showing.
const pingedRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (info.notificationType !== 'ring' || quiet) return;
if (info.notificationType !== 'ring') return;
if (pingedRef.current === info.refEventId) return;
pingedRef.current = info.refEventId;
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume, quiet]);
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
@@ -421,8 +410,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
@@ -579,15 +566,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
[startCall, navigateRoom],
);
if (!callInfo) return null;
@@ -619,15 +602,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
}
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
// embed's whole lifetime, rather than in CallControls (which only renders
// while the call room is selected) — so they keep working in PiP and behind
// the mobile in-call chat. Both are gated on `joined`.
useCallHotkeys(embed, joined);
useAfkAutoMute(joined ? embed : undefined);
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
@@ -639,22 +616,6 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
}, [setCallEmbed]),
);
// [Gitea #58] Warn before an accidental tab close/reload drops the user out
// of a live call. Only armed while actually joined, and torn down on
// hangup/dispose since this effect re-runs when `joined` flips back to false.
useEffect(() => {
if (!joined) return undefined;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Legacy browsers require returnValue to be set to show the prompt.
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, [joined]);
return null;
}
@@ -759,6 +720,12 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
};
}, []);
// Sync pip mode into CallControl so it can adjust behavior accordingly
useEffect(() => {
if (!callEmbed) return;
callEmbed.control.setPipMode(!!pipMode);
}, [pipMode, callEmbed]);
// When entering pip with screenshare active (or screenshare starts while in pip),
// enable spotlight so the screenshare fills the pip window.
// When screenshare ends, release the spotlight we auto-enabled.
@@ -1172,7 +1139,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
{callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children}
-7
View File
@@ -209,13 +209,6 @@ type GifPickerProps = {
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// Searches (and every keystroke) go straight to Giphy's API, so the picker
// is opt-in (Settings → Messages) and must not render or fetch until then.
if (!gifPickerEnabled) {
return null;
}
const containerStyle = lotusTerminal
? {
+1 -1
View File
@@ -248,7 +248,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
radii="300"
title="Record voice message"
>
<Icon src={Icons.Mic} />
<Icon src={Icons.Mic} size="100" />
</IconButton>
);
}
+11 -27
View File
@@ -16,16 +16,21 @@ export const EditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
'@media': {
// On phones the toolbar can hold many 44px buttons; let them wrap to a
// second line instead of overflowing horizontally.
'(max-width: 750px)': { flexWrap: 'wrap' },
},
},
]);
// The composer's before | editable | after row. It must NOT wrap: folds'
// Scroll (the editable's wrapper) is `width: 100%`, so a wrapping row always
// breaks into three stacked lines (before / editable / after) — the "wonky"
// phone composer. Narrow viewports keep one row by collapsing the secondary
// buttons behind the "+" overflow instead (RoomInput `compact`).
// The composer's before | editable | after row. On phones, allow the toolbar
// (`after`) to wrap below the input instead of squeezing the editable to zero
// and pushing the Send button off-screen.
export const EditorInputRow = style({
minWidth: 0,
'@media': {
'(max-width: 750px)': { flexWrap: 'wrap' },
},
});
export const EditorTextareaScroll = style({});
@@ -36,20 +41,6 @@ export const EditorTextarea = style([
flexGrow: 1,
height: '100%',
padding: `${toRem(13)} ${toRem(1)}`,
'@media': {
// Phone-width composer rows carry 44px touch targets (MobileTouchTarget),
// so the row is 60px instead of 48px; pad the text to keep it level with
// the buttons instead of hugging the top of the row. Only when the row
// actually has before/after buttons (not the edit-message editor).
'(max-width: 750px)': {
selectors: {
[`${EditorTextareaScroll}:not(:only-child) &`]: {
paddingTop: toRem(19),
paddingBottom: toRem(19),
},
},
},
},
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
@@ -79,13 +70,6 @@ export const EditorPlaceholderTextVisual = style([
display: 'block',
paddingTop: toRem(13),
paddingLeft: toRem(1),
'@media': {
'(max-width: 750px)': {
selectors: {
[`${EditorTextareaScroll}:not(:only-child) &`]: { paddingTop: toRem(19) },
},
},
},
},
]);
+1 -8
View File
@@ -23,13 +23,7 @@ import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
// One FRESH value per editor instance. slate-react keys its node→path weak
// maps by node object identity, so a module-level constant shared by every
// <Slate> (main composer + thread composer + message editor) makes the second
// mount hijack the first editor's nodes and the first editor throws "Unable to
// find the path for Slate node" on its next render — the app-wide crash when
// opening a thread on a pristine composer (Gitea #165 / #184).
const createInitialValue = (): CustomElement[] => [
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
@@ -98,7 +92,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref,
) => {
const [initialValue] = useState(createInitialValue);
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[],
-24
View File
@@ -65,27 +65,3 @@ test('no math conversion inside a code block', () => {
test('a message with no math is unchanged', () => {
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
});
// Gitea #184 O3 — markdown mode: fences and backtick spans typed as plain
// paragraphs are literal too (the block markdown parser only sees the fence
// after the lines are joined, so math must be skipped while serialising them).
const MD_OPTS = { ...OPTS, allowInlineMarkdown: true, allowBlockMarkdown: true };
test('markdown: no math conversion inside a typed ``` fence', () => {
const paragraphs = [
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('$x$ literal')]),
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('after $y$')]),
];
const out = toMatrixCustomHTML(paragraphs, MD_OPTS);
assert.ok(out.includes('<pre'), 'fence became a code block');
assert.ok(out.includes('$x$ literal'), 'code block content stays literal');
assert.ok(out.includes('data-mx-maths="y"'), 'math after the fence still converts');
});
test('markdown: no math conversion inside a backtick span, math outside still converts', () => {
const out = toMatrixCustomHTML(el(BlockType.Paragraph, [txt('use `$x$` and $y$')]), MD_OPTS);
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
assert.ok(out.includes('data-mx-maths="y"'));
});
-20
View File
@@ -36,18 +36,6 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
// applied inside inline code. Non-math text recurses with allowMath off so it
// still gets the normal marks + inline-markdown treatment.
if (opts.allowMath && !node.code) {
// Markdown inline code spans (`…`) are literal too: apply math only to the
// text between them (Gitea #184 O3 — `$x$` inside backticks stayed math).
if (opts.allowInlineMarkdown && /`[^`]*`/.test(node.text)) {
return node.text
.split(/(`+[^`]*`+)/)
.map((part) =>
part.startsWith('`')
? textToCustomHtml({ ...node, text: part }, { ...opts, allowMath: false })
: textToCustomHtml({ ...node, text: part }, opts),
)
.join('');
}
const segments = splitMathSegments(node.text);
if (segments.some((seg) => seg.type !== 'text')) {
return segments
@@ -140,20 +128,12 @@ export const toMatrixCustomHTML = (
opts: OutputOptions,
): string => {
let markdownLines = '';
// Inside a markdown ``` fence every line is literal: no `$…$` math conversion
// (the fence is only recognised by parseBlockMD after the lines are joined,
// which is too late — Gitea #184 O3).
let inFence = false;
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
const isFenceLine = /^\s*```/.test(toPlainText(n, false));
const literal = inFence || isFenceLine;
if (isFenceLine) inFence = !inFence;
const line = toMatrixCustomHTML(n, {
...opts,
allowInlineMarkdown: false,
allowBlockMarkdown: false,
allowMath: opts.allowMath && !literal,
})
.replace(/<br\/>$/, '\n')
.replace(/^(\\*)&gt;/, '$1>');
+4 -4
View File
@@ -268,15 +268,15 @@ export const getPointUntilChar = (
return targetPoint;
};
export const getPrevWordRange = (editor: Editor): BaseRange | undefined => {
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
const { selection } = editor;
if (!selection || !Range.isCollapsed(selection)) return undefined;
const [cursorPoint] = Range.edges(selection);
const wordStartPoint = getPointUntilChar(editor, cursorPoint, {
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
reverse: true,
match: (char) => char === ' ' || char === '',
match: (char) => char === ' ',
});
return wordStartPoint && Editor.range(editor, wordStartPoint, cursorPoint);
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
};
export const isEmptyEditor = (editor: Editor): boolean => {
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
@@ -27,37 +27,12 @@ export const ImageViewer = as<'div', ImageViewerProps>(
saveFile(fileContent, alt);
};
// [Gitea #164] Same keyboard model as the gallery lightbox: + / = zoom
// in, - zoom out, 0 reset; double-click toggles 1× ↔ 2×. The root takes
// focus on open so those keys (and Escape, handled by the modal) work
// immediately instead of only after clicking inside the viewer.
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus({ preventScroll: true });
}, []);
const handleKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key === '+' || evt.key === '=') zoomIn();
else if (evt.key === '-') zoomOut();
else if (evt.key === '0') setZoom(1);
else return;
evt.preventDefault();
};
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
role="dialog"
aria-modal
aria-label={alt || 'Image viewer'}
tabIndex={-1}
onKeyDown={handleKeyDown}
{...props}
ref={(node: HTMLDivElement | null) => {
rootRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
@@ -141,7 +116,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
alt={alt}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)}
/>
</Box>
</Box>
@@ -33,7 +33,6 @@ import {
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import { QRCodeSVG } from 'qrcode.react';
import { stopPropagation } from '../../utils/keyboard';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import {
@@ -238,25 +237,13 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
{/* Generated locally (qrcode.react) like RoomShareInvite the
old api.qrserver.com <img> leaked the room link to a third
party and is blocked by the prod CSP img-src anyway
(Gitea #192). White quiet-zone so it scans on any theme. */}
<Box
style={{
padding: config.space.S200,
background: '#ffffff',
borderRadius: config.radii.R300,
lineHeight: 0,
}}
>
<QRCodeSVG
value={inviteUrl}
size={164}
level="M"
title="QR code for room invite link"
/>
</Box>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
alt="QR code for room invite link"
width={180}
height={180}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
<Text
size="T200"
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
+1 -20
View File
@@ -21,31 +21,12 @@ type KaTeXProps = {
* inline (in its error colour) rather than throwing. The HTML returned by
* `renderToString` is produced by our own trusted call from a fixed options
* object it is safe to inject via `dangerouslySetInnerHTML`.
*
* `maxSize`/`maxExpand` cap how large a single glyph (`\\rule`, etc.) or macro
* expansion remote LaTeX can request, and `trust: false` disables commands
* that can embed arbitrary HTML/URLs (e.g. `\\includegraphics`, `\\href`)
* without these a hostile `$$...$$` from a remote message can DoS or (via
* `trust`) inject unsafe links (Gitea #65). `strict: 'ignore'` keeps unknown-
* but-harmless LaTeX from spamming the console as before. Extremely long
* source is rendered as plain text rather than handed to KaTeX at all.
*/
const MAX_LATEX_LENGTH = 5000;
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
if (latex.length > MAX_LATEX_LENGTH) {
const Plain = displayMode ? 'div' : 'span';
return <Plain>{latex}</Plain>;
}
const html = katex.renderToString(latex, {
displayMode,
throwOnError: false,
output: 'htmlAndMathml',
maxSize: 10,
maxExpand: 100,
trust: false,
strict: 'ignore',
});
const Wrapper = displayMode ? 'div' : 'span';
@@ -53,7 +34,7 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
@@ -1,71 +0,0 @@
import React, { MouseEventHandler } from 'react';
import { Box, Icon, Icons, Text, as, toRem } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames';
import * as css from './Reply.css';
import { ForwardedMeta } from '../../features/room/message/forwardContent';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
type ForwardedHeaderProps = {
mx: MatrixClient;
meta: ForwardedMeta;
hour24Clock: boolean;
dateFormatString: string;
/** Present when the viewer can open the original (they are in the source room). */
onJump?: MouseEventHandler;
};
/**
* "↪ Forwarded · from <sender> in <room> · <time>" the provenance line above
* a forwarded message (Gitea: forwards used to look like the forwarder's own
* words). Renders in the same visual slot and style as a reply quote so it
* reads as message context, not as content.
*/
export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
({ mx, meta, hour24Clock, dateFormatString, onJump, className, ...props }, ref) => {
const sourceRoom = meta.room_id ? mx.getRoom(meta.room_id) : null;
const senderName =
(sourceRoom && getMemberDisplayName(sourceRoom, meta.sender)) ??
mx.getUser(meta.sender)?.displayName ??
getMxIdLocalPart(meta.sender) ??
meta.sender;
const ts = meta.origin_server_ts;
const when = today(ts)
? timeHourMinute(ts, hour24Clock)
: yesterday(ts)
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
const canJump = !!sourceRoom && !!onJump;
return (
<Box
as={canJump ? 'button' : 'div'}
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
onClick={canJump ? onJump : undefined}
title={canJump ? 'Jump to the original message' : undefined}
aria-label={`Forwarded from ${senderName}${sourceRoom ? ` in ${sourceRoom.name}` : ''}, ${when}`}
{...props}
ref={ref}
>
<Box alignItems="Center" gap="100" shrink="No" style={{ maxWidth: toRem(420) }}>
<Icon size="100" src={Icons.ArrowGoRight} />
<Text size="T300" priority="300" truncate>
Forwarded from <b>{senderName}</b>
{sourceRoom ? (
<>
{' in '}
<b>{sourceRoom.name}</b>
</>
) : null}
{' · '}
{when}
</Text>
</Box>
</Box>
);
},
);
+16 -38
View File
@@ -15,7 +15,6 @@ import {
MessageVerificationRequestContent,
} from './content';
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { languageName } from '../../utils/translation/langUtils';
import {
IAudioContent,
@@ -63,9 +62,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
return () => observer.disconnect();
}, []);
// A one-time matchMedia() read never updated if the OS setting changed mid-session
// (Gitea #85); useReducedMotion subscribes to the change event instead.
const prefersReducedMotion = useReducedMotion();
const prefersReducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return (
<div>
@@ -635,13 +633,6 @@ type MLocationProps = {
};
export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation();
// The OpenStreetMap iframe used to mount unconditionally on render, silently
// handing the sender's coordinates (and this client's IP/UA) to
// openstreetmap.org for every location message rendered, autoplay or not.
// Gate it behind an explicit click instead (Gitea #66). There's no
// location-specific auto-load setting in settings.ts to opt back into this,
// so it always requires a click.
const [mapLoaded, setMapLoaded] = useState(false);
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
// location block so events from clients that only send the new shape (uri under
// org.matrix.msc3488.location / m.location) still render instead of appearing
@@ -669,33 +660,20 @@ export function MLocation({ content }: MLocationProps) {
return (
<Box direction="Column" alignItems="Start" gap="200">
{mapLoaded ? (
<iframe
title="Location"
src={mapSrc}
style={{
width: '280px',
height: '160px',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: '8px',
display: 'block',
}}
scrolling="no"
loading="lazy"
sandbox="allow-scripts"
/>
) : (
<Button
size="400"
radii="300"
variant="Secondary"
fill="Soft"
onClick={() => setMapLoaded(true)}
before={<Icon src={Icons.Pin} size="50" />}
>
<Text size="B300">{t('Organisms.Message.load_map', 'Load map')}</Text>
</Button>
)}
<iframe
title="Location"
src={mapSrc}
style={{
width: '280px',
height: '160px',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: '8px',
display: 'block',
}}
scrolling="no"
loading="lazy"
sandbox="allow-scripts"
/>
{description && (
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
{description}
-1
View File
@@ -8,4 +8,3 @@ export * from './Time';
export * from './MsgTypeRenderers';
export * from './FileHeader';
export * from './RenderBody';
export * from './ForwardedHeader';
@@ -84,15 +84,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
>
{buildOverlayContent(theme, true)}
</div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'scale(1)' },
'6%': { opacity: '1', transform: 'scale(1.015)' },
'12%': { opacity: '0.85', transform: 'scale(1)' },
'49%': { opacity: '0.85', transform: 'scale(1)' },
'50%': { opacity: '0', transform: 'scale(1)' },
'100%': { opacity: '0', transform: 'scale(1)' },
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
});
/**
+69 -103
View File
@@ -46,10 +46,6 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
// swatch (where scaled strokes disappeared entirely).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -62,7 +58,7 @@ function gridDataUri(): string {
rows.forEach((y) => {
lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
`stroke-width='1.4' stroke-opacity='0.9'/>`,
);
});
// Vertical lines fanning out from the top-center vanishing point.
@@ -71,7 +67,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground
lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
`stroke-width='1.4' stroke-opacity='0.8'/>`,
);
}
const svg =
@@ -109,13 +105,6 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri();
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
// narrower than ~330px. `cqw` resolves against the nearest size container —
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
// to the viewport width when there is no container, i.e. the full-screen
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => {
@@ -145,9 +134,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
].join(','),
contain: 'layout paint style',
}}
@@ -158,59 +147,41 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower
half of the screen the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position.
Two masks are nested (multiple mask-images on one element union by
default, and `mask-composite: intersect` isn't universal yet): the
outer wrapper fades the lattice through the central column where the
message timeline lives, so it frames the chat instead of striping
the text; the inner box fades it in from the horizon. */}
toward the viewer. Pure transform; never background-position. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
maskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
WebkitMaskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
opacity: reduced ? 0.4 : 0.46,
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
contain: 'layout paint style',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
/>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -226,7 +197,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%',
transform: 'translate(-50%, -50%)',
backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
contain: 'layout paint style',
}}
/>
@@ -238,10 +209,8 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%',
top: '38%',
height: '1.5px',
// Bright at the flanks, dropped out through the centre column so the
// rule frames the timeline rather than underlining a message.
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
opacity: 0.4,
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
opacity: 0.55,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}}
/>
@@ -304,7 +273,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
opacity: 0.32,
opacity: 0.5,
contain: 'layout paint style',
}}
>
@@ -316,7 +285,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px',
bottom: '-8px',
backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}}
@@ -340,54 +309,51 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}}
/>
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
blip, stacked bottom-right. That corner is the one spot that is
clear in every layout (below the members list, or the empty right
end of the read-receipt strip) top-left collided with the space
bar and bottom-centre sat on the composer. Static scene shows both
steady (no blink). The font-size clamp collapses the text to nothing
when the host is narrower than ~330px, so the 76px settings swatch
never shows clipped glyphs. */}
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
Static scene shows it steady (no blink). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
right: '14px',
bottom: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
bottom: '5%',
left: '50%',
transform: 'translateX(-50%)',
fontFamily: '"Courier New", monospace',
fontSize: HUD_FONT_SIZE,
fontSize: '12px',
fontWeight: 700,
lineHeight: 1,
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
<div
style={{
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
<div
style={{
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
INSERT COIN
</div>
{/* 7b. Corner SCORE HUD glyph a tiny pixel score that blips, top-left,
very low opacity so it reads as ambient chrome, not UI. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '2.5%',
left: '2%',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []);
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const heroStars = useMemo<HeroStar[]>(
() =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
@@ -183,17 +183,15 @@ export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
))}
</div>
{/* Blue-marble Earth tucked into the bottom-right corner
Kept above the composer strip: bottom offset + size keep the
globe's footprint within the top ~90% of the viewport. */}
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
<div
aria-hidden="true"
style={{
position: 'absolute',
right: '-4%',
bottom: '10%',
width: '200px',
height: '200px',
right: '-6%',
bottom: '-10%',
width: '300px',
height: '300px',
contain: 'layout paint style',
willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(1.02)' : undefined,
@@ -164,7 +164,7 @@ export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: reduced ? undefined : 'transform, opacity',
willChange: 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useId, useRef } from 'react';
import React, { ReactNode } from 'react';
import { Box, Text } from 'folds';
import { BreakWord } from '../../styles/Text.css';
@@ -10,35 +10,12 @@ type SettingTileProps = {
children?: ReactNode;
};
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
const titleId = useId();
const afterRef = useRef<HTMLDivElement>(null);
// [Gitea #185] The control in `after` is almost always an icon-only Switch
// (or a bare select/input) whose only visible label is this tile's title.
// Point it at the title so screen readers announce "Show Notifications,
// switch, on" instead of "switch, on". Explicit labels are left alone.
useEffect(() => {
if (!title || !afterRef.current) return;
afterRef.current
.querySelectorAll<HTMLElement>('[role="switch"], input, select, textarea, [role="combobox"]')
.forEach((el) => {
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) return;
if (
el.tagName === 'INPUT' &&
el.id &&
afterRef.current?.querySelector(`label[for="${el.id}"]`)
)
return;
el.setAttribute('aria-labelledby', titleId);
});
});
return (
<Box alignItems="Center" gap="300">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text id={titleId} className={BreakWord} size="T300">
<Text className={BreakWord} size="T300">
{title}
</Text>
)}
@@ -49,11 +26,7 @@ export function SettingTile({ title, description, before, after, children }: Set
)}
{children}
</Box>
{after && (
<Box shrink="No" ref={afterRef}>
{after}
</Box>
)}
{after && <Box shrink="No">{after}</Box>}
</Box>
);
}
@@ -73,32 +73,6 @@ const formatClipSeconds = (seconds: number): string => {
return `${m}:${s.toString().padStart(2, '0')}`;
};
/**
* [Gitea #31] Pure running-count cap check for `handleFiles`: given how many
* clips already exist (staged uploads included) before this batch starts,
* decide which of the batch's files fit under `max`. Kept pure/exported so the
* "running count, not a stale double-counted closure value" logic can be unit
* tested without a DOM/MatrixClient.
*/
export function acceptClips<T>(
currentCount: number,
files: readonly T[],
max: number,
): { accepted: T[]; rejected: T[] } {
const accepted: T[] = [];
const rejected: T[] = [];
let count = currentCount;
files.forEach((file) => {
if (count >= max) {
rejected.push(file);
} else {
accepted.push(file);
count += 1;
}
});
return { accepted, rejected };
}
type ClipDraft = {
url: string;
body: string;
@@ -212,19 +186,11 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
...existing.map((c) => c.shortcode),
...uploads.map((u) => u.shortcode),
]);
// [Gitea #31] `clipCount` already includes staged `uploads`, so don't
// add `uploads.length` again here (double-counting). And since
// `setUploads` inside the loop doesn't update this closure's
// `clipCount`, track the running total in a local variable that starts
// from the real current total instead of re-reading a stale value for
// every file in the batch.
const { accepted, rejected } = acceptClips(
clipCount,
Array.from(files),
SOUNDBOARD_MAX_CLIPS,
);
for (let i = 0; i < accepted.length; i += 1) {
const file = accepted[i];
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
@@ -249,9 +215,6 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
},
]);
}
if (rejected.length > 0) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {
@@ -214,56 +214,24 @@ function UserPrivateNotes({ userId }: { userId: string }) {
const [draft, setDraft] = useState(() => getNote(userId));
const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// True while the user has unsaved local edits — prevents the store-sync
// effect below from reacting to the echo of our own save and reverting text
// typed after the debounce fired but before that save's account-data echo
// landed (mirrors statusDirtyRef in Profile.tsx's ProfileStatus).
const dirtyRef = useRef(false);
// Latest draft/userId, kept current on every render so the unmount cleanup
// can flush a pending save without capturing a stale closure.
const draftRef = useRef(draft);
draftRef.current = draft;
const userIdRef = useRef(userId);
userIdRef.current = userId;
const setNoteRef = useRef(setNote);
setNoteRef.current = setNote;
const prevUserIdRef = useRef(userId);
// Sync if account data arrives after mount, but never while there are
// unsaved local edits (including our own save's in-flight echo).
// Sync if account data arrives after mount
useEffect(() => {
if (prevUserIdRef.current !== userId) {
prevUserIdRef.current = userId;
dirtyRef.current = false;
}
if (dirtyRef.current) return;
setDraft(getNote(userId));
}, [getNote, userId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
dirtyRef.current = true;
setDraft(val);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
dirtyRef.current = false;
setSaving(true);
await setNote(userId, val);
setSaving(false);
}, 800);
};
useEffect(
() => () => {
clearTimeout(saveTimer.current);
// Flush a still-pending debounced save instead of dropping it (e.g. the
// profile panel closes within the 800ms debounce window).
if (dirtyRef.current) {
setNoteRef.current(userIdRef.current, draftRef.current);
}
},
[],
);
useEffect(() => () => clearTimeout(saveTimer.current), []);
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
+11 -22
View File
@@ -103,8 +103,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
const displayRoomName = room?.name ?? bookmark.roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -163,7 +162,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{preview ?? (bookmark.previewText || 'Message unavailable')}
{preview ?? (bookmark.previewText || '(no preview)')}
</Text>
</Button>
</Box>
@@ -174,16 +173,13 @@ type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty
// states; bookmarks from E2EE rooms have no snapshot at all (account data is
// server-readable), so the live event is their only source of text.
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId),
);
const snapshot =
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
const snapshot = bookmark.previewText || '(no preview)';
let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
@@ -238,7 +234,7 @@ function RoomGroupHeader({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name || roomName || 'Unknown room';
const displayRoomName = room?.name ?? roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
@@ -331,20 +327,13 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
() =>
query.length === 0
? bookmarks
: bookmarks.filter((bk) => {
// E2EE-room bookmarks have no stored text: match against the locally
// cached event body / live room name instead (nothing is fetched here).
const room = mx.getRoom(bk.roomId);
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
return (
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query) ||
(room?.name.toLowerCase().includes(query) ?? false) ||
(bk.senderName?.toLowerCase().includes(query) ?? false)
);
}),
[mx, bookmarks, query],
(bk.senderName?.toLowerCase().includes(query) ?? false),
),
[bookmarks, query],
);
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
+6 -30
View File
@@ -1,13 +1,11 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -179,15 +177,6 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
// in-room CallControls bar, so the status bar can't be used to bypass it.
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const [shareConfirm, setShareConfirm] = useState(false);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
@@ -209,16 +198,7 @@ export function CallControl({
};
return (
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
<ScreenshareConfirm
open={shareConfirm}
align="Start"
onConfirm={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
onCancel={() => setShareConfirm(false)}
/>
<Box shrink="No" alignItems="Center" gap="300">
<Box alignItems="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
@@ -230,16 +210,12 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
)}
{!compact && showScreenshare && (
{!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/>
)}
+3 -39
View File
@@ -1,6 +1,6 @@
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
@@ -12,31 +12,8 @@ import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import { CallControlEvent } from '../../plugins/call/CallControl';
import * as css from './styles.css';
// [Gitea #56] Subscribes to CallControl's focus pin so the menu can render a
// "Focus camera" / "Unfocus camera" toggle instead of a one-way pin.
function useFocusedUserId(callEmbed?: CallEmbed): string | null {
const control = callEmbed?.control;
const [focusedUserId, setFocusedUserId] = useState<string | null>(control?.focusedUserId ?? null);
useEffect(() => {
if (!control) {
setFocusedUserId(null);
return undefined;
}
setFocusedUserId(control.focusedUserId);
const handleUpdate = () => setFocusedUserId(control.focusedUserId);
control.on(CallControlEvent.StateUpdate, handleUpdate);
return () => {
control.off(CallControlEvent.StateUpdate, handleUpdate);
};
}, [control]);
return focusedUserId;
}
type ParticipantMenuProps = {
anchor: RectCords;
name: string;
@@ -56,28 +33,15 @@ function ParticipantMenu({
profileCords,
}: ParticipantMenuProps) {
const openUserProfile = useOpenUserRoomProfile();
const focusedUserId = useFocusedUserId(callEmbed);
const isFocused = focusedUserId === userId;
const handleViewProfile = () => {
onClose();
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
};
// [Gitea #56] Toggle: focusing the already-focused participant clears the
// pin and returns EC to speaker-follows, instead of leaving no way back.
const handleFocusCamera = () => {
onClose();
if (isFocused) {
callEmbed?.control.clearFocusParticipant();
} else {
// [EC#30] Pass the fork's per-device media id when we have one (from
// io.lotus.call_state) so a multi-device user pins the active device.
const parts = callEmbed?.getLotusParticipants() ?? [];
const mine = parts.filter((p) => p.userId === userId);
const pick = mine.find((p) => p.speaking) ?? mine.find((p) => p.audioEnabled) ?? mine[0];
callEmbed?.control.focusCameraParticipant(userId, pick?.id ?? null);
}
callEmbed?.control.focusCameraParticipant(userId);
};
return (
@@ -114,7 +78,7 @@ function ParticipantMenu({
before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera}
>
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
<Text size="B300">Focus camera</Text>
</MenuItem>
)}
<MenuItem
+217 -26
View File
@@ -1,9 +1,10 @@
import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import {
Box,
Button,
Chip,
color,
config,
Icon,
IconButton,
@@ -38,10 +39,11 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
import { CallSoundboard } from './CallSoundboard';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from './ScreenshareConfirm';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomQualityContent } from '../../utils/callQuality';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -86,27 +88,56 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
useAfkAutoMute(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
if (!shareConfirm) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareConfirm(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [shareConfirm]);
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
// (this component only renders while the call room is selected). Only the
// visual PTT chip remains here.
const pttActive = useAtomValue(pttActiveAtom);
// [P5-31 / Gitea #101] Hard room publish policy — hide controls the server
// will refuse so users don't click dead buttons. Absent/true = allowed.
// Shared with the app-wide CallStatus bar's CallControl via useRoomCallPolicy
// so both surfaces apply the same gating.
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
// [P5-31] Hard room publish policy — hide controls the server will refuse so
// users don't click dead buttons. Absent/true = allowed.
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
const cameraAllowed = roomQuality?.allow_camera !== false;
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
const micBeforePTTRef = useRef<boolean | null>(null);
useEffect(() => {
if (pttMode && !pttModeRef.current) {
micBeforePTTRef.current = microphoneRef.current;
callEmbed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
micBeforePTTRef.current = null;
}
pttModeRef.current = pttMode;
}, [pttMode, callEmbed]);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
@@ -132,6 +163,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const pttActiveRef = useRef(false);
useEffect(() => {
if (!pttMode) return;
const iframeWindow = callEmbed.iframe.contentWindow;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return;
const target = e.target as HTMLElement;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
if (isEditable(target)) return;
e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen.
callEmbed.control.pttActive = true;
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
iframeWindow?.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keyup', onKeyUp);
iframeWindow?.addEventListener('blur', onBlur);
iframeWindow?.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
iframeWindow?.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keyup', onKeyUp);
iframeWindow?.removeEventListener('blur', onBlur);
iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
}
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, callEmbed]);
useEffect(() => {
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== deafenKey) return;
if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
callEmbed.control.toggleSound();
};
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const iframeWindow = callEmbed.iframe.contentWindow;
window.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]),
);
@@ -180,15 +319,67 @@ export function CallControls({ callEmbed }: CallControlsProps) {
</Text>
</Chip>
)}
<ScreenshareConfirm
open={shareConfirm}
align="Center"
onConfirm={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
onCancel={() => setShareConfirm(false)}
/>
{shareConfirm && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShareConfirm(false)}
aria-hidden="true"
/>
<Box
style={{
position: 'absolute',
bottom: '110%',
left: '50%',
transform: 'translateX(-50%)',
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
// Don't run past the screen edges on a narrow phone (centered via
// translateX(-50%)); clamp to the viewport minus a small margin.
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</>
)}
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
+1 -12
View File
@@ -115,18 +115,7 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
try {
const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master;
const result = await callEmbed.control.injectAudio(url, vol);
if (!result.played) {
// [EC#13] Refused fork-side (only reason today: local mic muted) —
// don't play it locally either, or the user would think it went out.
setError(
result.reason === 'muted'
? 'Unmute your microphone to play a soundboard clip.'
: 'Could not play that clip.',
);
done();
return;
}
callEmbed.control.injectAudio(url, vol);
const audio = playClipLocally(url, vol);
if (audio) {
audio.addEventListener('ended', done, { once: true });
+9 -5
View File
@@ -18,7 +18,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
@@ -113,9 +114,12 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
// [Gitea #30] Voice channel user limit (io.lotus.voice_limit), shared with the
// room-nav join path via useVoiceChannelFull so both agree on "full".
const { channelFull, current: callMembersCount, max: maxUsers } = useVoiceChannelFull(room);
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
@@ -140,7 +144,7 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
{!inOtherCall && hasPermission && channelFull && (
<ChannelFullMessage current={callMembersCount} max={maxUsers} />
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
+1 -2
View File
@@ -74,8 +74,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
outlined
>
<Icon
@@ -1,92 +0,0 @@
import React, { useEffect } from 'react';
import { Box, Button, Text, color, config } from 'folds';
export type ScreenshareConfirmProps = {
open: boolean;
onConfirm: () => void;
onCancel: () => void;
/**
* Horizontal placement relative to the trigger button. `Center` (the
* in-call bar's centered layout) transforms to center itself over the
* anchor; `Start` (the app-wide status bar, anchored to its own left edge)
* hugs the anchor's left edge instead.
*/
align?: 'Center' | 'Start';
};
/**
* [Gitea #101] Shared "Share your screen?" confirmation popover, used by both
* the in-call `CallControls` bar and the app-wide `CallStatus` bar's
* `CallControl`. Previously each bar carried its own near-identical copy;
* hoisted here so their behaviour (Escape / click-outside to close, confirm
* starts the share) can't drift apart.
*/
export function ScreenshareConfirm({
open,
onConfirm,
onCancel,
align = 'Center',
}: ScreenshareConfirmProps) {
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onCancel]);
if (!open) return null;
return (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={onCancel}
aria-hidden="true"
/>
<Box
style={{
position: 'absolute',
bottom: '110%',
...(align === 'Center' ? { left: '50%', transform: 'translateX(-50%)' } : { left: 0 }),
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
// Don't run past the screen edges on a narrow phone (centered via
// translateX(-50%)); clamp to the viewport minus a small margin.
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button size="300" variant="Success" fill="Solid" radii="300" onClick={onConfirm}>
<Text size="B300">Share</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={onCancel}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</>
);
}
@@ -41,7 +41,7 @@ type RoomQualityProps = {
/**
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
* ALL Matrix clients is a server-side follow-up (see cinny #174, D2-8).
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
*/
export function RoomQuality({ permissions }: RoomQualityProps) {
const mx = useMatrixClient();
@@ -55,6 +55,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
},
[mx, room.roomId],
@@ -31,7 +31,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
},
[mx, room.roomId],
@@ -51,11 +51,6 @@ export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }):
pushTimer.current = setTimeout(push, 300);
}, [push]);
// [Gitea #17] The fork asks for a re-push when its decoration handler
// (re)registers; our map hasn't changed, so the change-driven push above
// would never fire on its own.
useEffect(() => callEmbed.onForkStateRequest(schedulePush), [callEmbed, schedulePush]);
const onResolve = useCallback(
(userId: string, url: string | null) => {
const prev = map.current.get(userId);
@@ -5,7 +5,6 @@ import {
DECORATION_CATEGORIES,
ALL_DECORATIONS,
decorationUrl,
isValidDecorationSlug,
} from './avatarDecorations';
test('decorationUrl builds a CDN png url from the slug', () => {
@@ -67,20 +66,3 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
});
});
test('isValidDecorationSlug: accepts a real catalog slug', () => {
assert.equal(isValidDecorationSlug('joystick'), true);
assert.equal(isValidDecorationSlug('lotus_flower'), true);
});
test('isValidDecorationSlug: rejects a path-traversal string', () => {
assert.equal(isValidDecorationSlug('../../anything'), false);
});
test('isValidDecorationSlug: rejects a slug carrying a query string', () => {
assert.equal(isValidDecorationSlug('joystick?u=probe'), false);
});
test('isValidDecorationSlug: rejects an empty string', () => {
assert.equal(isValidDecorationSlug(''), false);
});
@@ -188,19 +188,6 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
(c) => c.decorations,
);
const DECORATION_SLUGS = new Set(ALL_DECORATIONS.map((d) => d.slug));
/**
* Whether `slug` is a known catalog decoration. `io.lotus.avatar_decoration`
* is a free-form MSC4133 profile field set by a remote user (and their
* homeserver), and its value is interpolated verbatim into `decorationUrl`
* so anything not in the catalog (path traversal, a query string, an
* oversized value) must be rejected before it reaches a URL.
*/
export function isValidDecorationSlug(slug: string): boolean {
return DECORATION_SLUGS.has(slug);
}
export function decorationUrl(slug: string): string {
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
}
@@ -36,7 +36,6 @@ import { mDirectAtom } from '../../state/mDirectList';
import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room';
import {
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
MessageSearchParams,
@@ -246,15 +245,8 @@ export function MessageSearch({
const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
// After a clear, the active search re-runs (cacheVersion) and would persist
// the in-memory rows straight back — skip that one write so "Clear cached
// index" actually leaves the disk empty until the next new search.
const skipPersistOnceRef = useRef(false);
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => {
skipPersistOnceRef.current = true;
setCacheVersion((v) => v + 1);
});
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
}, []);
// The rooms actually in scope for this search (mirrors server-side logic)
@@ -284,15 +276,12 @@ export function MessageSearch({
return undefined;
}
let cancelled = false;
const persist = !skipPersistOnceRef.current;
skipPersistOnceRef.current = false;
searchLocalMessages({
term: msgSearchParams.term ?? '',
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
persist,
}).then((result) => {
if (!cancelled) setLocalResult(result);
});
@@ -327,21 +316,12 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken,
});
// Shared client-side post-filter (date range + msgtype + pinned) applied to
// BOTH the server results and the local/encrypted-cache results, so the
// filter chips narrow the whole UI consistently rather than only the
// server section. The date range must be enforced here because the Matrix
// search API has no timestamp filter fields (see useMessageSearch.ts); the
// local/encrypted path already filters in-range before this runs, so this
// is a no-op there and only actually trims the server section.
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
// server results and the local/encrypted-cache results, so the filter chips
// narrow the whole UI consistently rather than only the server section.
const applyResultFilters = useCallback(
(allGroups: ResultGroup[]): ResultGroup[] => {
const inDateRange = filterGroupsByDateRange(
allGroups,
msgSearchParams.fromTs,
msgSearchParams.toTs,
);
const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters);
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
if (!pinnedOnly) return byMsgType;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate.
@@ -363,7 +343,7 @@ export function MessageSearch({
};
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
},
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
[msgTypeFilters, pinnedOnly, mx],
);
const groups = useMemo(() => {
@@ -1,56 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SearchCacheRow } from '../../utils/searchCache';
// useLocalMessageSearch.ts imports searchCacheEnabledAtom, which touches
// localStorage at module-load time (atomWithLocalStorage reads the initial
// value eagerly). Stub it before a dynamic import — a static import would
// hoist above the stub. Same pattern as state/plaintextCaches.test.ts.
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
const { rowToResultItem } = await import('./useLocalMessageSearch');
const row = (overrides: Partial<SearchCacheRow> = {}): SearchCacheRow => ({
roomId: '!r1',
eventId: '$1',
ts: 100,
sender: '@a',
body: 'hello world',
...overrides,
});
// Gitea #14 — cached rows for a locally-known-redacted event must carry a
// `redacted_because` marker so SearchResultGroup's guard renders the
// "message deleted" placeholder instead of the stale plaintext.
test('rowToResultItem: plain row has no redacted_because marker', () => {
const item = rowToResultItem(row());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).unsigned?.redacted_because, undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'hello world');
});
test('rowToResultItem: redacted=true sets the redacted_because marker', () => {
const item = rowToResultItem(row(), true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.ok((item.event as any).unsigned?.redacted_because);
});
test('rowToResultItem: falls back to pollText when body is empty', () => {
const item = rowToResultItem(row({ body: '', pollText: 'question answer' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'question answer');
});
test('rowToResultItem: carries formattedBody as HTML when present', () => {
const item = rowToResultItem(row({ formattedBody: '<b>hi</b>' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (item.event as any).content;
assert.equal(content.format, 'org.matrix.custom.html');
assert.equal(content.formatted_body, '<b>hi</b>');
});
@@ -1,4 +1,4 @@
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk';
import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -18,12 +18,6 @@ export type LocalSearchParams = {
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
/**
* Set false to run the scan without writing it back to IndexedDB used
* right after "Clear cached index" so the clear is not undone by the
* re-run of the active search (Gitea #184 O4).
*/
persist?: boolean;
};
export type LocalSearchResult = {
@@ -44,15 +38,13 @@ type ExtractedText = {
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/**
* Pull the text we index/search from an event type + content pair. Returns
* `null` when there's no searchable text (e.g. stickers). Split out from
* `extractText` so an edit's `m.new_content` can be run through the same
* logic as a normal event's content.
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractTextFromContent = (
evType: string,
content: Record<string, unknown>,
): ExtractedText | null => {
const extractText = (event: MatrixEvent): ExtractedText | null => {
const evType = event.getType();
const content = event.getContent();
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
@@ -82,13 +74,6 @@ const extractTextFromContent = (
return { body, formattedBody, pollText: '' };
};
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null =>
extractTextFromContent(event.getType(), event.getContent());
/** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) ||
@@ -100,17 +85,8 @@ const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower);
/**
* Build the synthetic result item a cached row renders as (text message).
*
* `redacted` marks a row whose event we can tell, from the local timeline,
* has since been redacted (the async cache-delete listener in
* `searchCacheInvalidation.ts` may not have caught up yet). It carries a
* `redacted_because` marker on `unsigned` so `SearchResultGroup`'s existing
* guard renders the "message deleted" placeholder instead of the stale
* plaintext (Gitea #14).
*/
export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultItem => {
/** Build the synthetic result item a cached row renders as (text message). */
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) {
@@ -124,7 +100,7 @@ export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultIt
sender: row.sender,
origin_server_ts: row.ts,
content,
unsigned: redacted ? { redacted_because: { content: {} } } : {},
unsigned: {},
};
return {
rank: 0,
@@ -155,7 +131,6 @@ export const useLocalMessageSearch = () => {
senders,
fromTs,
toTs,
persist = true,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -212,9 +187,8 @@ export const useLocalMessageSearch = () => {
const isMessageLike =
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
// Both modes are restricted to message-like events/stickers — sender-only
// mode must not surface membership/state/reaction/redaction events (Gitea #62).
if (!isMessageLike && !isSticker) continue;
// Sender-only mode indexes/returns all message types; text mode needs text.
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
const sender = event.getSender() ?? '';
const ts = event.getTs();
@@ -222,43 +196,16 @@ export const useLocalMessageSearch = () => {
// Persist every indexable (text-bearing) event we scanned, regardless
// of whether it matches the current term — future searches benefit.
if (cacheEnabled && event.getId()) {
// An edit (`m.replace`) event's own body is just a "* new text"
// fallback. Indexing it under its own event id would leave two
// separate matching rows (the stale pre-edit text and the edit
// fallback) searchable forever. Instead, upsert the *original*
// event's row with the edit's `m.new_content` (Gitea #14).
const editTargetId =
event.getRelation()?.rel_type === RelationType.Replace
? event.getRelation()?.event_id
: undefined;
if (editTargetId) {
const newContent = (event.getContent()['m.new_content'] ?? {}) as Record<
string,
unknown
>;
const editedText = extractTextFromContent(EventType.RoomMessage, newContent);
if (editedText) {
rowsToPersist.push({
roomId,
eventId: editTargetId,
ts: room.findEventById(editTargetId)?.getTs() ?? ts,
sender,
body: editedText.body,
...(editedText.formattedBody ? { formattedBody: editedText.formattedBody } : {}),
});
}
} else if (text) {
rowsToPersist.push({
roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
}
if (cacheEnabled && text && event.getId()) {
rowsToPersist.push({
roomId,
eventId: event.getId() as string,
ts,
sender,
body: text.body,
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
...(text.pollText ? { pollText: text.pollText } : {}),
});
}
if (senderSet && !senderSet.has(sender)) continue;
@@ -292,12 +239,7 @@ export const useLocalMessageSearch = () => {
if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
// The cache-delete listener (searchCacheInvalidation.ts) removes a
// row on redaction asynchronously; if the event is still around
// locally we can check for certain and must not surface stale
// plaintext in the meantime (Gitea #14).
const localEvent = room.findEventById(row.eventId);
cachedItems.push(rowToResultItem(row, localEvent?.isRedacted()));
cachedItems.push(rowToResultItem(row));
});
const items = mergeSearchResults(memoryItems, cachedItems);
@@ -309,7 +251,7 @@ export const useLocalMessageSearch = () => {
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && persist && rowsToPersist.length > 0) {
if (cacheEnabled && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
}
@@ -1,11 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
ResultGroup,
} from './useMessageSearch';
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId).
@@ -14,11 +9,6 @@ const item = (msgtype: string | undefined, eventId: string) => ({
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {},
});
const tsItem = (eventId: string, ts: number) => ({
rank: 1,
event: { event_id: eventId, origin_server_ts: ts, content: {} },
context: {},
});
const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[];
@@ -58,33 +48,6 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
assert.equal(out[0].items[0].event.event_id, '$2');
});
test('filterGroupsByDateRange: no bounds returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] });
assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups);
});
test('filterGroupsByDateRange: keeps only items within an inclusive range', () => {
const groups = mkGroups({
roomId: '!r1',
items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)],
});
const out = filterGroupsByDateRange(groups, 100, 150);
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2', '$3'],
);
});
test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [tsItem('$1', 50)] },
{ roomId: '!r2', items: [tsItem('$2', 500)] },
);
const out = filterGroupsByDateRange(groups, 100, undefined);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r2');
});
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal(
@@ -71,31 +71,6 @@ export const filterGroupsByPinned = (
.filter((group) => group.items.length > 0);
};
/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */
export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
/**
* Filter result groups to items whose `origin_server_ts` falls within
* [fromTs, toTs] (inclusive, either bound optional). The Matrix search API
* has no timestamp filter fields, so server results must be post-filtered
* here the same predicate the local/encrypted search already applies.
* Now-empty groups are dropped.
*/
export const filterGroupsByDateRange = (
groups: ResultGroup[],
fromTs?: number,
toTs?: number,
): ResultGroup[] => {
if (fromTs === undefined && toTs === undefined) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)),
}))
.filter((group) => group.items.length > 0);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
@@ -144,9 +119,7 @@ export type MessageSearchParams = {
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
// fromTs/toTs are intentionally not sent to the server (see comment below) —
// callers post-filter results with filterGroupsByDateRange instead.
const { term, order, rooms, senders, containsUrl } = params;
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
@@ -169,10 +142,9 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit,
rooms,
senders,
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not
// Matrix filter fields and the homeserver silently drops them, so the
// date range is instead enforced client-side (see filterGroupsByDateRange).
// contains_url is a valid spec field not yet in SDK types.
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
@@ -189,7 +161,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, containsUrl],
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
);
return searchMessages;
+60 -31
View File
@@ -6,7 +6,7 @@ import React, {
useRef,
useState,
} from 'react';
import { Room } from 'matrix-js-sdk';
import { MatrixClient, Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -42,6 +42,7 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -65,22 +66,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import {
LOCAL_ROOM_NAMES_KEY,
getLocalRoomNamesContent,
setLocalRoomName,
useHasLocalRoomName,
useLocalRoomName,
} from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { callChatAtom } from '../../state/callEmbed';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
@@ -138,16 +136,22 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const handleSave = useCallback(() => {
const newName = inputRef.current?.value.trim() ?? '';
if (newName.length > 255) return;
// Routed through the shared write queue (setLocalRoomName) instead of a
// read-modify-write against the SDK's local cache, which stays stale
// until the /sync echo lands and would otherwise let a second rename
// clobber a still-in-flight first rename.
setLocalRoomName(mx, room.roomId, newName);
const existing = getLocalRoomNamesContent(mx);
if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms;
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
} else {
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName },
});
}
onClose();
}, [mx, room.roomId, onClose]);
const handleClear = useCallback(() => {
setLocalRoomName(mx, room.roomId, '');
const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms;
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose();
}, [mx, room.roomId, onClose]);
@@ -269,6 +273,49 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
);
}
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -345,6 +392,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const handleMuteFor = useCallback(
async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference(
mx,
@@ -692,12 +741,8 @@ function RoomNavItem_({
const callMembers = useCallMembers(callSession);
const startCall = useCallStart(direct);
const callEmbed = useCallEmbed();
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
// second-click join path can't bypass a full channel.
const { channelFull, current: voiceCurrent, max: voiceMax } = useVoiceChannelFull(room);
const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const setToast = useSetAtom(toastQueueAtom);
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
@@ -719,22 +764,6 @@ function RoomNavItem_({
if (callEmbed) {
return;
}
// [Gitea #30] Refuse to start a call into a full voice channel — the
// prescreen already blocks this, but the sidebar second-click join path
// skipped the check entirely.
if (channelFull) {
evt.preventDefault();
setToast(
createErrorToast(
`Channel full (${voiceCurrent}/${voiceMax})`,
Icons.Warning,
'Cannot join',
),
);
return;
}
// Start call in second click
if (selected) {
evt.preventDefault();
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
-88
View File
@@ -1,88 +0,0 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
@@ -4,11 +4,9 @@ import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { createDetachedTimelineSet } from '../../utils/detachedTimeline';
import { useRoomName } from '../../hooks/useRoomMeta';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
type ExportFormat = 'txt' | 'json' | 'html';
@@ -65,19 +63,20 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
const rawRecords: RawExportRecord[] = [];
// mxc/media-file URL for message events that carry one, keyed by eventId -
// surfaced in the JSON export since the export never includes the actual
// media (see the UI note below the Export button).
const mediaUrlByEventId = new Map<string, string>();
type MsgRecord = {
ts: number;
sender: string;
body: string;
eventId: string;
msgtype: string;
};
const collected: MsgRecord[] = [];
// timeline.getEvents() returns the entire growing window on every call,
// so we must deduplicate by eventId to avoid re-adding the same events
// on each pagination step.
const seen = new Set<string>();
// [Gitea #163] Paginate a private timeline set, not the live one: the
// export can pull thousands of events and the room behind this modal
// renders the live timeline by index.
const timeline = createDetachedTimelineSet(mx, room).set.getLiveTimeline();
const timeline = room.getLiveTimeline();
let canLoadMore = true;
// Track the oldest collected timestamp incrementally so the fromTs check
// doesn't rescan the whole `collected` array on every pagination step.
@@ -114,39 +113,11 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue;
const content = ev.getContent();
// m.replace (edit) events must not become their own row — collect
// them separately and substitute the new content into the original
// event's row below, instead of adding a stale original + a garbled
// "* new text" duplicate line.
const relatesTo = content['m.relates_to'] as
| { rel_type?: string; event_id?: string }
| undefined;
if (relatesTo?.rel_type === 'm.replace' && relatesTo.event_id) {
const newContent = content['m.new_content'] as { body?: string } | undefined;
const newBody = newContent?.body ?? '';
if (!newBody) continue;
rawRecords.push({
eventId: evId,
ts,
sender: ev.getSender() ?? '',
body: '',
msgtype: '',
editsEventId: relatesTo.event_id,
newBody,
});
continue;
}
const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? '';
if (!body) continue;
if (ts < oldestTs) oldestTs = ts;
const mediaUrl =
(content.url as string | undefined) ??
(content.file as { url?: string } | undefined)?.url;
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
rawRecords.push({
collected.push({
ts,
sender: ev.getSender() ?? '',
body,
@@ -154,9 +125,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
msgtype,
});
}
// Approximate progress — excludes edit rows, which never become their
// own line in the final (edit-resolved) output.
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
setExportCount(collected.length);
};
await addEvents(timeline.getEvents());
@@ -190,10 +159,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents());
}
// Resolve m.replace edits against their target row (drops the edit rows,
// substitutes m.new_content into the original) before sorting/rendering.
const collected = resolveMessageEdits(rawRecords);
if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`);
return;
@@ -221,7 +186,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const d = new Date(msg.ts);
const pad = (n: number) => String(n).padStart(2, '0');
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`);
lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`);
}
content = lines.join('\n');
mimeType = 'text/plain';
@@ -236,8 +201,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
body: m.body,
eventId: m.eventId,
type: m.msgtype,
edited: m.edited,
mediaUrl: mediaUrlByEventId.get(m.eventId),
})),
};
content = JSON.stringify(payload, null, 2);
@@ -257,7 +220,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const d = new Date(msg.ts);
const pad = (n: number) => String(n).padStart(2, '0');
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(formatExportBody(msg))}</span></div>`;
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(msg.body)}</span></div>`;
})
.join('\n');
@@ -425,11 +388,6 @@ ${msgRows}
{notice}
</Text>
)}
<Text size="T200" priority="300">
Attachments (images, videos, audio, files) are not included in the export -
media messages are marked with a type label (e.g. &quot;[Image]&quot;) but only
their filename is exported, not the file itself.
</Text>
</SequenceCard>
</Box>
</Box>
@@ -24,14 +24,6 @@ const POLICY_USER_EVENT = 'm.policy.rule.user';
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
// Legacy, unstable-prefixed event types still emitted by Draupnir/Mjolnir
// policy lists that predate MSC stabilization (or haven't migrated). Queried
// alongside the stable types and merged/de-duped so those lists don't show
// as falsely empty.
const LEGACY_POLICY_USER_EVENT = 'org.matrix.mjolnir.rule.user';
const LEGACY_POLICY_ROOM_EVENT = 'org.matrix.mjolnir.rule.room';
const LEGACY_POLICY_SERVER_EVENT = 'org.matrix.mjolnir.rule.server';
type PolicyRuleContent = {
entity?: string;
reason?: string;
@@ -84,23 +76,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== '');
}
/**
* Merge policy entries from the stable and legacy event types for a rule
* kind, de-duplicating by entity+recommendation so a room that emits both a
* stable and a legacy rule for the same target isn't double-listed.
*/
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
const seen = new Set<string>();
const result: PolicyEntry[] = [];
entries.forEach((entry) => {
const key = `${entry.entity} ${entry.recommendation}`;
if (seen.has(key)) return;
seen.add(key);
result.push(entry);
});
return result;
}
// ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -226,24 +201,9 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
return;
}
setUserEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_USER_EVENT)),
]),
);
setRoomEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_ROOM_EVENT)),
]),
);
setServerEntries(
dedupePolicyEntries([
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)),
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_SERVER_EVENT)),
]),
);
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
setLoadedRoomId(roomId);
setError(undefined);
}, [mx, roomIdInput]);
@@ -1,14 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color, config } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import {
collectTimelineEvents,
createDetachedTimelineSet,
createTypesFilter,
} from '../../utils/detachedTimeline';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -68,26 +63,8 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
const prevMembership = prevContent.membership as string | undefined;
const reason = content.reason as string | undefined;
const targetName = getDisplayName(mx, stateKey);
// getPrevContent() falls back to {} when unsigned.prev_content is missing,
// which is common for state events fetched via back-pagination (homeservers
// don't always include it in /messages). That makes prevMembership
// indistinguishable from "no prior membership" (fresh join). Check the raw
// unsigned field so we only apply the join/leave-transition assumptions
// below to events where the SDK actually gave us prior state.
const hasPrevContent = ev.getUnsigned().prev_content !== undefined;
if (membership === 'join') {
if (!hasPrevContent) {
return {
text: (
<>
<strong>{targetName}</strong>&apos;s membership changed to <strong>joined</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (
prevMembership === 'invite' ||
prevMembership === 'knock' ||
@@ -117,17 +94,6 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
}
if (membership === 'leave') {
if (!hasPrevContent) {
return {
text: (
<>
<strong>{targetName}</strong>&apos;s membership changed to <strong>left</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (prevMembership === 'ban') {
return {
text: (
@@ -348,23 +314,15 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
// [Gitea #163] Page through a detached, type-filtered timeline set: the
// live timeline (rendered by index behind this modal) must not be mutated.
const detached = useMemo(
() =>
createDetachedTimelineSet(mx, room, {
filter: createTypesFilter(mx.getSafeUserId(), STATE_EVENT_TYPES, 'io.lotus.activity'),
filterSafeWhenEncrypted: true,
}),
[mx, room],
);
const getStateEvents = useCallback((): MatrixEvent[] => {
const typeSet = new Set<string>(STATE_EVENT_TYPES);
return collectTimelineEvents(detached.set.getLiveTimeline())
return room
.getLiveTimeline()
.getEvents()
.filter((ev) => typeSet.has(ev.getType()) && !ev.isRedacted())
.slice()
.reverse();
}, [detached]);
}, [room]);
const [events, setEvents] = useState<MatrixEvent[]>(() => getStateEvents());
@@ -376,7 +334,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
if (loading || !canLoadMore) return;
setLoading(true);
try {
const hasMore = await mx.paginateEventTimeline(detached.set.getLiveTimeline(), {
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
backwards: true,
limit: 50,
});
@@ -388,7 +346,7 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
} finally {
setLoading(false);
}
}, [loading, canLoadMore, mx, detached, getStateEvents]);
}, [loading, canLoadMore, mx, room, getStateEvents]);
// Auto-paginate on mount — state events are rarely in the initial sync
// window, so we immediately fetch backwards to populate the log.
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useMemo } from 'react';
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom';
@@ -20,15 +20,6 @@ function formatDate(ts: number): string {
});
}
function formatUpdatedAt(ts: number): string {
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// Throttle window for re-computing stats on new timeline events - avoids
// re-running every heatmap/list computation on every single incoming message
// during a burst.
const RECOMPUTE_THROTTLE_MS = 2000;
// ── Section header ────────────────────────────────────────────────────────────
function SectionHeader({ label }: { label: string }) {
@@ -78,52 +69,6 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const room = useRoom();
const useAuthentication = useMediaAuthentication();
const [lastUpdated, setLastUpdated] = useState(() => Date.now());
// Bumped to force the stats useMemo below to re-run; the value itself is unused.
const [recomputeTick, setRecomputeTick] = useState(0);
const recomputeNow = useCallback(() => {
setRecomputeTick((n) => n + 1);
setLastUpdated(Date.now());
}, []);
// Stats were previously computed once (keyed only on `room`, whose reference
// never changes) and never reflected new activity while the panel stayed
// open. Re-run on new timeline events for this room, throttled so a burst of
// messages doesn't recompute on every single event.
useEffect(() => {
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
const scheduleTrailing = () => {
throttleTimer = setTimeout(() => {
if (pending) {
pending = false;
recomputeNow();
scheduleTrailing();
} else {
throttleTimer = undefined;
}
}, RECOMPUTE_THROTTLE_MS);
};
const handleTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => {
if (eventRoom?.roomId !== room.roomId) return;
if (throttleTimer) {
pending = true;
return;
}
recomputeNow();
scheduleTrailing();
};
mx.on(RoomEvent.Timeline, handleTimeline);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimeline);
if (throttleTimer) clearTimeout(throttleTimer);
};
}, [mx, room, recomputeNow]);
const stats = useMemo(() => {
const events = room.getLiveTimeline().getEvents();
@@ -192,10 +137,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
newestTs,
totalCached: events.length,
};
// recomputeTick is intentionally in the deps (unused in the body) - it's the
// signal bumped by the timeline listener above to force this to re-run.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, recomputeTick]);
}, [room]);
const maxHour = Math.max(...stats.hourBuckets, 1);
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
@@ -225,7 +167,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{/* ── Disclaimer banner ── */}
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
<Box grow="Yes" direction="Column" gap="100">
<Box direction="Column" gap="100">
<Text size="T300">
<strong>
Based on {stats.totalMessages} locally cached message
@@ -237,20 +179,6 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text>
)}
<Text size="T200" priority="300">
Last updated {formatUpdatedAt(lastUpdated)}
</Text>
</Box>
<Box shrink="No">
<IconButton
onClick={recomputeNow}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Refresh insights"
>
<Icon src={Icons.Reload} size="100" />
</IconButton>
</Box>
</SequenceCard>
@@ -341,7 +341,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary"
/>
<Box direction="Column" gap="0">
{}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }}
@@ -1,93 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
resolveMessageEdits,
formatExportBody,
RawExportRecord,
ResolvedExportRecord,
} from './exportRoomHistory.utils';
const msg = (over: Partial<RawExportRecord>): RawExportRecord => ({
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hello',
msgtype: 'm.text',
...over,
});
test('resolveMessageEdits leaves unedited messages untouched', () => {
const out = resolveMessageEdits([msg({ eventId: '$1', body: 'hi' })]);
assert.deepEqual(out, [
{
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hi',
msgtype: 'm.text',
edited: false,
},
]);
});
test('resolveMessageEdits substitutes the new content into the original row and drops the edit row', () => {
const out = resolveMessageEdits([
msg({ eventId: '$1', body: 'origianl typo' }),
msg({ eventId: '$2', editsEventId: '$1', newBody: 'original fixed' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].eventId, '$1');
assert.equal(out[0].body, 'original fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits works regardless of whether the edit appears before its target', () => {
const out = resolveMessageEdits([
msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' }),
msg({ eventId: '$1', body: 'orig' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].body, 'fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits ignores an edit with no matching target (target never collected)', () => {
const out = resolveMessageEdits([msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' })]);
assert.deepEqual(out, []);
});
test('formatExportBody prefixes media messages with a type marker', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.image',
edited: false,
};
assert.equal(formatExportBody(record), '[Image] photo.jpg');
});
test('formatExportBody appends an (edited) suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'fixed text',
msgtype: 'm.text',
edited: true,
};
assert.equal(formatExportBody(record), 'fixed text (edited)');
});
test('formatExportBody combines media prefix and edited suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.file',
edited: true,
};
assert.equal(formatExportBody(record), '[File] photo.jpg (edited)');
});
@@ -1,76 +0,0 @@
// Pure helpers for ExportRoomHistory.tsx, kept SDK/DOM-free so they're easy to
// unit test in isolation.
export type RawExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
// Set when this record IS an m.replace edit event, to the event id it targets.
editsEventId?: string;
// The m.new_content.body carried by an m.replace edit event.
newBody?: string;
};
export type ResolvedExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
edited: boolean;
};
// Human-readable label for media msgtypes, used to prefix txt/html export rows
// so a media message isn't indistinguishable from a plain-text one.
export const MEDIA_TYPE_LABELS: Partial<Record<string, string>> = {
'm.image': 'Image',
'm.video': 'Video',
'm.audio': 'Audio',
'm.file': 'File',
};
/**
* Resolves m.replace (edit) events against the original message they target:
* the edit event is dropped from the output (it should not appear as its own
* row), and the original message's body is replaced with the edit's
* `m.new_content.body`, marked `edited: true`.
*
* Order-independent: edits may appear before or after their target in the
* input (e.g. across separate back-pagination batches).
*/
export function resolveMessageEdits(records: RawExportRecord[]): ResolvedExportRecord[] {
const edits = new Map<string, string>();
for (const record of records) {
if (record.editsEventId && record.newBody) {
edits.set(record.editsEventId, record.newBody);
}
}
const resolved: ResolvedExportRecord[] = [];
for (const record of records) {
// Edit events never become their own row.
if (record.editsEventId) continue;
const editedBody = edits.get(record.eventId);
resolved.push({
eventId: record.eventId,
ts: record.ts,
sender: record.sender,
body: editedBody ?? record.body,
msgtype: record.msgtype,
edited: editedBody !== undefined,
});
}
return resolved;
}
// Prefixes a media message's body with a "[Image]"-style marker for txt/html
// export output, and appends an "(edited)" suffix when applicable. Plain-text
// messages are returned unchanged (aside from the edited suffix).
export function formatExportBody(record: ResolvedExportRecord): string {
const label = MEDIA_TYPE_LABELS[record.msgtype];
const mediaPrefix = label ? `[${label}] ` : '';
const editedSuffix = record.edited ? ' (edited)' : '';
return `${mediaPrefix}${record.body}${editedSuffix}`;
}

Some files were not shown because too many files have changed in this diff Show More