Compare commits

...
20 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 2bed70d335 feat(calls): toast the fork's screenshare reminders (EC #39)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m4s
io.lotus.screenshare_notice → "Screen sharing stopped — the shared window
was closed.", "Your screen share is showing nothing — the shared window
may be minimised or hidden.", and a sticky "Still sharing?" after 30 min
with nobody else in the call (fork ≥ 0.25.0-lotus.12).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:59 -04:00
jaredandClaude Opus 5 c7aa4b9b19 ci: debounce the desktop build trigger to one bump per hour (matrix #9)
trigger-desktop checks the age of cinny-desktop's last main commit and
skips the submodule bump when it is under 60 minutes old; cinny-desktop's
new nightly catch-up workflow (or a manual dispatch) moves the submodule
to lotus HEAD for whatever landed inside the window. The desktop cadence
stops tracking every web commit, which was the biggest runner-load source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:39:55 -04:00
jaredandClaude Opus 5 81777ddfe1 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.11
Brings io.lotus.set_audio_output for the call-bar output switch (#119).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:38:14 -04:00
jaredandClaude Opus 5 eb4b88a028 fix(math): $…$ inside a backtick code span stays literal (#194 P4-4)
Verifying the KaTeX checklist: `$x^2$` in inline code was turned into
math and the backticks were left as literal text, because the math split
runs before markdown. The splitter now skips backtick code spans (N ticks
close with N), so markdown's inline code wins: wire is
<code>$y^2$</code>. Fenced blocks were already fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:35:54 -04:00
jaredandClaude Opus 5 e3883e0fce test(haptics): swap globalThis.navigator via defineProperty (Node 22)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 9m50s
CI's Node exposes a read-only navigator getter; assigning threw
"Cannot set property navigator ... which has only a getter".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:32:32 -04:00
jaredandClaude Opus 5 98c80f36cb feat(calls): audio output quick-switch in the call bar (#119)
Verified first: the embed hides Element Call's footer, so its in-call
settings sheet (which has the output picker) is unreachable from Lotus;
the cinny call bar had no output control.

Now a speaker button next to Deafen (desktop bar only; hidden where
setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu
of enumerateDevices() audio outputs with the current one checked; picking
one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11),
which selects it, and the choice is re-sent with the rest of the sticky
fork state after an EC remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:31:47 -04:00
jaredandClaude Opus 5 ad1cbcf792 feat(room-settings): one-line "who can see this" under the room name (#133)
CI / Build & Quality Checks (push) Failing after 1m43s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
Room Settings → General now reads e.g. "Encrypted · Invite only ·
History visible to members since they joined" under the name — derived
from m.room.encryption, m.room.join_rules and m.room.history_visibility
with the same words the preview-card chips and the history setting use.
One Text line, no card, no icons, no controls; spec defaults (invite-only,
shared history) when a state event is absent. Unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:18:08 -04:00
jaredandClaude Opus 5 492b57c60d feat(mobile): haptic tick on PTT press/release and on reactions (#125)
utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:15:04 -04:00
jaredandClaude Opus 5 0c45bde832 feat(composer): a single pasted/dropped image lands in its caption field (#129)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Verified first: after a paste the focus stayed in the composer and the
caption needed a click. Now, when exactly one image is pasted or dropped
into an empty composer, its upload card's caption input takes focus;
Enter there sends the board (with any composer text) and Escape returns
focus to the composer. Multi-file drops and non-empty composers are
unchanged. The target card is matched by file name + mtime because the
metadata strip re-wraps the File.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:11:05 -04:00
jaredandClaude Opus 5 082b8fc879 feat(sidebar): custom room sections via u.* tags (#108)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.

Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:05:30 -04:00
jaredandClaude Opus 5 c6c2e88df5 feat(settings): storage usage tile with persistence status (#120)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.

No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:53:42 -04:00
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00
jaredandClaude Opus 5 22d46a7922 feat(messages): reply quotes show a media thumbnail (#151)
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 9m44s
A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.

useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:27:23 -04:00
jaredandClaude Opus 5 be2c202543 feat(privacy): strip EXIF/XMP/IPTC from image uploads by default (#109)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Metadata was only dropped as a side effect of opt-in compression, so a
phone photo carried its GPS fix, camera model and timestamp into the room
and the media store. utils/stripImageMetadata.ts now removes it at the
container level, without touching pixels: JPEG drops APP1/APP13/COM
(writing back a minimal EXIF holding only Orientation when it isn't 1,
so sideways-stored photos still display upright), PNG drops eXIf and the
text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags.
Other types pass through.

Applied before encryption on every composer path (attach, paste, drop,
share target) and to user/room avatar picks; GIF upload is excluded.
Setting → General → Privacy "Remove Photo Metadata Before Sending",
default on. The upload card says "Photo metadata removed".

Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6,
JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to
end: the bytes stored by Synapse decode fine and carry only Orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:22:34 -04:00
jaredandClaude Opus 5 6363939654 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.10
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Brings io.lotus.call_summary for the hangup readout (#143).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:14:28 -04:00
jaredandClaude Opus 5 af244bba75 chore(lint): retire 19 explicit anys, ratchet to 49 warnings (#210)
Event handlers typed as React.MouseEvent, join errors as Error, the
service-worker session setter as strings, the UIA policy map and the
webkitAudioContext probe as narrow shapes. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:13:55 -04:00
jaredandClaude Opus 5 af1c0ee184 feat(calls): "Call ended · 41 min · connection was good" toast (#143)
One line in the existing toast style when a call you were in ends: the
duration from our own join clock, plus the fork's io.lotus.call_summary
readout (fork ≥ 0.25.0-lotus.10) when it arrives — "connection was
good", "3 reconnects", "connection was poor for 4 min". Nothing is
stored or sent; the summary is one postMessage at hangup. Without the
fork summary the toast still shows the duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:48:14 -04:00
jaredandClaude Opus 5 96a97a2f86 feat(pwa): register as an Android share target (#155)
public/manifest.json declares share_target (POST multipart to
/share-target: title/text/url + image/video/audio/pdf/text files). The
service worker answers that POST itself: it stashes the form in a Cache
API bucket and 303s to the in-app /share page, which lists what arrived,
offers a room search, and on pick writes the files into that room's
upload-board atom (encrypting first for E2EE rooms via the composer's
shared filesToUploadItems) and the title/text/url into its draft, then
opens the room — the user still presses Send. The stash is cleared once
placed; reopening /share afterwards says so.

nginx/caddy examples and the prod image config gain a 303 for
/share-target so a POST that reaches the origin before the worker
controls the page lands on /share instead of a 405. iOS has no share
target support and ignores the manifest entry.

Verified headless against the built preview: SW-controlled page → POST
/share-target (two PNGs + title + text) → /share lists both files and the
text → pick the DM → composer shows both files on the upload board and
the text in the draft → /share reports nothing pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:36:05 -04:00
jaredandClaude Opus 5 6aa77552b8 ci(a11y): axe-core gate + accessibility-tree snapshots (#222)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 8m52s
e2e/a11y.spec.ts runs @axe-core/playwright (WCAG 2.x A/AA) over the login
page, room timeline + composer, message options menu, thread panel, user
settings and room settings, failing on critical/serious findings other
than colour contrast (reported, not gated: generated avatar colours and
portal false positives). Aria snapshots of the composer, message menu,
thread panel and settings nav catch lost names/roles/live regions.

Burned down what the first run found:
- NavItem: callers' aria-selected is not valid on a div (axe critical);
  it now drives data-selected for styling and aria-current="page".
- Composer placeholder at 0.5 opacity was ~2.3:1; now P300.
- Voice-limit and explore custom-limit number inputs had no label.
- Thread panel is an <aside aria-label="Thread">; the settings modal is a
  role=dialog; the settings sections are a <nav>; the message action
  menu carries data-message-menu + a label.

Also allows WebKit's CI wording for the well-known probe ("Could not
connect … Connection refused") that failed run #2003.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:25:57 -04:00
jaredandClaude Opus 5 52e0cfaa83 ci(e2e): run the tagged specs under WebKit as the Safari/iOS proxy (#221)
CI / Build & Quality Checks (push) Successful in 3m11s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 18s
CI / Trigger Desktop Build (push) Successful in 17s
CI / Playwright smoke (e2e) (push) Failing after 8m58s
Two new Playwright projects — 'webkit' (Desktop Safari) for tests tagged
@webkit and 'iphone' (iPhone 14 descriptor) for @ios — covering boot,
login + send/receive, the thread panel and the gallery lightbox. The CI
e2e job installs webkit next to chromium.

WebKit reports handled fetch failures (well-known probes, a wasm fetch cut
short by our own navigation) as page errors with its own wording, so the
benign allowlist now applies to page errors as well.

Locally: 8/8 green twice in a row against the local Synapse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:11:38 -04:00
92 changed files with 2955 additions and 169 deletions
+21 -2
View File
@@ -132,11 +132,27 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
runs-on: ubuntu-latest
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
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEBOUNCE_MIN: '60'
run: |
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
cd desktop
git config user.email "ci@lotusguild.org"
@@ -275,8 +291,11 @@ jobs:
sleep $((attempt * 15))
done
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
# [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
+1 -1
View File
@@ -79,7 +79,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
**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.9`, 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.9`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**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.11`, 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.11`) → `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):
+4
View File
@@ -57,6 +57,10 @@ Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They bo
| **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 |
**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.
**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.
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
+1 -1
View File
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.9`, upstream base v0.25.0, source at
(`@lotusguild/element-call-embedded` `0.25.0-lotus.11`, upstream base v0.25.0, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
+4
View File
@@ -1,6 +1,10 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
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
file_server
+7
View File
@@ -26,6 +26,13 @@ server {
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;
# [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 / {
root /opt/cinny/dist/;
+125
View File
@@ -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
- 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
+2 -2
View File
@@ -5,7 +5,7 @@ import { collectConsole } from './helpers';
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
test.describe('boot', () => {
test('client boots to the login screen without errors', async ({ page }) => {
test('client boots to the login screen without errors @webkit @ios', async ({ page }) => {
const consoleLog = collectConsole(page);
await page.goto('/');
@@ -24,7 +24,7 @@ test.describe('boot', () => {
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
});
test('service worker script is served and registers', async ({ page }) => {
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/);
+10 -1
View File
@@ -18,6 +18,11 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
// 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,
];
@@ -45,8 +50,12 @@ export function collectConsole(page: Page): ConsoleCollector {
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.map((m) => `pageerror: ${m}`),
...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))),
],
};
+3 -3
View File
@@ -28,7 +28,7 @@ test.describe('local homeserver regression', () => {
bob = await ensureUser(uniq('e2e_bob_'));
});
test('logs in, opens a room, sends and receives', async ({ page }) => {
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);
@@ -140,7 +140,7 @@ test.describe('local homeserver regression', () => {
await expect(page.getByText('the original message')).toBeVisible();
});
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218)', async ({
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 } });
@@ -169,7 +169,7 @@ test.describe('local homeserver regression', () => {
await ctx.close();
});
test('timeline image opens the gallery lightbox (#219)', async ({ page }) => {
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
+22 -7
View File
@@ -80,7 +80,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -172,6 +173,19 @@
"@babel/runtime": "^7.0.0"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.13.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2681,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.9",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.9/element-call-embedded-0.25.0-lotus.9.tgz",
"integrity": "sha512-+mIrBA4SywAmb00EYgThyyVqDLShICjC2Mh8GR13NAWo/u4nPY+j4rMhZidfOJMdFTF5eaMHigX6SE0lHZazZA==",
"version": "0.25.0-lotus.11",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.11/element-call-embedded-0.25.0-lotus.11.tgz",
"integrity": "sha512-wLsVsEBLZ4UjhAdLhhk5BmAap1kZ4x8KKirOm2Ie+DbtM34GCv30oUFmQnUmVdO4by3EQAmNK+v+vYyIwq4DOg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
@@ -4847,10 +4861,11 @@
}
},
"node_modules/axe-core": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
+4 -3
View File
@@ -12,13 +12,13 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68",
"check:eslint": "eslint src/* --max-warnings 49",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"test:e2e:install": "playwright install chromium webkit",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -107,7 +107,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+10 -1
View File
@@ -25,7 +25,16 @@ export default defineConfig({
trace: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...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,
+16
View File
@@ -69,6 +69,22 @@
}
],
"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": [
{
"name": "New Message",
+14
View File
@@ -47,6 +47,10 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
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';
@@ -744,6 +748,9 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useAfkAutoMute(joined ? embed : undefined);
useCallJoinLeaveSounds(embed);
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
usePttHaptics();
useCallAnnouncements(embed, joined);
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
@@ -840,6 +847,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
type CallEmbedProviderProps = {
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) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -1292,6 +1305,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
<CallRejoin callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
+1 -1
View File
@@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src,
+2
View File
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<Modal
size="500"
variant="Background"
role="dialog"
aria-modal="true"
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+3 -1
View File
@@ -67,7 +67,9 @@ export const EditorTextarea = style([
export const EditorPlaceholderContainer = style([
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',
userSelect: 'none',
},
+7 -3
View File
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
<i>Original message not available</i>
</Text>
) : (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
</Box>
)}
</ReplyLayout>
</Box>
@@ -0,0 +1,75 @@
import React from 'react';
import { Icon, Icons, config, toRem } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { MessageEvent } from '../../../types/matrix/room';
const SIZE = 36;
/** Whether a reply quote for this event should carry a thumbnail. */
export const hasReplyMedia = (mEvent: MatrixEvent | null | undefined): boolean => {
if (!mEvent || mEvent.isRedacted()) return false;
if (mEvent.getType() === MessageEvent.Sticker) return true;
const msgtype = mEvent.getContent().msgtype;
return msgtype === MsgType.Image || msgtype === MsgType.Video;
};
/**
* [Gitea #151] 36 px thumbnail in a reply quote for an image/video/sticker.
* Uses the event's own thumbnail (decrypting it for E2EE media), never the
* full-size file.
*/
export function ReplyMediaThumb({ mEvent }: { mEvent: MatrixEvent }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType);
return (
<span
aria-hidden
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(SIZE),
height: toRem(SIZE),
flexShrink: 0,
borderRadius: config.radii.R300,
overflow: 'hidden',
background: 'rgba(127, 127, 127, 0.15)',
}}
>
{media.status === 'ok' ? (
<img
src={media.url}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Icon size="100" src={isVideo ? Icons.Play : Icons.Photo} />
)}
{isVideo && media.status === 'ok' && (
<Icon
size="50"
src={Icons.Play}
filled
style={{ position: 'absolute', color: 'white', filter: 'drop-shadow(0 0 2px black)' }}
/>
)}
</span>
);
}
@@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -143,7 +143,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,
+29 -10
View File
@@ -9,16 +9,35 @@ export const NavItem = as<
{
highlight?: boolean;
} & css.RoomSelectorVariants
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
{...props}
ref={ref}
>
{children}
</AsNavItem>
));
>(
(
{
as: AsNavItem = 'div',
className,
highlight,
variant,
radii,
children,
'aria-selected': selected,
...props
},
ref,
) => (
// [Gitea #222] Callers pass `aria-selected`, but that attribute is only
// valid on option/tab/row roles; on a plain div axe flags it as critical.
// Keep the prop for callers and styling, expose the state as aria-current.
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-selected={selected === true || selected === 'true' ? true : undefined}
aria-current={selected === true || selected === 'true' ? 'page' : undefined}
{...props}
ref={ref}
>
{children}
</AsNavItem>
),
);
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
({ className, ...props }, ref) => (
+1 -1
View File
@@ -69,7 +69,7 @@ const NavItemBase = style({
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
'&[data-selected=true]': {
backgroundColor: ContainerActive,
},
[`&:has(.${NavLink}:focus-visible)`]: {
@@ -283,6 +283,12 @@ type UploadCardRendererProps = {
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
/** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */
autoFocusCaption?: boolean;
/** Enter in the caption field: send the board (and any composer text). */
onCaptionSubmit?: () => void;
/** Escape in the caption field: hand focus back to the composer. */
onCaptionEscape?: () => void;
};
export function UploadCardRenderer({
isEncrypted,
@@ -290,6 +296,9 @@ export function UploadCardRenderer({
setMetadata,
onRemove,
onComplete,
autoFocusCaption,
onCaptionSubmit,
onCaptionEscape,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
@@ -379,9 +388,27 @@ export function UploadCardRenderer({
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
autoFocus={autoFocusCaption}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onCaptionSubmit?.();
} else if (e.key === 'Escape') {
e.preventDefault();
onCaptionEscape?.();
}
}}
/>
)}
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
{metadata.metadataStripped && (
<Box alignItems="Center" gap="100" style={{ marginTop: config.space.S100 }}>
<Icon size="50" src={Icons.Shield} style={{ color: color.Success.Main }} />
<Text size="T200" priority="300">
Photo metadata removed (location, camera, time)
</Text>
</Box>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
+1 -1
View File
@@ -88,7 +88,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
<Modal
size="500"
className={ModalMobileFull}
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
<ImageViewer
src={viewAvatar}
@@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
import { CallEmbed } from '../../plugins/call';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { stopPropagation } from '../../utils/keyboard';
/** Output selection needs setSinkId; Firefox/Safari/Android Chrome lack it. */
export const audioOutputSelectable = (): boolean =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype &&
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.enumerateDevices;
type OutputDevice = { id: string; label: string };
/**
* [Gitea #119] Speaker button in the call bar: a small menu of audio outputs
* (headset ↔ speakers) without opening Settings. The choice goes to the fork
* as io.lotus.set_audio_output; the fork's own picker is unreachable here
* because the embed hides Element Call's footer.
*/
export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disabled?: boolean }) {
const [anchor, setAnchor] = useState<RectCords>();
const [devices, setDevices] = useState<OutputDevice[]>([]);
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
const refresh = useCallback(async () => {
try {
const all = await navigator.mediaDevices.enumerateDevices();
setDevices(
all
.filter((d) => d.kind === 'audiooutput')
.map((d, i) => ({ id: d.deviceId, label: d.label || `Output ${i + 1}` })),
);
} catch {
setDevices([]);
}
}, []);
useEffect(() => {
if (!anchor) return undefined;
refresh();
navigator.mediaDevices.addEventListener('devicechange', refresh);
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
}, [anchor, refresh]);
const choose = (id: string) => {
embed.control.setAudioOutput(id);
setSelected(id);
setAnchor(undefined);
};
return (
<PopOut
anchor={anchor}
position="Top"
align="Center"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// The list is enumerated after the menu opens; until then the only
// tabbable node is the menu itself.
fallbackFocus: '#call-audio-output-menu',
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu
id="call-audio-output-menu"
tabIndex={-1}
style={{ maxWidth: toRem(280), width: '100vw' }}
aria-label="Audio output"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{devices.map((d) => {
const isSelected = selected ? d.id === selected : d.id === 'default';
return (
<MenuItem
key={d.id}
size="300"
radii="300"
role="menuitemradio"
aria-checked={isSelected}
after={isSelected ? <Icon size="100" src={Icons.Check} /> : undefined}
onClick={() => choose(d.id)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{d.label}
</Text>
</MenuItem>
);
})}
</Box>
</Menu>
</FocusTrap>
}
>
<TooltipProvider
position="Top"
tooltip={
<Tooltip>
<Text size="T200">Audio output</Text>
</Tooltip>
}
>
{(tipRef) => (
<IconButton
ref={tipRef}
variant="Surface"
fill="Soft"
radii="300"
size="300"
className={MobileTouchTarget}
outlined
disabled={disabled}
aria-label="Audio output"
aria-haspopup="menu"
aria-expanded={!!anchor}
onClick={(e) => setAnchor(e.currentTarget.getBoundingClientRect())}
>
<Icon size="100" src={Icons.VolumeHigh} />
</IconButton>
)}
</TooltipProvider>
</PopOut>
);
}
@@ -8,6 +8,7 @@ import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -230,6 +231,9 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/>
{!compact && audioOutputSelectable() && (
<AudioOutputButton embed={callEmbed} disabled={!callJoined} />
)}
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
+1 -1
View File
@@ -41,7 +41,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={(evt: any) =>
onClick={(evt: React.MouseEvent) =>
openUserProfile(
room.roomId,
undefined,
@@ -40,10 +40,15 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { describeRoomVisibility } from '../../../utils/roomVisibilityLine';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
@@ -138,7 +143,19 @@ export function RoomProfileEdit({
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
false,
);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -419,6 +436,14 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
const name = useRoomName(room);
const topic = useRoomTopic(room);
const joinRule = useRoomJoinRule(room);
// [Gitea #133] One line, no controls: encryption · join rule · history.
const historyVisibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
const visibilityLine = describeRoomVisibility({
encrypted: room.hasEncryptionStateEvent(),
joinRule: joinRule?.join_rule,
historyVisibility: historyVisibilityEvent?.getContent<{ history_visibility?: string }>()
.history_visibility,
});
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
@@ -459,6 +484,9 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
<Text className={BreakWord} size="H5">
{name ?? 'Unknown'}
</Text>
<Text size="T200" priority="300" className={BreakWord}>
{visibilityLine}
</Text>
{topic && (
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
{topic.format === 'org.matrix.custom.html' &&
@@ -65,6 +65,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
<Input
key={maxUsers}
name="limitInput"
aria-label="Voice channel participant limit"
defaultValue={maxUsers}
type="number"
min={0}
+126
View File
@@ -23,6 +23,7 @@ import {
Overlay,
OverlayBackdrop,
OverlayCenter,
color,
config,
PopOut,
toRem,
@@ -37,6 +38,13 @@ import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import {
isSectionTag,
listSectionNames,
sectionName,
sectionTag,
validateSectionName,
} from '../../utils/roomSections';
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -277,6 +285,14 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const [invitePrompt, setInvitePrompt] = useState(false);
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
// [Gitea #108] "Add to section" submenu: existing u.* sections + new one.
const [sectionMenuAnchor, setSectionMenuAnchor] = useState<RectCords>();
const [newSectionName, setNewSectionName] = useState('');
const [newSectionError, setNewSectionError] = useState<string>();
const sectionNames = useMemo(() => listSectionNames(mx), [mx]);
const roomSections = Object.keys(room.tags ?? {})
.filter(isSectionTag)
.map(sectionName);
const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite'];
@@ -316,6 +332,25 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
requestClose();
};
const handleToggleSection = (name: string) => {
const tag = sectionTag(name);
const op = room.tags?.[tag]
? mx.deleteRoomTag(room.roomId, tag)
: mx.setRoomTag(room.roomId, tag, { order: 0.5 });
op.catch(notifyTagFailure);
requestClose();
};
const handleNewSection = (evt: React.FormEvent) => {
evt.preventDefault();
const error = validateSectionName(newSectionName, sectionNames);
if (error) {
setNewSectionError(error);
return;
}
handleToggleSection(newSectionName.trim());
};
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
const handleMarkAsRead = () => {
@@ -498,6 +533,97 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
</Text>
</MenuItem>
<PopOut
anchor={sectionMenuAnchor}
position="Right"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSectionMenuAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(220), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{sectionNames.map((name) => {
const inSection = roomSections.includes(name);
return (
<MenuItem
key={name}
size="300"
radii="300"
role="menuitemcheckbox"
aria-checked={inSection}
after={inSection ? <Icon size="100" src={Icons.Check} /> : undefined}
onClick={() => handleToggleSection(name)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
{sectionNames.length > 0 && <Line variant="Surface" size="300" />}
<Box
as="form"
direction="Column"
gap="100"
onSubmit={handleNewSection}
style={{ padding: config.space.S100 }}
>
<Input
size="300"
variant="Background"
radii="300"
placeholder="New section…"
aria-label="New section name"
value={newSectionName}
onChange={(e) => {
setNewSectionName(e.currentTarget.value);
setNewSectionError(undefined);
}}
after={
<IconButton
type="submit"
size="300"
radii="300"
variant="Background"
aria-label="Create section"
>
<Icon size="100" src={Icons.Plus} />
</IconButton>
}
/>
{newSectionError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{newSectionError}
</Text>
)}
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.ChevronRight} />}
radii="300"
aria-pressed={!!sectionMenuAnchor}
aria-haspopup="menu"
onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{roomSections.length > 0
? `Sections: ${roomSections.join(', ')}`
: 'Add to Section'}
</Text>
</MenuItem>
</PopOut>
<MenuItem
onClick={handleInvite}
variant="Primary"
+3 -69
View File
@@ -17,7 +17,7 @@ import {
color,
config,
} from 'folds';
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
@@ -26,7 +26,8 @@ import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
@@ -56,67 +57,6 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
// ── Decrypt hook ──────────────────────────────────────────────────────────────
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
}
let cancelled = false;
setState({ status: 'loading' });
const run = async () => {
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
if (!httpUrl) throw new Error('bad url');
if (encInfo) {
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
);
const blobUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(blobUrl);
return;
}
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
prevBlobUrl.current = blobUrl;
setState({ status: 'ok', url: blobUrl });
} else {
setState({ status: 'ok', url: httpUrl });
}
};
run().catch(() => {
if (!cancelled) setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
},
[],
);
return state;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
@@ -154,12 +94,6 @@ function getSenderName(room: Room, userId: string): string {
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
+36 -33
View File
@@ -95,7 +95,8 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
@@ -157,6 +158,13 @@ const EmojiBoard = React.lazy(() =>
);
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */
const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean =>
!!target &&
file instanceof File &&
file.name === target.name &&
file.lastModified === target.lastModified;
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
interface RoomInputProps {
@@ -225,6 +233,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId;
// [Gitea #151] The quoted event, for a media thumbnail in the draft preview.
const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag();
@@ -250,6 +260,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
selectedFiles.map((f) => f.file),
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
// [Gitea #129] The file whose caption input should take focus once its card mounts.
// (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.)
const autoFocusCaptionRef = useRef<File | undefined>(undefined);
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
@@ -279,6 +292,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
@@ -375,39 +389,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
}),
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
}),
);
}
// [Gitea #129] One image into an empty composer: land in its caption
// field so "here's the bug" is one motion (Enter sends, Esc returns).
const focusCaption =
files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor);
setSelectedFiles({
type: 'PUT',
item: fileItems,
item: await filesToUploadItems(room, files, stripImageMetadata),
});
if (focusCaption) autoFocusCaptionRef.current = files[0];
},
[setSelectedFiles, room],
[setSelectedFiles, room, stripImageMetadata, editor],
);
const pickFile = useFilePicker(handleFiles, true);
const handleFilePaste = useFilePasteHandler(handleFiles);
@@ -1060,6 +1052,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
autoFocusCaption={isAutoFocusTarget(
fileItem.originalFile,
autoFocusCaptionRef.current,
)}
onCaptionSubmit={submit}
onCaptionEscape={() => ReactEditor.focus(editor)}
/>
))}
</UploadBoardContent>
@@ -1185,9 +1183,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
<ReplyMediaThumb mEvent={replyDraftEvent} />
)}
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</Box>
</ReplyLayout>
</Box>
</Box>
+6 -2
View File
@@ -91,9 +91,11 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
import { Lightbox, toLightboxItems } from './MediaGallery';
import { getThumbMxc } from '../../utils/mediaThumb';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
@@ -477,6 +479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
const [, setSeparatedTick] = useState(0);
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
@@ -1156,13 +1159,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
room.roomId,
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room],
[mx, room, hapticFeedback],
);
const handleEdit = useCallback(
(editEvtId?: string) => {
+1 -1
View File
@@ -36,7 +36,7 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
{joinState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement room!'}
</Text>
)}
</Box>
@@ -4,7 +4,8 @@ import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
import { useDecryptedMediaUrl } from '../../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../../utils/mediaThumb';
import { validBlurHash } from '../../../utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import * as css from './MediaGroupGrid.css';
+6 -6
View File
@@ -214,7 +214,7 @@ export const MessageAllReactionItem = as<
return (
<>
<Overlay
onContextMenu={(evt: any) => {
onContextMenu={(evt: React.MouseEvent) => {
evt.stopPropagation();
}}
open={open}
@@ -1087,7 +1087,7 @@ export const Message = React.memo(
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as any).tagName;
const tag = (evt.target as HTMLElement | null)?.tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
if (coarsePointer) {
@@ -1133,7 +1133,7 @@ export const Message = React.memo(
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
const menuJSX = (
<Menu>
<Menu data-message-menu aria-label="Message actions">
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
@@ -1155,7 +1155,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
onReplyClick(evt);
closeMenu();
}}
@@ -1232,7 +1232,7 @@ export const Message = React.memo(
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
onReplyClick(evt, true);
closeMenu();
}}
@@ -1599,7 +1599,7 @@ export const Event = React.memo(
const stateEvent = typeof mEvent.getStateKey() === 'string';
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName;
const tag = (evt.target as HTMLElement | null)?.tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
setMenuAnchor({
+1 -1
View File
@@ -202,7 +202,7 @@ export const Reactions = as<'div', ReactionsProps>(
)}
{reactions.length > 0 && (
<Overlay
onContextMenu={(evt: any) => {
onContextMenu={(evt: React.MouseEvent) => {
evt.stopPropagation();
}}
open={!!viewer}
@@ -177,6 +177,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
return (
<Box
as="aside"
aria-label="Thread"
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
@@ -78,6 +78,7 @@ import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
import { inSameDay, minuteDifference } from '../../../utils/time';
import { formatDayDivider } from '../../../utils/formatTimestamp';
import { tick } from '../../../utils/haptics';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
@@ -266,6 +267,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
const { navigateRoom } = useRoomNavigate();
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -573,6 +575,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply.
@@ -582,7 +585,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room, thread, getRelationTimelineSet],
[mx, room, thread, getRelationTimelineSet, hapticFeedback],
);
const handleEdit = useCallback(
+2 -2
View File
@@ -153,7 +153,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
{menuItems.map((item) => (
<MenuItem
key={item.name}
@@ -174,7 +174,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Text>
</MenuItem>
))}
</div>
</nav>
</PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
<UseStateProvider initial={false}>
+1 -1
View File
@@ -159,7 +159,7 @@ export function About({ requestClose }: AboutProps) {
>
<SettingTile
title="Clear Cache & Reload"
description="Clear all your locally stored data and reload from server."
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
after={
<Button
onClick={() => clearCacheAndReload(mx)}
+14 -1
View File
@@ -47,6 +47,7 @@ import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
@@ -89,7 +90,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
false,
);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -121,6 +121,8 @@ import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { isBindableCallKey } from '../../../utils/callKeybind';
import { StorageUsage } from './StorageUsage';
import { hapticsSupported } from '../../../utils/haptics';
/**
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -1442,6 +1444,7 @@ function Privacy() {
'warnOnUnverifiedDevices',
);
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
return (
<Box direction="Column" gap="100">
@@ -1453,6 +1456,13 @@ function Privacy() {
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Remove Photo Metadata Before Sending"
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Typing & Read Receipts"
@@ -1643,6 +1653,8 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
@@ -1938,6 +1950,28 @@ function Calls() {
direction="Column"
gap="400"
>
<SettingTile
title="After a Restart"
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
after={
<SettingsSelect<'ask' | 'auto' | 'off'>
value={callRejoin}
onChange={setCallRejoin}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'auto', label: 'Rejoin automatically' },
{ value: 'off', label: 'Do nothing' },
]}
/>
}
/>
{hapticsSupported() && (
<SettingTile
title="Haptic Feedback"
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
/>
)}
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
@@ -2717,6 +2751,7 @@ export function General({ requestClose }: GeneralProps) {
<Editor />
<Messages />
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<Calls />
<AppUpdates />
@@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { bytesToSize } from '../../../utils/common';
import {
percentOf,
readStorageUsage,
requestPersistentStorage,
StorageUsage as Usage,
storageUsageSupported,
} from '../../../utils/storageUsage';
/**
* [Gitea #120] One tile: how much this device stores for Lotus, whether the
* browser has promised to keep it, and a way to ask. Hidden entirely when
* `navigator.storage.estimate()` is unavailable rather than showing zeros.
*/
export function StorageUsage() {
const [usage, setUsage] = useState<Usage | undefined>();
const [requesting, setRequesting] = useState(false);
const refresh = useCallback(() => {
readStorageUsage()
.then(setUsage)
.catch(() => setUsage(undefined));
}, []);
useEffect(() => {
if (storageUsageSupported()) refresh();
}, [refresh]);
if (!usage) return null;
const handlePersist = async () => {
setRequesting(true);
await requestPersistentStorage();
setRequesting(false);
refresh();
};
const breakdown: string[] = [];
if (typeof usage.indexedDB === 'number')
breakdown.push(
`${bytesToSize(usage.indexedDB)} in IndexedDB (sync cache, encryption keys, search index)`,
);
if (typeof usage.caches === 'number')
breakdown.push(`${bytesToSize(usage.caches)} offline app files`);
return (
<Box direction="Column" gap="100">
<Text size="L400">Storage</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={`${bytesToSize(usage.usage)} used on this device`}
description={
<Box direction="Column" gap="100">
<Text size="T200">
{percentOf(usage.usage, usage.quota)}% of the {bytesToSize(usage.quota)} the browser
allows this site.
{breakdown.length > 0 && ` ${breakdown.join(' · ')}.`}
</Text>
<Text size="T200">
Images and videos you have viewed are kept in the browser&apos;s own cache, which it
manages and empties itself; they are not counted here. Encryption keys are never
cleared from this page losing them makes older encrypted messages unreadable on
this device. Cached search results can be cleared from Message Search.
</Text>
</Box>
}
after={
usage.persisted === undefined ? undefined : (
<Box direction="Column" alignItems="End" gap="100">
<Badge
variant={usage.persisted ? 'Success' : 'Warning'}
fill="Soft"
radii="300"
size="400"
>
<Text size="L400">{usage.persisted ? 'Protected' : 'May be evicted'}</Text>
</Badge>
{!usage.persisted && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={requesting}
onClick={handlePersist}
>
<Text size="B300">Keep my data</Text>
</Button>
)}
</Box>
)
}
/>
</SequenceCard>
</Box>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
import { toastQueueAtom } from '../state/toast';
import { describeCallEnd } from '../utils/callSummary';
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
// before Element Call's hangup echo; give a late one this long before toasting.
const SUMMARY_GRACE_MS = 400;
/**
* [Gitea #143] "Call ended · 41 min · connection was good" in the existing
* toast style when a call you were in ends. Duration comes from our own
* join clock; the quality readout from the fork, if it arrived.
*/
export function useCallEndedToast(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const fired = useRef(false);
useEffect(() => {
fired.current = false;
}, [embed]);
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
const durationMs = Date.now() - embed.joinedAt;
const toast = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
if (embed.lastSummary) toast();
else setTimeout(toast, SUMMARY_GRACE_MS);
});
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc';
import { useAtomValue, useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { ElementWidgetActions } from '../plugins/call/types';
import { CallEmbed, useCallControlState, useClientWidgetApiEvent } from '../plugins/call';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
import { useCallStart } from './useCallEmbed';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { toastQueueAtom, dismissToastAtom } from '../state/toast';
import { mDirectAtom } from '../state/mDirectList';
import {
CALL_SESSION_HEARTBEAT_MS,
clearCallSession,
decideRejoin,
readCallSession,
writeCallSession,
} from '../utils/callRejoin';
const TOAST_ID = 'call-rejoin';
/** How long after the first sync to wait for the RTC session to report members. */
const MEMBERSHIP_WAIT_MS = 10_000;
const isSynced = (mx: MatrixClient): boolean => {
const state = mx.getSyncState();
return state === SyncState.Prepared || state === SyncState.Syncing;
};
/**
* [Gitea #118] Keep a heartbeat record of the voice room you are in, clear
* it on a deliberate hangup, and on the next start put you back (or ask)
* when the call is still going and the record is fresh.
*/
export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): void {
const mx = useMatrixClient();
const [mode] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const { microphone, video } = useCallControlState(embed?.control);
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
const directs = useAtomValue(mDirectAtom);
const { microphone: prefMic, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const startRoomCall = useCallStart(false);
const startDmCall = useCallStart(true);
// --- record while joined
useEffect(() => {
if (!embed || !joined) return undefined;
const deviceId = mx.getDeviceId() ?? '';
const write = () =>
writeCallSession({
roomId: embed.roomId,
deviceId,
joinedAt: embed.joinedAt ?? Date.now(),
lastSeen: Date.now(),
microphone,
video,
});
write();
const timer = window.setInterval(write, CALL_SESSION_HEARTBEAT_MS);
return () => window.clearInterval(timer);
}, [embed, joined, mx, microphone, video]);
// --- deliberate hangup clears it
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.HangupCall, clearCallSession);
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.Close, clearCallSession);
// --- startup: offer / perform a rejoin once
// The startup effect must run exactly once and outlive re-renders (its
// membership wait spans several seconds), so it reads these through a ref.
const latest = useRef({
mode,
prefMic,
sound,
cameraOnJoin,
directs,
startRoomCall,
startDmCall,
});
latest.current = { mode, prefMic, sound, cameraOnJoin, directs, startRoomCall, startDmCall };
const checkedRef = useRef(false);
const [synced, setSynced] = useState(() => isSynced(mx));
useSyncState(
mx,
useCallback(() => {
if (isSynced(mx)) setSynced(true);
}, [mx]),
);
useEffect(() => {
// Wait for the first sync so the room and its call memberships are known;
// deciding earlier would read an empty session and wrongly drop the record.
if (checkedRef.current || embed || !synced) return undefined;
checkedRef.current = true;
const record = readCallSession();
if (!record) return undefined;
const room = mx.getRoom(record.roomId);
if (!room) {
clearCallSession();
return undefined;
}
const session = mx.matrixRTC.getRoomSession(room);
const rejoin = () => {
const l = latest.current;
const pref = {
microphone: record.microphone ?? l.prefMic,
video: l.cameraOnJoin && record.video,
sound: l.sound,
};
dismissToast(TOAST_ID);
clearCallSession();
try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch {
/* no embed container yet — the user can join from the room */
}
};
let done = false;
let timer: number | undefined;
const evaluate = (final: boolean) => {
if (done) return;
const memberships = session.memberships;
const decision = decideRejoin({
record,
mode: latest.current.mode,
now: Date.now(),
myDeviceId: mx.getDeviceId() ?? '',
roomJoined: room.getMyMembership() === 'join',
ownMemberDevices: memberships
.filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId),
memberCount: memberships.length,
});
// The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look.
if (decision.action === 'none' && memberships.length === 0 && !final) return;
done = true;
if (timer !== undefined) window.clearTimeout(timer);
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (decision.action === 'none') {
clearCallSession();
return;
}
if (decision.action === 'auto') {
rejoin();
return;
}
setToast({
id: TOAST_ID,
iconSrc: Icons.Phone,
displayName: 'Rejoin voice?',
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
roomName: room.name ?? '',
roomId: room.roomId,
sticky: true,
onClick: rejoin,
onDismiss: clearCallSession,
});
};
const onChange = () => evaluate(false);
session.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
timer = window.setTimeout(() => evaluate(true), MEMBERSHIP_WAIT_MS);
evaluate(false);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (timer !== undefined) window.clearTimeout(timer);
};
}, [embed, synced, mx, setToast, dismissToast]);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { IEncryptedFile } from '../../types/matrix/common';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../utils/matrix';
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
}
let cancelled = false;
setState({ status: 'loading' });
const run = async () => {
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
if (!httpUrl) throw new Error('bad url');
if (encInfo) {
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
);
const blobUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(blobUrl);
return;
}
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
prevBlobUrl.current = blobUrl;
setState({ status: 'ok', url: blobUrl });
} else {
setState({ status: 'ok', url: httpUrl });
}
};
run().catch(() => {
if (!cancelled) setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
},
[],
);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { pttActiveAtom } from './useCallHotkeys';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { tick } from '../utils/haptics';
/**
* [Gitea #125] One observer for every push-to-talk path (keyboard, global
* hotkey, on-screen chip): a tick on engage and on release.
*/
export function usePttHaptics(): void {
const active = useAtomValue(pttActiveAtom);
const [enabled] = useSetting(settingsAtom, 'hapticFeedback');
const prev = useRef(active);
useEffect(() => {
if (prev.current === active) return;
prev.current = active;
tick(active ? 'ptt-on' : 'ptt-off', enabled);
}, [active, enabled]);
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string | undefined => {
switch (kind) {
case 'ended':
return 'Screen sharing stopped — the shared window was closed.';
case 'no-frames':
return 'Your screen share is showing nothing — the shared window may be minimised or hidden.';
case 'alone':
return "Still sharing? You've been sharing your screen for 30 minutes with nobody else in the call.";
default:
return undefined;
}
};
/**
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
* closed, no frames for a while, or a long share with nobody watching.
*/
export function useScreenshareNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
useEffect(
() =>
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
'io.lotus.screenshare_notice',
(evt) => {
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
if (!body) return;
setToast({
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
iconSrc: Icons.ScreenShare,
displayName: 'Screen share',
body,
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
sticky: evt.detail?.data?.kind === 'alone',
});
},
),
[embed, setToast],
);
}
+13
View File
@@ -63,10 +63,14 @@ import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { getFallbackSession } from '../state/sessions';
import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
@@ -380,6 +384,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</React.Suspense>
}
/>
{/* [Gitea #155] PWA share-target landing page. */}
<Route
path={SHARE_PAGE_PATH}
element={
<React.Suspense fallback={null}>
<Share />
</React.Suspense>
}
/>
<Route
path={INBOX_PATH}
element={
+97 -3
View File
@@ -73,6 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
RoomsNotificationPreferences,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
@@ -80,6 +81,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
type HomeMenuProps = {
requestClose: () => void;
@@ -217,6 +219,73 @@ function HomeEmpty() {
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
type HomeCustomSectionProps = {
section: RoomSection;
closed: boolean;
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
filterQuery: string;
selectedRoomId?: string;
roomsWithUnreadSet: Set<string>;
notificationPreferences: RoomsNotificationPreferences;
};
/** [Gitea #108] One collapsible u.* section; mirrors the Favorites block. */
function HomeCustomSection({
section,
closed,
onCategoryClick,
filterQuery,
selectedRoomId,
roomsWithUnreadSet,
notificationPreferences,
}: HomeCustomSectionProps) {
const mx = useMatrixClient();
const categoryId = makeNavCategoryId('home', section.tag);
const items = useMemo(() => {
// Open: the tag's own order. Closed: only unread/selected, by activity.
const base = closed
? [...section.rooms]
.sort(factoryRoomIdByActivity(mx))
.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId)
: section.rooms;
if (!filterQuery.trim()) return base;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return base.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closed}
data-category-id={categoryId}
onClick={onCategoryClick}
>
{section.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
{items.map((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
})}
</NavCategory>
);
}
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
@@ -260,21 +329,34 @@ export function Home() {
// once some unrelated event (e.g. an unread-count change) forces a re-render.
const roomTagsVersion = useRoomTagsVersion(mx);
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
const favs: string[] = [];
const low: string[] = [];
const others: string[] = [];
// [Gitea #108] u.* tags → custom sections. A room in a section leaves the
// plain "Rooms" list but keeps its Favorite / Low Priority placement.
const { sections, sectioned } = deriveRoomSections(
rooms.map((rId) => {
const room = mx.getRoom(rId);
return { roomId: rId, name: room?.name ?? rId, tags: room?.tags };
}),
);
rooms.forEach((rId) => {
const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) {
favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else {
} else if (!sectioned.has(rId)) {
others.push(rId);
}
});
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
return {
favoriteRooms: favs,
lowPriorityRooms: low,
otherRooms: others,
customSections: sections,
};
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
// this memo to re-run whenever any room's tags change.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -536,6 +618,18 @@ export function Home() {
</div>
</NavCategory>
)}
{customSections.map((section) => (
<HomeCustomSection
key={section.tag}
section={section}
closed={closedCategories.has(makeNavCategoryId('home', section.tag))}
onCategoryClick={handleCategoryClick}
filterQuery={filterQuery}
selectedRoomId={selectedRoomId}
roomsWithUnreadSet={roomsWithUnreadSet}
notificationPreferences={notificationPreferences}
/>
))}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
+250
View File
@@ -0,0 +1,250 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
Icons,
Input,
MenuItem,
Scroll,
Spinner,
Text,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue, useStore } from 'jotai';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { mDirectAtom } from '../../../state/mDirectList';
import {
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../../state/room/roomInputDrafts';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { filesToUploadItems } from '../../../utils/uploadItems';
import {
clearSharedPayload,
readSharedPayload,
shareDraftText,
SharePayload,
} from '../../../../swShare';
import { BlockType } from '../../../components/editor';
import { bytesToSize } from '../../../utils/common';
import { getHomePath } from '../../pathUtils';
type Shared = { payload: SharePayload; files: File[] };
/**
* [Gitea #155] Landing page for the PWA share target: what was shared, pick
* a room, and the files land on that room's composer upload board (text/url
* as the draft) — the user still presses Send.
*/
export function Share() {
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
useEffect(() => {
readSharedPayload()
.then((s) => setShared(s ?? null))
.catch(() => setShared(null));
}, []);
const rooms = useMemo(() => {
const all = mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const q = query.trim().toLowerCase();
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
}, [mx, query]);
// Written straight into the room's draft atoms so its composer shows the
// upload board + text as soon as it mounts.
const store = useStore();
const pick = useCallback(
async (room: Room) => {
if (!shared || busy) return;
setBusy(true);
try {
if (shared.files.length) {
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
type: 'PUT',
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
});
}
const text = shareDraftText(shared.payload);
if (text) {
store.set(roomIdToMsgDraftAtomFamily(room.roomId), [
{ type: BlockType.Paragraph, children: [{ text }] },
]);
}
await clearSharedPayload();
navigateRoom(room.roomId);
} finally {
setBusy(false);
}
},
[shared, busy, navigateRoom, store, stripImageMetadata],
);
return (
<Page>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Send} />
<Text size="H3" truncate>
Share to Lotus Chat
</Text>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<Scroll hideTrack visibility="Hover">
<PageContent>
{shared === undefined && (
<Box justifyContent="Center" style={{ padding: config.space.S500 }}>
<Spinner size="400" />
</Box>
)}
{shared === null && (
<Box direction="Column" gap="300" alignItems="Start">
<Text size="T300">
Nothing was shared, or it has already been placed in a room.
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => navigate(getHomePath())}
>
<Text size="B300">Go home</Text>
</Button>
</Box>
)}
{shared && (
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Shared</Text>
{shared.files.map((f) => (
<Box key={f.name} alignItems="Center" gap="200">
<Icon
size="100"
src={f.type.startsWith('image/') ? Icons.Photo : Icons.File}
/>
<Text size="T300" truncate>
{f.name}
</Text>
<Text size="T200" priority="300">
{bytesToSize(f.size)}
</Text>
</Box>
))}
{shareDraftText(shared.payload) && (
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{shareDraftText(shared.payload)}
</Text>
)}
</Box>
<Box direction="Column" gap="200">
<Text size="L400">Send to</Text>
<Input
size="400"
variant="Background"
radii="400"
placeholder="Search rooms"
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)}
autoFocus
/>
<Box direction="Column" gap="100">
{rooms.slice(0, 60).map((room) => (
<ShareRoomRow
key={room.roomId}
room={room}
dm={directs.has(room.roomId)}
useAuthentication={useAuthentication}
disabled={busy}
onPick={pick}
/>
))}
{rooms.length === 0 && (
<Text size="T300" priority="300">
No rooms match.
</Text>
)}
</Box>
</Box>
</Box>
)}
</PageContent>
</Scroll>
</Box>
</Page>
);
}
function ShareRoomRow({
room,
dm,
useAuthentication,
disabled,
onPick,
}: {
room: Room;
dm: boolean;
useAuthentication: boolean;
disabled: boolean;
onPick: (room: Room) => void;
}) {
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
return (
<MenuItem
size="300"
radii="300"
variant="Background"
disabled={disabled}
onClick={() => onPick(room)}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
)}
/>
</Avatar>
}
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
Direct Message
</Text>
)}
</Box>
</MenuItem>
);
}
+1 -1
View File
@@ -378,7 +378,7 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
<Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement space!'}
</Text>
)}
</Box>
+19
View File
@@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
}
@@ -277,6 +278,24 @@ export class CallControl extends EventEmitter implements CallControlState {
// P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the
// fork's handler only exists once joined; onCallJoined() re-sends the current
// state so a pre-join deafen is not lost.
// [Gitea #119] Output device (headset ↔ speakers) chosen from the host's
// call bar; the fork applies it with mediaDevices.audioOutput.select().
private _audioOutputId: string | undefined;
public get audioOutputId(): string | undefined {
return this._audioOutputId;
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
}
private sendDeafenState(): void {
if (!this.joined) return;
this.call.transport
+29 -1
View File
@@ -49,6 +49,14 @@ export interface LotusCallParticipant {
speakingWhileMuted?: boolean;
}
/** [Gitea #143] The fork's one-shot io.lotus.call_summary payload. */
export interface LotusCallSummary {
durationMs: number;
reconnects: number;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
}
export class CallEmbed {
private mx: MatrixClient;
@@ -60,6 +68,12 @@ export class CallEmbed {
public joined = false;
/** [Gitea #143] When the first JoinCall landed, for the hangup readout. */
public joinedAt: number | undefined;
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
public lastSummary: LotusCallSummary | undefined;
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -354,7 +368,7 @@ export class CallEmbed {
return this.listenEvent('preparing', callback);
}
public onPreparingError(callback: (error: any) => void) {
public onPreparingError(callback: (error: unknown) => void) {
return this.listenEvent('error:preparing', callback);
}
@@ -390,6 +404,19 @@ export class CallEmbed {
this.forkStateRequestListeners.forEach((l) => l());
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_summary', (evt) => {
const data = (evt.detail as { data?: Partial<LotusCallSummary> } | undefined)?.data;
if (data && typeof data.durationMs === 'number') {
this.lastSummary = {
durationMs: data.durationMs,
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
};
}
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
@@ -537,6 +564,7 @@ export class CallEmbed {
return;
}
this.joined = true;
this.joinedAt = Date.now();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
+2
View File
@@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs';
import { clearRecentStickers } from './recentStickers';
import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearCallSession();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
};
+2
View File
@@ -15,6 +15,8 @@ export type TUploadMetadata = {
compressImage?: boolean;
/** Cached compression result (populated in the background when compressImage is set to true) */
compressionResult?: CompressionResult | null;
/** [Gitea #109] EXIF/XMP/IPTC was removed from this image before upload. */
metadataStripped?: boolean;
};
export type TUploadItem = {
+10
View File
@@ -268,6 +268,13 @@ export interface Settings {
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
// send, and from links rendered in the timeline. Local only.
stripTrackingParams: boolean;
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
// uploads without re-encoding. Default on.
stripImageMetadata: boolean;
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
// [Gitea #125] Vibration ticks on PTT press/release and reactions (Android only).
hapticFeedback: boolean;
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
// so other devices pick them up. Device-local itself (utils/settingsSync).
@@ -390,6 +397,9 @@ const defaultSettings: Settings = {
warnOnUnverifiedDevices: false,
stripTrackingParams: true,
stripImageMetadata: true,
callRejoinAfterRestart: 'ask',
hapticFeedback: true,
settingsSync: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

+56
View File
@@ -0,0 +1,56 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
const base: RejoinContext = {
record: {
roomId: '!r',
deviceId: 'DEV',
joinedAt: 900_000,
lastSeen: 1_000_000,
microphone: true,
video: false,
},
mode: 'ask',
now: 1_060_000,
myDeviceId: 'DEV',
roomJoined: true,
ownMemberDevices: [],
memberCount: 2,
};
describe('decideRejoin', () => {
it('asks by default when the call is still going', () => {
assert.deepEqual(decideRejoin(base), { action: 'ask', roomId: '!r' });
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('does nothing when off, stale, another device, or no record', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'off' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, record: undefined }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, now: 1_000_000 + CALL_REJOIN_MAX_AGE_MS + 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, myDeviceId: 'OTHER' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, roomJoined: false }), { action: 'none' });
});
it('skips when we already rejoined from another device', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['PHONE'], memberCount: 2 }), {
action: 'none',
});
});
it('skips when only our own stale membership is left', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: [], memberCount: 0 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 2 }), {
action: 'ask',
roomId: '!r',
});
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* [Gitea #118] Remember the voice room you were in so a crash, update or
* reload can put you back. Device-local (localStorage), never synced;
* cleared on a deliberate hangup and on logout.
*/
export const CALL_SESSION_KEY = 'lotus-call-session';
/** A record older than this (by last heartbeat) is stale — the call is over. */
export const CALL_REJOIN_MAX_AGE_MS = 10 * 60_000;
export const CALL_SESSION_HEARTBEAT_MS = 30_000;
export type CallSessionRecord = {
roomId: string;
deviceId: string;
joinedAt: number;
/** Refreshed while joined so a crash leaves a recent timestamp behind. */
lastSeen: number;
microphone: boolean;
video: boolean;
};
export type CallRejoinMode = 'ask' | 'auto' | 'off';
export const readCallSession = (): CallSessionRecord | undefined => {
try {
const raw = localStorage.getItem(CALL_SESSION_KEY);
if (!raw) return undefined;
const r = JSON.parse(raw) as Partial<CallSessionRecord>;
if (typeof r.roomId !== 'string' || typeof r.deviceId !== 'string') return undefined;
return {
roomId: r.roomId,
deviceId: r.deviceId,
joinedAt: Number(r.joinedAt) || 0,
lastSeen: Number(r.lastSeen) || 0,
microphone: r.microphone !== false,
video: r.video === true,
};
} catch {
return undefined;
}
};
export const writeCallSession = (record: CallSessionRecord): void => {
try {
localStorage.setItem(CALL_SESSION_KEY, JSON.stringify(record));
} catch {
/* quota / private mode */
}
};
export const clearCallSession = (): void => {
try {
localStorage.removeItem(CALL_SESSION_KEY);
} catch {
/* ignore */
}
};
export type RejoinContext = {
record: CallSessionRecord | undefined;
mode: CallRejoinMode;
now: number;
myDeviceId: string;
/** Whether the room still exists and we are joined to it. */
roomJoined: boolean;
/** Device ids of our own live call memberships in that room (other tabs/devices). */
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
/** Pure: whether startup should offer (or perform) a rejoin. */
export function decideRejoin(ctx: RejoinContext): RejoinDecision {
const { record, mode, now, myDeviceId } = ctx;
if (!record || mode === 'off') return { action: 'none' };
if (record.deviceId !== myDeviceId) return { action: 'none' };
if (now - record.lastSeen > CALL_REJOIN_MAX_AGE_MS) return { action: 'none' };
if (!ctx.roomJoined) return { action: 'none' };
// Already back in from another device/tab — don't double-join.
if (ctx.ownMemberDevices.some((d) => d !== myDeviceId)) return { action: 'none' };
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
return { action: mode, roomId: record.roomId };
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { describeCallDuration, describeCallEnd } from './callSummary';
describe('describeCallDuration', () => {
it('picks the unit', () => {
assert.equal(describeCallDuration(40_000), '40 s');
assert.equal(describeCallDuration(41 * 60_000), '41 min');
assert.equal(describeCallDuration(65 * 60_000), '1 h 05 min');
});
});
describe('describeCallEnd', () => {
const min = 60_000;
it('without a summary shows just the duration', () => {
assert.equal(describeCallEnd(41 * min), 'Call ended · 41 min');
});
it('good / unknown', () => {
assert.equal(
describeCallEnd(41 * min, {
durationMs: 41 * min,
reconnects: 1,
poorMs: 0,
verdict: 'good',
}),
'Call ended · 41 min · connection was good',
);
assert.equal(
describeCallEnd(2 * min, {
durationMs: 2 * min,
reconnects: 0,
poorMs: 0,
verdict: 'unknown',
}),
'Call ended · 2 min',
);
});
it('reconnects and poor spells', () => {
assert.equal(
describeCallEnd(41 * min, {
durationMs: 41 * min,
reconnects: 3,
poorMs: 0,
verdict: 'fair',
}),
'Call ended · 41 min · 3 reconnects',
);
assert.equal(
describeCallEnd(12 * min, {
durationMs: 12 * min,
reconnects: 1,
poorMs: 4 * min,
verdict: 'poor',
}),
'Call ended · 12 min · 1 reconnect, connection was poor for 4 min',
);
assert.equal(
describeCallEnd(12 * min, {
durationMs: 12 * min,
reconnects: 0,
poorMs: 2_000,
verdict: 'fair',
}),
'Call ended · 12 min · connection was fair',
);
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { LotusCallSummary } from '../plugins/call/CallEmbed';
/** "41 min", "1 h 05 min", "40 s". */
export const describeCallDuration = (ms: number): string => {
const s = Math.round(ms / 1000);
if (s < 60) return `${s} s`;
const m = Math.round(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
return `${h} h ${String(m % 60).padStart(2, '0')} min`;
};
/**
* [Gitea #143] One line for the call-ended toast: duration (cinny's own
* clock) plus the fork's verdict when it arrived in time.
* "Call ended · 41 min · connection was good"
* "Call ended · 41 min · 3 reconnects"
* "Call ended · 12 min · connection was poor for 4 min"
*/
export function describeCallEnd(durationMs: number, summary?: LotusCallSummary): string {
const parts = ['Call ended', describeCallDuration(durationMs)];
if (summary) {
if (summary.verdict === 'good') parts.push('connection was good');
else if (summary.verdict === 'fair' || summary.verdict === 'poor') {
const bits: string[] = [];
if (summary.reconnects > 0)
bits.push(`${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`);
if (summary.poorMs >= 5_000)
bits.push(`connection was poor for ${describeCallDuration(summary.poorMs)}`);
parts.push(bits.length ? bits.join(', ') : `connection was ${summary.verdict}`);
}
}
return parts.join(' · ');
}
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hapticsSupported, tick } from './haptics';
// Node 22 exposes a read-only `globalThis.navigator`; swap it via defineProperty.
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const setGlobal = (name: 'navigator' | 'window', value: unknown) =>
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
const restore = () => {
if (original) Object.defineProperty(globalThis, 'navigator', original);
else delete (globalThis as { navigator?: unknown }).navigator;
delete (globalThis as { window?: unknown }).window;
};
test('tick is a no-op without the API, with the setting off, or under reduced motion', () => {
setGlobal('navigator', {});
assert.equal(hapticsSupported(), false);
assert.equal(tick('ptt-on'), false);
const calls: number[] = [];
setGlobal('navigator', { vibrate: (ms: number) => calls.push(ms) === 1 });
setGlobal('window', { matchMedia: () => ({ matches: true }) });
assert.equal(tick('ptt-on'), false);
assert.deepEqual(calls, []);
setGlobal('window', { matchMedia: () => ({ matches: false }) });
assert.equal(tick('ptt-on', false), false);
assert.deepEqual(calls, []);
restore();
});
test('tick vibrates per kind when allowed', () => {
const calls: number[] = [];
setGlobal('navigator', { vibrate: (ms: number) => calls.push(ms) > 0 });
setGlobal('window', { matchMedia: () => ({ matches: false }) });
assert.equal(tick('ptt-on'), true);
assert.equal(tick('ptt-off'), true);
assert.equal(tick('reaction'), true);
assert.deepEqual(calls, [10, 10, 8]);
restore();
});
+26
View File
@@ -0,0 +1,26 @@
/**
* [Gitea #125] Tiny vibration ticks for touch feedback. Android Chrome/PWA
* only iOS has no web vibration API and nothing at all when the user
* prefers reduced motion. Every call is a cheap no-op elsewhere.
*/
export type HapticKind = 'ptt-on' | 'ptt-off' | 'reaction';
const PATTERN: Record<HapticKind, number> = { 'ptt-on': 10, 'ptt-off': 10, reaction: 8 };
export const hapticsSupported = (): boolean =>
typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
const reducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Fire a tick; returns whether one was requested. */
export function tick(kind: HapticKind, enabled = true): boolean {
if (!enabled || !hapticsSupported() || reducedMotion()) return false;
try {
return navigator.vibrate(PATTERN[kind]) === true;
} catch {
return false;
}
}
+3 -1
View File
@@ -68,7 +68,9 @@ export const isMLDenoiseSupported = (): boolean => {
// 1. AudioContext/webkitAudioContext (Web Audio API)
// 2. AudioWorklet (Real-time processing in a background thread)
// 3. getUserMedia (Microphone access)
const hasAudioContext = !!(window.AudioContext || (window as any).webkitAudioContext);
const hasAudioContext = !!(
window.AudioContext || (window as Window & { webkitAudioContext?: unknown }).webkitAudioContext
);
// Use `typeof` rather than `!!AudioWorkletNode`: on a browser that has
// AudioContext but no AudioWorkletNode binding (older Safari/Edge), a bare
// reference throws ReferenceError, making this feature-detection helper throw
+15
View File
@@ -81,3 +81,18 @@ test('block and inline mixed with text', () => {
{ type: 'text', value: ' ok' },
]);
});
test('math inside a backtick code span stays literal text (#194 P4-4)', () => {
assert.deepEqual(splitMathSegments('inline `$y^2$` here'), [
{ type: 'text', value: 'inline `$y^2$` here' },
]);
assert.deepEqual(splitMathSegments('``$a$`` and $b$'), [
{ type: 'text', value: '``$a$`` and ' },
{ type: 'inline', value: 'b' },
]);
// An unclosed backtick is just a character.
assert.deepEqual(splitMathSegments('tick ` then $c$'), [
{ type: 'text', value: 'tick ` then ' },
{ type: 'inline', value: 'c' },
]);
});
+17
View File
@@ -88,6 +88,23 @@ export const splitMathSegments = (text: string): MathSegment[] => {
};
while (i < text.length) {
// Backtick code spans are literal: `$x$` inside them must stay text so
// markdown's inline code wins (a run of N backticks closes with N).
if (text[i] === '`') {
let ticks = 0;
while (text[i + ticks] === '`') ticks += 1;
const fence = '`'.repeat(ticks);
const close = text.indexOf(fence, i + ticks);
if (close !== -1) {
buffer += text.slice(i, close + ticks);
i = close + ticks;
continue;
}
buffer += fence;
i += ticks;
continue;
}
// Escaped dollar: consume `\$` and emit a literal `$` as text.
if (text[i] === '\\' && text[i + 1] === '$') {
buffer += '$';
+4 -1
View File
@@ -72,7 +72,10 @@ export const getLoginTermUrl = (params: UIAParams): string | undefined => {
if (terms.policies === null) return undefined;
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
if (terms.policies.privacy_policy === null) return undefined;
const langToPolicy = terms.policies.privacy_policy as Record<string, any>;
const langToPolicy = terms.policies.privacy_policy as Record<
string,
{ url?: string } | undefined
>;
const url = langToPolicy.en?.url;
if (typeof url === 'string') return url;
+10
View File
@@ -0,0 +1,10 @@
import { MatrixEvent } from 'matrix-js-sdk';
import { IImageInfo, IThumbnailContent } from '../../types/matrix/common';
/** The mxc to show as a thumbnail for an image/video event (thumbnail if present, else the file). */
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { deriveRoomSections, sectionTag, validateSectionName } from './roomSections';
describe('deriveRoomSections', () => {
it('groups by u.* tags, orders members by order then name, sections by name', () => {
const { sections, sectioned } = deriveRoomSections([
{ roomId: '!a', name: 'Alpha', tags: { 'u.Raids': { order: 0.7 } } },
{ roomId: '!b', name: 'Beta', tags: { 'u.Raids': { order: 0.2 }, 'm.favourite': {} } },
{ roomId: '!c', name: 'Gamma', tags: { 'u.Raids': {}, 'u.off-topic': { order: '0.1' } } },
{ roomId: '!d', name: 'Delta', tags: { 'm.lowpriority': {} } },
{ roomId: '!e', name: 'Eps', tags: undefined },
]);
assert.deepEqual(
sections.map((s) => [s.name, s.tag, s.rooms]),
[
['off-topic', 'u.off-topic', ['!c']],
['Raids', 'u.Raids', ['!b', '!a', '!c']],
],
);
assert.deepEqual([...sectioned].sort(), ['!a', '!b', '!c']);
});
it('has no empty sections', () => {
assert.deepEqual(deriveRoomSections([{ roomId: '!a', name: 'A', tags: {} }]).sections, []);
});
it('ignores m.* and malformed u. tags', () => {
const { sections } = deriveRoomSections([
{ roomId: '!a', name: 'A', tags: { 'm.favourite': {}, 'u.': {}, 'x.y': {} } },
]);
assert.deepEqual(sections, []);
});
});
describe('section names', () => {
it('builds the tag and validates input', () => {
assert.equal(sectionTag(' Raids '), 'u.Raids');
assert.equal(validateSectionName('Raids', []), undefined);
assert.ok(validateSectionName('', []));
assert.ok(validateSectionName('raids', ['Raids']));
assert.ok(validateSectionName('a.b', []));
assert.ok(validateSectionName('x'.repeat(41), []));
});
});
+86
View File
@@ -0,0 +1,86 @@
import { MatrixClient } from 'matrix-js-sdk';
/**
* [Gitea #108] User-defined sidebar sections from standard `u.<name>` room
* tags, so they sync across devices and other clients see the same tags.
*/
export const SECTION_TAG_PREFIX = 'u.';
export type RoomSection = {
/** Display name ("Raids"). */
name: string;
/** Full tag name ("u.Raids"). */
tag: string;
/** Member room ids, by the tag's `order` (ascending), then by name. */
rooms: string[];
};
export type TaggedRoom = {
roomId: string;
name: string;
tags: Record<string, { order?: number | string } | undefined> | undefined;
};
export const sectionTag = (name: string): string => `${SECTION_TAG_PREFIX}${name.trim()}`;
export const isSectionTag = (tag: string): boolean =>
tag.startsWith(SECTION_TAG_PREFIX) && tag.length > SECTION_TAG_PREFIX.length;
export const sectionName = (tag: string): string => tag.slice(SECTION_TAG_PREFIX.length);
const orderOf = (v: { order?: number | string } | undefined): number => {
const n = typeof v?.order === 'string' ? parseFloat(v.order) : v?.order;
return typeof n === 'number' && Number.isFinite(n) ? n : 1;
};
/**
* Sections (alphabetical by name) with their members ordered by tag `order`
* then name; `sectioned` is the set of rooms that appear in any section.
* Empty sections simply don't exist — a section is its members' tags.
*/
export function deriveRoomSections(rooms: TaggedRoom[]): {
sections: RoomSection[];
sectioned: Set<string>;
} {
const byTag = new Map<string, { roomId: string; order: number; name: string }[]>();
const sectioned = new Set<string>();
rooms.forEach((room) => {
Object.entries(room.tags ?? {}).forEach(([tag, value]) => {
if (!isSectionTag(tag)) return;
const list = byTag.get(tag) ?? [];
list.push({ roomId: room.roomId, order: orderOf(value), name: room.name });
byTag.set(tag, list);
sectioned.add(room.roomId);
});
});
const sections = [...byTag.entries()]
.map(([tag, members]) => ({
tag,
name: sectionName(tag),
rooms: members
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
.map((m) => m.roomId),
}))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
return { sections, sectioned };
}
/** Every section name in use across the user's rooms (for the "Add to section" menu). */
export function listSectionNames(mx: MatrixClient): string[] {
const names = new Set<string>();
mx.getRooms().forEach((room) => {
Object.keys(room.tags ?? {}).forEach((tag) => {
if (isSectionTag(tag)) names.add(sectionName(tag));
});
});
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
/** Validate a new section name; returns the error to show, or undefined. */
export const validateSectionName = (raw: string, existing: string[]): string | undefined => {
const name = raw.trim();
if (!name) return 'Give the section a name.';
if (name.length > 40) return 'Keep it under 40 characters.';
if (name.includes('.')) return 'Dots are not allowed in section names.';
if (existing.some((e) => e.toLowerCase() === name.toLowerCase()))
return 'A section with that name already exists.';
return undefined;
};
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { describeRoomVisibility } from './roomVisibilityLine';
test('describeRoomVisibility composes the three facts', () => {
assert.equal(
describeRoomVisibility({ encrypted: true, joinRule: 'invite', historyVisibility: 'joined' }),
'Encrypted · Invite only · History visible to members since they joined',
);
assert.equal(
describeRoomVisibility({ encrypted: false, joinRule: 'public', historyVisibility: 'shared' }),
'Not encrypted · Anyone can join · History visible to members, including before they joined',
);
assert.equal(
describeRoomVisibility({
encrypted: false,
joinRule: 'knock',
historyVisibility: 'world_readable',
}),
'Not encrypted · Ask to join · History visible to anyone, even guests',
);
// Missing state: spec defaults are invite-only and "shared".
assert.equal(
describeRoomVisibility({ encrypted: true }),
'Encrypted · Invite only · History visible to members, including before they joined',
);
});
+49
View File
@@ -0,0 +1,49 @@
import { HistoryVisibility, JoinRule } from 'matrix-js-sdk';
/**
* [Gitea #133] One sentence for Room Settings General, under the name:
* "Encrypted · Invite only · History visible to members since they joined".
* Wording mirrors the room preview card chips and the history-visibility
* setting so the same words mean the same thing everywhere.
*/
export function describeRoomVisibility(input: {
encrypted: boolean;
joinRule?: string;
historyVisibility?: string;
}): string {
const parts: string[] = [input.encrypted ? 'Encrypted' : 'Not encrypted'];
switch (input.joinRule) {
case JoinRule.Public:
parts.push('Anyone can join');
break;
case JoinRule.Knock:
case 'knock_restricted':
parts.push('Ask to join');
break;
case JoinRule.Restricted:
parts.push('Restricted');
break;
case JoinRule.Private:
parts.push('Private');
break;
case JoinRule.Invite:
default:
parts.push('Invite only');
}
switch (input.historyVisibility) {
case HistoryVisibility.WorldReadable:
parts.push('History visible to anyone, even guests');
break;
case HistoryVisibility.Invited:
parts.push('History visible to members since they were invited');
break;
case HistoryVisibility.Joined:
parts.push('History visible to members since they joined');
break;
case HistoryVisibility.Shared:
default:
// "shared" is also the spec default when the state event is absent.
parts.push('History visible to members, including before they joined');
}
return parts.join(' · ');
}
+55
View File
@@ -0,0 +1,55 @@
/**
* [Gitea #120] What this device stores for Lotus, from the APIs that are cheap
* to ask. `estimate()` is the total the browser accounts for; Chromium adds a
* per-bucket breakdown (`usageDetails`), Firefox/Safari don't. Media files are
* not in here at all: they live in the browser's own HTTP cache, which manages
* and evicts them itself.
*/
export type StorageUsage = {
usage: number;
quota: number;
/** Chromium only: IndexedDB (sync store, crypto store, search index) bytes. */
indexedDB?: number;
/** Chromium only: Cache Storage (offline app shell) bytes. */
caches?: number;
/** Whether the browser granted persistent storage (won't evict under pressure). */
persisted: boolean | undefined;
};
type EstimateWithDetails = StorageEstimate & {
usageDetails?: { indexedDB?: number; caches?: number; serviceWorkerRegistrations?: number };
};
export const storageUsageSupported = (): boolean =>
typeof navigator !== 'undefined' && !!navigator.storage && !!navigator.storage.estimate;
export async function readStorageUsage(): Promise<StorageUsage | undefined> {
if (!storageUsageSupported()) return undefined;
const est = (await navigator.storage.estimate()) as EstimateWithDetails;
let persisted: boolean | undefined;
try {
persisted = navigator.storage.persisted ? await navigator.storage.persisted() : undefined;
} catch {
persisted = undefined;
}
return {
usage: est.usage ?? 0,
quota: est.quota ?? 0,
indexedDB: est.usageDetails?.indexedDB,
caches: est.usageDetails?.caches,
persisted,
};
}
/** Ask the browser to keep this origin's storage; returns the new state. */
export async function requestPersistentStorage(): Promise<boolean | undefined> {
if (!navigator.storage?.persist) return undefined;
try {
return await navigator.storage.persist();
} catch {
return undefined;
}
}
export const percentOf = (usage: number, quota: number): number =>
quota > 0 ? Math.min(100, Math.round((usage / quota) * 100)) : 0;
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable no-bitwise -- byte-level container parsing */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildOrientationApp1,
readJpegOrientation,
stripJpeg,
stripPng,
stripWebp,
stripImageMetadataBytes,
} from './stripImageMetadata';
const here = path.dirname(fileURLToPath(import.meta.url));
const fixture = (name: string) =>
new Uint8Array(fs.readFileSync(path.join(here, '__fixtures__', name)));
const has = (bytes: Uint8Array, text: string) =>
Buffer.from(bytes).includes(Buffer.from(text, 'latin1'));
/** Walk JPEG segments up to SOS; returns marker list (sanity: structure intact). */
const jpegMarkers = (b: Uint8Array): string[] => {
const out: string[] = [];
let pos = 2;
while (pos + 4 <= b.length && b[pos] === 0xff) {
const m = b[pos + 1];
out.push(m.toString(16));
if (m === 0xda) break;
pos += 2 + ((b[pos + 2] << 8) | b[pos + 3]);
}
return out;
};
describe('stripJpeg', () => {
it('removes GPS, model and timestamp, keeps a valid JPEG', () => {
const src = fixture('gps.jpg');
assert.ok(has(src, 'LotusPhone 9'));
const { out, stripped } = stripJpeg(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'LotusPhone 9'));
assert.ok(!has(out, '2026:09:20'));
assert.ok(out.length < src.length);
assert.deepEqual([out[0], out[1]], [0xff, 0xd8]);
assert.deepEqual([out[out.length - 2], out[out.length - 1]], [0xff, 0xd9]);
assert.ok(jpegMarkers(out).includes('da'));
// Pixel data untouched: everything from SOS on is byte-identical.
const sos = (b: Uint8Array) => Buffer.from(b).indexOf(Buffer.from([0xff, 0xda]));
assert.deepEqual(Buffer.from(out.subarray(sos(out))), Buffer.from(src.subarray(sos(src))));
});
it('keeps a non-default orientation in a minimal EXIF block', () => {
const { out, orientation } = stripJpeg(fixture('gps.jpg'));
assert.equal(orientation, 6);
const markers = jpegMarkers(out);
assert.ok(markers.includes('e1'));
// Find the APP1 we wrote and read the orientation back.
let pos = 2;
let found: number | undefined;
while (pos + 4 <= out.length && out[pos] === 0xff) {
const len = (out[pos + 2] << 8) | out[pos + 3];
if (out[pos + 1] === 0xe1) {
found = readJpegOrientation(out.subarray(pos + 4, pos + 2 + len));
assert.equal(len, buildOrientationApp1(6).length - 2);
}
if (out[pos + 1] === 0xda) break;
pos += 2 + len;
}
assert.equal(found, 6);
assert.ok(!has(out, 'LotusPhone 9'));
});
it('drops EXIF and the comment segment when orientation is 1 (no EXIF written back)', () => {
const src = fixture('plain.jpg');
assert.ok(has(src, 'made with love'));
const { out, stripped, orientation } = stripJpeg(src);
assert.equal(stripped, true);
assert.equal(orientation, undefined);
assert.ok(!has(out, 'made with love'));
assert.ok(!has(out, 'Cam'));
assert.ok(!jpegMarkers(out).includes('e1'));
});
it('returns the input untouched when there is nothing to strip', () => {
const src = fixture('clean.jpg');
const { out, stripped } = stripJpeg(src);
assert.equal(stripped, false);
assert.equal(out, src);
});
});
describe('stripPng', () => {
it('drops eXIf and text chunks, keeps IHDR/IDAT/IEND', () => {
const src = fixture('meta.png');
assert.ok(has(src, 'xmpmeta'));
const { out, stripped } = stripPng(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'xmpmeta'));
assert.ok(!has(out, 'LotusPhone'));
assert.ok(!has(out, 'eXIf'));
assert.ok(has(out, 'IHDR') && has(out, 'IDAT') && has(out, 'IEND'));
});
});
describe('stripWebp', () => {
it('drops the EXIF chunk, clears the VP8X flag and fixes the RIFF size', () => {
const src = fixture('meta.webp');
assert.ok(has(src, 'LotusPhone'));
const { out, stripped } = stripWebp(src);
assert.equal(stripped, true);
assert.ok(!has(out, 'LotusPhone'));
assert.ok(!has(out, 'EXIF'));
const riff = out[4] | (out[5] << 8) | (out[6] << 16) | (out[7] << 24);
assert.equal(riff, out.length - 8);
const vp8x = Buffer.from(out).indexOf(Buffer.from('VP8X'));
if (vp8x !== -1) assert.equal(out[vp8x + 8] & 0x0c, 0);
});
});
describe('stripImageMetadataBytes', () => {
it('passes unknown types through', () => {
const b = new Uint8Array([1, 2, 3]);
assert.deepEqual(stripImageMetadataBytes(b, 'image/gif'), { out: b, stripped: false });
});
});
+250
View File
@@ -0,0 +1,250 @@
/* eslint-disable no-bitwise -- byte-level container parsing */
/**
* [Gitea #109] Remove EXIF / XMP / IPTC metadata from an image WITHOUT
* re-encoding its pixels, so phone photos stop carrying GPS coordinates,
* device model and timestamps into the room. JPEG, PNG and WebP are handled
* at the container level; anything else is returned untouched.
*
* JPEG orientation: a bare strip would un-rotate photos whose pixels are
* stored sideways, so when the EXIF Orientation tag is not 1 a minimal EXIF
* block carrying only that tag is written back.
*/
export type StripResult = {
blob: Blob;
/** Whether any metadata segment was actually removed. */
stripped: boolean;
/** EXIF orientation that was preserved (JPEG only), if any. */
orientation?: number;
};
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
/** Read the EXIF Orientation tag (18) from an APP1 payload, or undefined. */
export function readJpegOrientation(app1: Uint8Array): number | undefined {
if (app1.length < 14) return undefined;
for (let i = 0; i < 6; i += 1) if (app1[i] !== EXIF_HEADER[i]) return undefined;
const tiff = 6;
const le = app1[tiff] === 0x49 && app1[tiff + 1] === 0x49;
const view = new DataView(app1.buffer, app1.byteOffset, app1.byteLength);
const u16 = (o: number) => view.getUint16(o, le);
const u32 = (o: number) => view.getUint32(o, le);
if (u16(tiff + 2) !== 0x2a) return undefined;
const ifd0 = tiff + u32(tiff + 4);
if (ifd0 + 2 > app1.length) return undefined;
const count = u16(ifd0);
for (let i = 0; i < count; i += 1) {
const entry = ifd0 + 2 + i * 12;
if (entry + 12 > app1.length) return undefined;
if (u16(entry) === 0x0112) {
const value = u16(entry + 8);
return value >= 1 && value <= 8 ? value : undefined;
}
}
return undefined;
}
/** A minimal APP1 segment: EXIF header + TIFF + one IFD0 entry (Orientation). */
export function buildOrientationApp1(orientation: number): Uint8Array {
// TIFF (big-endian): "MM", 0x002A, IFD offset 8; IFD0: count 1, entry, next 0.
const tiff = new Uint8Array([
0x4d,
0x4d,
0x00,
0x2a,
0x00,
0x00,
0x00,
0x08,
0x00,
0x01,
0x01,
0x12,
0x00,
0x03,
0x00,
0x00,
0x00,
0x01,
0x00,
orientation & 0xff,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
]);
const payload = new Uint8Array(EXIF_HEADER.length + tiff.length);
payload.set(EXIF_HEADER, 0);
payload.set(tiff, EXIF_HEADER.length);
const seg = new Uint8Array(4 + payload.length);
seg[0] = 0xff;
seg[1] = 0xe1;
const len = payload.length + 2;
seg[2] = (len >> 8) & 0xff;
seg[3] = len & 0xff;
seg.set(payload, 4);
return seg;
}
/** JPEG: drop APP1 (EXIF/XMP), APP13 (IPTC), COM; keep JFIF/ICC and the image. */
export function stripJpeg(bytes: Uint8Array): {
out: Uint8Array;
stripped: boolean;
orientation?: number;
} {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
return { out: bytes, stripped: false };
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
let pos = 2;
let stripped = false;
let orientation: number | undefined;
let insertedOrientation = false;
while (pos + 4 <= bytes.length) {
if (bytes[pos] !== 0xff) break;
const marker = bytes[pos + 1];
// Start of scan: everything from here to the end is entropy-coded data.
if (marker === 0xda) break;
const len = (bytes[pos + 2] << 8) | bytes[pos + 3];
const segEnd = pos + 2 + len;
if (len < 2 || segEnd > bytes.length) break;
const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
if (drop) {
stripped = true;
if (marker === 0xe1 && orientation === undefined) {
orientation = readJpegOrientation(bytes.subarray(pos + 4, segEnd));
}
} else {
parts.push(bytes.subarray(pos, segEnd));
}
pos = segEnd;
// Put the orientation-only EXIF right after SOI/JFIF, before anything else.
if (!insertedOrientation && orientation !== undefined && orientation !== 1 && marker !== 0xe0) {
insertedOrientation = true;
parts.push(buildOrientationApp1(orientation));
}
}
if (!stripped) return { out: bytes, stripped: false };
if (orientation !== undefined && orientation !== 1 && !insertedOrientation) {
parts.push(buildOrientationApp1(orientation));
}
parts.push(bytes.subarray(pos));
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let o = 0;
parts.forEach((p) => {
out.set(p, o);
o += p.length;
});
return { out, stripped, orientation: orientation !== 1 ? orientation : undefined };
}
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const PNG_DROP = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']);
/** PNG: drop eXIf and text chunks (XMP lives in iTXt); keep colour chunks. */
export function stripPng(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
if (bytes.length < 8) return { out: bytes, stripped: false };
for (let i = 0; i < 8; i += 1)
if (bytes[i] !== PNG_SIG[i]) return { out: bytes, stripped: false };
const parts: Uint8Array[] = [bytes.subarray(0, 8)];
let pos = 8;
let stripped = false;
while (pos + 8 <= bytes.length) {
const len =
((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
const type = String.fromCharCode(
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
);
const end = pos + 12 + len;
if (end > bytes.length) break;
if (PNG_DROP.has(type)) stripped = true;
else parts.push(bytes.subarray(pos, end));
pos = end;
}
if (!stripped) return { out: bytes, stripped: false };
parts.push(bytes.subarray(pos));
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let o = 0;
parts.forEach((p) => {
out.set(p, o);
o += p.length;
});
return { out, stripped };
}
const fourcc = (b: Uint8Array, o: number) =>
String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]);
/** WebP: drop EXIF/XMP chunks and clear their VP8X flags; fix the RIFF size. */
export function stripWebp(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
if (bytes.length < 12 || fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'WEBP') {
return { out: bytes, stripped: false };
}
const chunks: Uint8Array[] = [];
let pos = 12;
let stripped = false;
let vp8x: Uint8Array | undefined;
while (pos + 8 <= bytes.length) {
const id = fourcc(bytes, pos);
const len =
(bytes[pos + 4] | (bytes[pos + 5] << 8) | (bytes[pos + 6] << 16) | (bytes[pos + 7] << 24)) >>>
0;
const end = pos + 8 + len + (len % 2);
if (pos + 8 + len > bytes.length) break;
if (id === 'EXIF' || id === 'XMP ') stripped = true;
else {
const chunk = bytes.slice(pos, Math.min(end, bytes.length));
if (id === 'VP8X') vp8x = chunk;
chunks.push(chunk);
}
pos = end;
}
if (!stripped) return { out: bytes, stripped: false };
// VP8X flags byte: bit 3 = EXIF, bit 2 = XMP (0x08 / 0x04).
if (vp8x && vp8x.length >= 9) vp8x[8] &= ~0x0c;
const body = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(12 + body);
out.set(bytes.subarray(0, 12), 0);
const riffSize = 4 + body;
out[4] = riffSize & 0xff;
out[5] = (riffSize >> 8) & 0xff;
out[6] = (riffSize >> 16) & 0xff;
out[7] = (riffSize >> 24) & 0xff;
let o = 12;
chunks.forEach((c) => {
out.set(c, o);
o += c.length;
});
return { out, stripped };
}
export const STRIPPABLE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
export function stripImageMetadataBytes(
bytes: Uint8Array,
mimeType: string,
): { out: Uint8Array; stripped: boolean; orientation?: number } {
if (mimeType === 'image/jpeg') return stripJpeg(bytes);
if (mimeType === 'image/png') return stripPng(bytes);
if (mimeType === 'image/webp') return stripWebp(bytes);
return { out: bytes, stripped: false };
}
/** Strip a File/Blob; returns the same object when nothing changed or the type is unsupported. */
export async function stripImageMetadata(file: File): Promise<StripResult & { file: File }> {
if (!STRIPPABLE_TYPES.includes(file.type)) return { blob: file, file, stripped: false };
const bytes = new Uint8Array(await file.arrayBuffer());
const { out, stripped, orientation } = stripImageMetadataBytes(bytes, file.type);
if (!stripped) return { blob: file, file, stripped: false };
const next = new File([out as BlobPart], file.name, {
type: file.type,
lastModified: file.lastModified,
});
return { blob: next, file: next, stripped: true, orientation };
}
+46
View File
@@ -0,0 +1,46 @@
import { Room } from 'matrix-js-sdk';
import { TUploadItem } from '../state/room/roomInputDrafts';
import { encryptFile } from './matrix';
import { safeFile } from './mimeTypes';
import { fulfilledPromiseSettledResult } from './common';
import { stripImageMetadata } from './stripImageMetadata';
/**
* Files composer upload items for `room`, encrypting first when the room
* is E2EE. Shared by the composer's pick/drop/paste paths and the PWA share
* target (#155).
*/
export async function filesToUploadItems(
room: Room,
files: File[],
stripMetadata = true,
): Promise<TUploadItem[]> {
const prepared = await Promise.all(
files.map(async (raw) => {
const f = safeFile(raw);
// [Gitea #109] Drop EXIF/XMP/IPTC before anything else sees the bytes
// (including encryption), so what's uploaded never carried them.
const { file, stripped } = stripMetadata
? await stripImageMetadata(f)
: { file: f, stripped: false };
return {
file,
metadata: { markedAsSpoiler: false, metadataStripped: stripped || undefined },
};
}),
);
if (room.hasEncryptionStateEvent()) {
const encrypted = fulfilledPromiseSettledResult(
await Promise.allSettled(
prepared.map(async ({ file, metadata }) => ({ ...(await encryptFile(file)), metadata })),
),
);
return encrypted;
}
return prepared.map(({ file, metadata }) => ({
file,
originalFile: file,
encInfo: undefined,
metadata,
}));
}
+12 -1
View File
@@ -1,6 +1,7 @@
/// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { sendNotificationReply } from './swReply';
export type {};
@@ -62,7 +63,7 @@ async function cleanupDeadClients() {
});
}
function setSession(clientId: string, accessToken: any, baseUrl: any) {
function setSession(clientId: string, accessToken: string, baseUrl: string) {
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(clientId, { accessToken, baseUrl });
} else {
@@ -234,6 +235,16 @@ function fetchConfig(token: string): RequestInit {
self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request;
// [Gitea #155] Android share sheet → POST /share-target (manifest
// share_target). Stash the form and bounce to the in-app /share page.
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
event.respondWith(
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
);
return;
}
if (method !== 'GET' || !mediaPath(url)) return;
const { clientId } = event;
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shareDraftText } from './swShare';
const base = { files: [], receivedAt: 0 };
test('shareDraftText joins title, text and url', () => {
assert.equal(
shareDraftText({ ...base, title: 'A page', text: 'look at this', url: 'https://x.test/p' }),
'A page\nlook at this\nhttps://x.test/p',
);
});
test('shareDraftText drops a title/url the text already contains', () => {
assert.equal(
shareDraftText({
...base,
title: 'A page',
text: 'A page https://x.test/p',
url: 'https://x.test/p',
}),
'A page https://x.test/p',
);
assert.equal(shareDraftText({ ...base, text: 'from the share sheet' }), 'from the share sheet');
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
assert.equal(shareDraftText(base), '');
});
+88
View File
@@ -0,0 +1,88 @@
/**
* [Gitea #155] Web Share Target plumbing shared by the service worker and the
* app. Android's share sheet POSTs a multipart form to /share-target (see
* public/manifest.json); the worker stashes the payload in the Cache API and
* redirects to the in-app /share page, which reads it back, lets the user
* pick a room and pre-attaches the files to that room's composer.
*/
export const SHARE_TARGET_PATH = '/share-target';
export const SHARE_PAGE_PATH = '/share';
export const SHARE_CACHE = 'lotus-share-target';
export const SHARE_PAYLOAD_KEY = '/share-target/payload';
export const shareFileKey = (index: number) => `/share-target/file/${index}`;
export type SharePayload = {
title?: string;
text?: string;
url?: string;
files: { name: string; type: string; size: number }[];
receivedAt: number;
};
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
const form = await request.formData();
const files = form.getAll('files').filter((f): f is File => f instanceof File);
const str = (k: string) => {
const v = form.get(k);
return typeof v === 'string' && v.trim() ? v : undefined;
};
const payload: SharePayload = {
title: str('title'),
text: str('text'),
url: str('url'),
files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })),
receivedAt: Date.now(),
};
const cache = await caches.open(SHARE_CACHE);
await Promise.all([...(await cache.keys()).map((k) => cache.delete(k))]);
await cache.put(
SHARE_PAYLOAD_KEY,
new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }),
);
await Promise.all(
files.map((f, i) =>
cache.put(
shareFileKey(i),
new Response(f, {
headers: { 'Content-Type': f.type || 'application/octet-stream' },
}),
),
),
);
return Response.redirect(`${base}${SHARE_PAGE_PATH}`, 303);
}
/** The share sheet's title/text/url as one draft; title and url are dropped when the text already carries them. */
export const shareDraftText = (p: SharePayload): string => {
const text = p.text?.trim() ?? '';
const keep = (s: string | undefined) => !!s && s.trim() !== '' && !text.includes(s.trim());
return [keep(p.title) ? p.title : '', text, keep(p.url) ? p.url : ''].filter(Boolean).join('\n');
};
/** App side: read what the worker stashed (undefined when there is nothing). */
export async function readSharedPayload(): Promise<
{ payload: SharePayload; files: File[] } | undefined
> {
if (typeof caches === 'undefined') return undefined;
const cache = await caches.open(SHARE_CACHE);
const res = await cache.match(SHARE_PAYLOAD_KEY);
if (!res) return undefined;
const payload = (await res.json()) as SharePayload;
const files = await Promise.all(
payload.files.map(async (meta, i) => {
const r = await cache.match(shareFileKey(i));
const blob = r ? await r.blob() : new Blob();
return new File([blob], meta.name || `shared-${i + 1}`, {
type: meta.type || blob.type,
});
}),
);
return { payload, files };
}
export async function clearSharedPayload(): Promise<void> {
if (typeof caches === 'undefined') return;
await caches.delete(SHARE_CACHE);
}