Compare commits
84
Commits
sw-fix
..
df64b9ca56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df64b9ca56 | ||
|
|
b74f9f5699 | ||
|
|
62f214f441 | ||
|
|
81a6d9c9ed | ||
|
|
2f47fa32ee | ||
|
|
ba0cc3e4b4 | ||
|
|
57ebf4481b | ||
|
|
f50f50be72 | ||
|
|
039b74c2b9 | ||
|
|
0cb0f91e43 | ||
|
|
09562061a4 | ||
|
|
3a15bd89c0 | ||
|
|
6bb90b7e1d | ||
|
|
4d7a510e06 | ||
|
|
31b3cf63c6 | ||
|
|
2ca59be9fc | ||
|
|
2137c37c8b | ||
|
|
bd033baf19 | ||
|
|
34fe223aa4 | ||
|
|
19eded89c1 | ||
|
|
d0c13b1a49 | ||
|
|
ff575212ee | ||
|
|
a461543916 | ||
|
|
9f8fae964d | ||
|
|
7e64ea398b | ||
|
|
086e4b3b03 | ||
|
|
d0017e4a78 | ||
|
|
e96bd527a0 | ||
|
|
1a5ca81513 | ||
|
|
d280f66d5c | ||
|
|
e2e9f75e40 | ||
|
|
7aba71490a | ||
|
|
2e14980de6 | ||
|
|
6b16c789a4 | ||
|
|
535302aed9 | ||
|
|
a25777a77a | ||
|
|
3adba22ddf | ||
|
|
bacfef5558 | ||
|
|
6aa459df31 | ||
|
|
8f92029b47 | ||
|
|
49c4641ca8 | ||
|
|
ac0ec9f42d | ||
|
|
0f7f0100af | ||
|
|
91def3ad34 | ||
|
|
a29be7953b | ||
|
|
26c70f5a1d | ||
|
|
fc68e0a769 | ||
|
|
fb14db6d50 | ||
|
|
6b9c8de393 | ||
|
|
32e5fec6f3 | ||
|
|
8210ee7046 | ||
|
|
d0dccdeb67 | ||
|
|
dddaa4183e | ||
|
|
c8e49d3855 | ||
|
|
9bdf4ff1fd | ||
|
|
23ee156f2f | ||
|
|
9a85a48704 | ||
|
|
f3119e3dc2 | ||
|
|
cecf65a3a1 | ||
|
|
02592ed43c | ||
|
|
6bd2903de1 | ||
|
|
7c52027afb | ||
|
|
2344c8273e | ||
|
|
6e4c4bc795 | ||
|
|
e447fdc0f3 | ||
|
|
4dd0e6637d | ||
|
|
dfccaec9dc | ||
|
|
9019d7c21e | ||
|
|
eef1d14492 | ||
|
|
2b66dcc08c | ||
|
|
dac74f098e | ||
|
|
d4d1b4957f | ||
|
|
c4aa1567d7 | ||
|
|
a6ddafb446 | ||
|
|
6c1a9942b0 | ||
|
|
4bea48959e | ||
|
|
34a3352e21 | ||
|
|
3cc5f0cc6a | ||
|
|
fd93339ad4 | ||
|
|
b1ecb0c46b | ||
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 |
@@ -1,2 +1,6 @@
|
||||
node_modules/
|
||||
.git/
|
||||
dist/
|
||||
experiment/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
+196
-43
@@ -30,8 +30,13 @@ 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
|
||||
@@ -52,58 +57,69 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||
# ── #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/. ──
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# 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
|
||||
# ── Boot check — actually loads the built dist/, not just builds it ──
|
||||
- name: Boot check
|
||||
run: node scripts/boot-check.mjs
|
||||
|
||||
# ── 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 Check and Fix
|
||||
run: |
|
||||
npx prettier --write .
|
||||
npm run check:prettier
|
||||
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
# ── 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.
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
continue-on-error: true
|
||||
|
||||
# ── 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
|
||||
# ── 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 }}
|
||||
|
||||
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||
@@ -136,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.
|
||||
#
|
||||
# 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 (LOTUS_TODO), 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 }}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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,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
|
||||
@@ -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
|
||||
@@ -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. ⚠️
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -6,3 +6,7 @@ devAssets
|
||||
.DS_Store
|
||||
.ideapackage-lock.json
|
||||
public/decorations/
|
||||
|
||||
# Playwright (npm run test:e2e)
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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_-]+['"]''',
|
||||
]
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
+28
-11
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -154,13 +155,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:
|
||||
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):
|
||||
|
||||
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
|
||||
- **Digital Rain** — column scroll keyframe; 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** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
|
||||
- **Grid Pulse** — size breathe keyframe (4 s)
|
||||
- **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** — 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
|
||||
- **Fireflies** — position drift keyframe with per-firefly duration/delay so motion stays unsynchronised
|
||||
|
||||
### Files
|
||||
|
||||
@@ -176,7 +177,7 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
|
||||
### Themes
|
||||
|
||||
| Theme | Window | Effect |
|
||||
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
|
||||
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
|
||||
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
|
||||
@@ -184,8 +185,8 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
|
||||
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
|
||||
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
|
||||
| 🍂 Autumn | Sep 21–Oct 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 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
|
||||
| 👾 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 4–10 | 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 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
|
||||
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
|
||||
|
||||
@@ -742,7 +743,7 @@ never leaves it.
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||
- 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
|
||||
- A chip shows the active date range with an **×** button to clear it
|
||||
|
||||
### Encrypted Search Cache (P4-8, opt-in)
|
||||
@@ -1205,7 +1206,7 @@ Hook: `src/app/hooks/usePendingKnocks.ts`
|
||||
|
||||
### Room Emoji Prefix
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -829,3 +847,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
||||
**Verified working in live testing (2026-06):** A2, B1–B4, 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.
|
||||
|
||||
---
|
||||
|
||||
### Green CI but the fix isn't live?
|
||||
|
||||
`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.
|
||||
|
||||
+7
-3
@@ -372,9 +372,11 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
||||
|
||||
### 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/`).
|
||||
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/`).
|
||||
|
||||
**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.
|
||||
**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):
|
||||
|
||||
@@ -395,6 +397,7 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
|
||||
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`.
|
||||
@@ -409,4 +412,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).
|
||||
|
||||
@@ -13,38 +13,7 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
|
||||
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||
|
||||
---
|
||||
## Development Environment Setup
|
||||
#### Getting correct Node version
|
||||
- Ensure you have the correct version of node installed, specified in `.node-version`
|
||||
- Use this command from the terminal to install nvm
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||
```
|
||||
- Reload your terminal shell using (for Ubuntu):
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
```
|
||||
- Install the specified Node version
|
||||
```bash
|
||||
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
|
||||
nvm install "$NODE_VERSION"
|
||||
nvm use "$NODE_VERSION"
|
||||
```
|
||||
- verify the correct version was installed by running
|
||||
```bash
|
||||
node --version
|
||||
```
|
||||
and comparing the output to what is listed in `.node-version`
|
||||
#### Install npm packages
|
||||
- To install the npm packages listed in `package.json` run:
|
||||
```bash
|
||||
npm i
|
||||
```
|
||||
### Start Development Server
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
|
||||
|
||||
## Features
|
||||
|
||||
### Messaging
|
||||
@@ -53,7 +22,7 @@ You should now have an active development server at `localhost:8080`, where you
|
||||
- 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
|
||||
- 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)
|
||||
- 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
|
||||
@@ -104,7 +73,6 @@ You should now have an active development server at `localhost:8080`, where you
|
||||
- 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
|
||||
|
||||
@@ -151,6 +119,7 @@ You should now have an active development server at `localhost:8080`, where you
|
||||
- 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))
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
|
||||
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.
|
||||
|
||||
### SmartScreen Warning (Windows)
|
||||
|
||||
@@ -218,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
|
||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.3`, upstream base v0.25.0, 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.
|
||||
@@ -252,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`).
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@
|
||||
"allowCustomHomeservers": true,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
||||
"rooms": ["#jellyfin:matrix.org"],
|
||||
"servers": ["matrixrooms.info"]
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
+1
-1
@@ -109,7 +109,7 @@ export default [
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// jsx-a11y — media captions not required for this app
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
|
||||
Generated
+483
-419
File diff suppressed because it is too large
Load Diff
+20
-14
@@ -5,19 +5,22 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||
"check:eslint": "eslint src/* --max-warnings 68",
|
||||
"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",
|
||||
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||
},
|
||||
@@ -25,6 +28,11 @@
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
"*": "prettier --ignore-unknown --write"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ajay Bura",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -37,7 +45,7 @@
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@giphy/js-fetch-api": "5.8.0",
|
||||
"@giphy/js-types": "5.1.0",
|
||||
"@giphy/js-util": "2.0.0",
|
||||
"@giphy/js-util": "5.2.0",
|
||||
"@giphy/react-components": "10.1.2",
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
@@ -69,13 +77,13 @@
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.11",
|
||||
"katex": "0.16.47",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"matrix-widget-api": "1.18.0",
|
||||
"millify": "6.1.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"prismjs": "1.30.0",
|
||||
"qrcode": "1.5.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
@@ -88,8 +96,8 @@
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router-dom": "7.15.1",
|
||||
"sanitize-html": "2.17.6",
|
||||
"react-router-dom": "7.18.3",
|
||||
"sanitize-html": "2.17.7",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -99,7 +107,8 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
@@ -124,7 +133,6 @@
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
@@ -141,11 +149,9 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@giphy/js-util": {
|
||||
"dompurify": ">=3.3.4"
|
||||
"dompurify": ">=3.3.4",
|
||||
"uuid": ">=11.1.1"
|
||||
},
|
||||
"js-cookie": ">=3.0.6"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.1": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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();
|
||||
+41
-13
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -45,6 +45,8 @@ 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';
|
||||
@@ -58,6 +60,7 @@ 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';
|
||||
@@ -410,6 +413,8 @@ 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.
|
||||
@@ -566,11 +571,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
startCall(room, { microphone: true, video, sound: true });
|
||||
// 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 });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom],
|
||||
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
|
||||
);
|
||||
|
||||
if (!callInfo) return null;
|
||||
@@ -602,9 +611,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
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);
|
||||
@@ -616,6 +631,22 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
}, [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;
|
||||
}
|
||||
|
||||
@@ -720,12 +751,6 @@ 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.
|
||||
@@ -1139,7 +1164,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
|
||||
@@ -209,6 +209,13 @@ 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
|
||||
? {
|
||||
|
||||
@@ -21,12 +21,31 @@ 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';
|
||||
@@ -34,6 +53,7 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||
return (
|
||||
<Wrapper
|
||||
// KaTeX output is generated by our own render call (trusted-safe).
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MessageVerificationRequestContent,
|
||||
} from './content';
|
||||
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { languageName } from '../../utils/translation/langUtils';
|
||||
import {
|
||||
IAudioContent,
|
||||
@@ -62,8 +63,9 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
// 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();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -633,6 +635,13 @@ 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
|
||||
@@ -660,6 +669,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Start" gap="200">
|
||||
{mapLoaded ? (
|
||||
<iframe
|
||||
title="Location"
|
||||
src={mapSrc}
|
||||
@@ -674,6 +684,18 @@ export function MLocation({ content }: MLocationProps) {
|
||||
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>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
|
||||
{description}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
|
||||
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{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: '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)' },
|
||||
'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)' },
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,10 @@ 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
|
||||
@@ -58,7 +62,7 @@ function gridDataUri(): string {
|
||||
rows.forEach((y) => {
|
||||
lines.push(
|
||||
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
|
||||
);
|
||||
});
|
||||
// Vertical lines fanning out from the top-center vanishing point.
|
||||
@@ -67,7 +71,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.4' stroke-opacity='0.8'/>`,
|
||||
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
|
||||
);
|
||||
}
|
||||
const svg =
|
||||
@@ -105,6 +109,13 @@ 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[]>(() => {
|
||||
@@ -134,9 +145,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.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%)',
|
||||
'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%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
@@ -147,9 +158,27 @@ 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. */}
|
||||
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. */}
|
||||
<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,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-25%',
|
||||
@@ -161,7 +190,6 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@@ -177,12 +205,13 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
backgroundImage: GRID_URI,
|
||||
backgroundRepeat: 'repeat-y',
|
||||
backgroundSize: '100% 50%',
|
||||
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||
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
|
||||
where the grid meets the sky, with a thin bright rule on top of it
|
||||
@@ -197,7 +226,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.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||
'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%)',
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
right: '12%',
|
||||
top: '38%',
|
||||
height: '1.5px',
|
||||
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,
|
||||
// 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,
|
||||
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||
}}
|
||||
/>
|
||||
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
mixBlendMode: 'multiply',
|
||||
opacity: 0.5,
|
||||
opacity: 0.32,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
top: '-8px',
|
||||
bottom: '-8px',
|
||||
backgroundImage:
|
||||
'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)',
|
||||
'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)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||
}}
|
||||
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||
Static scene shows it steady (no blink). */}
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
right: '14px',
|
||||
bottom: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: '3px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '12px',
|
||||
fontSize: HUD_FONT_SIZE,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<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)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||
}}
|
||||
>
|
||||
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(16, 1000), []);
|
||||
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
|
||||
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
|
||||
const nearStars = useMemo<Star[]>(() => makeStars(22, 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.16)',
|
||||
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
|
||||
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,15 +183,17 @@ export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
|
||||
{/* ── 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. ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-6%',
|
||||
bottom: '-10%',
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
right: '-4%',
|
||||
bottom: '10%',
|
||||
width: '200px',
|
||||
height: '200px',
|
||||
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: 'transform, opacity',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
opacity: reduced ? 0.5 : undefined,
|
||||
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
|
||||
animation: reduced
|
||||
|
||||
@@ -73,6 +73,32 @@ 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;
|
||||
@@ -186,11 +212,19 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
...existing.map((c) => c.shortcode),
|
||||
...uploads.map((u) => u.shortcode),
|
||||
]);
|
||||
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).`);
|
||||
}
|
||||
// [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];
|
||||
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||
throw new Error(`"${file.name}" is too large (max 1 MB).`);
|
||||
}
|
||||
@@ -215,6 +249,9 @@ 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,24 +214,56 @@ 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
|
||||
// Sync if account data arrives after mount, but never while there are
|
||||
// unsaved local edits (including our own save's in-flight echo).
|
||||
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), []);
|
||||
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);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||
const displayRoomName = room?.name ?? bookmark.roomName;
|
||||
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
|
||||
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
@@ -162,7 +163,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 || '(no preview)')}
|
||||
{preview ?? (bookmark.previewText || 'Message unavailable')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -173,13 +174,16 @@ 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.
|
||||
// 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.
|
||||
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||
room.findEventById(bookmark.eventId),
|
||||
);
|
||||
|
||||
const snapshot = bookmark.previewText || '(no preview)';
|
||||
const snapshot =
|
||||
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
|
||||
let preview: ReactNode = snapshot;
|
||||
|
||||
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||
@@ -234,7 +238,7 @@ function RoomGroupHeader({
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(roomId) ?? undefined;
|
||||
const displayRoomName = room?.name ?? roomName;
|
||||
const displayRoomName = room?.name || roomName || 'Unknown room';
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
@@ -327,13 +331,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
() =>
|
||||
query.length === 0
|
||||
? bookmarks
|
||||
: bookmarks.filter(
|
||||
(bk) =>
|
||||
bk.previewText.toLowerCase().includes(query) ||
|
||||
: 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)) ||
|
||||
bk.roomName.toLowerCase().includes(query) ||
|
||||
(bk.senderName?.toLowerCase().includes(query) ?? false),
|
||||
),
|
||||
[bookmarks, query],
|
||||
(room?.name.toLowerCase().includes(query) ?? false) ||
|
||||
(bk.senderName?.toLowerCase().includes(query) ?? false)
|
||||
);
|
||||
}),
|
||||
[mx, bookmarks, query],
|
||||
);
|
||||
|
||||
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } 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;
|
||||
@@ -177,6 +179,15 @@ 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],
|
||||
@@ -198,7 +209,16 @@ export function CallControl({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box shrink="No" alignItems="Center" gap="300">
|
||||
<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 alignItems="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
@@ -210,12 +230,16 @@ export function CallControl({
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && <StatusDivider />}
|
||||
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||||
{showCamera && (
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||
{!compact && (
|
||||
)}
|
||||
{!compact && showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
@@ -12,8 +12,31 @@ 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;
|
||||
@@ -33,15 +56,28 @@ 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();
|
||||
callEmbed?.control.focusCameraParticipant(userId);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,7 +114,7 @@ function ParticipantMenu({
|
||||
before={<Icon size="100" src={Icons.VideoCamera} />}
|
||||
onClick={handleFocusCamera}
|
||||
>
|
||||
<Text size="B300">Focus camera</Text>
|
||||
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import React, { MouseEventHandler, useCallback, useRef, useState, useEffect } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -39,11 +38,10 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
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;
|
||||
@@ -88,56 +86,27 @@ 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] 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 [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());
|
||||
};
|
||||
@@ -163,114 +132,6 @@ 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]),
|
||||
);
|
||||
@@ -319,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={() => {
|
||||
<ScreenshareConfirm
|
||||
open={shareConfirm}
|
||||
align="Center"
|
||||
onConfirm={() => {
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
onCancel={() => setShareConfirm(false)}
|
||||
/>
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -115,7 +115,18 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
try {
|
||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||
const vol = (flat.clip.volume / 100) * master;
|
||||
callEmbed.control.injectAudio(url, vol);
|
||||
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;
|
||||
}
|
||||
const audio = playClipLocally(url, vol);
|
||||
if (audio) {
|
||||
audio.addEventListener('ended', done, { once: true });
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
@@ -114,12 +113,9 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
// 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;
|
||||
// [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);
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||||
|
||||
@@ -144,7 +140,7 @@ function CallPrescreen() {
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||||
{!inOtherCall && hasPermission && channelFull && (
|
||||
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||||
<ChannelFullMessage current={callMembersCount} max={maxUsers} />
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
|
||||
@@ -74,7 +74,8 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
|
||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||
async (type, content) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
|
||||
@@ -31,6 +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).
|
||||
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
|
||||
Binary file not shown.
@@ -51,6 +51,11 @@ 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,6 +5,7 @@ import {
|
||||
DECORATION_CATEGORIES,
|
||||
ALL_DECORATIONS,
|
||||
decorationUrl,
|
||||
isValidDecorationSlug,
|
||||
} from './avatarDecorations';
|
||||
|
||||
test('decorationUrl builds a CDN png url from the slug', () => {
|
||||
@@ -66,3 +67,20 @@ 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,6 +188,19 @@ 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,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
MessageSearchParams,
|
||||
@@ -316,12 +317,21 @@ export function MessageSearch({
|
||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const applyResultFilters = useCallback(
|
||||
(allGroups: ResultGroup[]): ResultGroup[] => {
|
||||
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
|
||||
const inDateRange = filterGroupsByDateRange(
|
||||
allGroups,
|
||||
msgSearchParams.fromTs,
|
||||
msgSearchParams.toTs,
|
||||
);
|
||||
const byMsgType = filterGroupsByMsgType(inDateRange, 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.
|
||||
@@ -343,7 +353,7 @@ export function MessageSearch({
|
||||
};
|
||||
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
|
||||
},
|
||||
[msgTypeFilters, pinnedOnly, mx],
|
||||
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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 } from 'matrix-js-sdk';
|
||||
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -38,14 +38,17 @@ type ExtractedText = {
|
||||
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
|
||||
|
||||
/**
|
||||
* Pull the text we index/search from a decrypted event's content. Returns
|
||||
* `null` for events that carry no searchable text (e.g. stickers).
|
||||
* 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.
|
||||
*/
|
||||
const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||
const evType = event.getType();
|
||||
const content = event.getContent();
|
||||
|
||||
const extractTextFromContent = (
|
||||
evType: string,
|
||||
content: Record<string, unknown>,
|
||||
): ExtractedText | null => {
|
||||
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;
|
||||
if (!poll) return null;
|
||||
const qBody =
|
||||
@@ -56,6 +59,7 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||
.map(
|
||||
(a) =>
|
||||
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
||||
'') as string,
|
||||
)
|
||||
@@ -72,6 +76,13 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
|
||||
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) ||
|
||||
@@ -83,8 +94,17 @@ 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). */
|
||||
const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||
/**
|
||||
* 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 => {
|
||||
const bodyText = row.body || row.pollText || '';
|
||||
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
|
||||
if (row.formattedBody) {
|
||||
@@ -98,10 +118,11 @@ const rowToResultItem = (row: SearchCacheRow): ResultItem => {
|
||||
sender: row.sender,
|
||||
origin_server_ts: row.ts,
|
||||
content,
|
||||
unsigned: {},
|
||||
unsigned: redacted ? { redacted_because: { content: {} } } : {},
|
||||
};
|
||||
return {
|
||||
rank: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: syntheticEvent as any,
|
||||
context: { events_before: [], events_after: [], profile_info: {} },
|
||||
};
|
||||
@@ -184,8 +205,9 @@ export const useLocalMessageSearch = () => {
|
||||
const isMessageLike =
|
||||
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||
|
||||
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||
// 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;
|
||||
|
||||
const sender = event.getSender() ?? '';
|
||||
const ts = event.getTs();
|
||||
@@ -193,7 +215,33 @@ export const useLocalMessageSearch = () => {
|
||||
|
||||
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||
// of whether it matches the current term — future searches benefit.
|
||||
if (cacheEnabled && text && event.getId()) {
|
||||
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,
|
||||
@@ -204,6 +252,7 @@ export const useLocalMessageSearch = () => {
|
||||
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (senderSet && !senderSet.has(sender)) continue;
|
||||
if (!inRange(ts)) continue;
|
||||
@@ -224,6 +273,7 @@ export const useLocalMessageSearch = () => {
|
||||
};
|
||||
memoryItems.push({
|
||||
rank: 0,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
event: syntheticEvent as any,
|
||||
context: { events_before: [], events_after: [], profile_info: {} },
|
||||
});
|
||||
@@ -235,7 +285,12 @@ export const useLocalMessageSearch = () => {
|
||||
if (senderSet && !senderSet.has(row.sender)) return;
|
||||
if (!inRange(row.ts)) return;
|
||||
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
|
||||
cachedItems.push(rowToResultItem(row));
|
||||
// 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()));
|
||||
});
|
||||
|
||||
const items = mergeSearchResults(memoryItems, cachedItems);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
ResultGroup,
|
||||
} from './useMessageSearch';
|
||||
|
||||
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
|
||||
// (event.content.msgtype, event.event_id, group.roomId).
|
||||
@@ -9,6 +14,11 @@ 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[];
|
||||
@@ -48,6 +58,33 @@ 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,6 +71,31 @@ 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[] = [];
|
||||
|
||||
@@ -119,7 +144,9 @@ export type MessageSearchParams = {
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
|
||||
// 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 searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
@@ -142,10 +169,12 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
limit,
|
||||
rooms,
|
||||
senders,
|
||||
// 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 }),
|
||||
// `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.
|
||||
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include_state: false,
|
||||
order_by: order as SearchOrderBy.Recent,
|
||||
@@ -160,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
},
|
||||
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
|
||||
[mx, term, order, rooms, senders, containsUrl],
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -42,7 +42,6 @@ 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';
|
||||
@@ -66,19 +65,22 @@ 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';
|
||||
@@ -136,22 +138,16 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
const handleSave = useCallback(() => {
|
||||
const newName = inputRef.current?.value.trim() ?? '';
|
||||
if (newName.length > 255) return;
|
||||
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 },
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
setLocalRoomName(mx, room.roomId, '');
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
@@ -273,49 +269,6 @@ 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;
|
||||
@@ -392,8 +345,6 @@ 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,
|
||||
@@ -741,8 +692,12 @@ 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);
|
||||
@@ -764,6 +719,22 @@ 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();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useRoom } from '../../hooks/useRoom';
|
||||
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';
|
||||
|
||||
@@ -63,15 +64,11 @@ 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;
|
||||
|
||||
type MsgRecord = {
|
||||
ts: number;
|
||||
sender: string;
|
||||
body: string;
|
||||
eventId: string;
|
||||
msgtype: string;
|
||||
};
|
||||
|
||||
const collected: MsgRecord[] = [];
|
||||
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>();
|
||||
// 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.
|
||||
@@ -113,11 +110,39 @@ 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;
|
||||
collected.push({
|
||||
const mediaUrl =
|
||||
(content.url as string | undefined) ??
|
||||
(content.file as { url?: string } | undefined)?.url;
|
||||
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
|
||||
rawRecords.push({
|
||||
ts,
|
||||
sender: ev.getSender() ?? '',
|
||||
body,
|
||||
@@ -125,7 +150,9 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
msgtype,
|
||||
});
|
||||
}
|
||||
setExportCount(collected.length);
|
||||
// Approximate progress — excludes edit rows, which never become their
|
||||
// own line in the final (edit-resolved) output.
|
||||
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
|
||||
};
|
||||
|
||||
await addEvents(timeline.getEvents());
|
||||
@@ -159,6 +186,10 @@ 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;
|
||||
@@ -186,7 +217,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}: ${msg.body}`);
|
||||
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`);
|
||||
}
|
||||
content = lines.join('\n');
|
||||
mimeType = 'text/plain';
|
||||
@@ -201,6 +232,8 @@ 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);
|
||||
@@ -220,7 +253,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(msg.body)}</span></div>`;
|
||||
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>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
@@ -388,6 +421,11 @@ ${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. "[Image]") but only
|
||||
their filename is exported, not the file itself.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -24,6 +24,14 @@ 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;
|
||||
@@ -76,6 +84,23 @@ 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 }) {
|
||||
@@ -201,9 +226,24 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
|
||||
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
|
||||
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
setLoadedRoomId(roomId);
|
||||
setError(undefined);
|
||||
}, [mx, roomIdInput]);
|
||||
|
||||
@@ -63,8 +63,26 @@ 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>'s membership changed to <strong>joined</strong>
|
||||
</>
|
||||
),
|
||||
iconSrc: Icons.User,
|
||||
filter: 'members',
|
||||
};
|
||||
}
|
||||
if (
|
||||
prevMembership === 'invite' ||
|
||||
prevMembership === 'knock' ||
|
||||
@@ -94,6 +112,17 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
|
||||
}
|
||||
|
||||
if (membership === 'leave') {
|
||||
if (!hasPrevContent) {
|
||||
return {
|
||||
text: (
|
||||
<>
|
||||
<strong>{targetName}</strong>'s membership changed to <strong>left</strong>
|
||||
</>
|
||||
),
|
||||
iconSrc: Icons.User,
|
||||
filter: 'members',
|
||||
};
|
||||
}
|
||||
if (prevMembership === 'ban') {
|
||||
return {
|
||||
text: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
@@ -20,6 +20,15 @@ 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 }) {
|
||||
@@ -69,6 +78,52 @@ 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();
|
||||
|
||||
@@ -137,7 +192,10 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
newestTs,
|
||||
totalCached: events.length,
|
||||
};
|
||||
}, [room]);
|
||||
// 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]);
|
||||
|
||||
const maxHour = Math.max(...stats.hourBuckets, 1);
|
||||
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
|
||||
@@ -167,7 +225,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 direction="Column" gap="100">
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
<strong>
|
||||
Based on {stats.totalMessages} locally cached message
|
||||
@@ -179,6 +237,20 @@ 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,6 +341,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
variant="Primary"
|
||||
/>
|
||||
<Box direction="Column" gap="0">
|
||||
{}
|
||||
<label
|
||||
htmlFor="allow-ip-literals"
|
||||
style={{ cursor: canEdit ? 'pointer' : 'default' }}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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)');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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}`;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
@@ -71,6 +72,7 @@ import { MemberVerificationBadge } from '../../components/MemberVerificationBadg
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
@@ -239,11 +241,55 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
const myUserId = mx.getUserId();
|
||||
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
||||
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
||||
const kickPowerLevel = readPowerLevel.action(powerLevels, 'kick');
|
||||
const canApproveKnock = myPowerLevel >= invitePowerLevel;
|
||||
// Deny uses mx.kick, which is gated by the room's kick power level, not invite -
|
||||
// these can differ (e.g. moderators can invite but only admins can kick).
|
||||
const canDenyKnock = myPowerLevel >= kickPowerLevel;
|
||||
const knockMembers = useMemo(
|
||||
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
|
||||
[room, canApproveKnock],
|
||||
);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const [pendingKnockAction, setPendingKnockAction] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleApproveKnock = useCallback(
|
||||
(userId: string) => {
|
||||
setPendingKnockAction(userId);
|
||||
mx.invite(room.roomId, userId)
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to approve knock request:', err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
'Could not approve this request. Please try again.',
|
||||
Icons.Warning,
|
||||
'Failed',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => setPendingKnockAction(undefined));
|
||||
},
|
||||
[mx, room.roomId, setToast],
|
||||
);
|
||||
|
||||
const handleDenyKnock = useCallback(
|
||||
(userId: string) => {
|
||||
setPendingKnockAction(userId);
|
||||
mx.kick(room.roomId, userId)
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to deny knock request:', err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
'Could not deny this request. Please try again.',
|
||||
Icons.Warning,
|
||||
'Failed',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => setPendingKnockAction(undefined));
|
||||
},
|
||||
[mx, room.roomId, setToast],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo(
|
||||
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
|
||||
@@ -462,20 +508,24 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||
disabled={pendingKnockAction === knockMember.userId}
|
||||
onClick={() => handleApproveKnock(knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Approve</Text>
|
||||
</Button>
|
||||
{canDenyKnock && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||
disabled={pendingKnockAction === knockMember.userId}
|
||||
onClick={() => handleDenyKnock(knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,12 @@ import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
interface PollCreatorProps {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
/** Set when the composer is inside a thread so the poll lands in that thread. */
|
||||
threadRootId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(440);
|
||||
const [question, setQuestion] = useState('');
|
||||
@@ -85,7 +87,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
|
||||
'\n',
|
||||
);
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
// Pass the thread id explicitly (like the sticker path in RoomInput); the
|
||||
// legacy 3-arg form always resolves to the main timeline.
|
||||
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
settingsAtom,
|
||||
} from '../../state/settings';
|
||||
import {
|
||||
buildCompressedUploadItem,
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
@@ -240,12 +241,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||
const showGif = composerToolbarButtons?.showGif ?? true;
|
||||
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||||
// toolbar button entirely when it's off so it never opens an empty popover.
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
// Schedule-send is hidden in thread mode (v1 reduction).
|
||||
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
|
||||
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
|
||||
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
|
||||
const showSchedule =
|
||||
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
|
||||
const composerButtonOrder = useMemo(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
@@ -394,7 +401,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
try {
|
||||
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||
if (stored) {
|
||||
const nodes = JSON.parse(stored);
|
||||
const parsed = JSON.parse(stored);
|
||||
// [Gitea #41] Only restore a draft this same account wrote. A legacy
|
||||
// draft (stored as a bare array, pre-dating user-scoping) or one
|
||||
// written by a different userId is foreign — drop it rather than
|
||||
// risk pre-filling another account's unsent text into the composer.
|
||||
const foreign =
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed.userId !== mx.getUserId();
|
||||
if (foreign) {
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
} else {
|
||||
const nodes = parsed.nodes;
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
Transforms.insertFragment(editor, nodes);
|
||||
// Mirror the restored draft into the atom so the draft indicator
|
||||
@@ -403,18 +423,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setMsgDraft(nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed stored draft
|
||||
}
|
||||
}
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||
// [Gitea #41] Tag the persisted draft with the writing user's id so a
|
||||
// different account logging into this browser can't have it hydrated
|
||||
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
|
||||
localStorage.setItem(
|
||||
`draft-msg-${draftKey}`,
|
||||
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
|
||||
);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
@@ -422,7 +449,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[draftKey, editor, setMsgDraft],
|
||||
[draftKey, editor, setMsgDraft, mx],
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
@@ -485,7 +512,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||||
type: compressedType,
|
||||
});
|
||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||
// Compression re-encodes the image, so in an encrypted room the new
|
||||
// bytes must be encrypted before upload (and the event must carry the
|
||||
// *new* encInfo) — reusing the original's encInfo would publish the
|
||||
// image in the clear and yield an undecryptable attachment.
|
||||
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
|
||||
const uploadRes = encrypted
|
||||
? await mx.uploadContent(encrypted.file)
|
||||
: await mx.uploadContent(compressedFile, {
|
||||
name: compressedName,
|
||||
type: compressedType,
|
||||
});
|
||||
@@ -494,13 +528,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Delete the pre-uploaded original so only one copy lives on the server.
|
||||
tryDeleteMxcContent(mx, upload.mxc);
|
||||
mxc = compressedMxc;
|
||||
// Build a synthetic fileItem that refers to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions and type.
|
||||
const compressedItem = {
|
||||
...fileItem,
|
||||
file: compressedFile,
|
||||
originalFile: compressedFile,
|
||||
};
|
||||
// Synthetic fileItem referring to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions, type and encInfo.
|
||||
const compressedItem = buildCompressedUploadItem(
|
||||
fileItem,
|
||||
compressedFile,
|
||||
encrypted,
|
||||
);
|
||||
return getImageMsgContent(mx, compressedItem, mxc);
|
||||
}
|
||||
}
|
||||
@@ -697,11 +731,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
||||
|
||||
const handleScheduleClick = useCallback(() => {
|
||||
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
||||
// open the modal for an encrypted room even if the button somehow renders.
|
||||
if (isEncrypted) return;
|
||||
// Pre-fill from editor if there's content; open blank if editor is empty.
|
||||
const content = buildCurrentTextContent();
|
||||
setScheduleContent(content);
|
||||
setScheduleOpen(true);
|
||||
}, [buildCurrentTextContent]);
|
||||
}, [buildCurrentTextContent, isEncrypted]);
|
||||
|
||||
const handleScheduled = useCallback(
|
||||
(delayId: string, sendAt: number, content: IContent) => {
|
||||
@@ -823,18 +860,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadRes = await mx.uploadContent(
|
||||
new File([blob], 'image.gif', { type: 'image/gif' }),
|
||||
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
|
||||
);
|
||||
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
|
||||
const baseContent = {
|
||||
msgtype: MsgType.Image,
|
||||
body: 'image.gif',
|
||||
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
||||
};
|
||||
|
||||
// Mirror the attachment/voice paths: in an encrypted room the media
|
||||
// itself must be encrypted, otherwise the homeserver (and anyone with
|
||||
// the mxc URI) can see the GIF even though the event body is encrypted.
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const { encInfo, file: encBlob } = await encryptFile(gifFile);
|
||||
const uploadRes = await mx.uploadContent(encBlob);
|
||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (!mxcUrl) return;
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
msgtype: MsgType.Image,
|
||||
body: 'image.gif',
|
||||
url: mxcUrl,
|
||||
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
||||
...baseContent,
|
||||
file: { ...encInfo, url: mxcUrl },
|
||||
} as any);
|
||||
} else {
|
||||
const uploadRes = await mx.uploadContent(gifFile, {
|
||||
type: 'image/gif',
|
||||
name: 'image.gif',
|
||||
includeFilename: false,
|
||||
});
|
||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||
if (!mxcUrl) return;
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
...baseContent,
|
||||
url: mxcUrl,
|
||||
} as any);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
|
||||
if (!alive()) return;
|
||||
@@ -844,7 +901,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
if (alive()) setGifUploading(false);
|
||||
}
|
||||
},
|
||||
[mx, roomId, threadRootId, alive],
|
||||
[mx, room, roomId, threadRootId, alive],
|
||||
);
|
||||
|
||||
const handleStickerSelect = useCallback(
|
||||
@@ -1446,7 +1503,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
{pollOpen && (
|
||||
<PollCreator
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={threadRootId}
|
||||
onClose={() => setPollOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{scheduleOpen && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
recentForwardTargetsAtom,
|
||||
addRecentForwardTarget,
|
||||
} from '../../../state/recentForwardTargets';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -314,11 +314,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
setError(null);
|
||||
|
||||
const ids = [...selectedRoomIds];
|
||||
|
||||
// `fwdContent.file` (present on encrypted attachments) carries the AES key/iv
|
||||
// needed to decrypt it. Sending it as-is into a room that isn't encrypted would
|
||||
// publish that key in plaintext (Gitea #63), so any unencrypted destination gets
|
||||
// a decrypted-and-re-uploaded plaintext version instead. Built once (not per
|
||||
// room) since every unencrypted destination gets the same re-upload.
|
||||
let plaintextContent: Record<string, unknown> | undefined;
|
||||
let plaintextContentError: string | undefined;
|
||||
if (fwdContent.file) {
|
||||
const needsPlaintext = ids.some((id) => !mx.getRoom(id)?.hasEncryptionStateEvent());
|
||||
if (needsPlaintext) {
|
||||
try {
|
||||
plaintextContent = await buildPlaintextAttachmentContent(
|
||||
mx,
|
||||
fwdContent,
|
||||
useAuthentication,
|
||||
);
|
||||
} catch {
|
||||
plaintextContentError = 'Could not prepare this attachment for an unencrypted room.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentBody = comment.trim();
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => {
|
||||
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
|
||||
// Encrypted destinations keep the original (possibly encrypted-attachment)
|
||||
// content; unencrypted ones get the plaintext version, or fail outright if
|
||||
// that couldn't be built — never fall back to sending the encrypted `file`.
|
||||
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
|
||||
if (fwdContent.file && !destEncrypted && !contentToSend) {
|
||||
return Promise.reject(new Error(plaintextContentError));
|
||||
}
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
|
||||
// Send the optional comment first so it reads as a note above the
|
||||
// forwarded content. The room counts as failed if either send rejects.
|
||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||
@@ -326,6 +358,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const needsComment = commentBody && !commentSentRef.current.has(id);
|
||||
const step = needsComment
|
||||
? mx
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
||||
.then(() => {
|
||||
commentSentRef.current.add(id);
|
||||
@@ -368,7 +401,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return;
|
||||
}
|
||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
|
||||
@@ -1273,6 +1273,9 @@ export const Message = React.memo(
|
||||
const content = mEvent.getContent();
|
||||
const body: string =
|
||||
(content?.body as string | undefined) ?? '';
|
||||
// For E2EE rooms useBookmarks strips the text
|
||||
// fields before persisting (account data is
|
||||
// server-readable); the panel resolves them live.
|
||||
addBookmark({
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
@@ -1390,6 +1393,7 @@ export const Message = React.memo(
|
||||
after={<Icon size="100" src={Icons.Send} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).resendEvent(mEvent, room);
|
||||
closeMenu();
|
||||
}}
|
||||
@@ -1408,6 +1412,7 @@ export const Message = React.memo(
|
||||
after={<Icon size="100" src={Icons.Cross} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).cancelPendingEvent(mEvent);
|
||||
closeMenu();
|
||||
}}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}
|
||||
|
||||
@@ -235,6 +236,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}, [
|
||||
mx,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
||||
import { IEncryptedFile } from '../../../../types/matrix/common';
|
||||
|
||||
/**
|
||||
* Build the content to forward:
|
||||
@@ -40,3 +43,44 @@ export function buildForwardContent(
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* `content.file` on an encrypted attachment carries the AES key/iv/hashes
|
||||
* needed to decrypt it. Forwarding that content verbatim into a room that
|
||||
* isn't itself encrypted would publish the key in plaintext to anyone who can
|
||||
* read the destination room (Gitea #63). Re-encrypting for the destination is
|
||||
* out of scope, so instead download+decrypt the attachment here and re-upload
|
||||
* it as a plain (unencrypted) upload, sending `url` in place of `file`.
|
||||
*
|
||||
* Throws if the attachment can't be fetched/decrypted — callers must treat
|
||||
* that as a hard failure for this forward rather than falling back to
|
||||
* sending the encrypted `file` block into the plaintext room.
|
||||
*/
|
||||
export async function buildPlaintextAttachmentContent(
|
||||
mx: MatrixClient,
|
||||
content: Record<string, unknown>,
|
||||
useAuthentication: boolean,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const file = content.file as IEncryptedFile;
|
||||
const info = content.info as Record<string, unknown> | undefined;
|
||||
const mimeType = (info?.mimetype as string | undefined) ?? FALLBACK_MIMETYPE;
|
||||
|
||||
const mediaUrl = mxcUrlToHttp(mx, file.url, useAuthentication);
|
||||
if (!mediaUrl) throw new Error('Invalid attachment URL');
|
||||
const blob = await downloadEncryptedMedia(mediaUrl, (buf) => decryptFile(buf, mimeType, file));
|
||||
const uploadResult = await mx.uploadContent(blob, { type: mimeType });
|
||||
|
||||
const plainContent = { ...content };
|
||||
delete plainContent.file;
|
||||
plainContent.url = uploadResult.content_uri;
|
||||
|
||||
// The thumbnail can carry its own encryption key (`thumbnail_file`); drop it
|
||||
// rather than leak it too — the full attachment still forwards fine without
|
||||
// a thumbnail.
|
||||
if (info && (info.thumbnail_file || info.thumbnail_url)) {
|
||||
const { thumbnail_file: _tf, thumbnail_url: _tu, ...restInfo } = info;
|
||||
plainContent.info = restInfo;
|
||||
}
|
||||
|
||||
return plainContent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { buildCompressedUploadItem } from './msgContent';
|
||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
||||
|
||||
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
|
||||
// (if any) the resulting m.image event carries. Getting this wrong either leaks
|
||||
// a plaintext image into an E2EE room or produces an undecryptable attachment.
|
||||
|
||||
const enc = (tag: string): EncryptedAttachmentInfo =>
|
||||
({
|
||||
v: 'v2',
|
||||
key: { alg: 'A256CTR', k: tag },
|
||||
iv: `iv-${tag}`,
|
||||
hashes: { sha256: `sha-${tag}` },
|
||||
}) as unknown as EncryptedAttachmentInfo;
|
||||
|
||||
const fakeFile = (name: string, size: number): File =>
|
||||
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
|
||||
|
||||
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
|
||||
({
|
||||
file: fakeFile('photo.png', 900),
|
||||
originalFile: fakeFile('photo.png', 900),
|
||||
encInfo,
|
||||
metadata: { markedAsSpoiler: false, compressImage: true },
|
||||
}) as unknown as TUploadItem;
|
||||
|
||||
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
|
||||
const compressed = fakeFile('photo.jpg', 300);
|
||||
const item = buildCompressedUploadItem(makeItem(), compressed);
|
||||
|
||||
assert.equal(item.file, compressed);
|
||||
assert.equal(item.originalFile, compressed);
|
||||
assert.equal(item.encInfo, undefined);
|
||||
});
|
||||
|
||||
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
|
||||
const compressed = fakeFile('photo.jpg', 300);
|
||||
const encryptedBlob = fakeFile('photo.jpg', 320);
|
||||
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
|
||||
file: encryptedBlob,
|
||||
encInfo: enc('compressed'),
|
||||
});
|
||||
|
||||
// The ciphertext is what gets uploaded; the plaintext stays available for
|
||||
// dimensions/blurhash only.
|
||||
assert.equal(item.file, encryptedBlob);
|
||||
assert.equal(item.originalFile, compressed);
|
||||
assert.deepEqual(item.encInfo, enc('compressed'));
|
||||
assert.notDeepEqual(item.encInfo, enc('original'));
|
||||
});
|
||||
|
||||
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
|
||||
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
|
||||
// stale encInfo (that is the bug this helper exists to prevent).
|
||||
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
|
||||
assert.equal(item.encInfo, undefined);
|
||||
});
|
||||
|
||||
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
|
||||
const base = makeItem();
|
||||
base.metadata.caption = 'a caption';
|
||||
base.metadata.markedAsSpoiler = true;
|
||||
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
|
||||
assert.equal(item.metadata.caption, 'a caption');
|
||||
assert.equal(item.metadata.markedAsSpoiler, true);
|
||||
});
|
||||
|
||||
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
|
||||
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
|
||||
// msgContent.test.ts builders, which share the same shape.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
|
||||
import to from 'await-to-js';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import {
|
||||
IThumbnailContent,
|
||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||
@@ -43,6 +44,28 @@ const generateThumbnailContent = async (
|
||||
return thumbnailContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the synthetic upload item for a *re-encoded* (compressed) image.
|
||||
*
|
||||
* The compressed bytes are a brand new payload, so the item must never inherit
|
||||
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
|
||||
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
|
||||
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
|
||||
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
|
||||
*/
|
||||
export const buildCompressedUploadItem = (
|
||||
item: TUploadItem,
|
||||
compressedFile: File,
|
||||
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
|
||||
): TUploadItem => ({
|
||||
...item,
|
||||
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
|
||||
// for dimensions + blurhash.
|
||||
file: encrypted?.file ?? compressedFile,
|
||||
originalFile: compressedFile,
|
||||
encInfo: encrypted?.encInfo,
|
||||
});
|
||||
|
||||
export const getImageMsgContent = async (
|
||||
mx: MatrixClient,
|
||||
item: TUploadItem,
|
||||
|
||||
@@ -116,6 +116,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
useKeyDown(
|
||||
@@ -157,7 +159,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
}
|
||||
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||
lastReadEventIdRef.current = latestId;
|
||||
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => {
|
||||
// Allow a retry on the next event if the receipt POST failed.
|
||||
if (lastReadEventIdRef.current === latestId) {
|
||||
lastReadEventIdRef.current = undefined;
|
||||
@@ -171,7 +173,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
thread.off(ThreadEvent.NewReply, markRead);
|
||||
thread.off(RoomEvent.Timeline, markRead);
|
||||
};
|
||||
}, [mx, thread, privateReadReceipts]);
|
||||
}, [mx, thread, privateReadReceipts, hideActivity]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -545,9 +545,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
[room, thread, setReplyDraft, editor],
|
||||
);
|
||||
|
||||
// Non-thread relations (reactions, edits) that target the thread root live only in
|
||||
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
|
||||
// for the root must use the room set instead of the thread set.
|
||||
const getRelationTimelineSet = useCallback(
|
||||
(eventId: string) =>
|
||||
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
|
||||
[room, thread],
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
(targetEventId: string, key: string, shortcode?: string) => {
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const isRoot = targetEventId === thread.id;
|
||||
const timelineSet = getRelationTimelineSet(targetEventId);
|
||||
const relations = getEventReactions(timelineSet, targetEventId);
|
||||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
@@ -563,12 +573,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
thread.id,
|
||||
// A reaction on the root is a main-timeline event, not a thread reply.
|
||||
isRoot ? null : thread.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread],
|
||||
[mx, room, thread, getRelationTimelineSet],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
@@ -714,7 +726,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
): ReactNode => {
|
||||
const mEventId = mEvent.getId();
|
||||
if (!mEventId) return null;
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const timelineSet = getRelationTimelineSet(mEventId);
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = !!reactions && reactions.length > 0;
|
||||
@@ -782,7 +794,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
);
|
||||
},
|
||||
[
|
||||
thread,
|
||||
room,
|
||||
messageSpacing,
|
||||
messageLayout,
|
||||
@@ -809,6 +820,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
lotusTerminal,
|
||||
mx,
|
||||
renderMessageContent,
|
||||
getRelationTimelineSet,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
if (viewingId === id) setViewingId(null);
|
||||
|
||||
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { widgetsFromStateEvents } from './useRoomWidgets';
|
||||
|
||||
const APP = 'https://chat.lotusguild.org';
|
||||
|
||||
// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads.
|
||||
const fakeEvent = (stateKey: string, sender: string, content: Record<string, unknown>) =>
|
||||
({
|
||||
getStateKey: () => stateKey,
|
||||
getSender: () => sender,
|
||||
getContent: () => content,
|
||||
}) as unknown as MatrixEvent;
|
||||
|
||||
test('returns a Widget for a valid im.vector.modular.widgets state event', () => {
|
||||
// Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which
|
||||
// compares URL.protocol ("https:") to "https" and rejects every URL,
|
||||
// making WidgetParser.parseWidgetsFromRoomState always return [].
|
||||
const events = new Map([
|
||||
[
|
||||
'w1',
|
||||
fakeEvent('w1', '@a:example.org', {
|
||||
id: 'w1',
|
||||
type: 'custom',
|
||||
url: 'https://example.com/widget',
|
||||
name: 'My Widget',
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const widgets = widgetsFromStateEvents(events, APP);
|
||||
assert.equal(widgets.length, 1);
|
||||
assert.equal(widgets[0].id, 'w1');
|
||||
assert.equal(widgets[0].templateUrl, 'https://example.com/widget');
|
||||
assert.equal(widgets[0].creatorUserId, '@a:example.org');
|
||||
assert.equal(widgets[0].name, 'My Widget');
|
||||
});
|
||||
|
||||
test('skips removed widgets (empty content)', () => {
|
||||
const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]);
|
||||
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
|
||||
});
|
||||
|
||||
test('skips non-https and same-origin urls', () => {
|
||||
const events = new Map([
|
||||
[
|
||||
'w1',
|
||||
fakeEvent('w1', '@a:example.org', {
|
||||
id: 'w1',
|
||||
type: 'custom',
|
||||
url: 'http://example.com/widget',
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'w2',
|
||||
fakeEvent('w2', '@a:example.org', {
|
||||
id: 'w2',
|
||||
type: 'custom',
|
||||
url: `${APP}/evil`,
|
||||
creatorUserId: '@a:example.org',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
|
||||
});
|
||||
|
||||
test('undefined state map yields no widgets', () => {
|
||||
assert.deepEqual(widgetsFromStateEvents(undefined, APP), []);
|
||||
});
|
||||
@@ -1,21 +1,67 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Room, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useMemo } from 'react';
|
||||
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
|
||||
import { Widget } from 'matrix-widget-api';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useRoomState } from '../../../hooks/useRoomState';
|
||||
import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState';
|
||||
import { isWidgetUrlSafe } from './widgetUtils';
|
||||
|
||||
/**
|
||||
* Builds the `Widget` list from raw `im.vector.modular.widgets` state events.
|
||||
*
|
||||
* NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In
|
||||
* matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is
|
||||
* always colon-suffixed, e.g. "https:") against the bare strings "http"/
|
||||
* "https", so it rejects every URL and the parser always returns []. We build
|
||||
* the `Widget`s ourselves with a correct scheme check plus the existing
|
||||
* `isWidgetUrlSafe` origin check.
|
||||
*/
|
||||
export const widgetsFromStateEvents = (
|
||||
widgetEvents: StateKeyToEvents | undefined,
|
||||
appOrigin: string,
|
||||
): Widget[] => {
|
||||
if (!widgetEvents) return [];
|
||||
const widgets: Widget[] = [];
|
||||
Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => {
|
||||
const content = event.getContent();
|
||||
// Removed widgets are represented as an empty content state event.
|
||||
if (!content || Object.keys(content).length === 0) return;
|
||||
|
||||
const id = event.getStateKey();
|
||||
const { type, url, name, data, waitForIframeLoad } = content;
|
||||
const creatorUserId = content.creatorUserId || event.getSender();
|
||||
if (!id || !type || !url || !creatorUserId) return;
|
||||
|
||||
let scheme: string;
|
||||
try {
|
||||
scheme = new URL(url).protocol;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (scheme !== 'https:') return;
|
||||
if (!isWidgetUrlSafe(url, appOrigin)) return;
|
||||
|
||||
widgets.push(
|
||||
new Widget({
|
||||
id,
|
||||
creatorUserId,
|
||||
type,
|
||||
url,
|
||||
name,
|
||||
data,
|
||||
waitForIframeLoad,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return widgets;
|
||||
};
|
||||
|
||||
/**
|
||||
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
|
||||
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
|
||||
*/
|
||||
export const useRoomWidgets = (room: Room): Widget[] => {
|
||||
const state = useRoomState(room);
|
||||
return useMemo(() => {
|
||||
const widgetEvents = state.get(StateEvent.Widget);
|
||||
if (!widgetEvents) return [];
|
||||
const stateEvents = Array.from(widgetEvents.values()).map(
|
||||
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
|
||||
return useMemo(
|
||||
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin),
|
||||
[state],
|
||||
);
|
||||
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
|
||||
}, [state]);
|
||||
};
|
||||
|
||||
@@ -751,22 +751,32 @@ function ProfilePronouns() {
|
||||
|
||||
const [pronouns, setPronouns] = useState<string>('');
|
||||
const [savedPronouns, setSavedPronouns] = useState<string>('');
|
||||
// True once the user has edited the field — guards against the mount-time
|
||||
// fetch below clobbering a fresh edit if it resolves late (mirrors
|
||||
// ProfileStatus's statusDirtyRef in this file).
|
||||
const pronounsDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
mx.http
|
||||
.authedRequest<{ 'm.pronouns': string }>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/m.pronouns`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled || pronounsDirtyRef.current) return;
|
||||
const val = res['m.pronouns'] ?? '';
|
||||
setPronouns(val);
|
||||
setSavedPronouns(val);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || pronounsDirtyRef.current) return;
|
||||
setPronouns('');
|
||||
setSavedPronouns('');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, savePronouns] = useAsyncCallback(
|
||||
@@ -788,10 +798,12 @@ function ProfilePronouns() {
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
pronounsDirtyRef.current = true;
|
||||
setPronouns(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
pronounsDirtyRef.current = true;
|
||||
setPronouns(savedPronouns);
|
||||
};
|
||||
|
||||
@@ -875,10 +887,15 @@ function ProfileTimezone() {
|
||||
|
||||
const [timezone, setTimezone] = useState<string>('');
|
||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||
// True once the user has edited the field — guards against the mount-time
|
||||
// fetch below clobbering a fresh edit if it resolves late (mirrors
|
||||
// ProfileStatus's statusDirtyRef in this file).
|
||||
const timezoneDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
|
||||
if (cached?.timezone) {
|
||||
if (cached?.timezone && !timezoneDirtyRef.current) {
|
||||
setTimezone(cached.timezone);
|
||||
setSavedTimezone(cached.timezone);
|
||||
}
|
||||
@@ -889,6 +906,7 @@ function ProfileTimezone() {
|
||||
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled || timezoneDirtyRef.current) return;
|
||||
const val = res.timezone ?? '';
|
||||
setTimezone(val);
|
||||
setSavedTimezone(val);
|
||||
@@ -896,6 +914,9 @@ function ProfileTimezone() {
|
||||
.catch(() => {
|
||||
/* no stored timezone yet */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
@@ -921,7 +942,13 @@ function ProfileTimezone() {
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
timezoneDirtyRef.current = true;
|
||||
setTimezone(value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
timezoneDirtyRef.current = true;
|
||||
setTimezone(savedTimezone);
|
||||
};
|
||||
|
||||
@@ -955,7 +982,7 @@ function ProfileTimezone() {
|
||||
{ value: '', label: '— select timezone —' },
|
||||
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
|
||||
]}
|
||||
onChange={setTimezone}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
aria-label="Timezone"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Text, Spinner, color } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
@@ -67,24 +67,47 @@ export function ProfileDecoration() {
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
// Distinguish "confirmed no decoration" from "failed to load": a fetch
|
||||
// failure must not be shown as (and saved over) "None".
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// True once the user has picked/cleared a decoration — guards against the
|
||||
// mount-time fetch below clobbering a fresh selection if it resolves late
|
||||
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDecoration = useCallback(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
|
||||
// 404s (a console error for anyone without a decoration). The full profile
|
||||
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
|
||||
mx.http
|
||||
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setLoadError(false);
|
||||
setLoading(false);
|
||||
if (dirtyRef.current) return;
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
setSelected(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrent(null);
|
||||
setSelected(null);
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
// Do NOT touch current/selected here — a network failure is not proof
|
||||
// there's no decoration, and defaulting to null risks the user saving
|
||||
// "None" over a real, still-set decoration (see #46).
|
||||
setLoadError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
useEffect(() => fetchDecoration(), [fetchDecoration]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (slug: string | null) => {
|
||||
@@ -105,16 +128,27 @@ export function ProfileDecoration() {
|
||||
const hasChanges = selected !== current;
|
||||
|
||||
const handleSelect = (slug: string) => {
|
||||
dirtyRef.current = true;
|
||||
setSelected((prev) => (prev === slug ? null : slug));
|
||||
};
|
||||
|
||||
const handleClear = () => setSelected(null);
|
||||
const handleClear = () => {
|
||||
dirtyRef.current = true;
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges || saving) return;
|
||||
// Refuse to save while the initial load failed: `current`/`selected` are
|
||||
// not known-good, so saving could silently overwrite a real decoration.
|
||||
if (!hasChanges || saving || loadError) return;
|
||||
save(selected);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
dirtyRef.current = false;
|
||||
fetchDecoration();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
@@ -159,13 +193,15 @@ export function ProfileDecoration() {
|
||||
</div>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
{selected
|
||||
{loadError
|
||||
? 'Failed to load'
|
||||
: selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
: 'None'}
|
||||
</Text>
|
||||
{selected && (
|
||||
{selected && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
@@ -178,7 +214,7 @@ export function ProfileDecoration() {
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
{hasChanges && !loadError && (
|
||||
<Button
|
||||
type="button"
|
||||
size="400"
|
||||
@@ -194,6 +230,26 @@ export function ProfileDecoration() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{loadError && (
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Could not load your current decoration. Saving is disabled until this succeeds, so you
|
||||
don’t overwrite it based on a wrong display.
|
||||
</Text>
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Failed to save. Try again.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
@@ -117,6 +118,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||
|
||||
/**
|
||||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||
@@ -394,6 +396,16 @@ function PageZoomInput() {
|
||||
setCurrentZoom(evt.target.value);
|
||||
};
|
||||
|
||||
// Shared by Enter and blur so a typed-but-unconfirmed value is never
|
||||
// silently dropped when the input loses focus.
|
||||
const commitZoom = (value: string) => {
|
||||
const newZoom = parseInt(value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom.toString());
|
||||
};
|
||||
|
||||
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||
if (isKeyHotkey('escape', evt)) {
|
||||
evt.stopPropagation();
|
||||
@@ -404,14 +416,14 @@ function PageZoomInput() {
|
||||
'value' in evt.target &&
|
||||
typeof evt.target.value === 'string'
|
||||
) {
|
||||
const newZoom = parseInt(evt.target.value, 10);
|
||||
if (Number.isNaN(newZoom)) return;
|
||||
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
|
||||
setPageZoom(safeZoom);
|
||||
setCurrentZoom(safeZoom.toString());
|
||||
commitZoom(evt.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||
commitZoom(evt.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
style={{ width: toRem(100) }}
|
||||
@@ -425,6 +437,7 @@ function PageZoomInput() {
|
||||
value={currentZoom}
|
||||
onChange={handleZoomChange}
|
||||
onKeyDown={handleZoomEnter}
|
||||
onBlur={handleZoomBlur}
|
||||
after={<Text size="T300">%</Text>}
|
||||
outlined
|
||||
/>
|
||||
@@ -1474,8 +1487,12 @@ function Privacy() {
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyBind(setter: (code: string) => void) {
|
||||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||||
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
|
||||
const [listening, setListening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||
|
||||
useEffect(
|
||||
@@ -1487,19 +1504,28 @@ function useKeyBind(setter: (code: string) => void) {
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (listening) return;
|
||||
setError(null);
|
||||
setListening(true);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code !== 'Escape') setter(e.code);
|
||||
if (e.code === 'Escape') {
|
||||
// Escape always cancels the rebind without changing the key.
|
||||
} else if (!isBindableCallKey(e.code)) {
|
||||
setError('That key can’t be bound — it’s needed for keyboard navigation.');
|
||||
} else if (otherKey && e.code === otherKey) {
|
||||
setError('That key is already bound to the other call shortcut.');
|
||||
} else {
|
||||
setter(e.code);
|
||||
}
|
||||
setListening(false);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
listenerRef.current = null;
|
||||
};
|
||||
listenerRef.current = onKey;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
}, [listening, setter]);
|
||||
}, [listening, setter, otherKey]);
|
||||
|
||||
return { listening, startListening };
|
||||
return { listening, startListening, error };
|
||||
}
|
||||
|
||||
const keyLabel = (code: string) =>
|
||||
@@ -1556,8 +1582,8 @@ function Calls() {
|
||||
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
};
|
||||
|
||||
const pttBind = useKeyBind(setPttKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey);
|
||||
const pttBind = useKeyBind(setPttKey, deafenKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey, pttKey);
|
||||
|
||||
const mlSupported = isMLDenoiseSupported();
|
||||
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
||||
@@ -1823,7 +1849,7 @@ function Calls() {
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key"
|
||||
description="Press a key to bind it as your push-to-talk key."
|
||||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
@@ -1841,7 +1867,9 @@ function Calls() {
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Deafen"
|
||||
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
|
||||
description={
|
||||
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
@@ -2328,6 +2356,7 @@ function Messages() {
|
||||
'translateTargetLang',
|
||||
);
|
||||
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
const translationSupported = chromeTranslationEngine.isSupported();
|
||||
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||||
? normalizeLang(translateTargetLang)
|
||||
@@ -2430,6 +2459,15 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="GIF Picker"
|
||||
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
|
||||
after={
|
||||
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Hidden Events"
|
||||
|
||||
@@ -11,12 +11,19 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { settingsAtom, Settings } from '../../../state/settings';
|
||||
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
|
||||
|
||||
const PRESETS: Array<{
|
||||
label: string;
|
||||
icon: IconSrc;
|
||||
description: string;
|
||||
patch: Partial<Settings>;
|
||||
// Whether applying this preset should also clear an active "Pause
|
||||
// Notifications" snooze. Work/Gaming both claim notifications end up on,
|
||||
// so a leftover snooze would silently contradict them (#48). Sleep's
|
||||
// description ("All notifications off") is still true with a snooze left
|
||||
// active, so it does not need to touch it.
|
||||
clearSnooze: boolean;
|
||||
}> = [
|
||||
{
|
||||
label: 'Gaming',
|
||||
@@ -29,6 +36,7 @@ const PRESETS: Array<{
|
||||
inviteSoundId: 'none',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: true,
|
||||
},
|
||||
{
|
||||
label: 'Work',
|
||||
@@ -41,6 +49,7 @@ const PRESETS: Array<{
|
||||
inviteSoundId: 'invite',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: true,
|
||||
},
|
||||
{
|
||||
label: 'Sleep',
|
||||
@@ -51,15 +60,23 @@ const PRESETS: Array<{
|
||||
isNotificationSounds: false,
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
clearSnooze: false,
|
||||
},
|
||||
];
|
||||
|
||||
function NotificationPresets() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setSettings = useSetAtom(settingsAtom);
|
||||
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
|
||||
|
||||
const applyPreset = (patch: Partial<Settings>) => {
|
||||
const applyPreset = (patch: Partial<Settings>, clearSnooze: boolean) => {
|
||||
setSettings({ ...settings, ...patch });
|
||||
// Work/Gaming promise notifications are on; an active snooze from an
|
||||
// earlier "Pause Notifications" would otherwise keep them silently
|
||||
// suppressed despite the preset applying successfully (#48).
|
||||
if (clearSnooze) {
|
||||
setSnoozeUntil(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -71,7 +88,7 @@ function NotificationPresets() {
|
||||
<Button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(preset.patch)}
|
||||
onClick={() => applyPreset(preset.patch, preset.clearSnooze)}
|
||||
title={preset.description}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
getNotificationModeOptionsFromActions,
|
||||
NotificationMode,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
@@ -131,7 +132,13 @@ type RuleModeSwitcherProps = {
|
||||
|
||||
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
|
||||
const mx = useMatrixClient();
|
||||
const getModeActions = useNotificationModeActions();
|
||||
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise
|
||||
// switching mode here rebuilds actions from scratch and silently drops them.
|
||||
const options = useMemo(
|
||||
() => getNotificationModeOptionsFromActions(pushRule.actions),
|
||||
[pushRule.actions],
|
||||
);
|
||||
const getModeActions = useNotificationModeActions(options);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MatrixError, Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
@@ -51,7 +52,10 @@ function fetchDecoration(
|
||||
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
// The remote profile field is free-form and attacker-controlled; only
|
||||
// accept it when it names a real catalog decoration (see decorationUrl).
|
||||
const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
Bookmark,
|
||||
cleanupEncryptedBookmarks,
|
||||
hasBookmarkText,
|
||||
stripBookmarkText,
|
||||
toStorableBookmark,
|
||||
} from './useBookmarks';
|
||||
import { cleanupEncryptedReminders, Reminder, toStorableReminder } from './useReminders';
|
||||
|
||||
// E2EE policy for bookmarks/reminders (Gitea #10): account data is stored
|
||||
// unencrypted on the homeserver, so entries for encrypted rooms must carry no
|
||||
// message text or names. `isEncryptedRoom` is injected to keep these pure.
|
||||
const isEncrypted = (roomId: string) => roomId === '!enc';
|
||||
|
||||
const full = (roomId: string): Bookmark => ({
|
||||
roomId,
|
||||
eventId: `$ev-${roomId}`,
|
||||
savedAt: 100,
|
||||
previewText: 'secret text',
|
||||
roomName: 'Room',
|
||||
senderName: 'Alice',
|
||||
});
|
||||
|
||||
test('stripBookmarkText keeps only roomId/eventId/savedAt', () => {
|
||||
const stripped = stripBookmarkText(full('!enc'));
|
||||
assert.deepEqual(stripped, { roomId: '!enc', eventId: '$ev-!enc', savedAt: 100, roomName: '' });
|
||||
assert.equal(hasBookmarkText(stripped), false);
|
||||
assert.equal(hasBookmarkText(full('!enc')), true);
|
||||
});
|
||||
|
||||
test('toStorableBookmark strips text for encrypted rooms only', () => {
|
||||
const plain = full('!plain');
|
||||
assert.equal(toStorableBookmark(plain, isEncrypted), plain);
|
||||
const enc = toStorableBookmark(full('!enc'), isEncrypted);
|
||||
assert.equal(enc.previewText, undefined);
|
||||
assert.equal(enc.senderName, undefined);
|
||||
assert.equal(enc.roomName, '');
|
||||
});
|
||||
|
||||
test('cleanupEncryptedBookmarks returns undefined when nothing to strip', () => {
|
||||
const already = stripBookmarkText(full('!enc'));
|
||||
assert.equal(cleanupEncryptedBookmarks([full('!plain'), already], isEncrypted), undefined);
|
||||
assert.equal(cleanupEncryptedBookmarks([], isEncrypted), undefined);
|
||||
});
|
||||
|
||||
test('cleanupEncryptedBookmarks strips legacy encrypted entries and keeps the rest', () => {
|
||||
const plain = full('!plain');
|
||||
const out = cleanupEncryptedBookmarks([plain, full('!enc')], isEncrypted);
|
||||
assert.ok(out);
|
||||
assert.equal(out[0], plain);
|
||||
assert.deepEqual(out[1], stripBookmarkText(full('!enc')));
|
||||
// Running it again on the result is a no-op (no write loop).
|
||||
assert.equal(cleanupEncryptedBookmarks(out, isEncrypted), undefined);
|
||||
});
|
||||
|
||||
const reminder = (roomId: string): Reminder => ({
|
||||
roomId,
|
||||
eventId: `$ev-${roomId}`,
|
||||
timestamp: 200,
|
||||
message: 'secret text',
|
||||
});
|
||||
|
||||
test('toStorableReminder drops message for encrypted rooms only', () => {
|
||||
const plain = reminder('!plain');
|
||||
assert.equal(toStorableReminder(plain, isEncrypted), plain);
|
||||
assert.deepEqual(toStorableReminder(reminder('!enc'), isEncrypted), {
|
||||
roomId: '!enc',
|
||||
eventId: '$ev-!enc',
|
||||
timestamp: 200,
|
||||
});
|
||||
});
|
||||
|
||||
test('cleanupEncryptedReminders strips legacy entries once', () => {
|
||||
const out = cleanupEncryptedReminders([reminder('!plain'), reminder('!enc')], isEncrypted);
|
||||
assert.ok(out);
|
||||
assert.equal(out[0].message, 'secret text');
|
||||
assert.equal(out[1].message, undefined);
|
||||
assert.equal(cleanupEncryptedReminders(out, isEncrypted), undefined);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
@@ -6,7 +7,14 @@ export type Bookmark = {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
savedAt: number;
|
||||
previewText: string;
|
||||
// Snapshot fields. Account data is stored UNENCRYPTED on the homeserver, so
|
||||
// for E2EE rooms none of these are persisted (see stripBookmarkText) — the
|
||||
// panel resolves the preview / room / author live from the local timeline
|
||||
// instead. They remain for unencrypted rooms (already server-visible) and for
|
||||
// entries saved before this policy, so older stored bookmarks still render.
|
||||
previewText?: string;
|
||||
// Kept as a (possibly empty) string in memory because the grouping helpers in
|
||||
// utils/bookmarks read it directly; the store omits it from the wire when empty.
|
||||
roomName: string;
|
||||
// Author display name, snapshotted at save time. Optional for backward
|
||||
// compatibility with bookmarks stored before attribution was added; the panel
|
||||
@@ -16,22 +24,83 @@ export type Bookmark = {
|
||||
senderName?: string;
|
||||
};
|
||||
|
||||
// Wire shape: identical to Bookmark except that `roomName` may be absent.
|
||||
type StoredBookmark = Omit<Bookmark, 'roomName'> & { roomName?: string };
|
||||
|
||||
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
|
||||
const MAX_BOOKMARKS = 500;
|
||||
|
||||
type BookmarksContent = {
|
||||
bookmarks: Bookmark[];
|
||||
bookmarks: StoredBookmark[];
|
||||
};
|
||||
|
||||
/** True when the entry carries any of the text fields we refuse to upload for E2EE rooms. */
|
||||
export const hasBookmarkText = (b: Bookmark): boolean =>
|
||||
b.previewText !== undefined || b.roomName !== '' || b.senderName !== undefined;
|
||||
|
||||
/**
|
||||
* Reduce a bookmark to its non-text fields ({roomId, eventId, savedAt}). Used for
|
||||
* messages in encrypted rooms so no decrypted plaintext (or who said it) ever
|
||||
* reaches the server's account-data store.
|
||||
*/
|
||||
export const stripBookmarkText = (b: Bookmark): Bookmark => ({
|
||||
roomId: b.roomId,
|
||||
eventId: b.eventId,
|
||||
savedAt: b.savedAt,
|
||||
roomName: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply the E2EE storage policy to a bookmark about to be persisted.
|
||||
* `isEncryptedRoom` is injected so the policy is testable without a client.
|
||||
*/
|
||||
export const toStorableBookmark = (
|
||||
b: Bookmark,
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Bookmark => (isEncryptedRoom(b.roomId) ? stripBookmarkText(b) : b);
|
||||
|
||||
/**
|
||||
* One-time cleanup for entries persisted before the E2EE policy: returns the list
|
||||
* with text fields stripped for every bookmark whose room is currently encrypted,
|
||||
* or `undefined` when nothing needs to change (so callers can skip the write).
|
||||
*/
|
||||
export const cleanupEncryptedBookmarks = (
|
||||
bookmarks: Bookmark[],
|
||||
isEncryptedRoom: (roomId: string) => boolean,
|
||||
): Bookmark[] | undefined => {
|
||||
let changed = false;
|
||||
const next = bookmarks.map((b) => {
|
||||
if (hasBookmarkText(b) && isEncryptedRoom(b.roomId)) {
|
||||
changed = true;
|
||||
return stripBookmarkText(b);
|
||||
}
|
||||
return b;
|
||||
});
|
||||
return changed ? next : undefined;
|
||||
};
|
||||
|
||||
const isEncryptedRoomFor =
|
||||
(mx: MatrixClient) =>
|
||||
(roomId: string): boolean =>
|
||||
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
||||
// lost updates, since setAccountData replaces the whole content with no merge).
|
||||
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
|
||||
eventType: BOOKMARKS_KEY,
|
||||
read: (content) => content?.bookmarks ?? [],
|
||||
write: (bookmarks) => ({ bookmarks }),
|
||||
read: (content) => (content?.bookmarks ?? []).map((b) => ({ ...b, roomName: b.roomName ?? '' })),
|
||||
write: (bookmarks) => ({
|
||||
// Drop the in-memory '' placeholder so stripped entries stay {roomId, eventId, savedAt}.
|
||||
bookmarks: bookmarks.map(({ roomName, ...rest }) => (roomName ? { ...rest, roomName } : rest)),
|
||||
}),
|
||||
});
|
||||
|
||||
// Guard so the load-time cleanup below runs once per client, not on every mount
|
||||
// (useBookmarks is mounted by every message row) and not again after its own
|
||||
// write echoes back.
|
||||
let cleanedUpFor: MatrixClient | null = null;
|
||||
|
||||
export function useBookmarks(): {
|
||||
bookmarks: Bookmark[];
|
||||
addBookmark: (b: Bookmark) => Promise<void>;
|
||||
@@ -41,12 +110,33 @@ export function useBookmarks(): {
|
||||
const mx = useMatrixClient();
|
||||
const bookmarks = bookmarksStore.useValue(mx);
|
||||
|
||||
// Strip text from entries saved (by older builds) for rooms that are encrypted,
|
||||
// writing the store back at most once per client.
|
||||
useEffect(() => {
|
||||
if (cleanedUpFor === mx) return;
|
||||
cleanedUpFor = mx;
|
||||
const cleaned = cleanupEncryptedBookmarks(bookmarksStore.getLatest(mx), isEncryptedRoomFor(mx));
|
||||
if (cleaned) {
|
||||
bookmarksStore
|
||||
.enqueueWrite(
|
||||
mx,
|
||||
(current) => cleanupEncryptedBookmarks(current, isEncryptedRoomFor(mx)) ?? current,
|
||||
)
|
||||
.catch(() => {
|
||||
// Retry on the next client (re)mount.
|
||||
if (cleanedUpFor === mx) cleanedUpFor = null;
|
||||
});
|
||||
}
|
||||
}, [mx]);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
(b: Bookmark) =>
|
||||
bookmarksStore.enqueueWrite(mx, (current) => {
|
||||
// Never upload plaintext for E2EE rooms — account data is server-readable.
|
||||
const stored = toStorableBookmark(b, isEncryptedRoomFor(mx));
|
||||
// Avoid duplicates
|
||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||
let next = [b, ...filtered];
|
||||
const filtered = current.filter((bk) => bk.eventId !== stored.eventId);
|
||||
let next = [stored, ...filtered];
|
||||
if (next.length > MAX_BOOKMARKS) {
|
||||
next = next.slice(0, MAX_BOOKMARKS);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
|
||||
|
||||
const key = (
|
||||
code: string,
|
||||
mods: Partial<Record<'repeat' | 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey', boolean>> = {},
|
||||
) => ({
|
||||
code,
|
||||
repeat: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...mods,
|
||||
});
|
||||
|
||||
test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => {
|
||||
assert.equal(isPttKeyDown(key('Space'), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true);
|
||||
assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false);
|
||||
assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false);
|
||||
});
|
||||
|
||||
test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => {
|
||||
assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true);
|
||||
assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
|
||||
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user