Compare commits

..
10 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 039b74c2b9 docs: CI additions, localization decision, upstream-merge path now CI-gated
CI / Build & Quality Checks (push) Successful in 1m25s
CI / Secret scan (gitleaks) (push) Failing after 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Docker image build & smoke test (push) Failing after 5s
CI / Playwright smoke (e2e) (push) Successful in 2m12s
Refs #53 #94 #95 #98

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:23 -04:00
jaredandClaude Opus 5 0cb0f91e43 test(e2e): Playwright smoke test — boot tier always, E2EE composer tier with credentials
Boot tier (runs against vite preview of dist/): login screen renders with
no page or console errors, sw.js is served and registers, the bundled
Element Call mounts in a frame with no failed asset requests. E2EE tier
(skipped without E2E_HOMESERVER/E2E_USER/E2E_PASSWORD): password login,
create an encrypted room, send text, attach a compressed JPEG, and assert
at the network level that every send is m.room.encrypted with no
plaintext body/url/file — the regression test #6/#7/#11 lacked.
Secrets and local usage documented in LOTUS_TESTING.md.

Fixes #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:23 -04:00
jaredandClaude Opus 5 09562061a4 ci: lockfile check, gitleaks, docker smoke job, renovate, shipped nginx security headers
- "Verify lockfile is in sync" (git diff --exit-code package-lock.json
  after npm ci) replaces the deleted GitHub lockfile workflow (#99).
- gitleaks 8.30.1 binary scan on push + PR with a small allowlist for
  the public homeserver/registry URLs (#95).
- docker job builds the image, runs it and asserts 200 + the security
  headers; continue-on-error until the runner is confirmed to have a
  Docker daemon (#93). .dockerignore keeps the context small.
- docker-nginx.conf now sends a CSP (frame-src allowlist matching
  videoEmbed.ts), frame-ancestors 'none', Referrer-Policy and nosniff —
  shipped config, verify against the live chat.lotusguild.org headers
  before adopting in prod nginx (#95, #44 shipped-config half).
- renovate.json + weekly renovate workflow for cinny and element-call;
  needs a RENOVATE_TOKEN secret (names starting GITEA_ are reserved) and
  stays continue-on-error until it exists (#94).
- e2e job appended for the Playwright smoke test (#90), continue-on-error
  until green on the runner.

Fixes #93
Fixes #94
Fixes #95

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:23 -04:00
jaredandClaude Opus 5 3a15bd89c0 chore(ci): delete the eight GitHub-only workflows inherited from upstream
They never ran on Gitea and implied CLA / PR-title / Netlify / GHCR
enforcement that doesn't exist. lockfile.yml's intent moves into
.gitea/workflows/ci.yml.

Fixes #99

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:23 -04:00
jaredandClaude Opus 5 6bb90b7e1d fix(i18n): English-only until Lotus strings are localized
The language detector switched the few upstream-translated strings to
the browser locale while every Lotus surface stayed English, producing a
mixed UI. supportedLngs is now ['en']; the detector, backend and locale
files stay so re-enabling is one line plus routing Lotus strings through
useTranslation.

Fixes #53

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
jaredandClaude Opus 5 4d7a510e06 fix(sidebar): favourite / low-priority changes move the room immediately
Home's categorisation memo keyed only on the room list, and nothing
observed m.tag changes. The SDK emits RoomEvent.Tags on the room and
re-emits it on the client (room.js addTags, sync.js reEmit); a small
client-level hook bumps a version that the memo depends on.

Fixes #20

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
jaredandClaude Opus 5 31b3cf63c6 refactor(calls): one ScreenshareConfirm and one room-policy hook for both call bars
Fixes #101

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
jaredandClaude Opus 5 2ca59be9fc fix(auth): OIDC callback evicts a cached client id the provider rejects
The redirect back with ?error=invalid_client is the only place a stale
dynamic client id is ever rejected; the callback now resolves the issuer
from the SDK's stored mx_oidc_<state> entry and invalidates the cache so
the next attempt re-registers. Degrades to a no-op if the state entry is
gone. Unit-tested.

Fixes #102

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
jaredandClaude Opus 5 2137c37c8b chore(scripts): syncDecorations fails loudly on unmatched entries; patch-folds is diagnosable and idempotent
- syncDecorations.mjs tracks which slugs its regex actually removed and
  exits 1 without writing if that set doesn't match the missing assets,
  instead of silently no-op'ing on a reformatted catalog.
- patch-folds.mjs matches a 4-line context block, reports the installed
  folds version and expected snippet when the target is missing, and
  distinguishes "already patched" (exit 0) from "pattern not found"
  (exit 1). Verified against all three states.

Fixes #88
Fixes #55

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
jaredandClaude Opus 5 bd033baf19 chore: remove unused useForceUpdate.js (the .ts implementation is the one imported)
Fixes #87

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-13 00:56:22 -04:00
38 changed files with 1152 additions and 584 deletions
+4
View File
@@ -1,2 +1,6 @@
node_modules/
.git/
dist/
experiment/
*.md
!README.md
+147
View File
@@ -57,6 +57,16 @@ 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
@@ -142,3 +152,140 @@ 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.
#
# `continue-on-error: true` — informational for now. The shared act_runner
# may not expose a Docker daemon to job containers (same class of problem
# as the unreachable cache server noted above); flip this off once it's
# confirmed the runner can actually run `docker build`/`docker run` here.
docker:
name: Docker image build & smoke test
needs: build
runs-on: ubuntu-latest
continue-on-error: true
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 (LOTUS_TODO), so `needs: build` only gates on the
# main job having passed, not on its artifact.
# continue-on-error: `playwright install --with-deps` needs apt on the
# runner image; promote to hard once green on the runner.
e2e:
name: Playwright smoke (e2e)
needs: build
runs-on: ubuntu-latest
continue-on-error: true
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 }}
+28
View File
@@ -0,0 +1,28 @@
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
continue-on-error: true # informational until RENOVATE_TOKEN is confirmed present
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
-40
View File
@@ -1,40 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,63 +0,0 @@
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
@@ -1,63 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,99 +0,0 @@
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 }}
+4
View File
@@ -6,3 +6,7 @@ devAssets
.DS_Store
.ideapackage-lock.json
public/decorations/
# Playwright (npm run test:e2e)
playwright-report/
test-results/
+19
View File
@@ -0,0 +1,19 @@
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''',
]
+19 -2
View File
@@ -26,8 +26,9 @@ Last updated: July 2026.
17. [Notifications](#notifications)
18. [Server Integration](#server-integration)
19. [Infrastructure](#infrastructure)
20. [Desktop App Features](#desktop-app-features)
21. [Key Custom Files](#key-custom-files)
20. [Localization](#localization)
21. [Desktop App Features](#desktop-app-features)
22. [Key Custom Files](#key-custom-files)
---
@@ -1408,6 +1409,22 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
---
## 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.
---
## Desktop App Features
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
+18
View File
@@ -52,6 +52,24 @@ Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgro
---
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
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:
| 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://` |
**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):
- `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.
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/`.
---
## A. Calls — new ringtone + notification work (highest priority)
### A1. Ringtone selection — preview in Settings
+2 -1
View File
@@ -411,4 +411,5 @@ Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint
- [ ] **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).
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build).
- [x] **CI-gate the `lotus-build.sh` upstream-merge path** — DONE (matrix repo, cinny#98): the script now merges, runs the local gates (npm ci, typecheck, eslint, prettier, tests), and pushes; CI + `lotus_deploy.sh` deploy exactly like any other lotus commit. A failed gate leaves the merge local (not pushed).
+3
View File
@@ -119,6 +119,7 @@ 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))
---
@@ -229,3 +230,5 @@ 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`).
+36
View File
@@ -2,6 +2,42 @@ 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
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,201 @@
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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,14 @@
{
"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"]
}
+47 -1
View File
@@ -81,6 +81,7 @@
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
@@ -120,7 +121,7 @@
"vite-plugin-static-copy": "4.1.0"
},
"engines": {
"node": ">=16.0.0"
"node": ">=20.0.0"
}
},
"node_modules/@apideck/better-ajv-errors": {
@@ -2973,6 +2974,22 @@
"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",
@@ -10708,6 +10725,35 @@
"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",
+3
View File
@@ -17,6 +17,8 @@
"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",
@@ -106,6 +108,7 @@
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2",
+35
View File
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,25 @@
{
"$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
}
]
}
+41 -13
View File
@@ -4,28 +4,56 @@ 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 {
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';
const content = readFileSync(foldsPath, 'utf8');
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)) {
content = content.replace(original, patched);
writeFileSync(foldsPath, content, 'utf8');
writeFileSync(foldsPath, content.replace(original, patched), 'utf8');
console.log('Applied defensive Icon src guard to folds.');
} else {
// 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").
// 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 LOTUS_TODO.md "Dependencies / Build / Hygiene" 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}`);
console.error(
'ERROR: folds Icon patch target not found - folds may have updated. ' +
'Update the patch target string in scripts/patch-folds.mjs before building.',
' folds likely changed its Icon implementation. Update the patch target ' +
'in scripts/patch-folds.mjs (see LOTUS_TODO -> "Dependencies / Build / Hygiene" ' +
'-> patch-folds.mjs entry) before building.',
);
process.exit(1);
}
+33 -4
View File
@@ -103,9 +103,38 @@ 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
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
missingSet.has(slug) ? '' : match,
);
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);
}
// Drop category blocks that now have an empty decorations array
updated = updated.replace(
@@ -118,6 +147,6 @@ updated = updated.replace(/\n{3,}/g, '\n\n');
writeFileSync(catalogPath, updated, 'utf8');
console.log(
`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`,
`\nDone. Removed ${removedSlugs.size} entr${removedSlugs.size === 1 ? 'y' : 'ies'} from the catalog.`,
);
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
+12 -81
View File
@@ -1,18 +1,5 @@
import {
Box,
Button,
Chip,
color,
config,
Icon,
IconButton,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback, useState } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
@@ -20,6 +7,7 @@ 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;
@@ -199,14 +187,6 @@ export function CallControl({
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
if (!shareConfirm) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareConfirm(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [shareConfirm]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
@@ -230,64 +210,15 @@ export function CallControl({
return (
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}>
{shareConfirm && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShareConfirm(false)}
aria-hidden="true"
/>
<Box
style={{
position: 'absolute',
bottom: '110%',
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',
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>
</>
)}
<ScreenshareConfirm
open={shareConfirm}
align="Start"
onConfirm={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
onCancel={() => setShareConfirm(false)}
/>
<Box alignItems="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
+19 -82
View File
@@ -1,10 +1,9 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
Box,
Button,
Chip,
color,
config,
Icon,
IconButton,
@@ -41,9 +40,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
import { CallSoundboard } from './CallSoundboard';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { RoomQualityContent } from '../../utils/callQuality';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from './ScreenshareConfirm';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -90,14 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
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 [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
@@ -107,16 +97,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// visual PTT chip remains here.
const pttActive = useAtomValue(pttActiveAtom);
// [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;
// [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);
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare;
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
@@ -191,67 +180,15 @@ export function CallControls({ callEmbed }: CallControlsProps) {
</Text>
</Chip>
)}
{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>
</>
)}
<ScreenshareConfirm
open={shareConfirm}
align="Center"
onConfirm={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
onCancel={() => setShareConfirm(false)}
/>
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
@@ -0,0 +1,92 @@
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>
</>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { useState } from 'react';
export function useForceUpdate() {
const [data, setData] = useState(null);
return [
data,
function forceUpdateHook() {
setData({});
},
];
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { MatrixClient, RoomEvent } from 'matrix-js-sdk';
import { useForceUpdate } from './useForceUpdate';
/**
* Bumps a counter whenever any room's `m.tag` account data changes
* (favourite/low-priority add or remove via `mx.setRoomTag`/`deleteRoomTag`).
*
* `RoomEvent.Tags` is emitted on the individual `Room` by `Room.addTags()`
* (matrix-js-sdk `lib/models/room.js`) and re-emitted onto the `MatrixClient`
* itself by the sync loop (`lib/sync.js`, the `client.reEmitter.reEmit(room, [...
* RoomEvent.Tags ...])` call), so a single client-level listener here sees the
* change for every room without needing to attach/detach a per-room listener.
*
* Consumers should add the returned counter to the deps of any memo that
* derives favourite/low-priority categorisation from `room.tags`, so toggling
* a tag moves the room immediately instead of waiting for an unrelated
* re-render to pick up the mutated (but stale-looking) `Room` object.
*/
export const useRoomTagsVersion = (mx: MatrixClient): number => {
const [version, bumpVersion] = useForceUpdate();
useEffect(() => {
mx.on(RoomEvent.Tags, bumpVersion);
return () => {
mx.removeListener(RoomEvent.Tags, bumpVersion);
};
}, [mx, bumpVersion]);
return version;
};
+10
View File
@@ -19,6 +19,16 @@ i18n
.init<HttpBackendOptions>({
debug: false,
fallbackLng: 'en',
// Lotus Chat is English-only for now: none of the Lotus-added UI (presence,
// calls, soundboard, decorations, seasonal settings, keyboard shortcuts help,
// etc.) is routed through useTranslation()/public/locales yet, so letting
// LanguageDetector pick a non-English browser locale produced a UI that was
// only partially translated (the ~11 upstream strings switched language,
// everything Lotus-added stayed English). Restricting supportedLngs keeps
// the detector/backend/mechanism intact (see LOTUS_FEATURES.md) so other
// languages can come back with a one-line change once Lotus strings are
// localized.
supportedLngs: ['en'],
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
+19 -1
View File
@@ -5,7 +5,12 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { setFallbackSession } from '../../../state/sessions';
import { completeAuthorizationCodeGrant } from './oidcLoginUtil';
import { getOidcCallbackUrl } from './oidcConfig';
import { parseOidcCallbackParams } from './oidcState';
import {
invalidateCachedClient,
isStaleClientError,
parseOidcCallbackParams,
readStoredOidcIssuer,
} from './oidcState';
/**
* Exchange the authorization code for a Matrix session and persist it. The SDK
@@ -76,6 +81,19 @@ export function OidcCallback() {
if (params.kind === 'success') complete(params.code, params.state);
}, [params, complete]);
useEffect(() => {
// #102 — the login-start path (startOidcLogin) already evicts a cached
// dynamic client id when the provider rejects it, but that only covers
// failures raised before the redirect. A client id can just as well be
// rejected on the way back (`?error=invalid_client` on this callback),
// and until now that case never invalidated the cache — so the same
// stale id gets retried, and rejected again, on every subsequent login.
if (params.kind === 'error' && isStaleClientError(params) && params.state) {
const issuer = readStoredOidcIssuer(params.state);
if (issuer) invalidateCachedClient(issuer);
}
}, [params]);
useEffect(() => {
// Session persisted — full-page reload at the app root so it boots the
// authenticated client (works for both hash and browser router configs).
+67
View File
@@ -6,6 +6,7 @@ import {
invalidateCachedClient,
parseOidcCallbackParams,
isStaleClientError,
readStoredOidcIssuer,
} from './oidcState';
const installStorage = (): Map<string, string> => {
@@ -22,6 +23,20 @@ const installStorage = (): Map<string, string> => {
return store;
};
const installSessionStorage = (): Map<string, string> => {
const store = new Map<string, string>();
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
setItem: (k: string, v: string) => {
store.set(k, String(v));
},
removeItem: (k: string) => {
store.delete(k);
},
};
return store;
};
test('registration cache: get / put / invalidate, scoped by issuer + redirectUri', () => {
installStorage();
assert.equal(getCachedClientId('iss', 'rd'), undefined);
@@ -52,11 +67,21 @@ test('parseOidcCallbackParams classifies success / error / invalid', () => {
kind: 'error',
error: 'access_denied',
errorDescription: 'nope',
state: undefined,
});
assert.deepEqual(parseOidcCallbackParams('?error=bad'), {
kind: 'error',
error: 'bad',
errorDescription: undefined,
state: undefined,
});
// #102 — the provider echoes back `state` on an error redirect too; the
// callback needs it to look up the pending sign-in's issuer.
assert.deepEqual(parseOidcCallbackParams('?error=invalid_client&state=xyz'), {
kind: 'error',
error: 'invalid_client',
errorDescription: undefined,
state: 'xyz',
});
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
@@ -82,3 +107,45 @@ test('isStaleClientError: transient / discovery / local failures keep the cache'
assert.equal(isStaleClientError(undefined), false);
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
});
test('isStaleClientError: also recognises the OidcCallback error-redirect shape (#102)', () => {
// Same field name (`error`) as the ErrorResponse shape, so no special-casing
// is needed — but pin it down since the callback now depends on this.
assert.equal(
isStaleClientError({
kind: 'error',
error: 'invalid_client',
errorDescription: undefined,
state: 'xyz',
}),
true,
);
assert.equal(
isStaleClientError({
kind: 'error',
error: 'access_denied',
errorDescription: undefined,
state: 'xyz',
}),
false,
);
});
test('readStoredOidcIssuer reads the issuer persisted by generateOidcAuthorizationUrl', () => {
const store = installSessionStorage();
assert.equal(readStoredOidcIssuer('xyz'), undefined); // nothing stored yet
store.set(
'mx_oidc_xyz',
JSON.stringify({ authority: 'https://issuer.example', client_id: 'abc' }),
);
assert.equal(readStoredOidcIssuer('xyz'), 'https://issuer.example');
assert.equal(readStoredOidcIssuer('other-state'), undefined); // different state = miss
});
test('readStoredOidcIssuer tolerates corrupt or missing storage', () => {
const store = installSessionStorage();
store.set('mx_oidc_bad', '{ not json');
assert.equal(readStoredOidcIssuer('bad'), undefined);
store.set('mx_oidc_noauth', JSON.stringify({ client_id: 'abc' }));
assert.equal(readStoredOidcIssuer('noauth'), undefined);
});
+38 -5
View File
@@ -45,14 +45,16 @@ export const invalidateCachedClient = (issuer: string): void => {
const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']);
/**
* #67 — pure: does a `startOidcLogin` failure indicate the CACHED client id is
* #67 / #102 — pure: does an OIDC failure indicate the CACHED client id is
* bad? Only then is dropping the registration cache justified; a transient
* network error, an offline discovery fetch, or a local (`crypto.randomUUID`)
* failure must leave it alone, otherwise every retry performs a fresh dynamic
* registration and piles throwaway clients onto the provider. Recognised
* shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by
* oidc-client-ts `ErrorResponse`), or an HTTP 400/401 carried as
* `httpStatus`/`status` (MatrixError-style) from the registration/authorize step.
* oidc-client-ts `ErrorResponse`, OR as echoed back on the callback redirect's
* `?error=` query param via {@link OidcCallbackParams}), or an HTTP 400/401
* carried as `httpStatus`/`status` (MatrixError-style) from the
* registration/authorize step.
*/
export const isStaleClientError = (e: unknown): boolean => {
if (!e || typeof e !== 'object') return false;
@@ -69,7 +71,7 @@ export const isStaleClientError = (e: unknown): boolean => {
/** Parsed shape of the provider's redirect back to our callback URL. */
export type OidcCallbackParams =
| { kind: 'success'; code: string; state: string }
| { kind: 'error'; error: string; errorDescription?: string }
| { kind: 'error'; error: string; errorDescription?: string; state?: string }
| { kind: 'invalid' };
/** Pure: classify the callback query string into success / error / invalid. */
@@ -77,10 +79,41 @@ export const parseOidcCallbackParams = (search: string): OidcCallbackParams => {
const params = new URLSearchParams(search);
const error = params.get('error');
if (error) {
return { kind: 'error', error, errorDescription: params.get('error_description') ?? undefined };
return {
kind: 'error',
error,
errorDescription: params.get('error_description') ?? undefined,
// OAuth error redirects echo back the `state` we sent, same as a
// success redirect would — needed to look up the pending sign-in's
// issuer (see readStoredOidcIssuer) when the client id itself was
// rejected.
state: params.get('state') ?? undefined,
};
}
const code = params.get('code');
const state = params.get('state');
if (code && state) return { kind: 'success', code, state };
return { kind: 'invalid' };
};
/**
* #102 — the issuer for a pending sign-in isn't available on the callback's
* error branch (we bail out before `completeAuthorizationCodeGrant`, which is
* what would otherwise surface it). oidc-client-ts's `generateOidcAuthorizationUrl`
* persists the pending sign-in's `SigninState` (including `authority` and
* `client_id`) into sessionStorage before redirecting, keyed by
* `mx_oidc_<state>` (WebStorageStateStore prefix `mx_oidc_`, keyed by the same
* `state` value the provider echoes back on redirect). Read it directly so a
* genuinely stale cached client id (`invalid_client`) can be evicted even when
* the callback errors before a token exchange is attempted.
*/
export const readStoredOidcIssuer = (state: string): string | undefined => {
try {
const raw = sessionStorage.getItem(`mx_oidc_${state}`);
if (!raw) return undefined;
const { authority } = JSON.parse(raw) as { authority?: unknown };
return typeof authority === 'string' ? authority : undefined;
} catch {
return undefined;
}
};
+10 -1
View File
@@ -79,6 +79,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
type HomeMenuProps = {
requestClose: () => void;
@@ -253,6 +254,11 @@ export function Home() {
const [homeRoomSort, setHomeRoomSort] = useSetting(settingsAtom, 'homeRoomSort');
const roomToUnread = useAtomValue(roomToUnreadAtom);
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
// Bumped whenever any room's `m.tag` account data changes (favourite/low-priority
// toggle via RoomNavItemMenu). Nothing else re-runs this memo on a tag change
// (see useRoomTagsVersion.ts), so without this dep a room only moves section
// once some unrelated event (e.g. an unread-count change) forces a re-render.
const roomTagsVersion = useRoomTagsVersion(mx);
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const favs: string[] = [];
@@ -269,7 +275,10 @@ export function Home() {
}
});
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
}, [mx, rooms]);
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
// this memo to re-run whenever any room's tags change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, rooms, roomTagsVersion]);
const sortedFavoriteRooms = useMemo(() => {
const isClosed = closedCategories.has(FAVORITES_CATEGORY_ID);