Compare commits
300
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da78bff316 | ||
|
|
4dcc5176e2 | ||
|
|
df395776b4 | ||
|
|
49dec686f1 | ||
|
|
ba48e95993 | ||
|
|
a8db61f79f | ||
|
|
f5d3c43b6c | ||
|
|
502b9dfd83 | ||
|
|
024edbf546 | ||
|
|
ba5b1ffe7d | ||
|
|
1b5e6a37f5 | ||
|
|
52d94058cf | ||
|
|
e6388fe294 | ||
|
|
78b1a2ff6f | ||
|
|
4323babfb9 | ||
|
|
d250333290 | ||
|
|
4c9af57a97 | ||
|
|
afd14719ef | ||
|
|
7c26268e38 | ||
|
|
fda6e0f74e | ||
|
|
14731f2229 | ||
|
|
6bb3f2af27 | ||
|
|
27c6dacc60 | ||
|
|
3e132069c0 | ||
|
|
e080059f37 | ||
|
|
75951f9040 | ||
|
|
06bb896a00 | ||
|
|
3449338b3e | ||
|
|
75d55e861b | ||
|
|
e91b5fe10e | ||
|
|
1ab7514741 | ||
|
|
2b1f4ee5bc | ||
|
|
b71f9c95c6 | ||
|
|
c0e8334f03 | ||
|
|
3e69c715e9 | ||
|
|
a0d1c565d0 | ||
|
|
a932b1999a | ||
|
|
64af736c6e | ||
|
|
c088b4c0e6 | ||
|
|
ea07fb5087 | ||
|
|
e39714a6e0 | ||
|
|
39589b40f3 | ||
|
|
c1661e48fa | ||
|
|
c96c47dd0d | ||
|
|
103c6f4624 | ||
|
|
7f2e93d389 | ||
|
|
ce8ed89fdc | ||
|
|
359c79a440 | ||
|
|
206a3e933a | ||
|
|
d5aa18e3ab | ||
|
|
862a128102 | ||
|
|
c0a9b2da6b | ||
|
|
ed87b700d9 | ||
|
|
f62c5d5778 | ||
|
|
cccd78fd43 | ||
|
|
e06600afe1 | ||
|
|
f3722ae859 | ||
|
|
4a9823890b | ||
|
|
e80b170fd1 | ||
|
|
76929a8763 | ||
|
|
146ba4d2ff | ||
|
|
25fa0f6ef9 | ||
|
|
b77720c3da | ||
|
|
6edfe70020 | ||
|
|
d9e7f77402 | ||
|
|
568f218fe9 | ||
|
|
23649f1255 | ||
|
|
343de27cad | ||
|
|
3b6de2fdac | ||
|
|
f5ee3c0d0e | ||
|
|
08abf5aec8 | ||
|
|
e393c45f50 | ||
|
|
fdec3ed7f2 | ||
|
|
cf80729a5c | ||
|
|
738067cfc6 | ||
|
|
2bed70d335 | ||
|
|
c7aa4b9b19 | ||
|
|
81777ddfe1 | ||
|
|
eb4b88a028 | ||
|
|
e3883e0fce | ||
|
|
98c80f36cb | ||
|
|
ad1cbcf792 | ||
|
|
492b57c60d | ||
|
|
0c45bde832 | ||
|
|
082b8fc879 | ||
|
|
c6c2e88df5 | ||
|
|
dc0d524989 | ||
|
|
22d46a7922 | ||
|
|
be2c202543 | ||
|
|
6363939654 | ||
|
|
af244bba75 | ||
|
|
af1c0ee184 | ||
|
|
96a97a2f86 | ||
|
|
6aa77552b8 | ||
|
|
52e0cfaa83 | ||
|
|
bd8c79e0e6 | ||
|
|
4d4a76214a | ||
|
|
8d11a62e14 | ||
|
|
bf05751eca | ||
|
|
2bdb2eb4cb | ||
|
|
4fe9c87010 | ||
|
|
b69099a862 | ||
|
|
dea1f7afc0 | ||
|
|
7da91bd803 | ||
|
|
2a0c409180 | ||
|
|
9363629ea2 | ||
|
|
74d8e3119b | ||
|
|
73b1d1e3a7 | ||
|
|
ef5d06eea3 | ||
|
|
7f17940d34 | ||
|
|
f5e7fb4746 | ||
|
|
bbe91a24d8 | ||
|
|
f7d40460f5 | ||
|
|
d2f56817b3 | ||
|
|
33e16e85b3 | ||
|
|
f23b215efa | ||
|
|
821760131c | ||
|
|
edb4624796 | ||
|
|
d4420905e6 | ||
|
|
53a80adb57 | ||
|
|
e078a2cc10 | ||
|
|
c921f11521 | ||
|
|
0e2671891f | ||
|
|
6f25035341 | ||
|
|
f111b3c9af | ||
|
|
8b1c9fa610 | ||
|
|
8658ec05c3 | ||
|
|
2e7915d086 | ||
|
|
84c906fe33 | ||
|
|
464951edf4 | ||
|
|
6df160a7bf | ||
|
|
60076a48d0 | ||
|
|
a475531b2b | ||
|
|
34574178a9 | ||
|
|
070a1ea012 | ||
|
|
bbcbdad55a | ||
|
|
a9e0b893be | ||
|
|
68ce13f081 | ||
|
|
70620d4b43 | ||
|
|
4e455ae42e | ||
|
|
30fd22a5c5 | ||
|
|
da2d7a7d4f | ||
|
|
50b4e2c16c | ||
|
|
230d147ec1 | ||
|
|
9fefd14944 | ||
|
|
53823f5466 | ||
|
|
f2f498425b | ||
|
|
5fc90feef9 | ||
|
|
6460d0569c | ||
|
|
b38621861b | ||
|
|
1a8fa5cd4c | ||
|
|
2e8244dc67 | ||
|
|
e9d419f84c | ||
|
|
e0b1c50155 | ||
|
|
14b6849f6e | ||
|
|
22371f8156 | ||
|
|
58a716c734 | ||
|
|
6d63c34b2c | ||
|
|
4c671fbf3a | ||
|
|
9d84f9153a | ||
|
|
f528e5e440 | ||
|
|
d929143f7d | ||
|
|
c5082a78ef | ||
|
|
9d7875ea80 | ||
|
|
00584d7809 | ||
|
|
bd1e61e8cd | ||
|
|
1ff28820f3 | ||
|
|
5b0d355417 | ||
|
|
470b5217ae | ||
|
|
61dfdea9e9 | ||
|
|
3dead4b3e1 | ||
|
|
4cdd221eff | ||
|
|
908e735933 | ||
|
|
ceada3e113 | ||
|
|
9c1c29f4fc | ||
|
|
9e566807b3 | ||
|
|
e0861849b7 | ||
|
|
bec248b228 | ||
|
|
62595ef066 | ||
|
|
e9d07513d2 | ||
|
|
8967c13878 | ||
|
|
6578bdf949 | ||
|
|
fcca7c21fb | ||
|
|
614eb4d246 | ||
|
|
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 | ||
|
|
c143b30060 | ||
|
|
9a49bf4661 | ||
|
|
5794a88a96 | ||
|
|
22c6f3c1b7 | ||
|
|
61e8f080e4 | ||
|
|
cfe186efd5 | ||
|
|
02a1c44c09 | ||
|
|
e9680fe10c | ||
|
|
cff50dc104 | ||
|
|
9a02e5ce67 | ||
|
|
34a3352e21 | ||
|
|
3cc5f0cc6a | ||
|
|
e046757d1b | ||
|
|
49785dbf23 | ||
|
|
3327011ea9 | ||
|
|
3f9bca3d9d | ||
|
|
fd93339ad4 | ||
|
|
b1ecb0c46b | ||
|
|
c434e0dda3 | ||
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 | ||
|
|
33f4ba3674 | ||
|
|
90837f7230 | ||
|
|
730a748aca | ||
|
|
ce9ae93212 | ||
|
|
2193d258d0 | ||
|
|
c46fe6826c | ||
|
|
6dbbea8304 | ||
|
|
cb858da188 | ||
|
|
a9245c8f46 | ||
|
|
ba742d4f4e | ||
|
|
5e00d517eb | ||
|
|
126e3860c2 | ||
|
|
8db07e9324 | ||
|
|
b4338d1cca | ||
|
|
e00625b8f8 | ||
|
|
b0b1ac6413 | ||
|
|
6280dfa522 |
@@ -1,2 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.git/
|
.git/
|
||||||
|
dist/
|
||||||
|
experiment/
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|||||||
+240
-49
@@ -30,17 +30,22 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: '.node-version'
|
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
|
- name: Install dependencies
|
||||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
# 5 times with backoff before failing the build.
|
# 3 times with backoff before failing the build.
|
||||||
run: |
|
run: |
|
||||||
npm config set fetch-retries 5
|
npm config set fetch-retries 5
|
||||||
npm config set fetch-retry-mintimeout 10000
|
npm config set fetch-retry-mintimeout 20000
|
||||||
npm config set fetch-retry-maxtimeout 60000
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
npm config set fetch-timeout 300000
|
npm config set fetch-timeout 600000
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "npm ci attempt $attempt…"
|
echo "npm ci attempt $attempt…"
|
||||||
npm ci && break
|
npm ci && break
|
||||||
@@ -52,60 +57,69 @@ jobs:
|
|||||||
sleep $((attempt * 15))
|
sleep $((attempt * 15))
|
||||||
done
|
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
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
# ── Boot check — actually loads the built dist/, not just builds it ──
|
||||||
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
- name: Boot check
|
||||||
# A failure blocks the deploy.
|
run: node scripts/boot-check.mjs
|
||||||
- name: Unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
# ── Security — hard gate. #24 cleared the outstanding advisories (0
|
||||||
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
# vulnerabilities on this tree, verified with `npm audit --omit=dev`), so
|
||||||
# formatted), so these gate real regressions instead of relying on local
|
# there is nothing left this should be soft against. Hard on both
|
||||||
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
# `push` and `pull_request`: a new high/critical advisory should block
|
||||||
# introduce upstream type/lint/format issues; that path deploys without
|
# the deploy just as much as it should block the PR.
|
||||||
# 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
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Prettier Check and Fix
|
|
||||||
run: |
|
|
||||||
npx prettier --write .
|
|
||||||
npm run check:prettier
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
# ── Bundle size budget — hard gate on pull_request, warning on push (a
|
||||||
- name: Report bundle sizes
|
# push has already merged; failing it can only delay deploying an
|
||||||
continue-on-error: true
|
# otherwise-good commit, not prevent the regression, so pull_request is
|
||||||
run: |
|
# where this should be caught). Budgets live in scripts/bundle-budget.json.
|
||||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
- name: Check bundle size budget
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
continue-on-error: ${{ github.event_name == 'push' }}
|
||||||
echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
|
run: node scripts/check-bundle-size.mjs ${{ github.event_name }}
|
||||||
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
|
|
||||||
name=$(basename "$f")
|
|
||||||
size=$(du -sh "$f" | cut -f1)
|
|
||||||
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
|
|
||||||
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── Desktop build trigger ──────────────────────────────────────────────
|
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||||
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||||
@@ -118,11 +132,27 @@ jobs:
|
|||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
# [matrix #9] Debounce: a Tauri build takes ~30 min on the shared runner,
|
||||||
|
# so at most one bump per DEBOUNCE_MIN. Commits that land inside the
|
||||||
|
# window are picked up by cinny-desktop's nightly catch-up workflow (or a
|
||||||
|
# manual dispatch of it) — the desktop cadence no longer tracks every web
|
||||||
|
# commit. The bump is also skipped when nothing changed.
|
||||||
- name: Bump cinny submodule
|
- name: Bump cinny submodule
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
DEBOUNCE_MIN: '60'
|
||||||
run: |
|
run: |
|
||||||
CINNY_SHA="${{ github.sha }}"
|
CINNY_SHA="${{ github.sha }}"
|
||||||
|
LAST=$(curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"https://code.lotusguild.org/api/v1/repos/LotusGuild/cinny-desktop/commits?sha=main&limit=1&stat=false&verification=false&files=false" \
|
||||||
|
| python3 -c 'import sys,json; c=json.load(sys.stdin); print(c[0]["commit"]["committer"]["date"] if c else "")' 2>/dev/null || true)
|
||||||
|
if [ -n "$LAST" ]; then
|
||||||
|
AGE=$(python3 -c "import sys,datetime; d=datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')); print(int((datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()//60))" "$LAST")
|
||||||
|
if [ "$AGE" -lt "$DEBOUNCE_MIN" ]; then
|
||||||
|
echo "Last desktop bump was ${AGE} min ago (< ${DEBOUNCE_MIN}); skipping — the nightly catch-up will pick this up."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
||||||
cd desktop
|
cd desktop
|
||||||
git config user.email "ci@lotusguild.org"
|
git config user.email "ci@lotusguild.org"
|
||||||
@@ -138,3 +168,164 @@ jobs:
|
|||||||
git push origin main
|
git push origin main
|
||||||
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||||
fi
|
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).
|
||||||
|
# local tier — (#220) regression suite against a Synapse this job starts
|
||||||
|
# itself; skips itself if that homeserver isn't reachable.
|
||||||
|
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
|
||||||
|
# on this Gitea runner (see LOTUS_REFERENCE.md → CI/CD), so `needs: build` only gates on the
|
||||||
|
# main job having passed, not on its artifact.
|
||||||
|
# Hard gate: proven green on the runner in run #1880 (chromium + deps
|
||||||
|
# install fine there). The E2EE tier self-skips without the E2E_* secrets.
|
||||||
|
e2e:
|
||||||
|
name: Playwright smoke (e2e)
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 20000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "npm ci attempt $attempt…"
|
||||||
|
npm ci && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
echo "npm ci failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
|
||||||
|
# [Gitea #221] WebKit too — Playwright's Safari/iOS proxy for the tagged
|
||||||
|
# subset (see playwright.config.ts projects). `--with-deps` pulls the
|
||||||
|
# GTK/GStreamer libraries WebKit needs on the runner.
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium webkit
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max_old_space_size=6144'
|
||||||
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# ── #220 — tier 3: a throwaway Synapse the job starts itself (SQLite,
|
||||||
|
# open registration, no rate limits; scripts/dev-homeserver.sh). The
|
||||||
|
# regression spec (e2e/local-homeserver.spec.ts) registers its own users
|
||||||
|
# and rooms through the CS API and drives the built client against it —
|
||||||
|
# no prod secrets involved. If the homeserver fails to come up the spec
|
||||||
|
# skips itself and the step below still reports why.
|
||||||
|
- name: Start local homeserver
|
||||||
|
id: local_hs
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python3 -m venv --help >/dev/null 2>&1 || { (command -v sudo >/dev/null && sudo -n true 2>/dev/null && sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv) || (apt-get update -qq && apt-get install -y -qq python3-venv); }
|
||||||
|
scripts/dev-homeserver.sh start
|
||||||
|
python3 scripts/dev-seed.py 20 > .dev-homeserver/seed.json
|
||||||
|
echo "E2E_LOCAL_HS=http://localhost:8008" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
|
||||||
|
- name: Stop local homeserver
|
||||||
|
if: always()
|
||||||
|
run: scripts/dev-homeserver.sh stop || true
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -57,7 +57,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Environement
|
label: Environment
|
||||||
description: |
|
description: |
|
||||||
Please provide information about your environment. Include the following:
|
Please provide information about your environment. Include the following:
|
||||||
- OS:
|
- OS:
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
### Description
|
||||||
|
|
||||||
|
<!-- Please include a summary of the change. -->
|
||||||
|
|
||||||
|
Fixes #
|
||||||
|
|
||||||
|
### Checklist:
|
||||||
|
|
||||||
|
- [ ] I have read and understood the [Contribution policy](https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md).
|
||||||
@@ -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,11 @@ devAssets
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.ideapackage-lock.json
|
.ideapackage-lock.json
|
||||||
public/decorations/
|
public/decorations/
|
||||||
|
|
||||||
|
# Playwright (npm run test:e2e)
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# local dev homeserver (scripts/dev-homeserver.sh)
|
||||||
|
.dev-homeserver/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -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 lint-staged
|
||||||
# npx tsc -p tsconfig.json --noEmit
|
|
||||||
# npx lint-staged
|
|
||||||
|
|||||||
+1
-2
@@ -2,5 +2,4 @@ dist
|
|||||||
node_modules
|
node_modules
|
||||||
package.json
|
package.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
|
||||||
Vendored
+1
-15
@@ -1,19 +1,5 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
"prettier.requireConfig": true,
|
|
||||||
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
},
|
|
||||||
"[javascriptreact]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.29.8-alpine
|
FROM nginx:1.31.5-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# Developing Cinny
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> We recommend using a version manager as versions change very quickly.
|
||||||
|
> You will likely need to switch between multiple Node.js versions based
|
||||||
|
> on the needs of different projects you're working on. [NVM-windows]
|
||||||
|
> on Windows and [nvm] on Linux/macOS are pretty good choices. Recommended
|
||||||
|
> nodejs version is Krypton LTS (v24.13.1).
|
||||||
|
|
||||||
|
[nvm-windows]: https://github.com/coreybutler/nvm-windows#installation--upgrades
|
||||||
|
[nvm]: https://github.com/nvm-sh/nvm
|
||||||
|
|
||||||
|
Execute the following commands to start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci # Installs all dependencies
|
||||||
|
npm start # Serve a development version
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build # Compiles the app into the dist/ directory
|
||||||
|
```
|
||||||
|
|
||||||
|
To commit changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run commit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
This repository includes a Dockerfile, which builds the application from
|
||||||
|
source and serves it with Nginx on port 80. To use this locally, you can
|
||||||
|
build the container like so:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t cinny:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then run the container you've built with a command similar to this:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -p 8080:80 cinny:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This will forward your `localhost` port 8080 to the container's port 80.
|
||||||
|
You can visit the app in your browser by navigating to `http://localhost:8080`.
|
||||||
|
|
||||||
|
## Code formatting
|
||||||
|
|
||||||
|
We use [ESLint](https://eslint.org/) for clean and stylistically
|
||||||
|
consistent code syntax, so make sure your pull request follow it.
|
||||||
|
|
||||||
|
## Helpful links
|
||||||
|
|
||||||
|
- [BEM methodology](http://getbem.com/introduction/)
|
||||||
|
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
|
||||||
|
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
|
||||||
+81
-33
@@ -26,8 +26,9 @@ Last updated: July 2026.
|
|||||||
17. [Notifications](#notifications)
|
17. [Notifications](#notifications)
|
||||||
18. [Server Integration](#server-integration)
|
18. [Server Integration](#server-integration)
|
||||||
19. [Infrastructure](#infrastructure)
|
19. [Infrastructure](#infrastructure)
|
||||||
20. [Desktop App Features](#desktop-app-features)
|
20. [Localization](#localization)
|
||||||
21. [Key Custom Files](#key-custom-files)
|
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)
|
### 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
|
- **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
|
- **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
|
### Files
|
||||||
|
|
||||||
@@ -175,19 +176,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
|
|||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
|
|
||||||
| Theme | Window | Effect |
|
| Theme | Window | Effect |
|
||||||
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
|
| 🎆 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 |
|
| 🏮 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 |
|
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
|
||||||
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
||||||
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
|
| 🃏 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 |
|
| 🌱 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 |
|
| 🍂 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 |
|
| 👾 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 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
|
| 🚀 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 |
|
| 🎃 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 |
|
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
@@ -331,7 +332,7 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||||
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||||
> work list are in
|
> work list are in
|
||||||
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
> the Element Call fork reference in [`LOTUS_REFERENCE.md`](./LOTUS_REFERENCE.md).
|
||||||
|
|
||||||
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
@@ -348,15 +349,16 @@ so a stock EC config is byte-for-byte upstream behavior.
|
|||||||
|
|
||||||
**Active (cinny drives them today):**
|
**Active (cinny drives them today):**
|
||||||
|
|
||||||
| # | Feature | Mechanism | Replaces (old hack) |
|
| # | Feature | Mechanism | Replaces (old hack) |
|
||||||
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ---- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||||
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||||
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||||
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||||
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||||
|
| P6-2 | **Deafen / screenshare-audio mute** | host sends `io.lotus.set_deafen {deafened, screenshareAudioMuted}`; both become the `muted` prop of EC's audio elements (server stops sending), so they hold across re-renders, late joiners and a sharer stopping + re-sharing (fixed in `0.25.0-lotus.4`) | the iframe-DOM `.muted` hack (kept only as a transitional fallback) and `setVolume(0, ScreenShareAudio)`, which EC's own volume controls reset on every new share |
|
||||||
|
|
||||||
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
**Now wired (cinny drives them — verified end-to-end on the local calls stack, 2026-09-18):**
|
||||||
|
|
||||||
| # | Capability | Widget action | cinny surface |
|
| # | Capability | Widget action | cinny surface |
|
||||||
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||||
@@ -364,7 +366,9 @@ so a stock EC config is byte-for-byte upstream behavior.
|
|||||||
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||||
|
|
||||||
> Both were dormant capabilities; cinny now drives them (armed via
|
> Both were dormant capabilities; cinny now drives them (armed via
|
||||||
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
> `lotusAudioInject=1`). Verified headless with two clients: a clip publishes as
|
||||||
|
> an extra `AUDIO/UNKNOWN` track on the SFU and is refused while muted; a room
|
||||||
|
> cap set mid-call re-sends `io.lotus.set_quality` with the clamped values. The **only** EC item still open is the P5-31
|
||||||
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||||
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||||
> client cap is best-effort.
|
> client cap is best-effort.
|
||||||
@@ -742,7 +746,7 @@ never leaves it.
|
|||||||
|
|
||||||
### Message Search Date Range
|
### 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
|
- A chip shows the active date range with an **×** button to clear it
|
||||||
|
|
||||||
### Encrypted Search Cache (P4-8, opt-in)
|
### Encrypted Search Cache (P4-8, opt-in)
|
||||||
@@ -1170,6 +1174,30 @@ Persists via the `homeRoomSort` setting.
|
|||||||
|
|
||||||
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
|
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
|
||||||
|
|
||||||
|
### Tracking-Parameter Stripping (Gitea #103)
|
||||||
|
|
||||||
|
Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `utm_*`, `fbclid`, `gclid`, YouTube `si=`, Amazon `ref=`/`tag=`, X `s=`/`t=`, TikTok `_r`/`_t`, and ~40 more, plus host-scoped rules so e.g. `si` is only removed on YouTube/Spotify. Runs entirely on the device (`src/app/utils/urlTracking.ts`, unit-tested). Wired at paste (re-inserted through Slate so multi-line pastes still split into paragraphs), at send/schedule/edit on both `body` and `formatted_body`, and at render (linkify + explicit `<a href>` in formatted HTML), so links from other clients are cleaned locally too. `matrix.to` and non-http(s) schemes are never touched; Amazon's `th`/`psc` variant selectors are kept. Toggle in **Settings → Privacy → Strip Tracking Parameters from Links** (default on).
|
||||||
|
|
||||||
|
### Settings Sync Across Devices (Gitea #104)
|
||||||
|
|
||||||
|
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
||||||
|
|
||||||
|
### Push to Deafen: off switch + typing-safe
|
||||||
|
|
||||||
|
**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding.
|
||||||
|
|
||||||
|
### Forwarded messages show their provenance
|
||||||
|
|
||||||
|
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
|
||||||
|
|
||||||
|
### Copy Lotus Link — direct permalinks (Gitea #130)
|
||||||
|
|
||||||
|
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
|
||||||
|
|
||||||
|
### PWA App-Icon Badge (Gitea #154)
|
||||||
|
|
||||||
|
When Lotus Chat is installed as a PWA (Android Chrome, desktop Chrome/Edge), the app icon carries a numeric badge via the Badging API (`navigator.setAppBadge`). The number is the same highlight count (mentions/DMs, leaf rooms only) that the tab title shows, so the two can never disagree; it clears when the count reaches zero. Lives in `FaviconUpdater` (`ClientNonUIFeatures.tsx`) next to the title/favicon logic. No-op in a plain browser tab (the API is absent) and under Tauri, where the native `set_badge_count` already owns the badge.
|
||||||
|
|
||||||
### Media Gallery
|
### Media Gallery
|
||||||
|
|
||||||
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||||
@@ -1180,7 +1208,7 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
|
|||||||
- **Files** — name/size/sender rows with download
|
- **Files** — name/size/sender rows with download
|
||||||
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
|
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
|
||||||
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
|
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
|
||||||
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
|
- **Auto-pagination** — an `IntersectionObserver` sentinel pulls older media as you scroll (manual retry on error). Since Gitea #163 this pages through the gallery's **own timeline set** (`useRoomMediaTimeline` → `utils/detachedTimeline.ts`), never the room's live timeline: unencrypted rooms use a server-side `contains_url` filter (a page is 100 media events, not 100 events), encrypted rooms page raw history into a private set and filter after decrypting. The message list behind the drawer no longer jumps into the past; the Activity log and history Export use the same helper.
|
||||||
|
|
||||||
### Knock-to-Join
|
### Knock-to-Join
|
||||||
|
|
||||||
@@ -1205,7 +1233,7 @@ Hook: `src/app/hooks/usePendingKnocks.ts`
|
|||||||
|
|
||||||
### Room Emoji Prefix
|
### 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)
|
### Configurable Composer Toolbar (P3-6)
|
||||||
|
|
||||||
@@ -1404,7 +1432,23 @@ The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10
|
|||||||
|
|
||||||
### Crypto Diagnostics (E2EE investigation kit)
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_REFERENCE.md`](./LOTUS_REFERENCE.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Lotus Chat is **English-only for now**, by explicit decision (Sept 2026 audit, #53). The i18next
|
||||||
|
mechanism (`i18next-browser-languagedetector` + `public/locales/{en,de}.json`, wired in
|
||||||
|
`src/app/i18n.ts`) is real and still used by the ~11 upstream-inherited files that call
|
||||||
|
`useTranslation()`, but none of the Lotus-added UI (presence picker, calls/soundboard, avatar
|
||||||
|
decorations, seasonal settings, keyboard shortcuts help, toasts, etc.) is routed through it. Letting
|
||||||
|
the language detector pick a non-English browser locale therefore produced a UI that was only
|
||||||
|
partially translated. `src/app/i18n.ts` now sets `supportedLngs: ['en']` so the whole app renders
|
||||||
|
consistently in English regardless of browser locale, while leaving the detector/backend/`de.json`
|
||||||
|
in place. Re-enabling another language requires two things: (1) route Lotus strings through
|
||||||
|
`useTranslation()`/`public/locales/<lng>.json` like the existing localized files, then (2) drop (or
|
||||||
|
extend) `supportedLngs` in `src/app/i18n.ts` — a one-line change.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1453,6 +1497,10 @@ Rounds out the native app beyond Windows (macOS out of scope):
|
|||||||
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
||||||
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
||||||
|
|
||||||
|
### System-Wide Voice Hotkeys (cinny-desktop #2)
|
||||||
|
|
||||||
|
Push to Talk and Push to Deafen keep working while a game or any other app has focus. The desktop does **not** register a global shortcut (that would swallow the key from every app — a bare `Space` PTT would stop other apps typing spaces); instead, only while a call is joined, a native thread polls `GetAsyncKeyState` for the two configured keys every ~8 ms and emits a `lotus-global-hotkey` DOM event on each press/release transition (`src-tauri/src/native/hotkeys.rs`). `useCallHotkeys` ignores those events while the Lotus window itself has focus (the DOM handlers own that case with their editable-field checks), so nothing double-fires. Windows only — Linux/Wayland has no non-consuming path; `global_hotkeys_supported` reports it and the toggle is hidden outside Tauri. Toggle: **Settings → Calls → Hotkeys Work Outside the Window** (device-local, default on).
|
||||||
|
|
||||||
### Custom Window Chrome (P5-47)
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Lotus Chat — Engineering Reference
|
||||||
|
|
||||||
|
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
|
||||||
|
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
|
||||||
|
|
||||||
|
> **There is no backlog in this file.** All open work lives in Gitea issues — [cinny](https://code.lotusguild.org/LotusGuild/cinny/issues), [element-call](https://code.lotusguild.org/LotusGuild/element-call/issues), [cinny-desktop](https://code.lotusguild.org/LotusGuild/cinny-desktop/issues), [matrix](https://code.lotusguild.org/LotusGuild/matrix/issues) (infra/CI) — grouped by milestone (`Features 2026-Q4`, `Desktop 2026-Q4`, `Manual QA backlog`, `Desktop QA backlog`, the `Audit 2026-09 · *` set). Shipped features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md); how to run the automated tests is in [LOTUS_TESTING.md](./LOTUS_TESTING.md). The former `LOTUS_TODO.md` backlog was migrated to issues on 2026-09-17 (full history in git).
|
||||||
|
|
||||||
|
This file keeps only what a contributor needs to have open while working: the two design laws, decisions already made, what the server blocks, and the operational reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
||||||
|
|
||||||
|
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
||||||
|
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
|
||||||
|
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
|
||||||
|
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
|
||||||
|
|
||||||
|
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
|
||||||
|
|
||||||
|
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
|
||||||
|
>
|
||||||
|
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
|
||||||
|
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
|
||||||
|
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred / dropped (decided — kept for context)
|
||||||
|
|
||||||
|
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
||||||
|
|
||||||
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
|
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
|
||||||
|
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Reference
|
||||||
|
|
||||||
|
### Server Capabilities (as of 2026-07)
|
||||||
|
|
||||||
|
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
||||||
|
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
|
||||||
|
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
|
||||||
|
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
||||||
|
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
||||||
|
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
||||||
|
|
||||||
|
### Key File Reference
|
||||||
|
|
||||||
|
| What | File | Lines |
|
||||||
|
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
|
||||||
|
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
|
||||||
|
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
|
||||||
|
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
|
||||||
|
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
|
||||||
|
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
|
||||||
|
| Power levels | `hooks/usePowerLevels.ts` | whole |
|
||||||
|
| Slash commands | `hooks/useCommands.ts` | 140-537 |
|
||||||
|
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
|
||||||
|
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
|
||||||
|
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
|
||||||
|
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
|
||||||
|
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
|
||||||
|
| Knock support check | `utils/matrix.ts` | 376-391 |
|
||||||
|
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
|
||||||
|
|
||||||
|
### Element Call fork — operational reference
|
||||||
|
|
||||||
|
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
|
||||||
|
|
||||||
|
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
|
||||||
|
|
||||||
|
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.12`, 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.12`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||||
|
|
||||||
|
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
|
||||||
|
|
||||||
|
| Action | Dir | Purpose | Module |
|
||||||
|
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
|
||||||
|
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
|
||||||
|
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
|
||||||
|
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
|
||||||
|
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
|
||||||
|
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
|
||||||
|
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
|
||||||
|
|
||||||
|
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
|
||||||
|
|
||||||
|
### CI/CD + per-feature checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
edit → commit → git push origin lotus
|
||||||
|
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
|
||||||
|
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
|
||||||
|
(a push that lands while a deploy is mid-build is queued and deployed right after — matrix@b6ea4a3; before that it was dropped)
|
||||||
|
```
|
||||||
|
|
||||||
|
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
|
||||||
|
|
||||||
|
**CI hardening (2026-07, reviewed):**
|
||||||
|
|
||||||
|
- [x] **Concurrency** — `cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
|
||||||
|
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
|
||||||
|
|
||||||
|
**CI follow-ups (open):**
|
||||||
|
|
||||||
|
**CI follow-ups** are tracked in the `matrix` repo: [#8](https://code.lotusguild.org/LotusGuild/matrix/issues/8) dedicated `desktop-linux` runner, [#9](https://code.lotusguild.org/LotusGuild/matrix/issues/9) debounce the desktop trigger, [#10](https://code.lotusguild.org/LotusGuild/matrix/issues/10) verify Gitea `concurrency`. Build-once/deploy-the-artifact was considered and deferred (noted on #8).
|
||||||
+32
-786
@@ -1,38 +1,33 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# Lotus Chat — Testing
|
||||||
|
|
||||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
|
> **Manual QA checklists no longer live here.** Every shipped-but-unverified behaviour is a Gitea issue labelled **`qa`** — cinny milestone [Manual QA backlog](https://code.lotusguild.org/LotusGuild/cinny/milestones), cinny-desktop milestone [Desktop QA backlog](https://code.lotusguild.org/LotusGuild/cinny-desktop/milestones). Each issue carries the full steps + expected results; tick items as they pass, comment on FAIL (what you saw vs expected, browser/OS, web vs desktop, theme, console errors), close when green. Migrated from this file on 2026-09-17 (full text in git history).
|
||||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
|
||||||
|
|
||||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
This file keeps what a contributor needs to run and extend the **automated** coverage, plus the ops tip at the bottom.
|
||||||
|
|
||||||
## Environment notes
|
## Environment notes
|
||||||
|
|
||||||
- You push from your own machine; these commits are local on `lotus` until you do.
|
- Test the **web** build (`chat.lotusguild.org`, LXC 106) first; re-run call + poll items on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
|
||||||
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
|
- Several call checks need a **second participant** (marked **👥 2 people** in the issues); a couple need a third room/call in parallel (**👥👥**).
|
||||||
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
|
|
||||||
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Commits covered
|
## Local dev environment — drive the real UI against a throwaway homeserver
|
||||||
|
|
||||||
| Commit | Area |
|
```
|
||||||
| :--------- | :--------------------------------------------------------------------------- |
|
scripts/dev-homeserver.sh start # Synapse in .dev-homeserver/ (venv, SQLite), open registration, no rate limits, :8008
|
||||||
| `caf6318a` | Poll vote buttons → folds tokens (N4) |
|
python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an image every 10th
|
||||||
| `c67aed01` | In-call incoming-call banner (#4b) |
|
npm start # Vite on :5173
|
||||||
| `4a875884` | Selectable ringtone (#4a) |
|
```
|
||||||
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
|
|
||||||
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct |
|
|
||||||
| `b7e1f89c` | Timeline/composer/emoji perf memoization |
|
|
||||||
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep) |
|
|
||||||
|
|
||||||
---
|
**Calls too:** `scripts/dev-homeserver.sh calls` adds a LiveKit SFU, a JWT issuer, the real `voice-limit-guard` from the `matrix` checkout and an https well-known — real two-party calls in headless Chromium with fake mic/camera (`--use-fake-device-for-media-stream`, `--use-file-for-fake-audio-capture=<tone.wav>` to trigger speaking detection, `--auto-select-desktop-capture-source="Entire screen"` for screenshare; `ignoreHTTPSErrors: true`). This is how #29, #161, #173/#174 and the guard's live-revoke bug were found and fixed on 2026-09-18.
|
||||||
|
|
||||||
|
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
|
||||||
|
|
||||||
## Automated coverage map — what the unit tests already pin (2026-07)
|
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||||
|
|
||||||
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
|
**Read this before working a `qa` issue.** Much of the _logic_ the manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
|
||||||
|
|
||||||
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
|
| QA item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
|
||||||
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
|
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
|
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
|
||||||
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
|
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
|
||||||
@@ -48,784 +43,35 @@
|
|||||||
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
|
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
|
||||||
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
|
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
|
||||||
|
|
||||||
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
|
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already verified live were dropped when the checklists moved to issues.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Calls — new ringtone + notification work (highest priority)
|
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
|
||||||
|
|
||||||
### A1. Ringtone selection — preview in Settings
|
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). Three tiers:
|
||||||
|
|
||||||
**Steps**
|
| 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://` |
|
||||||
|
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
|
||||||
|
|
||||||
1. Open **Settings → General**, scroll to the **Calls** section.
|
**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual.
|
||||||
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
|
|
||||||
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
|
|
||||||
|
|
||||||
**Expected**
|
**Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too.
|
||||||
|
|
||||||
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
|
**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):
|
||||||
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
|
|
||||||
- **Silent** plays nothing.
|
|
||||||
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
|
|
||||||
- No console errors.
|
|
||||||
|
|
||||||
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
|
- `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.
|
||||||
|
|
||||||
### A2. Ringtone selection persists
|
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/`.
|
||||||
|
|
||||||
1. Set Ringtone to **Retro**, reload the app.
|
|
||||||
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
|
|
||||||
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
|
|
||||||
|
|
||||||
### A3. Incoming call uses the selected ringtone — 👥 2 people
|
|
||||||
|
|
||||||
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
|
|
||||||
|
|
||||||
1. As A, pick a non-silent ringtone (e.g. **Chime**).
|
|
||||||
2. From B, **start a call** in that DM/room. Do **not** answer on A.
|
|
||||||
|
|
||||||
**Expected on A**
|
|
||||||
|
|
||||||
- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
|
|
||||||
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
|
|
||||||
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
|
|
||||||
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
|
|
||||||
|
|
||||||
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
|
|
||||||
|
|
||||||
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
|
|
||||||
|
|
||||||
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
|
|
||||||
|
|
||||||
**Expected on A**
|
|
||||||
|
|
||||||
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
|
|
||||||
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
|
|
||||||
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
|
|
||||||
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
|
|
||||||
- The banner auto-dismisses if the caller hangs up / the call times out.
|
|
||||||
|
|
||||||
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
|
|
||||||
|
|
||||||
### A5. Camera focus during screenshare (#1) — 👥 2 people
|
|
||||||
|
|
||||||
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
|
|
||||||
|
|
||||||
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
|
|
||||||
2. In the menu, click **"Focus camera"**.
|
|
||||||
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
|
|
||||||
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
|
|
||||||
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
|
|
||||||
|
|
||||||
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
|
|
||||||
|
|
||||||
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
|
|
||||||
|
|
||||||
1. Join a call with that participant.
|
|
||||||
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
|
|
||||||
|
|
||||||
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
|
||||||
|
|
||||||
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
|
|
||||||
|
|
||||||
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
|
|
||||||
|
|
||||||
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
|
||||||
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
|
||||||
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
|
|
||||||
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
|
|
||||||
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
|
||||||
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B. Polls (N4) — render correctly on non-TDS themes
|
|
||||||
|
|
||||||
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
|
||||||
|
|
||||||
### B1. Poll renders on a default theme — ✅ PASS
|
|
||||||
|
|
||||||
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
|
||||||
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
|
||||||
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- Each option is a clearly **bordered** button with visible rounded corners.
|
|
||||||
- A **radio circle** indicator is visible on the left of each option.
|
|
||||||
- Text, and (after votes) the percentage, are legible.
|
|
||||||
|
|
||||||
### B2. Voting + selected/progress state
|
|
||||||
|
|
||||||
1. **Vote** on an option.
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
|
|
||||||
- The percentage and total vote count update.
|
|
||||||
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
|
|
||||||
|
|
||||||
### B3. Multiple-choice poll
|
|
||||||
|
|
||||||
1. Create a poll allowing **multiple selections**.
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
|
||||||
- You can select **several** options; each shows its own progress fill.
|
|
||||||
|
|
||||||
### B4. Lotus Terminal theme regression — ✅ PASS
|
|
||||||
|
|
||||||
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
|
||||||
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## C. Robustness / background behavior
|
### Green CI but the fix isn't live?
|
||||||
|
|
||||||
### C1. Presence updates on tab close
|
`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.
|
||||||
|
|
||||||
1. Open the app, then **close the tab** (or quit the browser).
|
|
||||||
2. From another session/device, check your **presence** shortly after.
|
|
||||||
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
|
|
||||||
|
|
||||||
### C2. Upload retry on flaky network (best-effort)
|
|
||||||
|
|
||||||
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
|
|
||||||
**Expected**
|
|
||||||
|
|
||||||
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
|
|
||||||
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
|
|
||||||
|
|
||||||
### C3. General timeline/composer perf (no functional regression)
|
|
||||||
|
|
||||||
The memoization changes are invisible if correct. Just confirm **nothing broke**:
|
|
||||||
|
|
||||||
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
|
|
||||||
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
|
|
||||||
|
|
||||||
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
|
|
||||||
|
|
||||||
- [ ] **Mic** mute/unmute (icon + actual audio)
|
|
||||||
- [ ] **Camera** on/off
|
|
||||||
- [ ] **Deafen / Sound** toggle (your deafen key too)
|
|
||||||
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
|
|
||||||
- [ ] **Screenshare audio** mute toggle
|
|
||||||
- [ ] **Fullscreen** toggle
|
|
||||||
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
|
|
||||||
- [ ] **End** call leaves cleanly
|
|
||||||
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
|
|
||||||
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
|
|
||||||
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
|
|
||||||
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence
|
|
||||||
|
|
||||||
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`
|
|
||||||
|
|
||||||
> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
|
|
||||||
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
|
|
||||||
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
|
|
||||||
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
|
|
||||||
> the fork features won't be present, so don't test D2 yet.
|
|
||||||
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.
|
|
||||||
|
|
||||||
### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)
|
|
||||||
|
|
||||||
Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
|
|
||||||
shim is **removed**). This is the single change with the widest blast radius — test deliberately.
|
|
||||||
|
|
||||||
- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
|
|
||||||
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
|
|
||||||
so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
|
|
||||||
afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
|
|
||||||
regresses, mic dies on every reconnect.)_
|
|
||||||
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
|
|
||||||
`restart()` path as reconnect).
|
|
||||||
- [ ] **Mute → unmute** a few times: audio returns each time.
|
|
||||||
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet` —
|
|
||||||
each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
|
|
||||||
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
|
|
||||||
injected alongside the in-source engine).
|
|
||||||
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).
|
|
||||||
|
|
||||||
### D2-2. Speaking + mute indicators from widget **events** (#2)
|
|
||||||
|
|
||||||
Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
|
|
||||||
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.
|
|
||||||
|
|
||||||
- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
|
|
||||||
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.
|
|
||||||
|
|
||||||
### D2-3. Focus camera **during a screenshare** (#4 / A5)
|
|
||||||
|
|
||||||
Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.
|
|
||||||
|
|
||||||
- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
|
|
||||||
spotlighted **alongside/over** the shared screen (not ignored).
|
|
||||||
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).
|
|
||||||
|
|
||||||
### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**
|
|
||||||
|
|
||||||
Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
|
|
||||||
tiles out of scope — that's now in scope.
|
|
||||||
|
|
||||||
- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
|
|
||||||
their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
|
|
||||||
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.
|
|
||||||
|
|
||||||
### D2-5. Native transparent background (#5)
|
|
||||||
|
|
||||||
Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).
|
|
||||||
|
|
||||||
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
|
||||||
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
|
||||||
|
|
||||||
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
|
|
||||||
|
|
||||||
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
|
|
||||||
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
|
|
||||||
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
|
|
||||||
|
|
||||||
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
|
|
||||||
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
|
|
||||||
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
|
|
||||||
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
|
|
||||||
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
|
|
||||||
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
|
|
||||||
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
|
|
||||||
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
|
|
||||||
|
|
||||||
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
|
|
||||||
|
|
||||||
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
|
|
||||||
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
|
|
||||||
General → Voice → Call Quality Caps**.
|
|
||||||
|
|
||||||
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
|
|
||||||
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
|
|
||||||
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
|
|
||||||
still shares. ❌ tell us if any setting kills audio/screenshare.
|
|
||||||
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
|
|
||||||
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
|
|
||||||
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
|
|
||||||
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
|
|
||||||
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
|
|
||||||
|
|
||||||
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
|
|
||||||
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
|
|
||||||
|
|
||||||
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
|
|
||||||
|
|
||||||
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
|
|
||||||
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
|
|
||||||
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
|
|
||||||
|
|
||||||
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
|
|
||||||
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
|
|
||||||
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
|
|
||||||
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
|
|
||||||
not just our client hiding a button.
|
|
||||||
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
|
|
||||||
server-blocked for all clients; **microphones still work**.
|
|
||||||
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
|
|
||||||
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
|
|
||||||
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
|
|
||||||
on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
|
|
||||||
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
|
|
||||||
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
|
|
||||||
|
|
||||||
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
|
|
||||||
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backlog of previously-fixed-but-unverified items
|
|
||||||
|
|
||||||
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
|
||||||
|
|
||||||
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
|
||||||
|
|
||||||
### E1. Composer toolbar touch targets (#7)
|
|
||||||
|
|
||||||
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
|
||||||
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
|
|
||||||
|
|
||||||
### E2. Room Settings — no horizontal overflow (#8)
|
|
||||||
|
|
||||||
On a narrow phone screen, open **Room Settings**.
|
|
||||||
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
|
|
||||||
|
|
||||||
### E3. Modals go fullscreen on mobile (#9)
|
|
||||||
|
|
||||||
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
|
|
||||||
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
|
|
||||||
|
|
||||||
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
|
|
||||||
|
|
||||||
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
|
|
||||||
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
|
|
||||||
|
|
||||||
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
|
|
||||||
|
|
||||||
On a phone, **inside a room**, open the room header **··· More Options** menu.
|
|
||||||
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Visual / theming
|
|
||||||
|
|
||||||
### F1. Animated chat background — no flicker (#2)
|
|
||||||
|
|
||||||
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
|
|
||||||
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
|
|
||||||
|
|
||||||
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
|
|
||||||
|
|
||||||
In Settings → Appearance:
|
|
||||||
|
|
||||||
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
|
|
||||||
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
|
|
||||||
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
|
|
||||||
|
|
||||||
### F3. Background / seasonal picker grid layout (N81)
|
|
||||||
|
|
||||||
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
|
|
||||||
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## G. Calls — additional unverified (👥 2 people)
|
|
||||||
|
|
||||||
### G1. PiP mute badges point at the right person (#12)
|
|
||||||
|
|
||||||
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
|
|
||||||
|
|
||||||
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
|
|
||||||
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
|
|
||||||
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
|
|
||||||
|
|
||||||
### G2. Full-screen camera broadcasts
|
|
||||||
|
|
||||||
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
|
|
||||||
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
|
|
||||||
|
|
||||||
### G3. PTT badge renders on all themes (N53)
|
|
||||||
|
|
||||||
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
|
|
||||||
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H. Media / performance (needs a room with many images)
|
|
||||||
|
|
||||||
### H1. Lazy image decryption (P5-5 / MediaGallery)
|
|
||||||
|
|
||||||
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
|
|
||||||
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
|
|
||||||
|
|
||||||
### H2. Thumbnail framing (P5-6)
|
|
||||||
|
|
||||||
Look at **tall portrait** images in the timeline and in the media gallery.
|
|
||||||
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
|
|
||||||
|
|
||||||
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
|
|
||||||
|
|
||||||
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
|
|
||||||
- [ ] **Edit history** button announces "View edit history".
|
|
||||||
- [ ] **Thread indicator** announces "View thread".
|
|
||||||
- [ ] **Reply** (jump to original) announces "Jump to original message".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## J. Desktop / Tauri build only
|
|
||||||
|
|
||||||
### J1. Proactive update notifications (P5-40)
|
|
||||||
|
|
||||||
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
|
|
||||||
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
|
|
||||||
|
|
||||||
### J2. DTLN noise suppression sanity
|
|
||||||
|
|
||||||
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
|
|
||||||
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## K. Features — end-to-end unverified
|
|
||||||
|
|
||||||
### K1. Remind Me Later
|
|
||||||
|
|
||||||
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
|
|
||||||
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
|
|
||||||
|
|
||||||
### K2. Advanced search filters (P4-9)
|
|
||||||
|
|
||||||
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
|
|
||||||
**Expected:** each narrows results correctly and reflects in the search.
|
|
||||||
|
|
||||||
### K3. Notification content + click target (P5-20 partial)
|
|
||||||
|
|
||||||
Trigger a desktop/browser notification for a new message.
|
|
||||||
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## L. Fixed — verify
|
|
||||||
|
|
||||||
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
|
||||||
|
|
||||||
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
|
|
||||||
|
|
||||||
**To verify:**
|
|
||||||
|
|
||||||
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
|
||||||
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
|
||||||
3. **Unmute** → the indicator should re-appear (capture re-acquired).
|
|
||||||
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
|
|
||||||
|
|
||||||
### L2. Maskable PWA icon (N108) — Android install
|
|
||||||
|
|
||||||
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
|
|
||||||
2. Look at the **home-screen icon**.
|
|
||||||
|
|
||||||
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## M. New features (this round)
|
|
||||||
|
|
||||||
### M1. Search: `has:image` / `has:file` / `has:video` filters
|
|
||||||
|
|
||||||
1. Open message search (in a room with shared images/files/videos in history).
|
|
||||||
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
|
|
||||||
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
|
|
||||||
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
|
|
||||||
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
|
|
||||||
|
|
||||||
### M2. Search: recent searches
|
|
||||||
|
|
||||||
1. Run a few different searches, then **clear the search box** and focus it.
|
|
||||||
|
|
||||||
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
|
|
||||||
|
|
||||||
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
|
|
||||||
|
|
||||||
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
|
|
||||||
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
|
|
||||||
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
|
|
||||||
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
|
|
||||||
- **Reset** clears it back to the theme default.
|
|
||||||
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
|
|
||||||
- Reload → the chosen accent **persists**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### M4. Search: "Pinned only" filter
|
|
||||||
|
|
||||||
In message search, toggle the **Pinned** chip.
|
|
||||||
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
|
|
||||||
|
|
||||||
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
|
|
||||||
|
|
||||||
Settings → Appearance → theme picker → try each of the 5 new themes.
|
|
||||||
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
|
|
||||||
|
|
||||||
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
|
|
||||||
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
|
|
||||||
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
|
|
||||||
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
|
|
||||||
delta + a `config.json` override) OR against **mozilla.org** with a real account.
|
|
||||||
|
|
||||||
### N1. OIDC login flow (the core test) — needs a MAS homeserver
|
|
||||||
|
|
||||||
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
|
|
||||||
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
|
|
||||||
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
|
|
||||||
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
|
|
||||||
|
|
||||||
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
|
|
||||||
|
|
||||||
### N2. Session persists across reload (token storage)
|
|
||||||
|
|
||||||
After N1, hard-refresh the page.
|
|
||||||
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
|
|
||||||
|
|
||||||
### N3. Token refresh (long-lived session)
|
|
||||||
|
|
||||||
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
|
|
||||||
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
|
|
||||||
|
|
||||||
### N4. Logout revokes at the issuer
|
|
||||||
|
|
||||||
Log out from Settings.
|
|
||||||
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
|
|
||||||
|
|
||||||
### N5. Account-management deep-link
|
|
||||||
|
|
||||||
Settings → Account.
|
|
||||||
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
|
|
||||||
|
|
||||||
### N6. Non-OIDC regression — password login unchanged
|
|
||||||
|
|
||||||
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
|
||||||
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
|
||||||
|
|
||||||
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
|
||||||
|
|
||||||
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
|
||||||
|
|
||||||
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
|
|
||||||
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
|
|
||||||
3. Reply to a reply _inside_ the panel.
|
|
||||||
|
|
||||||
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
|
|
||||||
|
|
||||||
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
|
|
||||||
|
|
||||||
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
|
|
||||||
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
|
|
||||||
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
|
|
||||||
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
|
|
||||||
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
|
|
||||||
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
|
|
||||||
|
|
||||||
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
|
|
||||||
|
|
||||||
### O3. Math / LaTeX (P4-4)
|
|
||||||
|
|
||||||
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
|
|
||||||
|
|
||||||
### O4. Encrypted search cache (P4-8) — opt-in
|
|
||||||
|
|
||||||
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
|
|
||||||
|
|
||||||
### O5. Session hardening (N97a) — cross-tab
|
|
||||||
|
|
||||||
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
|
|
||||||
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
|
|
||||||
|
|
||||||
### O6. Audit-wave correctness fixes (AW-1)
|
|
||||||
|
|
||||||
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
|
|
||||||
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
|
|
||||||
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
|
|
||||||
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
|
|
||||||
|
|
||||||
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
|
|
||||||
|
|
||||||
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
|
|
||||||
|
|
||||||
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
|
|
||||||
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
|
|
||||||
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
|
|
||||||
|
|
||||||
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
|
||||||
|
|
||||||
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P. Accessibility (P3-4) — needs a browser + a screen reader
|
|
||||||
|
|
||||||
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
|
|
||||||
|
|
||||||
### P1. Keyboard-only golden path (no mouse)
|
|
||||||
|
|
||||||
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
|
|
||||||
|
|
||||||
### P2. `?` shortcuts dialog
|
|
||||||
|
|
||||||
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
|
|
||||||
|
|
||||||
### P3. Screen-reader: reading messages
|
|
||||||
|
|
||||||
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
|
|
||||||
|
|
||||||
### P4. Screen-reader: live announcements
|
|
||||||
|
|
||||||
- **New message** arrives while you're reading → announced (polite).
|
|
||||||
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
|
|
||||||
- **Editing a message** → the edit box announces "Editing message from X".
|
|
||||||
|
|
||||||
### P5. Focus return from dialogs
|
|
||||||
|
|
||||||
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
|
|
||||||
|
|
||||||
### P6. axe / Lighthouse scan
|
|
||||||
|
|
||||||
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q. Inline Media Embeds — video / audio / post players (needs the web deploy live)
|
|
||||||
|
|
||||||
The whole feature is behind **Settings → General → "Inline Media Players"** (default **on**). Everything loads from the homeserver's cached thumbnail first; the third-party player only mounts on **Play**. Test on the **web** build first, then re-check the video ones on **desktop (Tauri)** since the CSP differs. On any failure, grab the **browser console** (F12) — a blocked embed shows as a CSP `frame-src` violation naming the host.
|
|
||||||
|
|
||||||
### Q1. Facade + one of each kind plays in place
|
|
||||||
|
|
||||||
Paste each of these into a room and confirm a media tile (not a plain link) with a thumbnail + play button, and that clicking Play mounts the player **inline**:
|
|
||||||
|
|
||||||
- **16:9 video:** a YouTube `watch` link, a Vimeo link, a Dailymotion link, a Streamable link, a Twitch VOD/clip, a Loom `share` link.
|
|
||||||
- **9:16 portrait:** a YouTube **Shorts** link (renders tall, not letterboxed).
|
|
||||||
- **Audio player:** a Spotify track, a SoundCloud track, an Apple Music album, a Tidal album/track.
|
|
||||||
- **Post embed:** an X/Twitter post, an Instagram post, a Reddit post.
|
|
||||||
|
|
||||||
**Expected:** ✅ tile shows the thumbnail; **no** request to the third party until you press Play (check DevTools → Network); the player then plays inline. ❌ tell me any that stay a plain link, show a blank frame, or hit the network before you click.
|
|
||||||
|
|
||||||
### Q2. TikTok (the tricky one) + portrait fill
|
|
||||||
|
|
||||||
1. Paste a **full** TikTok URL and a **short** copy-link (`vm.tiktok.com/…` or `tiktok.com/t/…`).
|
|
||||||
2. Press Play on each.
|
|
||||||
|
|
||||||
**Expected:** both resolve to a clean **9:16** player that **fills the box** (no big empty band on the right). The short link shows a brief spinner while it resolves via oEmbed, then plays. ❌ tell me if a short link shows only the TikTok logo/♫ and never a play button, or if the player has dead space beside it.
|
|
||||||
|
|
||||||
### Q3. Post self-resize + Close / Fullscreen controls
|
|
||||||
|
|
||||||
1. Play a **Reddit**, **Instagram**, and **X/Twitter** post embed.
|
|
||||||
2. Watch the card height as the embed loads.
|
|
||||||
|
|
||||||
**Expected:** the card **grows to fit** the post (no clipped/scrollbarless content, no giant empty box). A **Close** button (✕) collapses the player back to the thumbnail; video players also show a **⛶ Fullscreen** control that works. Keyboard: Tab to the play button → it shows a visible **focus ring**.
|
|
||||||
|
|
||||||
### Q4. New providers (unverified) + the toggle + the cap
|
|
||||||
|
|
||||||
- **Bluesky / Loom / Kick** — these are freshly added and unverified live. Paste a `bsky.app/profile/…/post/…`, a `loom.com/share/…`, and a live `kick.com/{channel}` link. ✅ good if each plays/renders inline; ❌ if any is a broken frame (for **Bluesky** especially, note whether a **handle** URL resolves or only a DID one does — grab the console).
|
|
||||||
- **Toggle off:** Settings → General → **Inline Media Players** off → every media link reverts to a plain link tile (no player).
|
|
||||||
- **Cap:** paste a message with **8+** media links → at most **6** preview cards render (the rest are suppressed), and the page stays responsive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## R. Discovery-pass fixes (DP1–DP18, 2026-07)
|
|
||||||
|
|
||||||
Agent-surveyed + TPVR-verified low/med issues, now fixed (commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`). Behavioral items have concrete checks; the refactors just need a "still works" regression pass.
|
|
||||||
|
|
||||||
### Correctness
|
|
||||||
|
|
||||||
- [ ] **DP1 — slash-command errors are visible.** Run a slash command that must fail — e.g. `/kick @nobody:server` in a room where you lack permission, or `/join` a bad alias. **Expected:** an error toast appears (not a silent no-op); a successful command still clears the composer normally.
|
|
||||||
- [ ] **DP2 — no invite re-notify on reload.** With ≥1 pending invite, hard-reload (Ctrl+F5). **Expected:** NO "you have N new invitation" toast/sound on load. Then have someone invite you while the app is open → you DO get one notification for the new invite. (👥 2 accounts)
|
|
||||||
- [ ] **DP3 — status clear syncs across devices.** Set a status message on device A, confirm it shows on B; clear it on A. **Expected:** B clears too and does NOT re-publish the old status on its next presence heartbeat. Toggling Invisible must not wipe a real saved status. (👥 2 sessions)
|
|
||||||
- [ ] **DP4 — tag-toggle failure surfaced once.** With the network offline, toggle a room's Favourite/Low-priority. **Expected:** a single error toast (not two) on failure; on success the tag updates as before.
|
|
||||||
- [ ] **DP5 — soundboard packs update on room switch.** Open a soundboard in room A, then switch to room B (different pack) in the same mounted view. **Expected:** B's packs show without needing an unrelated event.
|
|
||||||
- [ ] **DP6 — declining a call still dismisses.** Decline an incoming call. **Expected:** the ringing UI dismisses even if the decline send fails (best-effort). (👥 2 accounts)
|
|
||||||
|
|
||||||
### a11y / UX (needs a screen reader + a narrow viewport)
|
|
||||||
|
|
||||||
- [ ] **DP7 / DP8 — call-control buttons announce correctly.** In a call with a screen reader: the deafen button announces "Deafen" when sound is on (not "Undeafen"); Sound / Video / Screenshare announce a consistent pressed/unpressed state like Mic.
|
|
||||||
- [ ] **DP9 — GIF picker focus + width.** Open the GIF picker, close it (Esc / click-out) → focus returns to the GIF button. On a ~320px viewport the picker doesn't overflow the page.
|
|
||||||
- [ ] **DP10 — search-filter clears by keyboard.** In message search, toggle a filter chip (Has link / msg-type / pinned) off with Enter; the date-range clears via its menu's Clear. No mouse-only clear needed.
|
|
||||||
- [ ] **DP11 — voice recorder fits + announces.** On a ~360px viewport, start a voice message → the recorder row doesn't overflow the composer; a screen reader can query the duration (role="timer") without being spammed.
|
|
||||||
- [ ] **DP12 — live-call count announced.** With a screen reader, when someone joins/leaves an active call, the "{n} Live" change is announced (polite status region).
|
|
||||||
|
|
||||||
### TDS colors (Lotus Terminal theme)
|
|
||||||
|
|
||||||
- [ ] **DP14 — send-status + receipt colors follow the theme.** In **TDS light** mode: the message send-status "failed" icon and the read-receipt pill use the theme's darker red/blue (from `--lt-*` tokens), NOT bright dark-mode cyan/red. TDS dark still looks right; non-TDS themes unchanged.
|
|
||||||
|
|
||||||
### Refactor regression pass (no behavior change intended)
|
|
||||||
|
|
||||||
- [ ] **DP13 — bookmarks / reminders / notes still work** (they now share one store engine). Add/remove a bookmark, set/clear a reminder, write/clear a user note; each persists across reload; rapid consecutive writes don't clobber each other.
|
|
||||||
- [ ] **DP15 / DP16 — state-event + account-data writes still work.** Edit room name/topic/avatar, join-rules, power levels, an emoji/soundboard pack (state events); toggle a setting stored in account data. All save + reflect correctly.
|
|
||||||
- [ ] **DP17 — link previews render.** Open a TikTok / Spotify / Steam / Reddit link preview; provider icons render (folds icons, not raw glyphs) and brand colors look right.
|
|
||||||
- [ ] **DP18 — member avatars/names live-update.** Read receipts + the "seen by" reader list show correct avatars/names and update live when a member changes their avatar or display name (no reload).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority if you're short on time
|
|
||||||
|
|
||||||
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
|
||||||
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
|
||||||
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
|
||||||
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
|
||||||
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
|
||||||
6. Everything else.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Outstanding verification backlog
|
|
||||||
|
|
||||||
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
|
|
||||||
|
|
||||||
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
|
|
||||||
|
|
||||||
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
|
|
||||||
|
|
||||||
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
|
|
||||||
|
|
||||||
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
|
||||||
|
|
||||||
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
|
||||||
|
|
||||||
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
|
|
||||||
|
|
||||||
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
|
|
||||||
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
|
|
||||||
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
|
|
||||||
|
|
||||||
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
|
||||||
|
|
||||||
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
|
||||||
|
|
||||||
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
|
||||||
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
|
||||||
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
|
||||||
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
|
||||||
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
|
||||||
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
|
||||||
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
|
||||||
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
|
||||||
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
|
||||||
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
|
||||||
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
|
||||||
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
|
||||||
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
|
||||||
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
|
||||||
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
|
||||||
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
|
||||||
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
|
||||||
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
|
||||||
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
|
||||||
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
|
||||||
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
|
||||||
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
|
||||||
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
|
||||||
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
|
||||||
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
|
||||||
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
|
||||||
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
|
||||||
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
|
||||||
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
|
||||||
|
|
||||||
**Verified working in live testing (2026-06):** A2, 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
-412
@@ -1,412 +0,0 @@
|
|||||||
# Lotus Chat — Work Backlog
|
|
||||||
|
|
||||||
**Repo:** `lotus` branch at `https://code.lotusguild.org/LotusGuild/cinny`
|
|
||||||
**Deploy:** push to `lotus` → CI → auto-deploy to `chat.lotusguild.org` (~11 min)
|
|
||||||
|
|
||||||
> Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md). Manual test steps live in [LOTUS_TESTING.md](./LOTUS_TESTING.md). This file is **open work only** — resolved audit findings and shipped-feature write-ups were removed 2026-07 (full history in git).
|
|
||||||
|
|
||||||
Status legend: `[ ]` pending · `[~]` in progress / shipped-awaiting-QA · `[x]` done · `[BLOCKED]` server/upstream-gated · `[DEFERRED]`/`[DROPPED]`/`[WON'T FIX]` decided.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
|
||||||
|
|
||||||
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
|
||||||
> Do NOT hardcode hex values. Do NOT invent new variable names. Canonical tokens: `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green`, `--lt-glow-*`, `--lt-box-glow-*`, `--lt-border-color`, `--lt-font-mono`. Syntax-highlight token classes: `.tok-kw .tok-str .tok-num .tok-cmt .tok-fn`.
|
|
||||||
> Reference patterns: `/root/code/tinker_tickets/` (markdown.js, base.js, ticket.css). Applies to every task without exception.
|
|
||||||
> New components must respect both TDS dark (`LotusTerminalTheme`) and TDS light (`LotusTerminalLightTheme`); non-TDS theme work uses vanilla-extract (match `src/lotus-terminal.css.ts`).
|
|
||||||
|
|
||||||
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
|
|
||||||
|
|
||||||
> **Every feature must feel native to upstream Cinny — indistinguishable from what the Cinny team would ship.** Reference: <https://github.com/cinnyapp/cinny>.
|
|
||||||
>
|
|
||||||
> - **Use the `folds` design system, not bespoke UI** (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, …) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`). **Use folds `Icon`/`Icons`, never literal emoji, in UI chrome.** No hardcoded hex/`rgba()`, no invented CSS variables.
|
|
||||||
> - **Match Cinny's existing patterns** — find the closest existing component/flow and mirror it before adding UI.
|
|
||||||
> - **The ONE exception:** explicit **TDS** features, which follow the TDS Design Law above (opt-in, only in Lotus Terminal mode).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Audit (2026-07) — closed out
|
|
||||||
|
|
||||||
A three-wave feature bug-hunt (~15 parallel agents, each batch independently reviewed) plus a low-tail cleanup. All confirmed 🔴/🟠 and the clean 🟡 tail are **fixed, reviewed, and gate-green**; details in git history + LOTUS_FEATURES. Only the minor items below remain open.
|
|
||||||
|
|
||||||
**Still open (low tail — all 🟡 minor):**
|
|
||||||
|
|
||||||
- ✅ **Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs` → `expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
|
|
||||||
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
|
|
||||||
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
|
|
||||||
- **EC fork (EC1–EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Shipped — Awaiting Live Verification
|
|
||||||
|
|
||||||
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then graduate to LOTUS_FEATURES.md. Includes the **desktop/native Tier A/B stack** (P5-35/36/41/42/43/44/46/47/48/49/55/56/57, P6-1 Linux parity) — all **CI-compile-verified, runtime-verify on Windows/Linux** — plus:
|
|
||||||
|
|
||||||
| Area | Test guide |
|
|
||||||
| :-------------------------------------------------------------------------- | :-------------------- |
|
|
||||||
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
|
|
||||||
| Advanced search filters + virtualized infinite scroll | K2 / M1 / M2 / M4 |
|
|
||||||
| Custom Accent Color Picker (non-TDS) · 5 Color Theme Presets | M3 / M5 |
|
|
||||||
| Intersection lazy media loading · context-aware thumbnails | H1 / H2 |
|
|
||||||
| Thread Panel (side drawer) + per-thread notification modes (P4-1) | (thread QA) |
|
|
||||||
| Encrypted message search indexing/caching (opt-in, default OFF) | search backlog |
|
|
||||||
| Remind Me Later · Mobile Bookmarks access | K1 / E5 |
|
|
||||||
| In-Call Soundboard (P5-15) · Quality Controls (P5-31) · Permissions (P5-31) | D2-7 / D2-8 / D2-9 |
|
|
||||||
| Desktop proactive update notifications (P5-40) | J1 |
|
|
||||||
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
|
|
||||||
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
|
|
||||||
| Inline media embeds (16 providers: video/audio/post + click-to-play facade) | Q1 / Q2 / Q3 / Q4 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Open — Actionable
|
|
||||||
|
|
||||||
### ✅ Discovery pass (2026-07) — DP1–DP18 DONE
|
|
||||||
|
|
||||||
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1–DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
|
|
||||||
|
|
||||||
### ✅ Discovery pass 2 (2026-07) — perf / security / correctness — DONE
|
|
||||||
|
|
||||||
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
|
|
||||||
|
|
||||||
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
|
|
||||||
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
|
|
||||||
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
|
|
||||||
- [x] **PERF-4 — message-search room filter re-sorted every render** → `useMemo`. `4708a179`.
|
|
||||||
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
|
|
||||||
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout** → `clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
|
|
||||||
- [x] **SEC-3 — `window.open(_blank)` without `noopener`** → `noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
|
|
||||||
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
|
|
||||||
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
|
|
||||||
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
|
|
||||||
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew** → `Math.abs(...)` (±20s). `ab01d27a`.
|
|
||||||
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
|
|
||||||
- [x] **COR-5 — upload cancel ignored during retry back-off** → `AbortSignal` threaded into the retry loop. `ab01d27a`.
|
|
||||||
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
|
|
||||||
|
|
||||||
**Deferred / decided (not built):**
|
|
||||||
|
|
||||||
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
|
|
||||||
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
|
|
||||||
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient` → `requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
|
|
||||||
|
|
||||||
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
|
|
||||||
|
|
||||||
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
|
|
||||||
|
|
||||||
**Embeds / URL previews**
|
|
||||||
|
|
||||||
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
|
|
||||||
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
|
|
||||||
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
|
|
||||||
|
|
||||||
**Voice / video calls**
|
|
||||||
|
|
||||||
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
|
|
||||||
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
|
|
||||||
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
|
|
||||||
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
|
|
||||||
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
|
|
||||||
|
|
||||||
**Theming / visuals**
|
|
||||||
|
|
||||||
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
|
|
||||||
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
|
|
||||||
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
|
|
||||||
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
|
|
||||||
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
|
|
||||||
|
|
||||||
**Threads / presence / UX**
|
|
||||||
|
|
||||||
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
|
|
||||||
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
|
|
||||||
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
|
|
||||||
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
|
|
||||||
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
|
|
||||||
|
|
||||||
**Rooms / moderation / notifications / infra / desktop**
|
|
||||||
|
|
||||||
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
|
|
||||||
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
|
|
||||||
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
|
|
||||||
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
|
|
||||||
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
|
|
||||||
|
|
||||||
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
|
|
||||||
|
|
||||||
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
|
|
||||||
|
|
||||||
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
|
|
||||||
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag` — `${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
|
|
||||||
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
|
|
||||||
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
|
||||||
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
|
||||||
|
|
||||||
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
|
|
||||||
|
|
||||||
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
|
|
||||||
|
|
||||||
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
|
||||||
|
|
||||||
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
|
||||||
|
|
||||||
**Fix:** `roomToUnread.ts` — `handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
|
|
||||||
|
|
||||||
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
|
|
||||||
|
|
||||||
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
|
|
||||||
|
|
||||||
- **KE-1 — OTK upload conflict storm (CRITICAL, root-cause candidate).** `POST /keys/upload` returns `400 M_UNKNOWN: One time key … already exists` continuously — the rust-crypto store and Synapse have **diverged OTK state** (upstream `matrix-rust-sdk#5200`, OPEN: on the 400 the SDK never marks the request sent → re-uploads forever; **not** fixed in 41.7.0). Leading web trigger: cinny never calls **`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable while the `localStorage` session survives → device resurrects with a blank store. **Buildable preventive fix (no call needed):** request persistent storage on login (+ optional multi-tab guard + a 400-loop→recovery prompt). Healing an already-diverged device still needs a clean logout+login.
|
|
||||||
- **KE-2 — EC media keys not arriving/decrypting → audio/video cut out (CRITICAL).** `MissingKey … for participant`, unexpected encrypted to-device `io.element.call.encryption_keys`. Almost certainly downstream of KE-1 (broken Olm sessions). This is the "friend's audio cuts out" symptom.
|
|
||||||
- **KE-3 — Timeline decrypt error: missing `algorithm` field (HIGH).** rust-crypto can't parse a malformed/legacy encrypted event — capture the offending event id + raw content.
|
|
||||||
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH).** `Restart delayed event timed out`, repeated `msc4157.update_delayed_event` — may be partly HS responsiveness; correlate with synapse latency. Same planning session (shares the call-reliability surface).
|
|
||||||
|
|
||||||
### Security & Privacy
|
|
||||||
|
|
||||||
- **N97 — Access token + device id in plaintext `localStorage`** (`state/sessions.ts`), XSS-exposed. Architectural — needs a token-protection / session-storage redesign.
|
|
||||||
- **Persisted PII without encryption:** user status message + expiry (`Profile.tsx`), unsent composer drafts (`RoomInput.tsx`). Leak risk on shared devices.
|
|
||||||
|
|
||||||
### PWA / Offline / Web Push
|
|
||||||
|
|
||||||
- **N107 — Web Push is non-functional:** `src/sw.ts` has no `push` handler. Needs a `push` listener + Matrix push-gateway integration. **The one substantive remaining feature** (session/crypto groundwork it waited on has landed).
|
|
||||||
- **No app-asset caching strategy** in `src/sw.ts` — no offline capability.
|
|
||||||
|
|
||||||
### Dependencies / Build / Hygiene
|
|
||||||
|
|
||||||
- Build-time: `lotusDenoise` does heavy sequential `fs` in `closeBundle`; `viteStaticCopy` has redundant renames — could be streamlined.
|
|
||||||
- `patch-folds.mjs` edits `node_modules` directly (robust today; `patch-package` considered but more brittle to folds restructuring — WON'T-DO unless it breaks).
|
|
||||||
- `types/matrix/` mirrors SDK types instead of importing them — drift risk; spot-fix highest-risk only.
|
|
||||||
- `contrib/nginx`/`contrib/caddy` examples: headers + `try_files` already synced with prod; the prod nginx `add_header` isn't inherited by cache `location` blocks (pre-existing; SPA entry `/` still gets all headers).
|
|
||||||
- `as any` casts across `src/` — gradual typing cleanup. Keep commits scoped (bisect-friendly). Keep README fork-sync version/logo current.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Matrix Protocol Gaps
|
|
||||||
|
|
||||||
Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audited 2026-07 against the codebase — almost everything else is built: pinning, stickers+picker, room directory, mutual rooms MSC2666, blurhash, key backup/recovery/SSSS, SAS verification, ignore list, invite spam-filter, voice messages, polls, threads, spaces, OIDC, extended profiles, delayed events, authed media). Build each **fully** — spec-correct events, native-Cinny folds UI, tests. Order = clean wins first.
|
|
||||||
|
|
||||||
**Phase A ✅ (2026-07, gate-green 683 tests):**
|
|
||||||
|
|
||||||
- [x] **Mark as Unread — MSC2867 `m.marked_unread`.** Room account data `{ unread: true }` (+ unstable `com.famedly.marked_unread`) via `mx.setRoomAccountData`; clear on read. Context-menu item in `RoomNavItem` + light the existing unread dot; integrate `state/room/roomToUnread.ts`.
|
|
||||||
- [x] **Low Priority rooms — `m.lowpriority` tag.** Mirror the favourite impl (`RoomNavItem.tsx:331-337` `setRoomTag/deleteRoomTag` + the favourites category in `home/Home.tsx`): context-menu toggle + a collapsed "Low Priority" category sorted to the bottom, excluded from normal unread nudging.
|
|
||||||
|
|
||||||
**Phase B ✅ (2026-07, gate-green 688 tests):**
|
|
||||||
|
|
||||||
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
|
|
||||||
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
|
|
||||||
|
|
||||||
**Phase C (Room Widgets ✅ 2026-07; Sliding Sync ❌ evaluated — parked):**
|
|
||||||
|
|
||||||
- [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api` — **extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
|
|
||||||
- **[PARKED] Sliding Sync — MSC3575 / simplified MSC4186** (evaluated 2026-07, 3 research passes). Server side is GA (`simplified_msc3575`), but the **client** side is not viable for a safe rollout: matrix-js-sdk's `SlidingSync`/`SlidingSyncSdk` are `_internal_`/`@experimental` (Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); **presence isn't delivered over sliding sync** (regresses Lotus presence badges/rings/status); **no upstream Cinny impl** to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the **full local room set** (`allRoomsAtom` ← `mx.getRooms()`), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). **Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended.** Full assessment: git plan history.
|
|
||||||
|
|
||||||
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf` → **`nginx -s reload`**) or external widgets are blocked.
|
|
||||||
|
|
||||||
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
|
||||||
|
|
||||||
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
|
|
||||||
|
|
||||||
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
|
|
||||||
|
|
||||||
Findings:
|
|
||||||
|
|
||||||
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
|
|
||||||
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
|
|
||||||
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
|
|
||||||
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
|
|
||||||
|
|
||||||
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy`→`Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
|
|
||||||
|
|
||||||
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
|
|
||||||
|
|
||||||
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
|
|
||||||
|
|
||||||
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
|
|
||||||
|
|
||||||
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
|
|
||||||
|
|
||||||
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
|
|
||||||
|
|
||||||
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
|
|
||||||
|
|
||||||
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
|
||||||
|
|
||||||
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
|
||||||
|
|
||||||
**✅ Buildable NOW (client-only, no server/infra change):**
|
|
||||||
|
|
||||||
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
|
|
||||||
|
|
||||||
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
|
|
||||||
|
|
||||||
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
|
|
||||||
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
|
|
||||||
|
|
||||||
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
|
|
||||||
|
|
||||||
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
|
|
||||||
- Reaction / relation redaction (**MSC3892** — `false`)
|
|
||||||
- ~~Room preview before joining (MSC3266)~~ — **DONE** (client was always built; unstable `im.nheko.summary` endpoint returns 200 — verified on 1.156)
|
|
||||||
- Thread subscriptions (**MSC4306** — `false`)
|
|
||||||
|
|
||||||
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
|
|
||||||
|
|
||||||
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Open Feature Backlog
|
|
||||||
|
|
||||||
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
|
|
||||||
|
|
||||||
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
|
|
||||||
|
|
||||||
### [x] P4-4 · Math / LaTeX Rendering — DONE
|
|
||||||
|
|
||||||
Rendering shipped (KaTeX, `$…$`/`$$…$$` + spec `data-mx-maths`, lazy-loaded,
|
|
||||||
`<pre>/<code>`-guarded) — see LOTUS_FEATURES.md. **Outgoing cross-client interop
|
|
||||||
added (2026-07):** the composer now emits spec `data-mx-maths` HTML on send
|
|
||||||
(`editor/output.ts`, reusing `splitMathSegments`), so math a Lotus user types
|
|
||||||
renders on Element and every other client, not just Lotus. Deferred: multi-line
|
|
||||||
block `$$…$$` (spans editor paragraph nodes) still renders on Lotus via the
|
|
||||||
plain-body `$…$` path only.
|
|
||||||
|
|
||||||
### [~] P5-20 · Quick Reply from Browser Notification (partial)
|
|
||||||
|
|
||||||
Done: notifications show the real body, click navigates to the specific event + focuses the tab. **Remaining:** inline reply via Notification Actions API needs the SW `push`+`notificationclick` pipeline (switch `new Notification()` → `serviceWorkerRegistration.showNotification()` so the SW receives `notificationclick`; on `event.action==='reply'` POST `m.room.message` with the stored `{roomId, threadId}`). Ties into N107.
|
|
||||||
|
|
||||||
### [~] P5-30 · Advanced ML Noise Suppression — open verification
|
|
||||||
|
|
||||||
Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex; AEC on, AGC off for ML tier; never-silent watchdog). **Open:** real-call by-ear **A/B** — model choice, `lotusDenoiseFloor`, AGC on/off (LOTUS_TESTING §D2-1 / J2). **GTCRN (deferred):** tiny MIT 16 kHz model beating RNNoise, but no drop-in browser package — needs `onnxruntime-web` in a Web Worker behind a custom AudioWorklet ring-buffer (ORT can't run in an AudioWorklet, issue #13072); ~1-week build. Revisit only if low-power quality proves insufficient. HW-gated (FRCRN/Maxine) = desktop-Rust-only future.
|
|
||||||
|
|
||||||
### [~] P6-2 · Element Call fork — retire remaining DOM hacks (Phase 2 needs publish)
|
|
||||||
|
|
||||||
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1`→`lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1–EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
|
|
||||||
|
|
||||||
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
|
|
||||||
|
|
||||||
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1–M6 + N1–N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
|
|
||||||
|
|
||||||
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
|
|
||||||
|
|
||||||
**NOT done — needs a real device / product decisions (open):**
|
|
||||||
|
|
||||||
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
|
|
||||||
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
|
|
||||||
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
|
|
||||||
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
|
|
||||||
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
|
|
||||||
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
|
|
||||||
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
|
|
||||||
|
|
||||||
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
|
||||||
|
|
||||||
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
|
||||||
|
|
||||||
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
|
||||||
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
|
||||||
- **Vimeo `event/{id}` (live events) + `ondemand/…`** — event embed host is `vimeo.com` (**not** `player.vimeo.com`, so it needs a new CSP `frame-src` host); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case if `vimeo.com` is widened for another reason.
|
|
||||||
|
|
||||||
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
|
|
||||||
|
|
||||||
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
|
|
||||||
|
|
||||||
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
|
|
||||||
|
|
||||||
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
|
|
||||||
|
|
||||||
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
|
|
||||||
|
|
||||||
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
|
|
||||||
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
|
|
||||||
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
|
|
||||||
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
|
|
||||||
|
|
||||||
### Deferred / dropped (decided — kept for context)
|
|
||||||
|
|
||||||
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
|
||||||
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
|
|
||||||
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Reference
|
|
||||||
|
|
||||||
### Server Capabilities (as of 2026-07)
|
|
||||||
|
|
||||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
|
||||||
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
|
|
||||||
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
|
|
||||||
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
|
||||||
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
|
||||||
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
|
||||||
|
|
||||||
### Key File Reference
|
|
||||||
|
|
||||||
| What | File | Lines |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------------- | ------------------- |
|
|
||||||
| Global keydown / room nav | `hooks/useKeyDown.ts` · `hooks/useRoomNavigate.ts` | whole / 19-72 |
|
|
||||||
| Room unread counts atom | `state/room/roomToUnread.ts` | `roomToUnreadAtom` |
|
|
||||||
| Overlay portal provider | `pages/App.tsx` · `index.html` | 65 / 101 |
|
|
||||||
| Room settings tabs | `features/room-settings/RoomSettings.tsx` | 27-56 |
|
|
||||||
| State event read/write pattern | `features/common-settings/general/RoomEncryption.tsx` | 42-52 |
|
|
||||||
| Power levels | `hooks/usePowerLevels.ts` | whole |
|
|
||||||
| Slash commands | `hooks/useCommands.ts` | 140-537 |
|
|
||||||
| Chat background picker/defs | `features/settings/general/General.tsx` · `lotus/chatBackground.ts` | 945-981 / whole |
|
|
||||||
| Matrix.to URL builder | `plugins/matrix-to.ts` | `getMatrixToRoom()` |
|
|
||||||
| Media URL conversion | `utils/matrix.ts` | `mxcUrlToHttp()` |
|
|
||||||
| Search pagination / virtual | `features/message-search/{useMessageSearch,MessageSearch}.tsx` | 74-121 / 234-365 |
|
|
||||||
| Call mic control | `plugins/call/CallControl.ts` | 206-212 |
|
|
||||||
| Knock support check | `utils/matrix.ts` | 376-391 |
|
|
||||||
| Notification mute push rules | `hooks/useRoomsNotificationPreferences.ts` | 110-150 |
|
|
||||||
|
|
||||||
### Element Call fork — operational reference
|
|
||||||
|
|
||||||
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
|
|
||||||
|
|
||||||
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
|
|
||||||
|
|
||||||
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
|
|
||||||
|
|
||||||
| Action | Dir | Purpose | Module |
|
|
||||||
| :--------------------------- | :------ | :----------------------------------------------------- | :-------------------- |
|
|
||||||
| `io.lotus.call_state` | EC→host | speaker/mute/camera stream (`lotusCallState=1`) | `lotusCallState.ts` |
|
|
||||||
| `io.lotus.focus_participant` | host→EC | spotlight (works during screenshare) | `lotusFocus.ts` |
|
|
||||||
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into call (`lotusAudioInject=1`) | `lotusAudioInject.ts` |
|
|
||||||
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
|
|
||||||
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
|
|
||||||
| `io.lotus.set_deafen` | host→EC | LiveKit-source deafen (P6-2) | `lotusDeafen.ts` |
|
|
||||||
|
|
||||||
Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-source ML denoise).
|
|
||||||
|
|
||||||
### CI/CD + per-feature checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
edit → commit → git push origin lotus
|
|
||||||
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
|
|
||||||
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
|
|
||||||
```
|
|
||||||
|
|
||||||
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
|
|
||||||
|
|
||||||
**CI hardening (2026-07, reviewed):**
|
|
||||||
|
|
||||||
- [x] **Concurrency** — `cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
|
|
||||||
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
|
|
||||||
|
|
||||||
**CI follow-ups (open):**
|
|
||||||
|
|
||||||
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
|
|
||||||
- [ ] **Debounce the desktop trigger** — `trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
|
|
||||||
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
|
|
||||||
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.7
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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
|
## Features
|
||||||
|
|
||||||
### Messaging
|
### 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)
|
- 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)
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
- Bookmark any message and revisit saved messages from the sidebar
|
- 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
|
- Click "edited" on any message to see the full edit history
|
||||||
- Drafts are saved automatically and survive page reloads
|
- Drafts are saved automatically and survive page reloads
|
||||||
- Long messages collapse automatically — click "Read more" to expand
|
- 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
|
- Toggle to pause background animations
|
||||||
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||||
- Night Light / blue light filter with an adjustable intensity slider
|
- 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
|
- Rename any room for yourself only — other members see the original name
|
||||||
- Emoji picker on all room name inputs
|
- 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
|
- 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)
|
- Homeserver support contact displayed in Help & About (MSC1929)
|
||||||
- Server notice rooms are visually distinct from regular DMs
|
- 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
|
||||||
|
|
||||||
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)
|
### SmartScreen Warning (Windows)
|
||||||
|
|
||||||
@@ -196,7 +174,7 @@ Beyond the web client, the desktop app adds native OS integration (Windows-focus
|
|||||||
|
|
||||||
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
|
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
|
||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_REFERENCE.md](LOTUS_REFERENCE.md) for the design laws and operational reference (open work is in [Gitea issues](https://code.lotusguild.org/LotusGuild/cinny/issues)).
|
||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
@@ -218,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
|
|||||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
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.12`, upstream base v0.25.0, source at
|
||||||
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
`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
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
behavior is editable source instead of fragile DOM/widget hacks.
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
@@ -231,7 +209,7 @@ avatar decorations on EC video tiles, and a native transparent background.
|
|||||||
(`io.lotus.set_quality`).
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
The fork's `io.lotus.*` action catalog + the publish procedure are in
|
The fork's `io.lotus.*` action catalog + the publish procedure are in
|
||||||
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
|
**[`LOTUS_REFERENCE.md`](LOTUS_REFERENCE.md)** ("Element Call fork — operational reference");
|
||||||
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
|
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
|
||||||
Search the docs for the **`[EC-FORK]`** tag to find every related note.
|
Search the docs for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
@@ -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
|
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`).
|
||||||
|
|||||||
+40
-42
@@ -19,11 +19,10 @@
|
|||||||
*
|
*
|
||||||
* Any failure falls back to the unprocessed mic so calls never break.
|
* Any failure falls back to the unprocessed mic so calls never break.
|
||||||
*/
|
*/
|
||||||
// TODO: MAKE THIS A TS FILE
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let params;
|
var params;
|
||||||
try {
|
try {
|
||||||
params = new URLSearchParams(window.location.search);
|
params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('lotusDenoise') !== 'ml') return;
|
if (params.get('lotusDenoise') !== 'ml') return;
|
||||||
@@ -34,31 +33,31 @@
|
|||||||
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||||
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||||
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||||
let targetOrigin;
|
var targetOrigin;
|
||||||
try {
|
try {
|
||||||
let parentUrl = params.get('parentUrl');
|
var parentUrl = params.get('parentUrl');
|
||||||
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
targetOrigin = window.location.origin;
|
targetOrigin = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
let md = navigator.mediaDevices;
|
var md = navigator.mediaDevices;
|
||||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
|
|
||||||
let ASSET_BASE = './denoise/';
|
var ASSET_BASE = './denoise/';
|
||||||
|
|
||||||
let MODEL = params.get('lotusModel') || 'rnnoise';
|
var MODEL = params.get('lotusModel') || 'rnnoise';
|
||||||
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
||||||
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
||||||
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
||||||
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
||||||
let SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
var SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
||||||
let USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
var USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
||||||
let USE_GATE = params.get('lotusGate') === 'true';
|
var USE_GATE = params.get('lotusGate') === 'true';
|
||||||
let GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
var GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
||||||
|
|
||||||
let PROCESSORS = {
|
var PROCESSORS = {
|
||||||
rnnoise: {
|
rnnoise: {
|
||||||
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
||||||
script: 'rnnoiseWorklet.js',
|
script: 'rnnoiseWorklet.js',
|
||||||
@@ -92,9 +91,9 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let origGetUserMedia = md.getUserMedia.bind(md);
|
var origGetUserMedia = md.getUserMedia.bind(md);
|
||||||
let wasmPromises = {};
|
var wasmPromises = {};
|
||||||
let ctxPromise = null;
|
var ctxPromise = null;
|
||||||
|
|
||||||
function checkSimd() {
|
function checkSimd() {
|
||||||
try {
|
try {
|
||||||
@@ -113,12 +112,12 @@
|
|||||||
|
|
||||||
function loadWasm(modelId) {
|
function loadWasm(modelId) {
|
||||||
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
||||||
let p = PROCESSORS[modelId];
|
var p = PROCESSORS[modelId];
|
||||||
if (!p || !p.wasm) return Promise.resolve(null);
|
if (!p || !p.wasm) return Promise.resolve(null);
|
||||||
|
|
||||||
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
||||||
function (simd) {
|
function (simd) {
|
||||||
let file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
var file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
||||||
return fetch(ASSET_BASE + file).then(function (r) {
|
return fetch(ASSET_BASE + file).then(function (r) {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (simd && p.simdWasm)
|
if (simd && p.simdWasm)
|
||||||
@@ -138,7 +137,7 @@
|
|||||||
function getContext() {
|
function getContext() {
|
||||||
if (!ctxPromise) {
|
if (!ctxPromise) {
|
||||||
ctxPromise = (function () {
|
ctxPromise = (function () {
|
||||||
let ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
if (ctx.sampleRate !== SAMPLE_RATE) {
|
if (ctx.sampleRate !== SAMPLE_RATE) {
|
||||||
try {
|
try {
|
||||||
ctx.close();
|
ctx.close();
|
||||||
@@ -147,7 +146,7 @@
|
|||||||
}
|
}
|
||||||
// Load worklet modules. DTLN registers its own processor via the
|
// Load worklet modules. DTLN registers its own processor via the
|
||||||
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
||||||
let scripts = [];
|
var scripts = [];
|
||||||
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
||||||
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
||||||
|
|
||||||
@@ -170,7 +169,7 @@
|
|||||||
return ctxPromise;
|
return ctxPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasNotifiedActive = false;
|
var hasNotifiedActive = false;
|
||||||
|
|
||||||
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
||||||
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
||||||
@@ -188,9 +187,9 @@
|
|||||||
if (MODEL === 'deepfilternet') {
|
if (MODEL === 'deepfilternet') {
|
||||||
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
||||||
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
||||||
let dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
var dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
||||||
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
||||||
let core = new mod.DeepFilterNet3Core({
|
var core = new mod.DeepFilterNet3Core({
|
||||||
sampleRate: SAMPLE_RATE,
|
sampleRate: SAMPLE_RATE,
|
||||||
noiseReductionLevel: 80,
|
noiseReductionLevel: 80,
|
||||||
assetConfig: { cdnUrl: dfnBase },
|
assetConfig: { cdnUrl: dfnBase },
|
||||||
@@ -213,8 +212,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||||
let node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
numberOfInputs: 1,
|
numberOfInputs: 1,
|
||||||
numberOfOutputs: 1,
|
numberOfOutputs: 1,
|
||||||
@@ -232,21 +230,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processStream(stream) {
|
function processStream(stream) {
|
||||||
let audioTracks = stream.getAudioTracks();
|
var audioTracks = stream.getAudioTracks();
|
||||||
if (audioTracks.length === 0) return Promise.resolve(stream);
|
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||||
|
|
||||||
return Promise.all([loadWasm(MODEL), getContext()])
|
return Promise.all([loadWasm(MODEL), getContext()])
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
let wasmBinary = res[0];
|
var wasmBinary = res[0];
|
||||||
let ctx = res[1];
|
var ctx = res[1];
|
||||||
|
|
||||||
let source = ctx.createMediaStreamSource(stream);
|
var source = ctx.createMediaStreamSource(stream);
|
||||||
let dest = ctx.createMediaStreamDestination();
|
var dest = ctx.createMediaStreamDestination();
|
||||||
let head = source;
|
var head = source;
|
||||||
|
|
||||||
// 1. Optional Noise Gate
|
// 1. Optional Noise Gate
|
||||||
if (USE_GATE) {
|
if (USE_GATE) {
|
||||||
let gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
var gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
||||||
processorOptions: {
|
processorOptions: {
|
||||||
openThreshold: GATE_THRESHOLD,
|
openThreshold: GATE_THRESHOLD,
|
||||||
closeThreshold: GATE_THRESHOLD - 5,
|
closeThreshold: GATE_THRESHOLD - 5,
|
||||||
@@ -260,7 +258,7 @@
|
|||||||
|
|
||||||
// 2. ML Processor
|
// 2. ML Processor
|
||||||
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
||||||
let mlNode = ml.node;
|
var mlNode = ml.node;
|
||||||
head.connect(mlNode);
|
head.connect(mlNode);
|
||||||
mlNode.connect(dest);
|
mlNode.connect(dest);
|
||||||
|
|
||||||
@@ -268,15 +266,15 @@
|
|||||||
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
||||||
if (ml.ready && typeof ml.ready.then === 'function') {
|
if (ml.ready && typeof ml.ready.then === 'function') {
|
||||||
ml.ready.catch(function (err) {
|
ml.ready.catch(function (err) {
|
||||||
let m = err instanceof Error ? err.message : String(err);
|
var m = err instanceof Error ? err.message : String(err);
|
||||||
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let origTrack = audioTracks[0];
|
var origTrack = audioTracks[0];
|
||||||
let processedTrack = dest.stream.getAudioTracks()[0];
|
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||||
|
|
||||||
let torndown = false;
|
var torndown = false;
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (torndown) return;
|
if (torndown) return;
|
||||||
torndown = true;
|
torndown = true;
|
||||||
@@ -295,7 +293,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawStop = processedTrack.stop.bind(processedTrack);
|
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||||
processedTrack.stop = function () {
|
processedTrack.stop = function () {
|
||||||
cleanup();
|
cleanup();
|
||||||
rawStop();
|
rawStop();
|
||||||
@@ -321,7 +319,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let out = new MediaStream();
|
var out = new MediaStream();
|
||||||
out.addTrack(processedTrack);
|
out.addTrack(processedTrack);
|
||||||
stream.getVideoTracks().forEach(function (t) {
|
stream.getVideoTracks().forEach(function (t) {
|
||||||
out.addTrack(t);
|
out.addTrack(t);
|
||||||
@@ -330,7 +328,7 @@
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
let msg = e instanceof Error ? e.message : String(e);
|
var msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error('[lotus-denoise] Setup failed:', msg);
|
console.error('[lotus-denoise] Setup failed:', msg);
|
||||||
window.parent.postMessage(
|
window.parent.postMessage(
|
||||||
{ type: 'lotus-denoise-status', active: false, error: msg },
|
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||||
@@ -341,10 +339,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigator.mediaDevices.getUserMedia = function (constraints) {
|
navigator.mediaDevices.getUserMedia = function (constraints) {
|
||||||
let wantsAudio = !!(constraints && constraints.audio);
|
var wantsAudio = !!(constraints && constraints.audio);
|
||||||
let effective = constraints;
|
var effective = constraints;
|
||||||
if (wantsAudio) {
|
if (wantsAudio) {
|
||||||
let audioC =
|
var audioC =
|
||||||
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||||
audioC.noiseSuppression = USE_NATIVE_NS;
|
audioC.noiseSuppression = USE_NATIVE_NS;
|
||||||
audioC.channelCount = 1;
|
audioC.channelCount = 1;
|
||||||
|
|||||||
+3
-3
@@ -4,9 +4,9 @@
|
|||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [],
|
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
||||||
"rooms": [],
|
"rooms": ["#jellyfin:matrix.org"],
|
||||||
"servers": []
|
"servers": ["matrixrooms.info"]
|
||||||
},
|
},
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
root * /path/to/cinny/dist
|
||||||
|
# [Gitea #155] PWA share target: the service worker answers this POST; if it
|
||||||
|
# isn't controlling the page yet, land on /share instead of a 405.
|
||||||
|
redir /share-target /share 303
|
||||||
|
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## Insert wasm type into nginx mime.types file so they load correctly.
|
## Insert wasm type into nginx mime.types file so they load correctly.
|
||||||
|
|
||||||
`/etc/nginx/mime.types`:
|
`/etc/nginx/mime.types`:
|
||||||
|
|
||||||
```
|
```
|
||||||
types {
|
types {
|
||||||
..
|
..
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ server {
|
|||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
|
# [Gitea #155] PWA share target. The service worker normally answers this
|
||||||
|
# POST itself; if it isn't controlling the page yet, land on /share
|
||||||
|
# (the shared files are lost, but nothing 405s).
|
||||||
|
location = /share-target {
|
||||||
|
return 303 /share;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -39,6 +39,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 1a. MAS `config.yaml` — the parts that matter
|
### 1a. MAS `config.yaml` — the parts that matter
|
||||||
|
|
||||||
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
|
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -48,18 +49,19 @@ http:
|
|||||||
database:
|
database:
|
||||||
uri: postgresql://synapse:pw@postgres/synapse
|
uri: postgresql://synapse:pw@postgres/synapse
|
||||||
matrix:
|
matrix:
|
||||||
homeserver: localhost # the server_name
|
homeserver: localhost # the server_name
|
||||||
endpoint: http://synapse:8008/
|
endpoint: http://synapse:8008/
|
||||||
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
secret: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
|
||||||
clients:
|
clients:
|
||||||
- client_id: "0000000000000000000SYNAPSE"
|
- client_id: '0000000000000000000SYNAPSE'
|
||||||
client_auth_method: client_secret_basic
|
client_auth_method: client_secret_basic
|
||||||
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
|
||||||
passwords: # so you can create a local test account in the MAS UI
|
passwords: # so you can create a local test account in the MAS UI
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
|
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
|
||||||
|
|
||||||
See `synapse-msc3861.yaml` in this folder; the key block is:
|
See `synapse-msc3861.yaml` in this folder; the key block is:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -67,17 +69,18 @@ experimental_features:
|
|||||||
msc3861:
|
msc3861:
|
||||||
enabled: true
|
enabled: true
|
||||||
issuer: http://localhost:8090/
|
issuer: http://localhost:8090/
|
||||||
client_id: "0000000000000000000SYNAPSE"
|
client_id: '0000000000000000000SYNAPSE'
|
||||||
client_auth_method: client_secret_basic
|
client_auth_method: client_secret_basic
|
||||||
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
|
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET' # == MAS clients[].client_secret
|
||||||
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
|
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN' # == MAS matrix.secret
|
||||||
account_management_url: "http://localhost:8090/account"
|
account_management_url: 'http://localhost:8090/account'
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a test user via the MAS UI (`http://localhost:8090/`) or
|
Create a test user via the MAS UI (`http://localhost:8090/`) or
|
||||||
`docker compose exec mas mas-cli manage register-user`.
|
`docker compose exec mas mas-cli manage register-user`.
|
||||||
|
|
||||||
Sanity check discovery (the client relies on this):
|
Sanity check discovery (the client relies on this):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
|
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
|
||||||
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
|
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
|
||||||
@@ -108,5 +111,6 @@ See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
|
|||||||
revocation, account-management link, and the non-OIDC-regression check).
|
revocation, account-management link, and the non-OIDC-regression check).
|
||||||
|
|
||||||
## Files here
|
## Files here
|
||||||
|
|
||||||
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
|
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
|
||||||
- `config.local.json` — the Lotus `public/config.json` override.
|
- `config.local.json` — the Lotus `public/config.json` override.
|
||||||
|
|||||||
@@ -2,6 +2,42 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
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 / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import {
|
||||||
|
createRoom,
|
||||||
|
hsReachable,
|
||||||
|
loginUI,
|
||||||
|
openRoom,
|
||||||
|
ensureUser,
|
||||||
|
sendText,
|
||||||
|
uniq,
|
||||||
|
TestUser,
|
||||||
|
} from './localHs';
|
||||||
|
|
||||||
|
// [Gitea #222] Machine-checkable accessibility on every push. Two parts:
|
||||||
|
// 1. axe-core over the main surfaces, failing on critical/serious findings.
|
||||||
|
// Colour-contrast is reported but not gated: several hits are generated
|
||||||
|
// avatar colours and portal false positives (see the issue for the list).
|
||||||
|
// 2. Accessibility-tree snapshots of the composer, message menu, thread
|
||||||
|
// panel and settings nav, so a lost name/role/live-region shows up as a
|
||||||
|
// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`.
|
||||||
|
// A real screen-reader pass still needs a human.
|
||||||
|
|
||||||
|
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
|
||||||
|
|
||||||
|
async function auditPage(page: Page, label: string) {
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||||
|
const describe = (v: (typeof results.violations)[number]) =>
|
||||||
|
`[${v.impact}] ${v.id}: ${v.help}\n${v.nodes
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`)
|
||||||
|
.join('\n')}`;
|
||||||
|
const contrast = results.violations.filter((v) => v.id === 'color-contrast');
|
||||||
|
if (contrast.length) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`);
|
||||||
|
}
|
||||||
|
const gated = results.violations.filter(
|
||||||
|
(v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'),
|
||||||
|
);
|
||||||
|
expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('accessibility', () => {
|
||||||
|
test('login page passes axe @webkit', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByLabel('Username or email')).toBeVisible();
|
||||||
|
await auditPage(page, 'login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('signed in', () => {
|
||||||
|
let alice: TestUser;
|
||||||
|
let room: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), 'no local homeserver');
|
||||||
|
alice = await ensureUser(uniq('e2e_a11y_'));
|
||||||
|
room = await createRoom(alice, 'A11y Room');
|
||||||
|
const root = await sendText(alice, room, 'thread root for a11y');
|
||||||
|
await sendText(alice, room, 'a reply', {
|
||||||
|
'm.relates_to': {
|
||||||
|
rel_type: 'm.thread',
|
||||||
|
event_id: root,
|
||||||
|
is_falling_back: true,
|
||||||
|
'm.in_reply_to': { event_id: root },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 850 });
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await expect(page.getByText('thread root for a11y')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room timeline + composer', async ({ page }) => {
|
||||||
|
await auditPage(page, 'room');
|
||||||
|
await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({
|
||||||
|
name: 'composer-editor.aria.yml',
|
||||||
|
});
|
||||||
|
await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({
|
||||||
|
name: 'composer-send.aria.yml',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message options menu', async ({ page }) => {
|
||||||
|
const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' });
|
||||||
|
await msg.hover();
|
||||||
|
await msg.getByRole('button', { name: 'More options' }).click();
|
||||||
|
const menu = page.locator('[data-message-menu]').first();
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await auditPage(page, 'message menu');
|
||||||
|
await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread panel', async ({ page }) => {
|
||||||
|
await page
|
||||||
|
.locator('[data-message-item]', { hasText: 'thread root for a11y' })
|
||||||
|
.getByText(/1 reply/)
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
|
||||||
|
await auditPage(page, 'thread panel');
|
||||||
|
await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({
|
||||||
|
name: 'thread-panel.aria.yml',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user settings', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||||
|
const dialog = page.getByRole('dialog').first();
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await auditPage(page, 'settings');
|
||||||
|
await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({
|
||||||
|
name: 'settings-nav.aria.yml',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room settings', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||||
|
await page.getByText('Room Settings', { exact: true }).click();
|
||||||
|
await expect(page.getByRole('dialog').first()).toBeVisible();
|
||||||
|
await auditPage(page, 'room settings');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- textbox "Send a message...":
|
||||||
|
- paragraph: Send a message...
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- button "Send message":
|
||||||
|
- img
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
- button "Add Reaction":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Reply":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Forward":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Bookmark Message":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Remind Me":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Edit Message":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Read Receipts":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Copy Text":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Translate":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Copy Link":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Copy Lotus Link":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Pin Message":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
|
- button "Delete":
|
||||||
|
- text: ''
|
||||||
|
- img
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
- navigation "Settings sections":
|
||||||
|
- button "General" [pressed]:
|
||||||
|
- img
|
||||||
|
- paragraph: General
|
||||||
|
- button "Account":
|
||||||
|
- img
|
||||||
|
- paragraph: Account
|
||||||
|
- button "Notifications":
|
||||||
|
- img
|
||||||
|
- paragraph: Notifications
|
||||||
|
- button "Devices":
|
||||||
|
- img
|
||||||
|
- paragraph: Devices
|
||||||
|
- button "Emojis & Stickers":
|
||||||
|
- img
|
||||||
|
- paragraph: Emojis & Stickers
|
||||||
|
- button "Developer Tools":
|
||||||
|
- img
|
||||||
|
- paragraph: Developer Tools
|
||||||
|
- button "About":
|
||||||
|
- img
|
||||||
|
- paragraph: About
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
- complementary "Thread":
|
||||||
|
- paragraph: Thread
|
||||||
|
- paragraph: /A11y Room · started by e2e_a11y_\w+, .+/
|
||||||
|
- button "Thread notifications":
|
||||||
|
- img
|
||||||
|
- button "Close thread":
|
||||||
|
- img
|
||||||
|
- log "Thread timeline":
|
||||||
|
- article:
|
||||||
|
- button /e2e_a11y_\w+, open profile/:
|
||||||
|
- img
|
||||||
|
- button /e2e_a11y_\w+/
|
||||||
|
- time: /\d+:\d+ (AM|PM)/
|
||||||
|
- text: thread root for a11y
|
||||||
|
- paragraph: 1 reply
|
||||||
|
- article:
|
||||||
|
- button /e2e_a11y_\w+, open profile/:
|
||||||
|
- img
|
||||||
|
- button /e2e_a11y_\w+/
|
||||||
|
- time: /\d+:\d+ (AM|PM)/
|
||||||
|
- text: a reply
|
||||||
|
- button "More actions":
|
||||||
|
- img
|
||||||
|
- textbox "Send a message...":
|
||||||
|
- paragraph: Send a message...
|
||||||
|
- button "Insert sticker":
|
||||||
|
- img
|
||||||
|
- button "Insert emoji":
|
||||||
|
- img
|
||||||
|
- button "Send message":
|
||||||
|
- img
|
||||||
@@ -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 @webkit @ios', 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 @webkit @ios', 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,87 @@
|
|||||||
|
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,
|
||||||
|
// Tier 3 (local homeserver named "localhost"): the client's well-known
|
||||||
|
// autodiscovery probes https://localhost/, which is either not listening
|
||||||
|
// (CI) or a self-signed dev server (local calls stack).
|
||||||
|
/ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i,
|
||||||
|
// WebKit's spellings of the same discovery failures (#221).
|
||||||
|
/Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i,
|
||||||
|
// Also a fetch cut short by our own navigation (e.g. the crypto wasm while
|
||||||
|
// the test moves from /home to a room) — WebKit words that the same way.
|
||||||
|
/due to access control checks/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,
|
||||||
|
// WebKit surfaces handled fetch failures (well-known probes) as page
|
||||||
|
// errors rather than console lines, so the allowlist applies to both.
|
||||||
|
unexpected: () => [
|
||||||
|
...pageErrors
|
||||||
|
.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m)))
|
||||||
|
.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,335 @@
|
|||||||
|
import { test, expect, devices } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
HS,
|
||||||
|
api,
|
||||||
|
createRoom,
|
||||||
|
enc,
|
||||||
|
ensureUser,
|
||||||
|
hsReachable,
|
||||||
|
joinRoom,
|
||||||
|
loginUI,
|
||||||
|
openRoom,
|
||||||
|
sendText,
|
||||||
|
uniq,
|
||||||
|
TestUser,
|
||||||
|
} from './localHs';
|
||||||
|
import { collectConsole } from './helpers';
|
||||||
|
|
||||||
|
// Tier 3 — regression suite against the job's own Synapse (Gitea #220).
|
||||||
|
// Each test seeds what it needs through the CS API and drives the built
|
||||||
|
// client; nothing here touches a real deployment.
|
||||||
|
test.describe('local homeserver regression', () => {
|
||||||
|
let alice: TestUser;
|
||||||
|
let bob: TestUser;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||||
|
alice = await ensureUser(uniq('e2e_alice_'));
|
||||||
|
bob = await ensureUser(uniq('e2e_bob_'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs in, opens a room, sends and receives @webkit @ios', async ({ page }) => {
|
||||||
|
const console_ = collectConsole(page);
|
||||||
|
const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] });
|
||||||
|
await joinRoom(bob, room);
|
||||||
|
await sendText(bob, room, 'hello from bob');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await expect(page.getByText('hello from bob')).toBeVisible();
|
||||||
|
await page.locator('[data-slate-editor]').first().click();
|
||||||
|
await page.keyboard.type('hello from alice');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByText('hello from alice')).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await api<{ chunk: { content: { body: string } }[] }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=1`,
|
||||||
|
bob.token,
|
||||||
|
)
|
||||||
|
).chunk[0]?.content.body,
|
||||||
|
)
|
||||||
|
.toBe('hello from alice');
|
||||||
|
expect(console_.unexpected()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
||||||
|
const room = await createRoom(alice, 'Scroll Room');
|
||||||
|
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||||
|
(chain, _, i) => chain.then(() => sendText(alice, room, `filler ${i}`)),
|
||||||
|
Promise.resolve(),
|
||||||
|
);
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await page.mouse.move(600, 350);
|
||||||
|
await page.mouse.wheel(0, -600);
|
||||||
|
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
|
||||||
|
await page.locator('[data-slate-editor]').first().click();
|
||||||
|
await page.keyboard.type('sent while scrolled up');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByText('sent while scrolled up')).toBeInViewport();
|
||||||
|
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/kick failure is reported, not swallowed (#216)', async ({ page }) => {
|
||||||
|
const room = await createRoom(alice, 'Kick Room', { invite: [bob.userId] });
|
||||||
|
await joinRoom(bob, room);
|
||||||
|
await loginUI(page, bob);
|
||||||
|
await openRoom(page, room);
|
||||||
|
const editor = page.locator('[data-slate-editor]').first();
|
||||||
|
await editor.click();
|
||||||
|
await page.keyboard.type('/kick', { delay: 40 });
|
||||||
|
await page.keyboard.press('Tab'); // accept the command chip
|
||||||
|
await page.keyboard.type(` ${alice.userId}`, { delay: 20 });
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByText(/Could not kick .*You cannot kick/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload failure shows a plain sentence, never the raw MatrixError (#213)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const room = await createRoom(alice, 'Upload Room');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await page.route(/\/_matrix\/media\/v3\/upload/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 413,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'nope' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const chooser = page.waitForEvent('filechooser');
|
||||||
|
await page.getByRole('button', { name: 'Attach file' }).first().click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({
|
||||||
|
name: 'pic.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const send = page.getByRole('button', { name: /^Send$/ });
|
||||||
|
if (await send.count()) await send.first().click();
|
||||||
|
else await page.getByRole('button', { name: 'Send message' }).click();
|
||||||
|
await expect(page.getByText(/This file is larger than the server allows/)).toBeVisible();
|
||||||
|
await expect(page.getByText(/MatrixError|_matrix\/media/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwarded message carries its provenance header', async ({ page }) => {
|
||||||
|
const src = await createRoom(bob, 'Source Room', { invite: [alice.userId] });
|
||||||
|
const dst = await createRoom(alice, 'Destination Room');
|
||||||
|
await joinRoom(alice, src);
|
||||||
|
await sendText(bob, src, 'the original message');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, src);
|
||||||
|
const msg = page.locator('[data-message-item]', { hasText: 'the original message' }).first();
|
||||||
|
await msg.hover();
|
||||||
|
await msg.getByRole('button', { name: 'More options' }).click();
|
||||||
|
await page.getByText('Forward', { exact: true }).click();
|
||||||
|
const search = page.getByPlaceholder('Search rooms…');
|
||||||
|
await search.fill('Destination Room');
|
||||||
|
await page.locator('button', { hasText: 'Destination Room' }).first().click();
|
||||||
|
await page.getByRole('button', { name: /^Send to 1 room/ }).click();
|
||||||
|
await expect(page.getByText(/Forwarded to/)).toBeVisible();
|
||||||
|
await openRoom(page, dst);
|
||||||
|
await expect(page.getByText(/Forwarded from .* in Source Room/)).toBeVisible();
|
||||||
|
await expect(page.getByText('the original message')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218) @webkit', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const room = await createRoom(alice, 'Thread Room');
|
||||||
|
const root = await sendText(alice, room, 'thread root');
|
||||||
|
await sendText(alice, room, 'a reply', {
|
||||||
|
'm.relates_to': {
|
||||||
|
rel_type: 'm.thread',
|
||||||
|
event_id: root,
|
||||||
|
is_falling_back: true,
|
||||||
|
'm.in_reply_to': { event_id: root },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await page
|
||||||
|
.locator('[data-message-item]', { hasText: 'thread root' })
|
||||||
|
.getByText(/1 reply/)
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
|
||||||
|
const widths = await page
|
||||||
|
.locator('[data-slate-editor]')
|
||||||
|
.evaluateAll((els) => els.map((e) => e.getBoundingClientRect().width));
|
||||||
|
expect(Math.min(...widths)).toBeGreaterThan(120);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a thread I started keeps its unread replies while I view the room (#217)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const room = await createRoom(alice, 'Thread Unread Room', { invite: [bob.userId] });
|
||||||
|
await joinRoom(bob, room);
|
||||||
|
const inThread = (root: string) => ({
|
||||||
|
'm.relates_to': {
|
||||||
|
rel_type: 'm.thread',
|
||||||
|
event_id: root,
|
||||||
|
is_falling_back: true,
|
||||||
|
'm.in_reply_to': { event_id: root },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = await sendText(alice, room, 'my thread root');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
// While the room is open and at the bottom: a thread reply, then a newer
|
||||||
|
// main-timeline message. Neither may clear the thread without opening it.
|
||||||
|
await sendText(bob, room, 'reply in your thread', inThread(root));
|
||||||
|
await sendText(bob, room, 'newer main message');
|
||||||
|
await expect(page.getByText('newer main message')).toBeVisible();
|
||||||
|
const chip = page
|
||||||
|
.locator('[data-message-item]', { hasText: 'my thread root' })
|
||||||
|
.getByRole('button', { name: /1 reply/ });
|
||||||
|
const threadUnread = async () => {
|
||||||
|
// not_types varies so Synapse's sync response cache can't serve a stale answer.
|
||||||
|
const filter = {
|
||||||
|
room: {
|
||||||
|
rooms: [room],
|
||||||
|
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sync = await api<{
|
||||||
|
rooms: {
|
||||||
|
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
|
||||||
|
alice.token,
|
||||||
|
);
|
||||||
|
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
|
||||||
|
};
|
||||||
|
await page.waitForTimeout(2000); // let any receipt the room view would send go out
|
||||||
|
expect(await threadUnread()).toBe(true);
|
||||||
|
await expect(chip).toHaveAccessibleName(/unread replies/);
|
||||||
|
await chip.click();
|
||||||
|
await expect.poll(threadUnread).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||||
|
const room = await createRoom(alice, 'Lightbox Room');
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
const up = await fetch(`${HS}/_matrix/media/v3/upload?filename=a.png`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${alice.token}`, 'Content-Type': 'image/png' },
|
||||||
|
body: png,
|
||||||
|
}).then((r) => r.json() as Promise<{ content_uri: string }>);
|
||||||
|
await api(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/send/m.room.message/img1`,
|
||||||
|
alice.token,
|
||||||
|
{
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: 'a.png',
|
||||||
|
url: up.content_uri,
|
||||||
|
info: { mimetype: 'image/png', size: png.length, w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await page.locator('[data-message-item] img[alt="a.png"]').click();
|
||||||
|
const viewer = page.getByRole('dialog', { name: 'Media viewer' });
|
||||||
|
await expect(viewer).toBeVisible();
|
||||||
|
await expect(viewer.getByText('1 / 1')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(viewer).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns when the local clock is far off the server (#158)', async ({ page }) => {
|
||||||
|
const room = await createRoom(alice, 'Skew Room', { invite: [bob.userId] });
|
||||||
|
await joinRoom(bob, room);
|
||||||
|
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await sendText(bob, room, `tick ${i}`);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
|
||||||
|
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status save survives the presence rate limit (#226)', async ({ page }) => {
|
||||||
|
let lastOk = 0;
|
||||||
|
await page.route(/\/presence\/[^/]+\/status/, (route) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastOk < 10_000) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
errcode: 'M_LIMIT_EXCEEDED',
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
retry_after_ms: 10_000 - (now - lastOk),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastOk = now;
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await page
|
||||||
|
.locator('button', { hasText: new RegExp(`^${alice.localpart[0]}$`) })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByText('Account', { exact: true }).first().click();
|
||||||
|
const input = page.getByLabel('Status message');
|
||||||
|
const status = uniq('status ');
|
||||||
|
await input.fill(status);
|
||||||
|
await input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(page.getByText(/Failed to save status/)).toHaveCount(0);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await api<{ status_msg?: string }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
|
||||||
|
alice.token,
|
||||||
|
)
|
||||||
|
).status_msg,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const room = await createRoom(alice, 'Touch Room');
|
||||||
|
await sendText(alice, room, 'press and hold me');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
const msg = page.locator('[data-message-item]', { hasText: 'press and hold me' });
|
||||||
|
const box = (await msg.boundingBox())!;
|
||||||
|
const cdp = await ctx.newCDPSession(page);
|
||||||
|
const x = box.x + 100;
|
||||||
|
const y = box.y + box.height / 2;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
const sheet = page.getByRole('dialog', { name: 'Message actions' });
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(sheet.getByText('Reply', { exact: true })).toBeVisible();
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier 3 (Gitea #220): regression tests against a throwaway Synapse the job
|
||||||
|
* starts itself (`scripts/dev-homeserver.sh start`). No prod secrets. Set
|
||||||
|
* E2E_LOCAL_HS (default http://localhost:8008) — the spec skips when the
|
||||||
|
* server isn't reachable.
|
||||||
|
*/
|
||||||
|
export const HS = process.env.E2E_LOCAL_HS || 'http://localhost:8008';
|
||||||
|
export const PASSWORD = 'password123';
|
||||||
|
export const enc = encodeURIComponent;
|
||||||
|
|
||||||
|
export async function hsReachable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HS}/_matrix/client/versions`);
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T = Record<string, unknown>>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
token?: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${HS}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestUser = { localpart: string; userId: string; token: string };
|
||||||
|
|
||||||
|
/** Registers (open registration) or logs in a test user. */
|
||||||
|
export async function ensureUser(localpart: string): Promise<TestUser> {
|
||||||
|
const reg = await api<{ user_id?: string; access_token?: string }>(
|
||||||
|
'POST',
|
||||||
|
'/_matrix/client/v3/register',
|
||||||
|
undefined,
|
||||||
|
{ username: localpart, password: PASSWORD, auth: { type: 'm.login.dummy' } },
|
||||||
|
);
|
||||||
|
if (reg.access_token && reg.user_id) {
|
||||||
|
return { localpart, userId: reg.user_id, token: reg.access_token };
|
||||||
|
}
|
||||||
|
const login = await api<{ user_id: string; access_token: string }>(
|
||||||
|
'POST',
|
||||||
|
'/_matrix/client/v3/login',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: { type: 'm.id.user', user: localpart },
|
||||||
|
password: PASSWORD,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { localpart, userId: login.user_id, token: login.access_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRoom(
|
||||||
|
owner: TestUser,
|
||||||
|
name: string,
|
||||||
|
opts: { invite?: string[]; preset?: string } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await api<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', owner.token, {
|
||||||
|
name,
|
||||||
|
preset: opts.preset ?? 'public_chat',
|
||||||
|
invite: opts.invite,
|
||||||
|
});
|
||||||
|
return res.room_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinRoom(user: TestUser, roomId: string): Promise<void> {
|
||||||
|
await api('POST', `/_matrix/client/v3/rooms/${enc(roomId)}/join`, user.token, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let txn = 0;
|
||||||
|
export async function sendText(
|
||||||
|
user: TestUser,
|
||||||
|
roomId: string,
|
||||||
|
body: string,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
): Promise<string> {
|
||||||
|
txn += 1;
|
||||||
|
const res = await api<{ event_id: string }>(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(roomId)}/send/m.room.message/e2e${Date.now()}_${txn}`,
|
||||||
|
user.token,
|
||||||
|
{ msgtype: 'm.text', body, ...extra },
|
||||||
|
);
|
||||||
|
return res.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Password login through the UI against the local homeserver. */
|
||||||
|
export async function loginUI(page: Page, user: TestUser): Promise<void> {
|
||||||
|
await page.goto(`/login/${enc(HS)}/`);
|
||||||
|
await page.getByLabel('Username or email').fill(user.localpart);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openRoom(page: Page, roomId: string): Promise<void> {
|
||||||
|
await page.goto(`/home/${enc(roomId)}`);
|
||||||
|
await expect(page.locator('[data-slate-editor]').first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uniq = (prefix: string): string =>
|
||||||
|
`${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
@@ -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: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-shadow': 'error',
|
'@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 captions not required for this app
|
||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
|
|||||||
@@ -27,9 +27,6 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
|
||||||
|
|||||||
Generated
+4899
-1841
File diff suppressed because it is too large
Load Diff
+32
-19
@@ -1,23 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "lotus-chat",
|
"name": "lotus-chat",
|
||||||
"version": "4.12.3-lotus",
|
"version": "4.12.7-lotus",
|
||||||
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
"check:eslint": "eslint src/* --max-warnings 36",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:install": "playwright install chromium webkit",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs",
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
"sync:decorations": "node scripts/syncDecorations.mjs"
|
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||||
},
|
},
|
||||||
@@ -25,6 +28,11 @@
|
|||||||
"*.{ts,tsx,js,jsx}": "eslint",
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
"*": "prettier --ignore-unknown --write"
|
"*": "prettier --ignore-unknown --write"
|
||||||
},
|
},
|
||||||
|
"config": {
|
||||||
|
"commitizen": {
|
||||||
|
"path": "./node_modules/cz-conventional-changelog"
|
||||||
|
}
|
||||||
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
@@ -35,22 +43,24 @@
|
|||||||
"@eslint/eslintrc": "3.3.5",
|
"@eslint/eslintrc": "3.3.5",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@fontsource-variable/inter": "5.2.8",
|
"@fontsource-variable/inter": "5.2.8",
|
||||||
|
"@giphy/js-fetch-api": "5.8.0",
|
||||||
"@giphy/js-types": "5.1.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",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@workadventure/noise-suppression": "0.1.1",
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.2.0",
|
"chroma-js": "3.2.0",
|
||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.3.0",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
@@ -71,10 +81,9 @@
|
|||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"matrix-js-sdk": "41.7.0",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.18.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"oidc-client-ts": "3.5.0",
|
"pdfjs-dist": "6.3.289",
|
||||||
"pdfjs-dist": "5.7.284",
|
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
@@ -87,8 +96,8 @@
|
|||||||
"react-google-recaptcha": "3.1.0",
|
"react-google-recaptcha": "3.1.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-range": "1.10.0",
|
"react-range": "1.10.0",
|
||||||
"react-router": "8.3.0",
|
"react-router-dom": "7.18.3",
|
||||||
"sanitize-html": "2.17.6",
|
"sanitize-html": "2.17.7",
|
||||||
"slate": "0.124.1",
|
"slate": "0.124.1",
|
||||||
"slate-dom": "0.124.1",
|
"slate-dom": "0.124.1",
|
||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
@@ -98,12 +107,15 @@
|
|||||||
"workbox-precaching": "7.4.1"
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
"@axe-core/playwright": "4.13.0",
|
||||||
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/qrcode": "1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
@@ -111,35 +123,36 @@
|
|||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/react-google-recaptcha": "2.1.9",
|
"@types/react-google-recaptcha": "2.1.9",
|
||||||
"@types/sanitize-html": "2.16.1",
|
"@types/sanitize-html": "2.16.1",
|
||||||
|
"@types/ua-parser-js": "0.7.39",
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||||
"@typescript-eslint/parser": "8.59.4",
|
"@typescript-eslint/parser": "8.59.4",
|
||||||
"@vanilla-extract/css": "1.20.1",
|
"@vanilla-extract/css": "1.20.1",
|
||||||
"@vanilla-extract/recipes": "0.5.7",
|
"@vanilla-extract/recipes": "0.5.7",
|
||||||
"@vanilla-extract/vite-plugin": "5.2.2",
|
"@vanilla-extract/vite-plugin": "5.2.2",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"buffer": "6.0.3",
|
||||||
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
"eslint-config-airbnb-base": "15.0.0",
|
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
"eslint-plugin-import": "2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.2.0",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-static-copy": "4.1.0"
|
"vite-plugin-static-copy": "4.1.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@giphy/js-util": {
|
"@giphy/js-util": {
|
||||||
"dompurify": ">=3.3.4"
|
"dompurify": ">=3.3.4",
|
||||||
|
"uuid": ">=11.1.1"
|
||||||
},
|
},
|
||||||
"js-cookie": ">=3.0.6"
|
"js-cookie": ">=3.0.6"
|
||||||
},
|
|
||||||
"allowScripts": {
|
|
||||||
"protobufjs": true,
|
|
||||||
"esbuild": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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'] } },
|
||||||
|
// [Gitea #221] WebKit as the Safari/iOS proxy. It runs the subset tagged
|
||||||
|
// @webkit / @ios (boot, composer round-trip, thread panel, lightbox): it
|
||||||
|
// catches WebKit-only breakage (CSS, dvh, IndexedDB, media decode) that
|
||||||
|
// Chromium emulation can't, but does not emulate the iOS keyboard or the
|
||||||
|
// Home-Screen install flow.
|
||||||
|
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: /@webkit/ },
|
||||||
|
{ name: 'iphone', use: { ...devices['iPhone 14'] }, grep: /@ios/ },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: `npx vite preview --port ${PORT} --strictPort`,
|
||||||
|
url: BASE_URL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang=en>
|
|
||||||
<meta charset=utf-8>
|
|
||||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
|
||||||
<title>Error 404 (Not Found)!!1</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
|
||||||
</style>
|
|
||||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
|
||||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
|
||||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang=en>
|
|
||||||
<meta charset=utf-8>
|
|
||||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
|
||||||
<title>Error 404 (Not Found)!!1</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
|
||||||
</style>
|
|
||||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
|
||||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
|
||||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang=en>
|
|
||||||
<meta charset=utf-8>
|
|
||||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
|
||||||
<title>Error 404 (Not Found)!!1</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
|
||||||
</style>
|
|
||||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
|
||||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
|
||||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -49,3 +49,25 @@
|
|||||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
/* VT323 (OFL) for the Lotus Terminal theme — was Google Fonts (Gitea #214) */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/VT323-latin-ext.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||||
|
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||||
|
U+A720-A7FF;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'VT323';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/VT323-latin.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,22 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"categories": ["social", "communication", "productivity"],
|
||||||
|
"share_target": {
|
||||||
|
"action": "./share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "New Message",
|
"name": "New Message",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -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();
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local throwaway Synapse for driving the real UI (Playwright / a browser)
|
||||||
|
# against a homeserver you control — open registration, no rate limits,
|
||||||
|
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
|
||||||
|
#
|
||||||
|
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
|
||||||
|
# scripts/dev-homeserver.sh calls # + LiveKit SFU, JWT issuer, voice-limit guard, https well-known
|
||||||
|
# scripts/dev-homeserver.sh stop
|
||||||
|
# scripts/dev-homeserver.sh reset # wipe the database and media
|
||||||
|
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images (+ "Voice Lounge" call room)
|
||||||
|
#
|
||||||
|
# Calls: `calls` downloads livekit-server (v1.13.7 release binary) into
|
||||||
|
# .dev-homeserver/, runs it on :7880 with key devkey, a minimal lk-jwt-service
|
||||||
|
# clone (scripts/dev-lk-jwt.py, :8071), the real voice-limit-guard from the
|
||||||
|
# sibling matrix repo in front of it (:8070, needs ../matrix checked out and an
|
||||||
|
# admin user — dev-seed makes alice one), and a self-signed https server on
|
||||||
|
# :443 serving /.well-known/matrix/client with the LiveKit focus (the client
|
||||||
|
# autodiscovers the server NAME, not :8008). Drive it with Playwright using
|
||||||
|
# --use-fake-device-for-media-stream and ignoreHTTPSErrors.
|
||||||
|
#
|
||||||
|
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
|
||||||
|
# as alice / password123.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DIR="$ROOT/.dev-homeserver"
|
||||||
|
VENV="$DIR/venv"
|
||||||
|
CFG="$DIR/homeserver.yaml"
|
||||||
|
PIDFILE="$DIR/synapse.pid"
|
||||||
|
|
||||||
|
install() {
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
|
python3 -m venv --without-pip "$VENV"
|
||||||
|
curl -sS https://bootstrap.pypa.io/get-pip.py -o "$DIR/get-pip.py"
|
||||||
|
"$VENV/bin/python" "$DIR/get-pip.py" -q
|
||||||
|
"$VENV/bin/pip" install -q "matrix-synapse[url-preview]"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$CFG" ]; then
|
||||||
|
(cd "$DIR" && "$VENV/bin/python" -m synapse.app.homeserver \
|
||||||
|
--server-name localhost --config-path homeserver.yaml --generate-config --report-stats=no >/dev/null)
|
||||||
|
# the generated listener only serves `client`; add media + open the door
|
||||||
|
python3 - "$CFG" <<'PY'
|
||||||
|
import sys, re
|
||||||
|
p = sys.argv[1]; s = open(p).read()
|
||||||
|
s = s.replace(" - client\n", " - client\n - media\n - federation\n", 1)
|
||||||
|
s += """
|
||||||
|
enable_registration: true
|
||||||
|
enable_registration_without_verification: true
|
||||||
|
rc_message: { per_second: 1000, burst_count: 10000 }
|
||||||
|
rc_registration: { per_second: 1000, burst_count: 10000 }
|
||||||
|
rc_login: { address: { per_second: 1000, burst_count: 10000 }, account: { per_second: 1000, burst_count: 10000 }, failed_attempts: { per_second: 1000, burst_count: 10000 } }
|
||||||
|
rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_second: 1000, burst_count: 10000 } }
|
||||||
|
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
|
||||||
|
max_upload_size: 50M
|
||||||
|
suppress_key_server_warning: true
|
||||||
|
# URL previews (embed facades use the homeserver's cached thumbnail)
|
||||||
|
url_preview_enabled: true
|
||||||
|
url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7']
|
||||||
|
# scheduled messages (MSC4140 delayed events) + MatrixRTC transports (MSC4143) + room summaries (MSC3266)
|
||||||
|
max_event_delay_duration: 24h
|
||||||
|
experimental_features:
|
||||||
|
msc4140_enabled: true
|
||||||
|
msc4143_enabled: true
|
||||||
|
msc3266_enabled: true
|
||||||
|
msc4133_enabled: true
|
||||||
|
matrix_rtc:
|
||||||
|
transports:
|
||||||
|
- type: livekit
|
||||||
|
livekit_service_url: http://127.0.0.1:8070
|
||||||
|
"""
|
||||||
|
open(p, "w").write(s)
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
install
|
||||||
|
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||||
|
echo "already running (pid $(cat "$PIDFILE"))"; return
|
||||||
|
fi
|
||||||
|
(cd "$DIR" && setsid nohup "$VENV/bin/python" -m synapse.app.homeserver --config-path homeserver.yaml \
|
||||||
|
> synapse.log 2>&1 < /dev/null & echo $! > "$PIDFILE")
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
sleep 1
|
||||||
|
curl -sf http://127.0.0.1:8008/_matrix/client/versions >/dev/null && { echo "synapse up on http://localhost:8008"; return; }
|
||||||
|
done
|
||||||
|
echo "synapse did not come up — see $DIR/synapse.log" >&2; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
|
||||||
|
for f in "$DIR"/calls-*.pid; do [ -f "$f" ] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; }; done
|
||||||
|
}
|
||||||
|
|
||||||
|
calls() {
|
||||||
|
start
|
||||||
|
local LK="$DIR/livekit-server"
|
||||||
|
if [ ! -x "$LK" ]; then
|
||||||
|
curl -sL "https://github.com/livekit/livekit/releases/download/v1.13.7/livekit_1.13.7_linux_amd64.tar.gz" | tar xz -C "$DIR" livekit-server
|
||||||
|
fi
|
||||||
|
cat > "$DIR/livekit.yaml" <<'YAML'
|
||||||
|
port: 7880
|
||||||
|
bind_addresses: ["127.0.0.1"]
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 50100
|
||||||
|
use_external_ip: false
|
||||||
|
node_ip: 127.0.0.1
|
||||||
|
keys:
|
||||||
|
devkey: devsecretdevsecretdevsecretdevsecret
|
||||||
|
room:
|
||||||
|
auto_create: true
|
||||||
|
YAML
|
||||||
|
local bg
|
||||||
|
bg() { local name="$1"; shift; ( setsid nohup "$@" > "$DIR/calls-$name.log" 2>&1 < /dev/null & echo $! > "$DIR/calls-$name.pid" ); }
|
||||||
|
bg livekit "$LK" --config "$DIR/livekit.yaml"
|
||||||
|
bg jwt env PORT=8071 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
|
||||||
|
if [ ! -f "$DIR/wk.crt" ]; then openssl req -x509 -newkey rsa:2048 -nodes -keyout "$DIR/wk.key" -out "$DIR/wk.crt" -days 365 -subj "/CN=localhost" 2>/dev/null; fi
|
||||||
|
bg wellknown env CERT="$DIR/wk.crt" KEY="$DIR/wk.key" python3 "$ROOT/scripts/dev-wellknown.py"
|
||||||
|
local GUARD="$ROOT/../matrix/livekit/voice-limit-guard.py"
|
||||||
|
local TOKEN_FILE="$DIR/admin.token"
|
||||||
|
if [ -f "$GUARD" ] && [ -f "$TOKEN_FILE" ]; then
|
||||||
|
bg guard env GUARD_BIND_HOST=127.0.0.1 GUARD_BIND_PORT=8070 GUARD_UPSTREAM=http://127.0.0.1:8071 LIVEKIT_API=http://127.0.0.1:7880 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret SYNAPSE_API=http://127.0.0.1:8008 MATRIX_TOKEN="$(cat "$TOKEN_FILE")" python3 "$GUARD"
|
||||||
|
else
|
||||||
|
echo "voice-limit-guard not started (need ../matrix checkout and $TOKEN_FILE from dev-seed.py); pointing the focus straight at the issuer"
|
||||||
|
bg guard env PORT=8070 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
echo "calls stack up: livekit :7880, jwt :8071, guard :8070, well-known https://localhost/.well-known/matrix/client"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
start) start ;;
|
||||||
|
calls) calls ;;
|
||||||
|
stop) stop ;;
|
||||||
|
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
|
||||||
|
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal lk-jwt-service clone for local testing.
|
||||||
|
POST /sfu/get {room, openid_token{access_token, matrix_server_name}, device_id}
|
||||||
|
-> validates the OpenID token against Synapse (federation openid/userinfo),
|
||||||
|
mints a LiveKit JWT for identity "<user_id>:<device_id>" and returns {url, jwt}.
|
||||||
|
"""
|
||||||
|
import base64, hashlib, hmac, json, os, time, urllib.parse, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", "8071"))
|
||||||
|
LK_KEY = os.environ.get("LIVEKIT_KEY", "devkey")
|
||||||
|
LK_SECRET = os.environ.get("LIVEKIT_SECRET", "devsecretdevsecretdevsecretdevsecret")
|
||||||
|
LK_URL = os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880")
|
||||||
|
SYNAPSE = os.environ.get("SYNAPSE_API", "http://127.0.0.1:8008")
|
||||||
|
|
||||||
|
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||||
|
def jwt(payload):
|
||||||
|
h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()); p = b64(json.dumps(payload).encode())
|
||||||
|
sig = b64(hmac.new(LK_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest())
|
||||||
|
return f"{h}.{p}.{sig}"
|
||||||
|
|
||||||
|
def userinfo(access_token):
|
||||||
|
q = urllib.parse.urlencode({"access_token": access_token})
|
||||||
|
with urllib.request.urlopen(f"{SYNAPSE}/_matrix/federation/v1/openid/userinfo?{q}", timeout=5) as r:
|
||||||
|
return json.load(r)["sub"]
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def _cors(self):
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204); self._cors(); self.end_headers()
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
data = json.loads(self.rfile.read(n) or b"{}")
|
||||||
|
room = data.get("room") or data.get("room_id") or ""
|
||||||
|
oid = data.get("openid_token") or {}
|
||||||
|
try:
|
||||||
|
user = userinfo(oid.get("access_token", ""))
|
||||||
|
except Exception as e: # noqa
|
||||||
|
self.send_response(401); self._cors(); self.end_headers(); self.wfile.write(json.dumps({"errcode": "M_LOOKUP_FAILED", "error": str(e)}).encode()); return
|
||||||
|
identity = f"{user}:{data.get('device_id', '')}"
|
||||||
|
now = int(time.time())
|
||||||
|
token = jwt({"iss": LK_KEY, "sub": identity, "name": user, "nbf": now - 10, "exp": now + 3600,
|
||||||
|
"video": {"room": room, "roomJoin": True, "roomCreate": True, "canPublish": True, "canSubscribe": True, "canPublishData": True}})
|
||||||
|
body = json.dumps({"url": LK_URL, "jwt": token}).encode()
|
||||||
|
self.send_response(200); self._cors(); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||||
|
print(f"issued {identity} room={room}", flush=True)
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
|
||||||
|
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users,
|
||||||
|
a busy unencrypted room and a voice room: alice + bob, "Busy Room" (N
|
||||||
|
messages, one image every 10th), "Voice Lounge" (org.matrix.msc3417.call with
|
||||||
|
the call-member power level Lotus applies). Makes alice a server admin (the
|
||||||
|
voice-limit guard reads room state through the admin API) and writes her
|
||||||
|
token to .dev-homeserver/admin.token for `dev-homeserver.sh calls`.
|
||||||
|
Idempotent for the users; every run creates new rooms.
|
||||||
|
|
||||||
|
python3 scripts/dev-seed.py [N=400]
|
||||||
|
"""
|
||||||
|
import json, os, sqlite3, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
|
||||||
|
|
||||||
|
HS = "http://127.0.0.1:8008"
|
||||||
|
PASSWORD = "password123"
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, data=None, token=None, raw=None, ctype="application/json"):
|
||||||
|
headers = {"Content-Type": ctype}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
body = raw if raw is not None else (json.dumps(data).encode() if data is not None else None)
|
||||||
|
r = urllib.request.Request(HS + path, data=body, headers=headers, method=method)
|
||||||
|
return json.load(urllib.request.urlopen(r))
|
||||||
|
|
||||||
|
|
||||||
|
def register_or_login(user):
|
||||||
|
try:
|
||||||
|
return req("POST", "/_matrix/client/v3/register",
|
||||||
|
{"username": user, "password": PASSWORD, "auth": {"type": "m.login.dummy"}})
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return req("POST", "/_matrix/client/v3/login",
|
||||||
|
{"type": "m.login.password", "identifier": {"type": "m.id.user", "user": user}, "password": PASSWORD})
|
||||||
|
|
||||||
|
|
||||||
|
def png(w, h, rgb):
|
||||||
|
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
||||||
|
def chunk(t, d):
|
||||||
|
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
||||||
|
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
n = int(sys.argv[1]) if len(sys.argv) > 1 else 400
|
||||||
|
alice, bob = register_or_login("alice"), register_or_login("bob")
|
||||||
|
ta, tb = alice["access_token"], bob["access_token"]
|
||||||
|
room = req("POST", "/_matrix/client/v3/createRoom",
|
||||||
|
{"name": "Busy Room", "preset": "public_chat", "visibility": "public"}, ta)["room_id"]
|
||||||
|
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(room)}", {}, tb)
|
||||||
|
txn = int(time.time() * 1000)
|
||||||
|
for i in range(n):
|
||||||
|
tok = ta if i % 2 == 0 else tb
|
||||||
|
if i % 10 == 0:
|
||||||
|
data = png(64, 48, ((i * 37) % 256, (i * 91) % 256, (i * 17) % 256))
|
||||||
|
up = req("POST", f"/_matrix/media/v3/upload?filename=img{i}.png", token=tok, raw=data, ctype="image/png")
|
||||||
|
content = {"msgtype": "m.image", "body": f"img{i}.png", "url": up["content_uri"],
|
||||||
|
"info": {"mimetype": "image/png", "w": 64, "h": 48, "size": len(data)}}
|
||||||
|
else:
|
||||||
|
content = {"msgtype": "m.text", "body": f"message #{i}"}
|
||||||
|
txn += 1
|
||||||
|
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
|
||||||
|
voice = req("POST", "/_matrix/client/v3/createRoom", {
|
||||||
|
"name": "Voice Lounge", "preset": "public_chat",
|
||||||
|
"creation_content": {"type": "org.matrix.msc3417.call"},
|
||||||
|
"initial_state": [{"type": "org.matrix.msc3401.call", "state_key": "", "content": {}}],
|
||||||
|
"power_level_content_override": {"events": {"org.matrix.msc3401.call.member": 0}},
|
||||||
|
}, ta)["room_id"]
|
||||||
|
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(voice)}", {}, tb)
|
||||||
|
# admin flag for the guard (takes effect for tokens issued after a Synapse restart-free
|
||||||
|
# cache miss; the guard only needs it for the admin state API)
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
db = os.path.join(here, "..", ".dev-homeserver", "homeserver.db")
|
||||||
|
if os.path.exists(db):
|
||||||
|
c = sqlite3.connect(db); c.execute("UPDATE users SET admin=1 WHERE name=?", (alice["user_id"],)); c.commit(); c.close()
|
||||||
|
with open(os.path.join(here, "..", ".dev-homeserver", "admin.token"), "w") as f:
|
||||||
|
f.write(ta)
|
||||||
|
print(json.dumps({"room": room, "voice_room": voice, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Self-signed https server on :443 serving the client well-known with the
|
||||||
|
LiveKit focus for the local calls stack (see scripts/dev-homeserver.sh calls)."""
|
||||||
|
import http.server, os, ssl, json
|
||||||
|
BODY = json.dumps({"m.homeserver": {"base_url": "http://localhost:8008"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "http://127.0.0.1:8070"}]}).encode()
|
||||||
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith('/.well-known/matrix/client'):
|
||||||
|
self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Content-Length',str(len(BODY))); self.end_headers(); self.wfile.write(BODY)
|
||||||
|
else:
|
||||||
|
self.send_response(404); self.end_headers()
|
||||||
|
def log_message(self,*a): pass
|
||||||
|
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 443), H)
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(os.environ.get('CERT', 'wk.crt'), os.environ.get('KEY', 'wk.key')); srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
|
||||||
|
srv.serve_forever()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build static picker thumbnails for avatar decorations.
|
||||||
|
|
||||||
|
The decorations are animated APNGs (~1 MB each). The settings picker shows
|
||||||
|
static WebP thumbnails instead and only loads the animated file on hover,
|
||||||
|
focus or selection, so browsing the catalog costs a few MB, not hundreds.
|
||||||
|
|
||||||
|
Usage: python3 scripts/makeDecorationThumbs.py <dir-of-slug.png> <out-dir>
|
||||||
|
Then upload <out-dir>/*.webp to `${DECORATION_CDN}/thumbs/`.
|
||||||
|
A missing thumbnail is harmless: the picker falls back to the full PNG.
|
||||||
|
|
||||||
|
Requires Pillow (pip install pillow).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageSequence
|
||||||
|
|
||||||
|
SIZE = 144 # 2x the 72px picker cell
|
||||||
|
|
||||||
|
|
||||||
|
def best_frame(im: Image.Image) -> Image.Image:
|
||||||
|
# Many animations start (or loop through) an empty frame, so pick the frame
|
||||||
|
# with the most visible pixels rather than frame 0.
|
||||||
|
best, best_score = None, -1
|
||||||
|
frames = list(ImageSequence.Iterator(im))
|
||||||
|
step = max(1, len(frames) // 24)
|
||||||
|
for frame in frames[::step]:
|
||||||
|
rgba = frame.convert('RGBA')
|
||||||
|
score = sum(rgba.getchannel('A').histogram()[33:])
|
||||||
|
if score > best_score:
|
||||||
|
best, best_score = rgba, score
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def main(src: str, out: str) -> None:
|
||||||
|
out_dir = Path(out)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for png in sorted(Path(src).glob('*.png')):
|
||||||
|
with Image.open(png) as im:
|
||||||
|
thumb = best_frame(im).resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
thumb.save(out_dir / f'{png.stem}.webp', 'WEBP', quality=82, method=6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
+41
-13
@@ -4,28 +4,56 @@ import { join, dirname } from 'path';
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js');
|
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 {
|
try {
|
||||||
let content = readFileSync(foldsPath, 'utf8');
|
const content = readFileSync(foldsPath, 'utf8');
|
||||||
|
|
||||||
// Defensive guard: if src is not a function, render null instead of crashing
|
|
||||||
const original = 'children: src(filled)';
|
|
||||||
const patched = 'children: typeof src === "function" ? src(filled) : null';
|
|
||||||
|
|
||||||
if (content.includes(patched)) {
|
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.');
|
console.log('folds patch already applied.');
|
||||||
} else if (content.includes(original)) {
|
} else if (content.includes(original)) {
|
||||||
content = content.replace(original, patched);
|
writeFileSync(foldsPath, content.replace(original, patched), 'utf8');
|
||||||
writeFileSync(foldsPath, content, 'utf8');
|
|
||||||
console.log('Applied defensive Icon src guard to folds.');
|
console.log('Applied defensive Icon src guard to folds.');
|
||||||
} else {
|
} else {
|
||||||
// Genuine "patch could not be applied" case: the target string is gone
|
// Genuine "patch could not be applied" case: neither the original nor the
|
||||||
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
// patched form was found, meaning folds changed the Icon implementation.
|
||||||
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
// Fail loudly so the postinstall hook / CI breaks instead of silently
|
||||||
// an unpatched folds (which crashes at render with "src is not a function").
|
// shipping an unpatched folds (which crashes at render with "src is not a
|
||||||
|
// function"). See cinny #210 (build hygiene notes) for
|
||||||
|
// context on why this is a direct node_modules patch rather than
|
||||||
|
// patch-package.
|
||||||
|
console.error('ERROR: folds Icon patch target not found.');
|
||||||
|
console.error(` folds version installed: ${foldsVersion()}`);
|
||||||
|
console.error(` Expected to find (surrounding context):\n${original}`);
|
||||||
console.error(
|
console.error(
|
||||||
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
' folds likely changed its Icon implementation. Update the patch target ' +
|
||||||
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
'in scripts/patch-folds.mjs (see cinny #210 ' +
|
||||||
|
'-> patch-folds.mjs entry) before building.',
|
||||||
);
|
);
|
||||||
process.exit(1);
|
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));
|
const missingSet = new Set(missing.map((r) => r.slug));
|
||||||
|
|
||||||
// Remove individual entries for missing slugs
|
// Remove individual entries for missing slugs
|
||||||
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
|
const removedSlugs = new Set();
|
||||||
missingSet.has(slug) ? '' : match,
|
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
|
// Drop category blocks that now have an empty decorations array
|
||||||
updated = updated.replace(
|
updated = updated.replace(
|
||||||
@@ -118,6 +147,6 @@ updated = updated.replace(/\n{3,}/g, '\n\n');
|
|||||||
|
|
||||||
writeFileSync(catalogPath, updated, 'utf8');
|
writeFileSync(catalogPath, updated, 'utf8');
|
||||||
console.log(
|
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');
|
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback } from 'react';
|
import { ReactNode, useCallback } from 'react';
|
||||||
import { matchPath, useLocation, useNavigate } from 'react-router';
|
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
getDirectPath,
|
getDirectPath,
|
||||||
getExplorePath,
|
getExplorePath,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
RoomEvent,
|
RoomEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||||
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
import {
|
import {
|
||||||
CallEmbedContextProvider,
|
CallEmbedContextProvider,
|
||||||
@@ -33,18 +34,29 @@ import {
|
|||||||
useCallHangupEvent,
|
useCallHangupEvent,
|
||||||
useCallJoined,
|
useCallJoined,
|
||||||
useCallThemeSync,
|
useCallThemeSync,
|
||||||
useCallMemberSoundSync,
|
|
||||||
useCallStart,
|
useCallStart,
|
||||||
} from '../hooks/useCallEmbed';
|
} from '../hooks/useCallEmbed';
|
||||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
|
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
|
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||||
|
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||||
|
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||||
|
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||||
|
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||||
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||||
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||||
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||||
|
import { SrOnly } from '../features/call-status/styles.css';
|
||||||
|
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||||
|
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||||
import { useCallQuality } from '../hooks/useCallQuality';
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
@@ -58,6 +70,7 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
|||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useReducedMotion } from '../hooks/useReducedMotion';
|
import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
@@ -65,6 +78,8 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
|||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
|
import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||||
|
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
@@ -108,9 +123,17 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
const rtcSupported = webRTCSupported();
|
const rtcSupported = webRTCSupported();
|
||||||
const canAnswer = livekitSupported && rtcSupported;
|
const canAnswer = livekitSupported && rtcSupported;
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
|
// [Gitea #135] Answer follows cameraOnJoin: say so, and offer the audio-only
|
||||||
|
// alternative, rather than putting the user on video without warning.
|
||||||
|
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||||
|
const willAnswerWithCamera = info.intent === 'video' && !!cameraOnJoinPref;
|
||||||
|
|
||||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
// Gitea #28 — don't ring during quiet hours / DND / Focus Assist / snooze. The
|
||||||
|
// call can still be answered from this overlay; only the audible ring is
|
||||||
|
// skipped.
|
||||||
|
const quiet = useNotificationsQuiet();
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
@@ -132,10 +155,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info.notificationType !== 'ring') return undefined;
|
if (info.notificationType !== 'ring' || quiet) return undefined;
|
||||||
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
return stop;
|
return stop;
|
||||||
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
}, [info.notificationType, ringtoneId, ringtoneVolume, quiet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -158,7 +181,15 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
escapeDeactivates: false,
|
escapeDeactivates: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
<Dialog
|
||||||
|
style={{ maxWidth: toRem(324) }}
|
||||||
|
// #187: the overlay had no role or name, so a screen reader landed
|
||||||
|
// on an unlabelled button with no hint that a call was ringing.
|
||||||
|
// Match the in-call banner (role="alert" + label).
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Incoming ${info.intent === 'video' ? 'video' : 'voice'} call from ${getMemberName(info.room, info.sender) ?? info.sender}`}
|
||||||
|
>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||||
<Text size="T200" align="Center">
|
<Text size="T200" align="Center">
|
||||||
{getMemberName(info.room, info.sender)}
|
{getMemberName(info.room, info.sender)}
|
||||||
@@ -209,6 +240,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
|
{willAnswerWithCamera && (
|
||||||
|
<Text size="T200" align="Center" priority="300">
|
||||||
|
Answering turns your camera on.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
variant="Success"
|
variant="Success"
|
||||||
@@ -225,9 +261,25 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
disabled={!canAnswer}
|
disabled={!canAnswer}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
Answer
|
{willAnswerWithCamera ? 'Answer with camera' : 'Answer'}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{willAnswerWithCamera && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onAnswer(room, false)}
|
||||||
|
before={<Icon size="200" src={Icons.Phone} filled />}
|
||||||
|
disabled={!canAnswer}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Answer without camera
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
variant={dm ? 'Critical' : 'Secondary'}
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
@@ -269,9 +321,14 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
const isVideo = info.intent === 'video';
|
const isVideo = info.intent === 'video';
|
||||||
|
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||||
|
const bannerCameraOn = isVideo && !!cameraOnJoinPref;
|
||||||
|
|
||||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
// Gitea #28 — no ping during quiet hours / DND / Focus Assist / snooze; the
|
||||||
|
// banner itself still shows so the call can be answered.
|
||||||
|
const quiet = useNotificationsQuiet();
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
@@ -298,11 +355,11 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
// ringtone settings while the banner is showing.
|
// ringtone settings while the banner is showing.
|
||||||
const pingedRef = useRef<string | undefined>(undefined);
|
const pingedRef = useRef<string | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info.notificationType !== 'ring') return;
|
if (info.notificationType !== 'ring' || quiet) return;
|
||||||
if (pingedRef.current === info.refEventId) return;
|
if (pingedRef.current === info.refEventId) return;
|
||||||
pingedRef.current = info.refEventId;
|
pingedRef.current = info.refEventId;
|
||||||
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
|
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume, quiet]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -364,7 +421,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box gap="200">
|
<Box gap="200" wrap="Wrap">
|
||||||
<Button
|
<Button
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
variant="Success"
|
variant="Success"
|
||||||
@@ -373,11 +430,27 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => onAnswer(room, isVideo)}
|
onClick={() => onAnswer(room, isVideo)}
|
||||||
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||||
|
title={bannerCameraOn ? 'Answering turns your camera on' : undefined}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
Answer
|
{bannerCameraOn ? 'Answer with camera' : 'Answer'}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{bannerCameraOn && (
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onAnswer(room, false)}
|
||||||
|
before={<Icon size="100" src={Icons.Phone} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
Answer without camera
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
style={{ flexGrow: 1 }}
|
style={{ flexGrow: 1 }}
|
||||||
variant={dm ? 'Critical' : 'Secondary'}
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
@@ -410,6 +483,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
const startCall = useCallStart(dm);
|
const startCall = useCallStart(dm);
|
||||||
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
const { microphone, sound } = useCallPreferences();
|
||||||
|
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||||
|
|
||||||
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
||||||
// against the component unmounting during that await.
|
// against the component unmounting during that await.
|
||||||
@@ -546,6 +622,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
setCallInfo(undefined);
|
setCallInfo(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// [Gitea #161] Stop ringing here when the call was handled ELSEWHERE:
|
||||||
|
// - answered on another of our devices (our own m.call.member appears),
|
||||||
|
// - declined on another of our devices (our own RTCDecline for this ring),
|
||||||
|
// - the caller hung up before we picked up (nobody left in the session).
|
||||||
|
// Without this every other device kept ringing for the full lifetime.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!callInfo) return undefined;
|
||||||
|
const { room, refEventId } = callInfo;
|
||||||
|
const myUserId = mx.getSafeUserId();
|
||||||
|
const dismiss = () =>
|
||||||
|
setCallInfo((current) => (current?.refEventId === refEventId ? undefined : current));
|
||||||
|
|
||||||
|
const session = mx.matrixRTC.getRoomSession(room);
|
||||||
|
const checkMemberships = () => {
|
||||||
|
const { memberships } = session;
|
||||||
|
if (memberships.some((m) => m.sender === myUserId))
|
||||||
|
dismiss(); // answered elsewhere
|
||||||
|
else if (memberships.length === 0) dismiss(); // caller gone
|
||||||
|
};
|
||||||
|
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
|
event,
|
||||||
|
eventRoom,
|
||||||
|
_s,
|
||||||
|
_r,
|
||||||
|
data,
|
||||||
|
) => {
|
||||||
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||||
|
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||||
|
const related = event.getRelation()?.event_id;
|
||||||
|
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||||
|
}
|
||||||
|
};
|
||||||
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||||
|
mx.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
// The caller's membership may not have arrived yet when the ring starts —
|
||||||
|
// only treat "empty" as hung-up after it has had a moment to sync.
|
||||||
|
const settle = setTimeout(checkMemberships, 5000);
|
||||||
|
return () => {
|
||||||
|
session.off(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||||
|
mx.removeListener(RoomEvent.Timeline, onTimeline);
|
||||||
|
clearTimeout(settle);
|
||||||
|
};
|
||||||
|
}, [mx, callInfo]);
|
||||||
|
|
||||||
const handleReject = useCallback(
|
const handleReject = useCallback(
|
||||||
(room: Room, eventId: string) => {
|
(room: Room, eventId: string) => {
|
||||||
// Best-effort: the local UI dismisses regardless (below), but a failed
|
// Best-effort: the local UI dismisses regardless (below), but a failed
|
||||||
@@ -565,12 +685,26 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAnswer = useCallback(
|
const handleAnswer = useCallback(
|
||||||
(room: Room, video: boolean) => {
|
async (room: Room, video: boolean) => {
|
||||||
startCall(room, { microphone: true, video, sound: true });
|
|
||||||
setCallInfo(undefined);
|
setCallInfo(undefined);
|
||||||
|
// [Gitea #195] Answering from the in-call banner is the one call-to-call
|
||||||
|
// switch path. Disposing the current embed alone leaves our ghost
|
||||||
|
// `m.call.member` in the old room for ~17 s (until the delayed leave
|
||||||
|
// expires) — hang up explicitly and wait for the membership to clear.
|
||||||
|
if (callEmbed?.joined) {
|
||||||
|
await hangupCallAndWait(mx, callEmbed);
|
||||||
|
// Dispose it ourselves now: its HangupCall echo would otherwise land
|
||||||
|
// after startCall() and clear the NEW embed from the atom.
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}
|
||||||
|
// 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 });
|
||||||
navigateRoom(room.roomId);
|
navigateRoom(room.roomId);
|
||||||
},
|
},
|
||||||
[startCall, navigateRoom],
|
[startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!callInfo) return null;
|
if (!callInfo) return null;
|
||||||
@@ -602,11 +736,38 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
|
||||||
|
function CallAnnouncementRegion() {
|
||||||
|
const announcement = useAtomValue(callAnnouncementAtom);
|
||||||
|
return (
|
||||||
|
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{announcement}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
useCallMemberSoundSync(embed);
|
// [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);
|
||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
|
useCallEndedToast(embed);
|
||||||
|
useScreenshareNotices(embed);
|
||||||
|
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||||
|
// as the host bar's (hidden where the server would refuse the share).
|
||||||
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
|
useEffect(() => {
|
||||||
|
embed.control.setFrameScreenshareAllowed(allowScreenshare);
|
||||||
|
}, [embed, allowScreenshare]);
|
||||||
|
usePttHaptics();
|
||||||
|
useCallAnnouncements(embed, joined);
|
||||||
|
useMutedTalkWarning(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallQuality(embed);
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
@@ -616,6 +777,22 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
}, [setCallEmbed]),
|
}, [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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +862,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
|||||||
type CallEmbedProviderProps = {
|
type CallEmbedProviderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
|
||||||
|
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
|
||||||
|
useCallRejoin(callEmbed, joined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||||
@@ -720,12 +903,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),
|
// When entering pip with screenshare active (or screenshare starts while in pip),
|
||||||
// enable spotlight so the screenshare fills the pip window.
|
// enable spotlight so the screenshare fills the pip window.
|
||||||
// When screenshare ends, release the spotlight we auto-enabled.
|
// When screenshare ends, release the spotlight we auto-enabled.
|
||||||
@@ -1139,9 +1316,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CallEmbedContextProvider value={callEmbed}>
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||||
|
<CallAnnouncementRegion />
|
||||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||||
|
<CallRejoin callEmbed={callEmbed} joined={joined} />
|
||||||
{children}
|
{children}
|
||||||
</CallEmbedRefContextProvider>
|
</CallEmbedRefContextProvider>
|
||||||
<div
|
<div
|
||||||
@@ -1153,7 +1332,15 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '50%',
|
height: '50%',
|
||||||
...(callVisible && !pipMode ? wallpaperStyle : {}),
|
// [Gitea #224] The fork renders EC transparent (lotusTransparent) so the
|
||||||
|
// wallpaper shows through the full-size embed; in PiP the thing behind
|
||||||
|
// the frame is whatever room you navigated to, so give it an opaque
|
||||||
|
// surface instead of letting the timeline bleed through the tiles.
|
||||||
|
...(pipMode
|
||||||
|
? { background: color.Surface.Container }
|
||||||
|
: callVisible
|
||||||
|
? wallpaperStyle
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
ref={callEmbedRef}
|
ref={callEmbedRef}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { invokeTauri, isTauri, tauriInvoke, useTauriEvent } from '../hooks/useTauri';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cinny-desktop #5: the first time the window is closed, ask whether Lotus Chat
|
||||||
|
* should keep running in the tray (calls, messages, notifications) or quit —
|
||||||
|
* and, in the same moment, whether it should start when you sign in. Asked
|
||||||
|
* once; changeable in Settings → General. The native side sends
|
||||||
|
* `lotus-close-requested` only while the choice is still "ask", and never
|
||||||
|
* during a call (a call always goes to the tray).
|
||||||
|
*/
|
||||||
|
export function CloseBehaviorPrompt() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(420);
|
||||||
|
|
||||||
|
// Tells the native side we're listening (see get_close_behavior).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return;
|
||||||
|
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useTauriEvent('lotus-close-requested', () => setOpen(true));
|
||||||
|
|
||||||
|
const choose = useCallback(
|
||||||
|
(value: 'tray' | 'quit') => {
|
||||||
|
if (launchOnLogin) invokeTauri('plugin:autostart|enable');
|
||||||
|
setOpen(false);
|
||||||
|
invokeTauri('resolve_close_request', { value });
|
||||||
|
},
|
||||||
|
[launchOnLogin],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: '#close-behavior-keep',
|
||||||
|
// Dismissing without choosing keeps the window open; the next close
|
||||||
|
// asks again.
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="close-behavior-title"
|
||||||
|
aria-describedby="close-behavior-body"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Text as="h2" size="H4" id="close-behavior-title">
|
||||||
|
Keep Lotus Chat running?
|
||||||
|
</Text>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text priority="400" id="close-behavior-body">
|
||||||
|
Lotus Chat can keep running in the system tray when you close this window, so
|
||||||
|
messages, notifications and calls still reach you. Open it again from the tray icon.
|
||||||
|
</Text>
|
||||||
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
|
<Checkbox
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
checked={launchOnLogin}
|
||||||
|
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
|
||||||
|
setLaunchOnLogin(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
||||||
|
<Text size="B400">Keep running in the tray</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
||||||
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
You can change this in Settings → General.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import {
|
||||||
|
EventStatus,
|
||||||
|
HttpApiEvent,
|
||||||
|
HttpApiEventHandlerMap,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
|
import { failedForConsent, safeConsentUri } from '../utils/consent';
|
||||||
|
|
||||||
|
// After "Later", don't re-open for every background retry; the next send the
|
||||||
|
// user makes after this window explains again.
|
||||||
|
const SNOOZE_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A homeserver with a consent requirement (e.g. Synapse's user_consent) blocks
|
||||||
|
* sending until its terms are accepted, answering 403 M_CONSENT_NOT_GIVEN with a `consent_uri`. Without
|
||||||
|
* this the message just shows "Failed to send". The SDK emits
|
||||||
|
* HttpApiEvent.NoConsent for every such response; we explain why, link to the
|
||||||
|
* acceptance page, and offer to resend what was blocked once accepted.
|
||||||
|
*/
|
||||||
|
export function ConsentRequiredPrompt() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [consentUri, setConsentUri] = useState<string>();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [blockedCount, setBlockedCount] = useState(0);
|
||||||
|
const snoozedUntil = useRef(0);
|
||||||
|
const blocked = useRef(new Map<MatrixEvent, Room>());
|
||||||
|
const modalStyle = useModalStyle(440);
|
||||||
|
const server = mx.getDomain() ?? 'Your homeserver';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onNoConsent: HttpApiEventHandlerMap[HttpApiEvent.NoConsent] = (_message, uri) => {
|
||||||
|
const safe = safeConsentUri(uri);
|
||||||
|
if (safe) setConsentUri(safe);
|
||||||
|
if (Date.now() >= snoozedUntil.current) setOpen(true);
|
||||||
|
};
|
||||||
|
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
|
||||||
|
if (failedForConsent(event)) blocked.current.set(event, room);
|
||||||
|
else if (!blocked.current.delete(event)) return;
|
||||||
|
setBlockedCount(blocked.current.size);
|
||||||
|
};
|
||||||
|
mx.on(HttpApiEvent.NoConsent, onNoConsent);
|
||||||
|
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(HttpApiEvent.NoConsent, onNoConsent);
|
||||||
|
mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const review = useCallback(() => {
|
||||||
|
if (!consentUri) return;
|
||||||
|
window.open(consentUri, '_blank', 'noopener,noreferrer');
|
||||||
|
setOpened(true);
|
||||||
|
}, [consentUri]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setOpened(false);
|
||||||
|
const pending = Array.from(blocked.current.entries());
|
||||||
|
blocked.current.clear();
|
||||||
|
setBlockedCount(0);
|
||||||
|
pending.forEach(([event, room]) => {
|
||||||
|
if (event.status === EventStatus.NOT_SENT) mx.resendEvent(event, room);
|
||||||
|
});
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const later = useCallback(() => {
|
||||||
|
snoozedUntil.current = Date.now() + SNOOZE_MS;
|
||||||
|
setOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: '#consent-review',
|
||||||
|
fallbackFocus: '#consent-later',
|
||||||
|
onDeactivate: later,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="consent-title"
|
||||||
|
aria-describedby="consent-body"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Text as="h2" size="H4" id="consent-title">
|
||||||
|
Accept the terms to keep chatting
|
||||||
|
</Text>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box id="consent-body" direction="Column" gap="200">
|
||||||
|
<Text priority="400">
|
||||||
|
Your message wasn't sent. <b>{server}</b> requires you to review and accept
|
||||||
|
its terms of service before you can send messages.
|
||||||
|
</Text>
|
||||||
|
<Text priority="400">
|
||||||
|
{consentUri
|
||||||
|
? 'Nothing you sent is lost: accept the terms, then come back here and retry.'
|
||||||
|
: 'Your homeserver didn’t include a link to its terms. Contact its administrator, or accept them in another Matrix app, then retry.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{consentUri && (
|
||||||
|
<Button
|
||||||
|
id="consent-review"
|
||||||
|
variant={opened ? 'Secondary' : 'Primary'}
|
||||||
|
fill={opened ? 'Soft' : 'Solid'}
|
||||||
|
onClick={review}
|
||||||
|
after={<Icon size="100" src={Icons.External} />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Review and accept</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={opened ? 'Primary' : 'Secondary'}
|
||||||
|
fill={opened ? 'Solid' : 'Soft'}
|
||||||
|
onClick={retry}
|
||||||
|
>
|
||||||
|
<Text size="B400">
|
||||||
|
{blockedCount > 0 ? "I've accepted — retry sending" : "I've accepted"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button id="consent-later" variant="Secondary" fill="None" onClick={later}>
|
||||||
|
<Text size="B400">Later</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
||||||
import QRCode from 'qrcode';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -221,12 +220,16 @@ function QrCodeImage({ data }: { data: Uint8ClampedArray }) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
// Byte-mode so the raw verification bytes round-trip (a string value would
|
// Byte-mode so the raw verification bytes round-trip (a string value would
|
||||||
// mangle high bytes via UTF-8).
|
// mangle high bytes via UTF-8). The QR library is loaded on demand.
|
||||||
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
|
import('qrcode')
|
||||||
width: 220,
|
.then(({ default: QRCode }) =>
|
||||||
margin: 2,
|
QRCode.toCanvas(canvas, [{ data: new Uint8Array(data), mode: 'byte' }], {
|
||||||
color: { dark: '#000000', light: '#ffffff' },
|
width: 220,
|
||||||
}).catch(() => undefined);
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
return (
|
return (
|
||||||
<Box justifyContent="Center">
|
<Box justifyContent="Center">
|
||||||
@@ -365,15 +368,23 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
fallbackFocus: '#deviceverification-dialog-1',
|
||||||
clickOutsideDeactivates: false,
|
clickOutsideDeactivates: false,
|
||||||
escapeDeactivates: false,
|
escapeDeactivates: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface" style={modalStyle}>
|
<Dialog
|
||||||
|
id="deviceverification-dialog-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deviceverification-dialog-1-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
variant="Surface"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text id="deviceverification-dialog-1-title" as="h2" size="H4">
|
||||||
Device Verification
|
Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useSaveFile } from '../hooks/useSaveFile';
|
|||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { copyToClipboard } from '../utils/dom';
|
import { useSensitiveCopy } from '../hooks/useSensitiveCopy';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
||||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||||
@@ -232,9 +232,8 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const saveFile = useSaveFile();
|
const saveFile = useSaveFile();
|
||||||
|
|
||||||
const handleCopy = () => {
|
// [Gitea #156] The key leaves the clipboard again after 60 s, visibly.
|
||||||
copyToClipboard(recoveryKey);
|
const { copy: handleCopy, secondsLeft } = useSensitiveCopy(recoveryKey);
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const blob = new Blob([recoveryKey], {
|
const blob = new Blob([recoveryKey], {
|
||||||
@@ -272,8 +271,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button onClick={handleCopy}>
|
<Button onClick={handleCopy} aria-live="polite">
|
||||||
<Text size="B400">Copy</Text>
|
<Text size="B400">
|
||||||
|
{secondsLeft !== null ? `Copied · clears in ${secondsLeft} s` : 'Copy'}
|
||||||
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDownload} fill="Soft">
|
<Button onClick={handleDownload} fill="Soft">
|
||||||
<Text size="B400">Download</Text>
|
<Text size="B400">Download</Text>
|
||||||
@@ -292,7 +293,15 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
const modalStyle = useModalStyle(480);
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref} style={modalStyle}>
|
<Dialog
|
||||||
|
id="deviceverificationsetup-dialog-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deviceverificationsetup-dialog-1-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -302,7 +311,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text id="deviceverificationsetup-dialog-1-title" as="h2" size="H4">
|
||||||
Setup Device Verification
|
Setup Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -330,7 +339,15 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
const modalStyle = useModalStyle(480);
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref} style={modalStyle}>
|
<Dialog
|
||||||
|
id="deviceverificationsetup-dialog-2"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="deviceverificationsetup-dialog-2-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -340,7 +357,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text id="deviceverificationsetup-dialog-2-title" as="h2" size="H4">
|
||||||
Reset Device Verification
|
Reset Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
|
|||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box, color, config } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
|
import { TapToSendBar } from './tap-to-send/TapToSendBar';
|
||||||
|
import { useRecentTouch } from '../hooks/useRecentTouch';
|
||||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
|
|
||||||
const sendGif = useCallback(
|
const sendGif = useCallback(
|
||||||
(gif: RecentGif) => {
|
(gif: RecentGif) => {
|
||||||
setRecents((prev) => addRecentGif(prev, gif));
|
const { url, width, height, previewUrl } = gif;
|
||||||
|
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
|
||||||
onSelect(gif.url, gif.width, gif.height);
|
onSelect(gif.url, gif.width, gif.height);
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
[onSelect, requestClose, setRecents],
|
[onSelect, requestClose, setRecents],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
|
||||||
|
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { wasTouch } = useRecentTouch(containerRef);
|
||||||
|
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
|
||||||
|
const pick = useCallback(
|
||||||
|
(gif: RecentGif & { title?: string }) => {
|
||||||
|
if (wasTouch() && pending?.url !== gif.url) {
|
||||||
|
setPending(gif);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(undefined);
|
||||||
|
sendGif(gif);
|
||||||
|
},
|
||||||
|
[wasTouch, pending, sendGif],
|
||||||
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(gif: IGif, e: React.SyntheticEvent) => {
|
(gif: IGif, e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
gif.images.fixed_width_small_still?.url ??
|
gif.images.fixed_width_small_still?.url ??
|
||||||
gif.images.downsized_still?.url ??
|
gif.images.downsized_still?.url ??
|
||||||
gif.images.original_still?.url;
|
gif.images.original_still?.url;
|
||||||
sendGif({
|
pick({
|
||||||
url: r.url,
|
url: r.url,
|
||||||
width: Number(r.width) || 200,
|
width: Number(r.width) || 200,
|
||||||
height: Number(r.height) || 200,
|
height: Number(r.height) || 200,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
|
title: gif.title,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendGif],
|
[pick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||||
@@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||||
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||||
// narrower than 312px with a fixed 296px grid.
|
// narrower than 312px with a fixed 296px grid.
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
useCallback(() => containerRef.current, []),
|
useCallback(() => containerRef.current, []),
|
||||||
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
{pending && (
|
||||||
|
<TapToSendBar
|
||||||
|
previewUrl={pending.previewUrl ?? pending.url}
|
||||||
|
label={pending.title || 'GIF'}
|
||||||
|
onSend={() => {
|
||||||
|
setPending(undefined);
|
||||||
|
sendGif(pending);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPending(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||||
>
|
>
|
||||||
{showRecents && (
|
{showRecents && (
|
||||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
|
||||||
)}
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
key={searchKey}
|
key={searchKey}
|
||||||
@@ -209,6 +240,13 @@ type GifPickerProps = {
|
|||||||
|
|
||||||
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
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
|
const containerStyle = lotusTerminal
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
|||||||
<Modal
|
<Modal
|
||||||
className={ModalWide}
|
className={ModalWide}
|
||||||
size="500"
|
size="500"
|
||||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
src,
|
src,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import React, { forwardRef, useCallback } from 'react';
|
import React, { forwardRef, useCallback } from 'react';
|
||||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { logoutClient } from '../../client/initMatrix';
|
import { logoutClient } from '../../client/initMatrix';
|
||||||
|
import { callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||||
@@ -11,6 +14,11 @@ import {
|
|||||||
VerificationStatus,
|
VerificationStatus,
|
||||||
} from '../hooks/useDeviceVerificationStatus';
|
} from '../hooks/useDeviceVerificationStatus';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Element Call to hang up and wait (bounded) until our own MatrixRTC
|
||||||
|
* membership has actually been removed from the room, so the leave reaches
|
||||||
|
* the homeserver before the client is stopped and the token is revoked.
|
||||||
|
*/
|
||||||
type LogoutDialogProps = {
|
type LogoutDialogProps = {
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -26,16 +34,34 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
mx.getDeviceId() ?? undefined,
|
mx.getDeviceId() ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||||
|
|
||||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
|
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||||
|
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||||
|
// everyone else sees you "in call" until it times out.
|
||||||
|
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||||
|
await hangupCallAndWait(mx, callEmbed);
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}
|
||||||
await logoutClient(mx);
|
await logoutClient(mx);
|
||||||
}, [mx]),
|
}, [mx, callEmbed, setCallEmbed]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog variant="Surface" ref={ref} style={modalStyle}>
|
<Dialog
|
||||||
|
id="logoutdialog-dialog-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="logoutdialog-dialog-1-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
variant="Surface"
|
||||||
|
ref={ref}
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -45,7 +71,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text id="logoutdialog-dialog-1-title" as="h2" size="H4">
|
||||||
Logout
|
Logout
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||||||
<Modal
|
<Modal
|
||||||
size="500"
|
size="500"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||||
// squish the two-pane settings layout.
|
// squish the two-pane settings layout.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, color, config, Text } from 'folds';
|
import { Box, Button, color, config, Text } from 'folds';
|
||||||
import jsQR from 'jsqr';
|
|
||||||
|
|
||||||
type QrScannerProps = {
|
type QrScannerProps = {
|
||||||
onScan: (bytes: Uint8ClampedArray) => void;
|
onScan: (bytes: Uint8ClampedArray) => void;
|
||||||
@@ -10,6 +9,7 @@ type QrScannerProps = {
|
|||||||
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
|
// Camera QR scanner. Decodes frames with jsQR and hands back the raw byte
|
||||||
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
|
// segment (`result.binaryData`) — Matrix QR verification needs the raw bytes,
|
||||||
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
|
// not a decoded string, so the string-only `BarcodeDetector` can't be used.
|
||||||
|
// jsQR is loaded on demand: it is large and only needed while scanning.
|
||||||
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
@@ -20,15 +20,22 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
|||||||
let raf = 0;
|
let raf = 0;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
let decode: typeof import('jsqr').default | undefined;
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!doneRef.current && video && ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
if (
|
||||||
|
decode &&
|
||||||
|
!doneRef.current &&
|
||||||
|
video &&
|
||||||
|
ctx &&
|
||||||
|
video.readyState === video.HAVE_ENOUGH_DATA
|
||||||
|
) {
|
||||||
canvas.width = video.videoWidth;
|
canvas.width = video.videoWidth;
|
||||||
canvas.height = video.videoHeight;
|
canvas.height = video.videoHeight;
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
const result = jsQR(image.data, image.width, image.height);
|
const result = decode(image.data, image.width, image.height);
|
||||||
if (result && result.binaryData.length > 0) {
|
if (result && result.binaryData.length > 0) {
|
||||||
doneRef.current = true;
|
doneRef.current = true;
|
||||||
onScan(new Uint8ClampedArray(result.binaryData));
|
onScan(new Uint8ClampedArray(result.binaryData));
|
||||||
@@ -40,9 +47,12 @@ export function QrScanner({ onScan, onCancel }: QrScannerProps) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
const [lib, media] = await Promise.all([
|
||||||
video: { facingMode: 'environment' },
|
import('jsqr'),
|
||||||
});
|
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }),
|
||||||
|
]);
|
||||||
|
decode = lib.default;
|
||||||
|
stream = media;
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.srcObject = stream;
|
videoRef.current.srcObject = stream;
|
||||||
await videoRef.current.play();
|
await videoRef.current.play();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MsgType } from 'matrix-js-sdk';
|
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import { Opts } from 'linkifyjs';
|
import { Opts } from 'linkifyjs';
|
||||||
import { config, Text } from 'folds';
|
import { config, Text } from 'folds';
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
VerificationRequestContent,
|
VerificationRequestContent,
|
||||||
VideoContent,
|
VideoContent,
|
||||||
} from './message';
|
} from './message';
|
||||||
|
import { DiffToken } from '../utils/wordDiff';
|
||||||
|
import { EditDiffContext } from './message/content/EditDiffContext';
|
||||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
||||||
import { Image, MediaControl, Video } from './media';
|
import { Image, MediaControl, Video } from './media';
|
||||||
import { ImageViewer } from './image-viewer';
|
import { ImageViewer } from './image-viewer';
|
||||||
@@ -70,8 +72,21 @@ type RenderMessageContentProps = {
|
|||||||
linkifyOpts: Opts;
|
linkifyOpts: Opts;
|
||||||
outlineAttachment?: boolean;
|
outlineAttachment?: boolean;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
|
/** [Gitea #219] Open the room's shared media lightbox at this event. */
|
||||||
|
onOpenImageViewer?: () => void;
|
||||||
|
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
|
||||||
|
mEvent?: MatrixEvent;
|
||||||
|
/** [Gitea #144] Word diff of the last edit, shown on "(edited)" hover. */
|
||||||
|
editDiff?: DiffToken[];
|
||||||
};
|
};
|
||||||
export function RenderMessageContent({
|
export function RenderMessageContent({ editDiff, ...props }: RenderMessageContentProps) {
|
||||||
|
return (
|
||||||
|
<EditDiffContext.Provider value={editDiff}>
|
||||||
|
<RenderMessageContentBody {...props} />
|
||||||
|
</EditDiffContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function RenderMessageContentBody({
|
||||||
displayName,
|
displayName,
|
||||||
msgType,
|
msgType,
|
||||||
ts,
|
ts,
|
||||||
@@ -85,7 +100,9 @@ export function RenderMessageContent({
|
|||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
eventId,
|
eventId,
|
||||||
}: RenderMessageContentProps) {
|
onOpenImageViewer,
|
||||||
|
mEvent,
|
||||||
|
}: Omit<RenderMessageContentProps, 'editDiff'>) {
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||||
@@ -241,6 +258,7 @@ export function RenderMessageContent({
|
|||||||
autoPlay={mediaAutoLoad}
|
autoPlay={mediaAutoLoad}
|
||||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||||
renderViewer={(p) => <ImageViewer {...p} />}
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
|
onOpenViewer={onOpenImageViewer}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
outlined={outlineAttachment}
|
outlined={outlineAttachment}
|
||||||
@@ -342,7 +360,7 @@ export function RenderMessageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === 'm.bad.encrypted') {
|
if (msgType === 'm.bad.encrypted') {
|
||||||
return <MBadEncrypted />;
|
return <MBadEncrypted mEvent={mEvent} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === 'm.key.verification.request') {
|
if (msgType === 'm.key.verification.request') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriTaskbarProgress } from '../hooks/useTauriTaskbarProgress';
|
||||||
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
@@ -18,6 +19,7 @@ export function TauriDesktopFeatures(): null {
|
|||||||
useTauriCallPower(); // P5-46 no-sleep during calls
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriTaskbarProgress(); // cinny-desktop #10 upload progress on the taskbar button
|
||||||
useTauriSmtc(); // P5-43 system media transport controls
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
|||||||
@@ -71,7 +71,15 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
|||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||||
<Modal size="500" style={MOBILE_FULLSCREEN}>
|
<Modal
|
||||||
|
id="user-profile-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="User profile"
|
||||||
|
tabIndex={-1}
|
||||||
|
size="500"
|
||||||
|
style={MOBILE_FULLSCREEN}
|
||||||
|
>
|
||||||
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
|
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
|
||||||
profile has no self-close, so provide an explicit close. */}
|
profile has no self-close, so provide an explicit close. */}
|
||||||
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
|
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
title="Record voice message"
|
title="Record voice message"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Mic} size="100" />
|
<Icon src={Icons.Mic} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -383,7 +383,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
audio.pause();
|
audio.pause();
|
||||||
setPreviewPlaying(false);
|
setPreviewPlaying(false);
|
||||||
} else {
|
} else {
|
||||||
audio.play();
|
audio.play().catch(() => setPreviewPlaying(false));
|
||||||
setPreviewPlaying(true);
|
setPreviewPlaying(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Box, Overlay, OverlayBackdrop, config, color, toRem } from 'folds';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type ActionSheetProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
'aria-label'?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #166] A bottom sheet for touch screens: slides up from the bottom,
|
||||||
|
* swipe-down or backdrop tap dismisses. Content is whatever the caller
|
||||||
|
* renders (quick reactions + a folds Menu, for message actions).
|
||||||
|
*/
|
||||||
|
export function ActionSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: ActionSheetProps) {
|
||||||
|
const [dragY, setDragY] = useState(0);
|
||||||
|
const startY = useRef<number | null>(null);
|
||||||
|
// The long-press that opened us ends with a touchend/click that lands
|
||||||
|
// "outside" the sheet — ignore outside clicks for the first moments.
|
||||||
|
const openedAt = useRef(Date.now());
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((evt: React.TouchEvent) => {
|
||||||
|
startY.current = evt.touches[0]?.clientY ?? null;
|
||||||
|
}, []);
|
||||||
|
const onTouchMove = useCallback((evt: React.TouchEvent) => {
|
||||||
|
if (startY.current === null) return;
|
||||||
|
const dy = (evt.touches[0]?.clientY ?? startY.current) - startY.current;
|
||||||
|
setDragY(Math.max(0, dy));
|
||||||
|
}, []);
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
const dismiss = dragY > 80;
|
||||||
|
startY.current = null;
|
||||||
|
setDragY(0);
|
||||||
|
if (dismiss) onClose();
|
||||||
|
}, [dragY, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: () => Date.now() - openedAt.current > 600,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
maxHeight: '75vh',
|
||||||
|
background: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderTopLeftRadius: toRem(16),
|
||||||
|
borderTopRightRadius: toRem(16),
|
||||||
|
boxShadow: '0 -8px 32px rgba(0,0,0,0.35)',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
transform: dragY ? `translateY(${dragY}px)` : undefined,
|
||||||
|
transition: dragY ? undefined : 'transform 120ms ease-out',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={onTouchEnd}
|
||||||
|
>
|
||||||
|
<Box justifyContent="Center" shrink="No" style={{ padding: config.space.S200 }}>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
width: toRem(36),
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(2),
|
||||||
|
background: color.Surface.ContainerLine,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" style={{ overflowY: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ActionSheet';
|
||||||
@@ -16,21 +16,16 @@ export const EditorOptions = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
'@media': {
|
|
||||||
// On phones the toolbar can hold many 44px buttons; let them wrap to a
|
|
||||||
// second line instead of overflowing horizontally.
|
|
||||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// The composer's before | editable | after row. On phones, allow the toolbar
|
// The composer's before | editable | after row. It must NOT wrap: folds'
|
||||||
// (`after`) to wrap below the input instead of squeezing the editable to zero
|
// Scroll (the editable's wrapper) is `width: 100%`, so a wrapping row always
|
||||||
// and pushing the Send button off-screen.
|
// breaks into three stacked lines (before / editable / after) — the "wonky"
|
||||||
|
// phone composer. Narrow viewports keep one row by collapsing the secondary
|
||||||
|
// buttons behind the "+" overflow instead (RoomInput `compact`).
|
||||||
export const EditorInputRow = style({
|
export const EditorInputRow = style({
|
||||||
'@media': {
|
minWidth: 0,
|
||||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const EditorTextareaScroll = style({});
|
export const EditorTextareaScroll = style({});
|
||||||
@@ -41,6 +36,20 @@ export const EditorTextarea = style([
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
padding: `${toRem(13)} ${toRem(1)}`,
|
padding: `${toRem(13)} ${toRem(1)}`,
|
||||||
|
'@media': {
|
||||||
|
// Phone-width composer rows carry 44px touch targets (MobileTouchTarget),
|
||||||
|
// so the row is 60px instead of 48px; pad the text to keep it level with
|
||||||
|
// the buttons instead of hugging the top of the row. Only when the row
|
||||||
|
// actually has before/after buttons (not the edit-message editor).
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
selectors: {
|
||||||
|
[`${EditorTextareaScroll}:not(:only-child) &`]: {
|
||||||
|
paddingTop: toRem(19),
|
||||||
|
paddingBottom: toRem(19),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${EditorTextareaScroll}:first-child &`]: {
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
paddingLeft: toRem(13),
|
paddingLeft: toRem(13),
|
||||||
@@ -58,7 +67,9 @@ export const EditorTextarea = style([
|
|||||||
export const EditorPlaceholderContainer = style([
|
export const EditorPlaceholderContainer = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
opacity: config.opacity.Placeholder,
|
// [Gitea #222] folds' Placeholder opacity (0.5) lands at ~2.3:1 on the
|
||||||
|
// composer surface; P300 keeps it visibly secondary at AA contrast.
|
||||||
|
opacity: config.opacity.P300,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
},
|
},
|
||||||
@@ -70,6 +81,13 @@ export const EditorPlaceholderTextVisual = style([
|
|||||||
display: 'block',
|
display: 'block',
|
||||||
paddingTop: toRem(13),
|
paddingTop: toRem(13),
|
||||||
paddingLeft: toRem(1),
|
paddingLeft: toRem(1),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
selectors: {
|
||||||
|
[`${EditorTextareaScroll}:not(:only-child) &`]: { paddingTop: toRem(19) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import { CustomElement } from './slate';
|
|||||||
import * as css from './Editor.css';
|
import * as css from './Editor.css';
|
||||||
import { toggleKeyboardShortcut } from './keyboard';
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
|
||||||
const initialValue: CustomElement[] = [
|
// One FRESH value per editor instance. slate-react keys its node→path weak
|
||||||
|
// maps by node object identity, so a module-level constant shared by every
|
||||||
|
// <Slate> (main composer + thread composer + message editor) makes the second
|
||||||
|
// mount hijack the first editor's nodes and the first editor throws "Unable to
|
||||||
|
// find the path for Slate node" on its next render — the app-wide crash when
|
||||||
|
// opening a thread on a pristine composer (Gitea #165 / #184).
|
||||||
|
const createInitialValue = (): CustomElement[] => [
|
||||||
{
|
{
|
||||||
type: BlockType.Paragraph,
|
type: BlockType.Paragraph,
|
||||||
children: [{ text: '' }],
|
children: [{ text: '' }],
|
||||||
@@ -92,6 +98,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const [initialValue] = useState(createInitialValue);
|
||||||
const renderElement = useCallback(
|
const renderElement = useCallback(
|
||||||
(props: RenderElementProps) => <RenderElement {...props} />,
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AutocompleteMenu } from './AutocompleteMenu';
|
|||||||
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
||||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
|
import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
@@ -126,19 +127,21 @@ export function RoomMentionAutocomplete({
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyDown(window, (evt: KeyboardEvent) => {
|
const pickTop = () => {
|
||||||
onTabPress(evt, () => {
|
if (autoCompleteRoomIds.length === 0) {
|
||||||
if (autoCompleteRoomIds.length === 0) {
|
const alias = roomAliasFromQueryText(mx, query.text);
|
||||||
const alias = roomAliasFromQueryText(mx, query.text);
|
handleAutocomplete(alias, alias);
|
||||||
handleAutocomplete(alias, alias);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const rId = autoCompleteRoomIds[0];
|
||||||
const rId = autoCompleteRoomIds[0];
|
const r = mx.getRoom(rId);
|
||||||
const r = mx.getRoom(rId);
|
const name = r?.name ?? rId;
|
||||||
const name = r?.name ?? rId;
|
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
|
||||||
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
|
};
|
||||||
});
|
|
||||||
});
|
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
|
||||||
|
// Same rule as people: a fully typed #alias:server sends as typed.
|
||||||
|
useAutocompleteEnter(!query.text.includes(':'), pickTop);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
|
<AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useAsyncSearch,
|
useAsyncSearch,
|
||||||
} from '../../../hooks/useAsyncSearch';
|
} from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
|
import { useAutocompleteEnter } from '../../../hooks/useAutocompleteEnter';
|
||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||||
@@ -124,21 +125,24 @@ export function UserMentionAutocomplete({
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyDown(window, (evt: KeyboardEvent) => {
|
const pickTop = () => {
|
||||||
onTabPress(evt, () => {
|
if (query.text === 'room') {
|
||||||
if (query.text === 'room') {
|
handleAutocomplete(roomAliasOrId, '@room');
|
||||||
handleAutocomplete(roomAliasOrId, '@room');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (autoCompleteMembers.length === 0) {
|
||||||
if (autoCompleteMembers.length === 0) {
|
const userId = userIdFromQueryText(mx, query.text);
|
||||||
const userId = userIdFromQueryText(mx, query.text);
|
handleAutocomplete(userId, userId);
|
||||||
handleAutocomplete(userId, userId);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const roomMember = autoCompleteMembers[0];
|
||||||
const roomMember = autoCompleteMembers[0];
|
handleAutocomplete(roomMember.userId, getMemberName(room, roomMember.userId));
|
||||||
handleAutocomplete(roomMember.userId, roomMember.name);
|
};
|
||||||
});
|
|
||||||
});
|
useKeyDown(window, (evt: KeyboardEvent) => onTabPress(evt, pickTop));
|
||||||
|
// Enter picks while a partial name is being typed ("hey @bo"). Once a full
|
||||||
|
// user ID has been typed ("/kick @alice:server"), Enter sends as typed.
|
||||||
|
useAutocompleteEnter(!query.text.includes(':'), pickTop);
|
||||||
|
|
||||||
const getName = (member: RoomMember) => getMemberName(room, member.userId);
|
const getName = (member: RoomMember) => getMemberName(room, member.userId);
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,46 @@ test('no math conversion inside a code block', () => {
|
|||||||
test('a message with no math is unchanged', () => {
|
test('a message with no math is unchanged', () => {
|
||||||
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
|
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gitea #184 O3 — markdown mode: fences and backtick spans typed as plain
|
||||||
|
// paragraphs are literal too (the block markdown parser only sees the fence
|
||||||
|
// after the lines are joined, so math must be skipped while serialising them).
|
||||||
|
const MD_OPTS = { ...OPTS, allowInlineMarkdown: true, allowBlockMarkdown: true };
|
||||||
|
|
||||||
|
test('markdown: no math conversion inside a typed ``` fence', () => {
|
||||||
|
const paragraphs = [
|
||||||
|
el(BlockType.Paragraph, [txt('```')]),
|
||||||
|
el(BlockType.Paragraph, [txt('$x$ literal')]),
|
||||||
|
el(BlockType.Paragraph, [txt('```')]),
|
||||||
|
el(BlockType.Paragraph, [txt('after $y$')]),
|
||||||
|
];
|
||||||
|
const out = toMatrixCustomHTML(paragraphs, MD_OPTS);
|
||||||
|
assert.ok(out.includes('<pre'), 'fence became a code block');
|
||||||
|
assert.ok(out.includes('$x$ literal'), 'code block content stays literal');
|
||||||
|
assert.ok(out.includes('data-mx-maths="y"'), 'math after the fence still converts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markdown: no math conversion inside a backtick span, math outside still converts', () => {
|
||||||
|
const out = toMatrixCustomHTML(el(BlockType.Paragraph, [txt('use `$x$` and $y$')]), MD_OPTS);
|
||||||
|
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
|
||||||
|
assert.ok(out.includes('data-mx-maths="y"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#107] a code block language becomes class="language-…"', () => {
|
||||||
|
const block = {
|
||||||
|
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('let a = 1;')])]) as object),
|
||||||
|
lang: 'js',
|
||||||
|
} as unknown as Descendant;
|
||||||
|
assert.equal(
|
||||||
|
toMatrixCustomHTML(block, OPTS),
|
||||||
|
'<pre><code class="language-js">let a = 1;\n</code></pre>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#107] an unsafe language value is dropped', () => {
|
||||||
|
const block = {
|
||||||
|
...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('x')])]) as object),
|
||||||
|
lang: 'js" onclick="x',
|
||||||
|
} as unknown as Descendant;
|
||||||
|
assert.equal(toMatrixCustomHTML(block, OPTS), '<pre><code>x\n</code></pre>');
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
|||||||
// applied inside inline code. Non-math text recurses with allowMath off so it
|
// applied inside inline code. Non-math text recurses with allowMath off so it
|
||||||
// still gets the normal marks + inline-markdown treatment.
|
// still gets the normal marks + inline-markdown treatment.
|
||||||
if (opts.allowMath && !node.code) {
|
if (opts.allowMath && !node.code) {
|
||||||
|
// Markdown inline code spans (`…`) are literal too: apply math only to the
|
||||||
|
// text between them (Gitea #184 O3 — `$x$` inside backticks stayed math).
|
||||||
|
if (opts.allowInlineMarkdown && /`[^`]*`/.test(node.text)) {
|
||||||
|
return node.text
|
||||||
|
.split(/(`+[^`]*`+)/)
|
||||||
|
.map((part) =>
|
||||||
|
part.startsWith('`')
|
||||||
|
? textToCustomHtml({ ...node, text: part }, { ...opts, allowMath: false })
|
||||||
|
: textToCustomHtml({ ...node, text: part }, opts),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
const segments = splitMathSegments(node.text);
|
const segments = splitMathSegments(node.text);
|
||||||
if (segments.some((seg) => seg.type !== 'text')) {
|
if (segments.some((seg) => seg.type !== 'text')) {
|
||||||
return segments
|
return segments
|
||||||
@@ -74,7 +86,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
|||||||
case BlockType.CodeLine:
|
case BlockType.CodeLine:
|
||||||
return `${children}\n`;
|
return `${children}\n`;
|
||||||
case BlockType.CodeBlock:
|
case BlockType.CodeBlock:
|
||||||
return `<pre><code>${children}</code></pre>`;
|
// [Gitea #107] Only a plain identifier is ever put in the class.
|
||||||
|
return node.lang && /^[a-z0-9+#-]{1,20}$/.test(node.lang)
|
||||||
|
? `<pre><code class="language-${node.lang}">${children}</code></pre>`
|
||||||
|
: `<pre><code>${children}</code></pre>`;
|
||||||
case BlockType.QuoteLine:
|
case BlockType.QuoteLine:
|
||||||
return `${children}<br/>`;
|
return `${children}<br/>`;
|
||||||
case BlockType.BlockQuote:
|
case BlockType.BlockQuote:
|
||||||
@@ -128,12 +143,20 @@ export const toMatrixCustomHTML = (
|
|||||||
opts: OutputOptions,
|
opts: OutputOptions,
|
||||||
): string => {
|
): string => {
|
||||||
let markdownLines = '';
|
let markdownLines = '';
|
||||||
|
// Inside a markdown ``` fence every line is literal: no `$…$` math conversion
|
||||||
|
// (the fence is only recognised by parseBlockMD after the lines are joined,
|
||||||
|
// which is too late — Gitea #184 O3).
|
||||||
|
let inFence = false;
|
||||||
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
|
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
|
||||||
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
|
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
|
||||||
|
const isFenceLine = /^\s*```/.test(toPlainText(n, false));
|
||||||
|
const literal = inFence || isFenceLine;
|
||||||
|
if (isFenceLine) inFence = !inFence;
|
||||||
const line = toMatrixCustomHTML(n, {
|
const line = toMatrixCustomHTML(n, {
|
||||||
...opts,
|
...opts,
|
||||||
allowInlineMarkdown: false,
|
allowInlineMarkdown: false,
|
||||||
allowBlockMarkdown: false,
|
allowBlockMarkdown: false,
|
||||||
|
allowMath: opts.allowMath && !literal,
|
||||||
})
|
})
|
||||||
.replace(/<br\/>$/, '\n')
|
.replace(/<br\/>$/, '\n')
|
||||||
.replace(/^(\\*)>/, '$1>');
|
.replace(/^(\\*)>/, '$1>');
|
||||||
|
|||||||
Vendored
+2
@@ -64,6 +64,8 @@ export type CodeLineElement = {
|
|||||||
};
|
};
|
||||||
export type CodeBlockElement = {
|
export type CodeBlockElement = {
|
||||||
type: BlockType.CodeBlock;
|
type: BlockType.CodeBlock;
|
||||||
|
/** [Gitea #107] Optional language → `<code class="language-…">`. */
|
||||||
|
lang?: string;
|
||||||
children: CodeLineElement[];
|
children: CodeLineElement[];
|
||||||
};
|
};
|
||||||
export type QuoteLineElement = {
|
export type QuoteLineElement = {
|
||||||
|
|||||||
@@ -268,15 +268,15 @@ export const getPointUntilChar = (
|
|||||||
return targetPoint;
|
return targetPoint;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
|
export const getPrevWordRange = (editor: Editor): BaseRange | undefined => {
|
||||||
const { selection } = editor;
|
const { selection } = editor;
|
||||||
if (!selection || !Range.isCollapsed(selection)) return undefined;
|
if (!selection || !Range.isCollapsed(selection)) return undefined;
|
||||||
const [cursorPoint] = Range.edges(selection);
|
const [cursorPoint] = Range.edges(selection);
|
||||||
const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
|
const wordStartPoint = getPointUntilChar(editor, cursorPoint, {
|
||||||
reverse: true,
|
reverse: true,
|
||||||
match: (char) => char === ' ',
|
match: (char) => char === ' ' || char === '',
|
||||||
});
|
});
|
||||||
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
|
return wordStartPoint && Editor.range(editor, wordStartPoint, cursorPoint);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isEmptyEditor = (editor: Editor): boolean => {
|
export const isEmptyEditor = (editor: Editor): boolean => {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
|
|||||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useRecentTouch } from '../../hooks/useRecentTouch';
|
||||||
|
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
|
||||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +54,7 @@ import {
|
|||||||
EmojiGroup,
|
EmojiGroup,
|
||||||
EmojiBoardLayout,
|
EmojiBoardLayout,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
|
||||||
import { VirtualTile } from '../virtualizer';
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
@@ -518,10 +520,27 @@ export function EmojiBoard({
|
|||||||
});
|
});
|
||||||
const vItems = virtualizer.getVirtualItems();
|
const vItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
|
||||||
|
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
|
||||||
|
const { wasTouch } = useRecentTouch(contentScrollRef);
|
||||||
|
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
|
||||||
|
const stickerUseAuthentication = useMediaAuthentication();
|
||||||
|
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
|
||||||
|
onStickerSelect?.(info.data, info.shortcode, info.label);
|
||||||
|
setRecentStickers((prev) =>
|
||||||
|
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
|
||||||
|
);
|
||||||
|
setPendingSticker(undefined);
|
||||||
|
if (close) requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleGroupItemClick: MouseEventHandler = (evt) => {
|
const handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||||
if (!emojiInfo) return;
|
if (!emojiInfo) {
|
||||||
|
if (pendingSticker) setPendingSticker(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (emojiInfo.type === EmojiType.Emoji) {
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
@@ -533,14 +552,12 @@ export function EmojiBoard({
|
|||||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
}
|
}
|
||||||
if (emojiInfo.type === EmojiType.Sticker) {
|
if (emojiInfo.type === EmojiType.Sticker) {
|
||||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
|
||||||
setRecentStickers((prev) =>
|
setPendingSticker(emojiInfo);
|
||||||
addRecentSticker(prev, {
|
return;
|
||||||
url: emojiInfo.data,
|
}
|
||||||
shortcode: emojiInfo.shortcode,
|
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
|
||||||
body: emojiInfo.label,
|
return;
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
};
|
};
|
||||||
@@ -670,7 +687,18 @@ export function EmojiBoard({
|
|||||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||||
</EmojiGroupHolder>
|
</EmojiGroupHolder>
|
||||||
</Box>
|
</Box>
|
||||||
<Preview previewAtom={previewAtom} />
|
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
|
||||||
|
<TapToSendBar
|
||||||
|
previewUrl={
|
||||||
|
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
|
||||||
|
}
|
||||||
|
label={pendingSticker.label}
|
||||||
|
onSend={() => sendSticker(pendingSticker, true)}
|
||||||
|
onCancel={() => setPendingSticker(undefined)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Preview previewAtom={previewAtom} />
|
||||||
|
)}
|
||||||
</EmojiBoardLayout>
|
</EmojiBoardLayout>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
|
import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp';
|
||||||
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
|
|
||||||
function formatReadTs(ts: number, hour24Clock: boolean): string {
|
const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs);
|
||||||
const timeStr = timeHourMinute(ts, hour24Clock);
|
|
||||||
if (today(ts)) return `Today at ${timeStr}`;
|
|
||||||
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
|
|
||||||
const sameYear = timeYear(ts) === timeYear(Date.now());
|
|
||||||
return sameYear
|
|
||||||
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
|
|
||||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventReaderItemProps = {
|
type EventReaderItemProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
readerId: string;
|
readerId: string;
|
||||||
hour24Clock: boolean;
|
prefs: TimestampPrefs;
|
||||||
lotusTerminal: boolean;
|
lotusTerminal: boolean;
|
||||||
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
function EventReaderItem({
|
function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) {
|
||||||
room,
|
|
||||||
readerId,
|
|
||||||
hour24Clock,
|
|
||||||
lotusTerminal,
|
|
||||||
onSelect,
|
|
||||||
}: EventReaderItemProps) {
|
|
||||||
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
||||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||||
|
|
||||||
@@ -86,7 +73,7 @@ function EventReaderItem({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatReadTs(receiptTs, hour24Clock)}
|
{formatReadTs(receiptTs, prefs)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||||
const openProfile = useOpenUserRoomProfile();
|
const openProfile = useOpenUserRoomProfile();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const { prefs } = useTimestampFormatter();
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
key={readerId}
|
key={readerId}
|
||||||
room={room}
|
room={room}
|
||||||
readerId={readerId}
|
readerId={readerId}
|
||||||
hour24Clock={hour24Clock}
|
prefs={prefs}
|
||||||
lotusTerminal={lotusTerminal}
|
lotusTerminal={lotusTerminal}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
openProfile(
|
openProfile(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
@@ -27,12 +27,37 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
saveFile(fileContent, alt);
|
saveFile(fileContent, alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #164] Same keyboard model as the gallery lightbox: + / = zoom
|
||||||
|
// in, - zoom out, 0 reset; double-click toggles 1× ↔ 2×. The root takes
|
||||||
|
// focus on open so those keys (and Escape, handled by the modal) work
|
||||||
|
// immediately instead of only after clicking inside the viewer.
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
rootRef.current?.focus({ preventScroll: true });
|
||||||
|
}, []);
|
||||||
|
const handleKeyDown = (evt: React.KeyboardEvent) => {
|
||||||
|
if (evt.key === '+' || evt.key === '=') zoomIn();
|
||||||
|
else if (evt.key === '-') zoomOut();
|
||||||
|
else if (evt.key === '0') setZoom(1);
|
||||||
|
else return;
|
||||||
|
evt.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.ImageViewer, className)}
|
className={classNames(css.ImageViewer, className)}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
aria-label={alt || 'Image viewer'}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={(node: HTMLDivElement | null) => {
|
||||||
|
rootRef.current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<Header className={css.ImageViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
@@ -116,6 +141,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
|
onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useDirectUsers } from '../../hooks/useDirectUsers';
|
import { useDirectUsers } from '../../hooks/useDirectUsers';
|
||||||
import {
|
import {
|
||||||
@@ -188,14 +189,21 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog style={modalStyle}>
|
<Dialog
|
||||||
|
id="inviteuserprompt-dialog-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="inviteuserprompt-dialog-1-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4" truncate>
|
<Text id="inviteuserprompt-dialog-1-title" size="H4" truncate>
|
||||||
{t('Organisms.InviteUser.invite')}
|
{t('Organisms.InviteUser.invite')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -237,13 +245,25 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
{/* Generated locally (qrcode.react) like RoomShareInvite — the
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
|
old api.qrserver.com <img> leaked the room link to a third
|
||||||
alt="QR code for room invite link"
|
party and is blocked by the prod CSP img-src anyway
|
||||||
width={180}
|
(Gitea #192). White quiet-zone so it scans on any theme. */}
|
||||||
height={180}
|
<Box
|
||||||
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
style={{
|
||||||
/>
|
padding: config.space.S200,
|
||||||
|
background: '#ffffff',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
lineHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={inviteUrl}
|
||||||
|
size={164}
|
||||||
|
level="M"
|
||||||
|
title="QR code for room invite link"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
|
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user