Compare commits
75
Commits
1a8fa5cd4c
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bed70d335 | ||
|
|
c7aa4b9b19 | ||
|
|
81777ddfe1 | ||
|
|
eb4b88a028 | ||
|
|
e3883e0fce | ||
|
|
98c80f36cb | ||
|
|
ad1cbcf792 | ||
|
|
492b57c60d | ||
|
|
0c45bde832 | ||
|
|
082b8fc879 | ||
|
|
c6c2e88df5 | ||
|
|
dc0d524989 | ||
|
|
22d46a7922 | ||
|
|
be2c202543 | ||
|
|
6363939654 | ||
|
|
af244bba75 | ||
|
|
af1c0ee184 | ||
|
|
96a97a2f86 | ||
|
|
6aa77552b8 | ||
|
|
52e0cfaa83 | ||
|
|
bd8c79e0e6 | ||
|
|
4d4a76214a | ||
|
|
8d11a62e14 | ||
|
|
bf05751eca | ||
|
|
2bdb2eb4cb | ||
|
|
4fe9c87010 | ||
|
|
b69099a862 | ||
|
|
dea1f7afc0 | ||
|
|
7da91bd803 | ||
|
|
2a0c409180 | ||
|
|
9363629ea2 | ||
|
|
74d8e3119b | ||
|
|
73b1d1e3a7 | ||
|
|
ef5d06eea3 | ||
|
|
7f17940d34 | ||
|
|
f5e7fb4746 | ||
|
|
bbe91a24d8 | ||
|
|
f7d40460f5 | ||
|
|
d2f56817b3 | ||
|
|
33e16e85b3 | ||
|
|
f23b215efa | ||
|
|
821760131c | ||
|
|
edb4624796 | ||
|
|
d4420905e6 | ||
|
|
53a80adb57 | ||
|
|
e078a2cc10 | ||
|
|
c921f11521 | ||
|
|
0e2671891f | ||
|
|
6f25035341 | ||
|
|
f111b3c9af | ||
|
|
8b1c9fa610 | ||
|
|
8658ec05c3 | ||
|
|
2e7915d086 | ||
|
|
84c906fe33 | ||
|
|
464951edf4 | ||
|
|
6df160a7bf | ||
|
|
60076a48d0 | ||
|
|
a475531b2b | ||
|
|
34574178a9 | ||
|
|
070a1ea012 | ||
|
|
bbcbdad55a | ||
|
|
a9e0b893be | ||
|
|
68ce13f081 | ||
|
|
70620d4b43 | ||
|
|
4e455ae42e | ||
|
|
30fd22a5c5 | ||
|
|
da2d7a7d4f | ||
|
|
50b4e2c16c | ||
|
|
230d147ec1 | ||
|
|
9fefd14944 | ||
|
|
53823f5466 | ||
|
|
f2f498425b | ||
|
|
5fc90feef9 | ||
|
|
6460d0569c | ||
|
|
b38621861b |
+42
-2
@@ -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"
|
||||
@@ -238,6 +254,8 @@ jobs:
|
||||
# as m.room.encrypted. Skips itself unless the E2E_* secrets
|
||||
# below are set (create them under repo → Settings → Actions
|
||||
# → Secrets; they are empty until then).
|
||||
# local tier — (#220) regression suite against a Synapse this job starts
|
||||
# itself; skips itself if that homeserver isn't reachable.
|
||||
# dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work
|
||||
# on this Gitea runner (see LOTUS_REFERENCE.md → CI/CD), so `needs: build` only gates on the
|
||||
# main job having passed, not on its artifact.
|
||||
@@ -273,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
|
||||
@@ -282,6 +303,21 @@ jobs:
|
||||
NODE_OPTIONS: '--max_old_space_size=6144'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# ── #220 — tier 3: a throwaway Synapse the job starts itself (SQLite,
|
||||
# open registration, no rate limits; scripts/dev-homeserver.sh). The
|
||||
# regression spec (e2e/local-homeserver.spec.ts) registers its own users
|
||||
# and rooms through the CS API and drives the built client against it —
|
||||
# no prod secrets involved. If the homeserver fails to come up the spec
|
||||
# skips itself and the step below still reports why.
|
||||
- name: Start local homeserver
|
||||
id: local_hs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python3 -m venv --help >/dev/null 2>&1 || { (command -v sudo >/dev/null && sudo -n true 2>/dev/null && sudo apt-get update -qq && sudo apt-get install -y -qq python3-venv) || (apt-get update -qq && apt-get install -y -qq python3-venv); }
|
||||
scripts/dev-homeserver.sh start
|
||||
python3 scripts/dev-seed.py 20 > .dev-homeserver/seed.json
|
||||
echo "E2E_LOCAL_HS=http://localhost:8008" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Playwright smoke test
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
@@ -289,3 +325,7 @@ jobs:
|
||||
E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }}
|
||||
E2E_USER: ${{ secrets.E2E_USER }}
|
||||
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
|
||||
- name: Stop local homeserver
|
||||
if: always()
|
||||
run: scripts/dev-homeserver.sh stop || true
|
||||
|
||||
+20
-9
@@ -349,15 +349,16 @@ so a stock EC config is byte-for-byte upstream behavior.
|
||||
|
||||
**Active (cinny drives them today):**
|
||||
|
||||
| # | Feature | Mechanism | Replaces (old hack) |
|
||||
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||
| # | Feature | Mechanism | Replaces (old hack) |
|
||||
| ---- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||
| P6-2 | **Deafen / screenshare-audio mute** | host sends `io.lotus.set_deafen {deafened, screenshareAudioMuted}`; both become the `muted` prop of EC's audio elements (server stops sending), so they hold across re-renders, late joiners and a sharer stopping + re-sharing (fixed in `0.25.0-lotus.4`) | the iframe-DOM `.muted` hack (kept only as a transitional fallback) and `setVolume(0, ScreenShareAudio)`, which EC's own volume controls reset on every new share |
|
||||
|
||||
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||
**Now wired (cinny drives them — verified end-to-end on the local calls stack, 2026-09-18):**
|
||||
|
||||
| # | Capability | Widget action | cinny surface |
|
||||
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||
@@ -365,7 +366,9 @@ so a stock EC config is byte-for-byte upstream behavior.
|
||||
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||
|
||||
> Both were dormant capabilities; cinny now drives them (armed via
|
||||
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||
> `lotusAudioInject=1`). Verified headless with two clients: a clip publishes as
|
||||
> an extra `AUDIO/UNKNOWN` track on the SFU and is refused while muted; a room
|
||||
> cap set mid-call re-sends `io.lotus.set_quality` with the clamped values. The **only** EC item still open is the P5-31
|
||||
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||
> client cap is best-effort.
|
||||
@@ -1179,6 +1182,14 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `
|
||||
|
||||
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
||||
|
||||
### Push to Deafen: off switch + typing-safe
|
||||
|
||||
**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding.
|
||||
|
||||
### Forwarded messages show their provenance
|
||||
|
||||
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
|
||||
|
||||
### Copy Lotus Link — direct permalinks (Gitea #130)
|
||||
|
||||
`matrix.to` cannot be pointed at this deployment (its Cinny adapter is hard-coded to `app.cinny.in`; `web-instance[]` only works for Element), so every **Copy Link** (message ⋯ menu, space header menu, sidebar space-tab menu) has a **Copy Lotus Link** beside it that yields `https://chat.lotusguild.org/home/<room>/<event>?viaServers=…` (spaces: `/<space>/`). Helpers in `src/app/plugins/lotus-permalink.ts` (unit-tested). Lotus links pasted into a room render and click like matrix.to links (`toMatrixToHref` in the HTML parser rewrites them into the existing mention pipeline). Supporting fixes: `/home/<room>` for a room you are already in but that lives under a space or in Direct now redirects to its own route instead of a preview card (this is also the form matrix.to → "Continue in Cinny" produces); `?via=a,b` is accepted as an alias of `?viaServers=` (the matrix.to Cinny adapter emits `via`); and a deep link opened while logged out is honoured after an **OIDC/SSO** login too — the OIDC callback reloads at the app root, which previously discarded the stored path (`takeAfterLoginPath` is now consumed by the index route as well as the password flow). matrix.to stays the default, interoperable link and the Share Room QR is unchanged.
|
||||
|
||||
+1
-1
@@ -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.3`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.3`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
**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):
|
||||
|
||||
|
||||
+12
-5
@@ -19,6 +19,8 @@ python3 scripts/dev-seed.py 400 # alice + bob, "Busy Room": 400 messages, an
|
||||
npm start # Vite on :5173
|
||||
```
|
||||
|
||||
**Calls too:** `scripts/dev-homeserver.sh calls` adds a LiveKit SFU, a JWT issuer, the real `voice-limit-guard` from the `matrix` checkout and an https well-known — real two-party calls in headless Chromium with fake mic/camera (`--use-fake-device-for-media-stream`, `--use-file-for-fake-audio-capture=<tone.wav>` to trigger speaking detection, `--auto-select-desktop-capture-source="Entire screen"` for screenshare; `ignoreHTTPSErrors: true`). This is how #29, #161, #173/#174 and the guard's live-revoke bug were found and fixed on 2026-09-18.
|
||||
|
||||
Log in at `http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/` as `alice` / `password123` (bob is the second participant; both can also be driven over the client API with their tokens). Playwright is installed (`npm run test:e2e:install`), so a scripted reproduction is `node` + `chromium.launch()` against `:5173` — this is how Gitea #163 was reproduced and its fix verified in both plain and encrypted rooms. `scripts/dev-homeserver.sh reset` wipes the database; `stop` shuts it down.
|
||||
|
||||
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||
@@ -47,12 +49,17 @@ Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgro
|
||||
|
||||
## Playwright smoke test (Gitea #90) — `npm run test:e2e`
|
||||
|
||||
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Two tiers:
|
||||
Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They boot the **built** `dist/` through `vite preview` on port 4173, so run `npm run build` first (one-time: `npm run test:e2e:install` downloads the pinned Chromium). Three tiers:
|
||||
|
||||
| Tier | File | When it runs | What it proves |
|
||||
| :------------------------ | :-------------------------- | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
|
||||
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
|
||||
| Tier | File | When it runs | What it proves |
|
||||
| :-------------------------------- | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Boot** (always) | `e2e/boot.spec.ts` | every CI run (`e2e` job in `.gitea/workflows/ci.yml`) and locally | login page renders with `#root` populated and **no** `pageerror` / unexpected `console.error` (allowlist in `e2e/helpers.ts`: the README's avatar-thumbnail 404, the login page's `POST /register` 401 probe, offline discovery), `sw.js` is served and registers, bundled Element Call mounts in a frame with every `/public/element-call/` asset returning 200 |
|
||||
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
|
||||
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
|
||||
|
||||
**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):
|
||||
|
||||
|
||||
@@ -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.3`, 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/;
|
||||
|
||||
|
||||
@@ -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
@@ -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/);
|
||||
|
||||
+14
-1
@@ -14,6 +14,15 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
|
||||
// Homeserver discovery pings can fail on a runner with no outbound network.
|
||||
/\/\.well-known\/matrix\/client/i,
|
||||
/Failed to fetch|NetworkError|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED/i,
|
||||
// Tier 3 (local homeserver named "localhost"): the client's well-known
|
||||
// autodiscovery probes https://localhost/, which is either not listening
|
||||
// (CI) or a self-signed dev server (local calls stack).
|
||||
/ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i,
|
||||
// WebKit's spellings of the same discovery failures (#221).
|
||||
/Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i,
|
||||
// Also a fetch cut short by our own navigation (e.g. the crypto wasm while
|
||||
// the test moves from /home to a room) — WebKit words that the same way.
|
||||
/due to access control checks/i,
|
||||
// React devtools hint in production bundles.
|
||||
/Download the React DevTools/i,
|
||||
];
|
||||
@@ -41,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))),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import {
|
||||
HS,
|
||||
api,
|
||||
createRoom,
|
||||
enc,
|
||||
ensureUser,
|
||||
hsReachable,
|
||||
joinRoom,
|
||||
loginUI,
|
||||
openRoom,
|
||||
sendText,
|
||||
uniq,
|
||||
TestUser,
|
||||
} from './localHs';
|
||||
import { collectConsole } from './helpers';
|
||||
|
||||
// Tier 3 — regression suite against the job's own Synapse (Gitea #220).
|
||||
// Each test seeds what it needs through the CS API and drives the built
|
||||
// client; nothing here touches a real deployment.
|
||||
test.describe('local homeserver regression', () => {
|
||||
let alice: TestUser;
|
||||
let bob: TestUser;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
alice = await ensureUser(uniq('e2e_alice_'));
|
||||
bob = await ensureUser(uniq('e2e_bob_'));
|
||||
});
|
||||
|
||||
test('logs in, opens a room, sends and receives @webkit @ios', async ({ page }) => {
|
||||
const console_ = collectConsole(page);
|
||||
const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
await sendText(bob, room, 'hello from bob');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await expect(page.getByText('hello from bob')).toBeVisible();
|
||||
await page.locator('[data-slate-editor]').first().click();
|
||||
await page.keyboard.type('hello from alice');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByText('hello from alice')).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await api<{ chunk: { content: { body: string } }[] }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=1`,
|
||||
bob.token,
|
||||
)
|
||||
).chunk[0]?.content.body,
|
||||
)
|
||||
.toBe('hello from alice');
|
||||
expect(console_.unexpected()).toEqual([]);
|
||||
});
|
||||
|
||||
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Scroll Room');
|
||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||
(chain, _, i) => chain.then(() => sendText(alice, room, `filler ${i}`)),
|
||||
Promise.resolve(),
|
||||
);
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.mouse.move(600, 350);
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toBeVisible();
|
||||
await page.locator('[data-slate-editor]').first().click();
|
||||
await page.keyboard.type('sent while scrolled up');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByText('sent while scrolled up')).toBeInViewport();
|
||||
await expect(page.getByRole('button', { name: /Jump to Latest/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/kick failure is reported, not swallowed (#216)', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Kick Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
await loginUI(page, bob);
|
||||
await openRoom(page, room);
|
||||
const editor = page.locator('[data-slate-editor]').first();
|
||||
await editor.click();
|
||||
await page.keyboard.type('/kick', { delay: 40 });
|
||||
await page.keyboard.press('Tab'); // accept the command chip
|
||||
await page.keyboard.type(` ${alice.userId}`, { delay: 20 });
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByText(/Could not kick .*You cannot kick/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('upload failure shows a plain sentence, never the raw MatrixError (#213)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const room = await createRoom(alice, 'Upload Room');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.route(/\/_matrix\/media\/v3\/upload/, (route) =>
|
||||
route.fulfill({
|
||||
status: 413,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'nope' }),
|
||||
}),
|
||||
);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Attach file' }).first().click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: 'pic.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
),
|
||||
});
|
||||
const send = page.getByRole('button', { name: /^Send$/ });
|
||||
if (await send.count()) await send.first().click();
|
||||
else await page.getByRole('button', { name: 'Send message' }).click();
|
||||
await expect(page.getByText(/This file is larger than the server allows/)).toBeVisible();
|
||||
await expect(page.getByText(/MatrixError|_matrix\/media/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('forwarded message carries its provenance header', async ({ page }) => {
|
||||
const src = await createRoom(bob, 'Source Room', { invite: [alice.userId] });
|
||||
const dst = await createRoom(alice, 'Destination Room');
|
||||
await joinRoom(alice, src);
|
||||
await sendText(bob, src, 'the original message');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, src);
|
||||
const msg = page.locator('[data-message-item]', { hasText: 'the original message' }).first();
|
||||
await msg.hover();
|
||||
await msg.getByRole('button', { name: 'More options' }).click();
|
||||
await page.getByText('Forward', { exact: true }).click();
|
||||
const search = page.getByPlaceholder('Search rooms…');
|
||||
await search.fill('Destination Room');
|
||||
await page.locator('button', { hasText: 'Destination Room' }).first().click();
|
||||
await page.getByRole('button', { name: /^Send to 1 room/ }).click();
|
||||
await expect(page.getByText(/Forwarded to/)).toBeVisible();
|
||||
await openRoom(page, dst);
|
||||
await expect(page.getByText(/Forwarded from .* in Source Room/)).toBeVisible();
|
||||
await expect(page.getByText('the original message')).toBeVisible();
|
||||
});
|
||||
|
||||
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218) @webkit', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } });
|
||||
const page = await ctx.newPage();
|
||||
const room = await createRoom(alice, 'Thread Room');
|
||||
const root = await sendText(alice, room, 'thread root');
|
||||
await sendText(alice, room, 'a reply', {
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root },
|
||||
},
|
||||
});
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page
|
||||
.locator('[data-message-item]', { hasText: 'thread root' })
|
||||
.getByText(/1 reply/)
|
||||
.click();
|
||||
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
|
||||
const widths = await page
|
||||
.locator('[data-slate-editor]')
|
||||
.evaluateAll((els) => els.map((e) => e.getBoundingClientRect().width));
|
||||
expect(Math.min(...widths)).toBeGreaterThan(120);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Lightbox Room');
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const up = await fetch(`${HS}/_matrix/media/v3/upload?filename=a.png`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${alice.token}`, 'Content-Type': 'image/png' },
|
||||
body: png,
|
||||
}).then((r) => r.json() as Promise<{ content_uri: string }>);
|
||||
await api(
|
||||
'PUT',
|
||||
`/_matrix/client/v3/rooms/${enc(room)}/send/m.room.message/img1`,
|
||||
alice.token,
|
||||
{
|
||||
msgtype: 'm.image',
|
||||
body: 'a.png',
|
||||
url: up.content_uri,
|
||||
info: { mimetype: 'image/png', size: png.length, w: 1, h: 1 },
|
||||
},
|
||||
);
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.locator('[data-message-item] img[alt="a.png"]').click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Media viewer' });
|
||||
await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByText('1 / 1')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('warns when the local clock is far off the server (#158)', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Skew Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sendText(bob, room, `tick ${i}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
|
||||
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('status save survives the presence rate limit (#226)', async ({ page }) => {
|
||||
let lastOk = 0;
|
||||
await page.route(/\/presence\/[^/]+\/status/, (route) => {
|
||||
const now = Date.now();
|
||||
if (now - lastOk < 10_000) {
|
||||
return route.fulfill({
|
||||
status: 429,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
errcode: 'M_LIMIT_EXCEEDED',
|
||||
error: 'Too Many Requests',
|
||||
retry_after_ms: 10_000 - (now - lastOk),
|
||||
}),
|
||||
});
|
||||
}
|
||||
lastOk = now;
|
||||
return route.continue();
|
||||
});
|
||||
await loginUI(page, alice);
|
||||
await page
|
||||
.locator('button', { hasText: new RegExp(`^${alice.localpart[0]}$`) })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByText('Account', { exact: true }).first().click();
|
||||
const input = page.getByLabel('Status message');
|
||||
const status = uniq('status ');
|
||||
await input.fill(status);
|
||||
await input.locator('xpath=ancestor::form[1]').getByRole('button', { name: 'Save' }).click();
|
||||
await expect(page.getByText(/Failed to save status/)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await api<{ status_msg?: string }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/presence/${enc(alice.userId)}/status`,
|
||||
alice.token,
|
||||
)
|
||||
).status_msg,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(status);
|
||||
});
|
||||
|
||||
test('touch: long-press opens the message action sheet (#166)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 7'] });
|
||||
const page = await ctx.newPage();
|
||||
const room = await createRoom(alice, 'Touch Room');
|
||||
await sendText(alice, room, 'press and hold me');
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
const msg = page.locator('[data-message-item]', { hasText: 'press and hold me' });
|
||||
const box = (await msg.boundingBox())!;
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
const x = box.x + 100;
|
||||
const y = box.y + box.height / 2;
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] });
|
||||
await page.waitForTimeout(700);
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
const sheet = page.getByRole('dialog', { name: 'Message actions' });
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText('Reply', { exact: true })).toBeVisible();
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Tier 3 (Gitea #220): regression tests against a throwaway Synapse the job
|
||||
* starts itself (`scripts/dev-homeserver.sh start`). No prod secrets. Set
|
||||
* E2E_LOCAL_HS (default http://localhost:8008) — the spec skips when the
|
||||
* server isn't reachable.
|
||||
*/
|
||||
export const HS = process.env.E2E_LOCAL_HS || 'http://localhost:8008';
|
||||
export const PASSWORD = 'password123';
|
||||
export const enc = encodeURIComponent;
|
||||
|
||||
export async function hsReachable(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${HS}/_matrix/client/versions`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${HS}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export type TestUser = { localpart: string; userId: string; token: string };
|
||||
|
||||
/** Registers (open registration) or logs in a test user. */
|
||||
export async function ensureUser(localpart: string): Promise<TestUser> {
|
||||
const reg = await api<{ user_id?: string; access_token?: string }>(
|
||||
'POST',
|
||||
'/_matrix/client/v3/register',
|
||||
undefined,
|
||||
{ username: localpart, password: PASSWORD, auth: { type: 'm.login.dummy' } },
|
||||
);
|
||||
if (reg.access_token && reg.user_id) {
|
||||
return { localpart, userId: reg.user_id, token: reg.access_token };
|
||||
}
|
||||
const login = await api<{ user_id: string; access_token: string }>(
|
||||
'POST',
|
||||
'/_matrix/client/v3/login',
|
||||
undefined,
|
||||
{
|
||||
type: 'm.login.password',
|
||||
identifier: { type: 'm.id.user', user: localpart },
|
||||
password: PASSWORD,
|
||||
},
|
||||
);
|
||||
return { localpart, userId: login.user_id, token: login.access_token };
|
||||
}
|
||||
|
||||
export async function createRoom(
|
||||
owner: TestUser,
|
||||
name: string,
|
||||
opts: { invite?: string[]; preset?: string } = {},
|
||||
): Promise<string> {
|
||||
const res = await api<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', owner.token, {
|
||||
name,
|
||||
preset: opts.preset ?? 'public_chat',
|
||||
invite: opts.invite,
|
||||
});
|
||||
return res.room_id;
|
||||
}
|
||||
|
||||
export async function joinRoom(user: TestUser, roomId: string): Promise<void> {
|
||||
await api('POST', `/_matrix/client/v3/rooms/${enc(roomId)}/join`, user.token, {});
|
||||
}
|
||||
|
||||
let txn = 0;
|
||||
export async function sendText(
|
||||
user: TestUser,
|
||||
roomId: string,
|
||||
body: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
txn += 1;
|
||||
const res = await api<{ event_id: string }>(
|
||||
'PUT',
|
||||
`/_matrix/client/v3/rooms/${enc(roomId)}/send/m.room.message/e2e${Date.now()}_${txn}`,
|
||||
user.token,
|
||||
{ msgtype: 'm.text', body, ...extra },
|
||||
);
|
||||
return res.event_id;
|
||||
}
|
||||
|
||||
/** Password login through the UI against the local homeserver. */
|
||||
export async function loginUI(page: Page, user: TestUser): Promise<void> {
|
||||
await page.goto(`/login/${enc(HS)}/`);
|
||||
await page.getByLabel('Username or email').fill(user.localpart);
|
||||
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await page.waitForURL(/\/home/, { timeout: 60_000 });
|
||||
}
|
||||
|
||||
export async function openRoom(page: Page, roomId: string): Promise<void> {
|
||||
await page.goto(`/home/${enc(roomId)}`);
|
||||
await expect(page.locator('[data-slate-editor]').first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export const uniq = (prefix: string): string =>
|
||||
`${prefix}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -27,9 +27,6 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
|
||||
Generated
+22
-7
@@ -80,7 +80,8 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.3",
|
||||
"@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.3",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.3/element-call-embedded-0.25.0-lotus.3.tgz",
|
||||
"integrity": "sha512-UjOi8DXosVjBeVb5SiPNFApJSI9gggpU67xAUn0EWevD3oj9wQFhtjdMkxGbhnQ7B9r6QEUivDGWsBpsfKe0/w==",
|
||||
"version": "0.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
@@ -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.3",
|
||||
"@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
@@ -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,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
Binary file not shown.
Binary file not shown.
@@ -49,3 +49,25 @@
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* VT323 (OFL) for the Lotus Terminal theme — was Google Fonts (Gitea #214) */
|
||||
@font-face {
|
||||
font-family: 'VT323';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/VT323-latin-ext.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
|
||||
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'VT323';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/VT323-latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,22 @@
|
||||
}
|
||||
],
|
||||
"categories": ["social", "communication", "productivity"],
|
||||
"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",
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
# SQLite, media served. Everything lives in .dev-homeserver/ (gitignored).
|
||||
#
|
||||
# scripts/dev-homeserver.sh start # install (first run) + start on :8008
|
||||
# scripts/dev-homeserver.sh calls # + LiveKit SFU, JWT issuer, voice-limit guard, https well-known
|
||||
# scripts/dev-homeserver.sh stop
|
||||
# scripts/dev-homeserver.sh reset # wipe the database and media
|
||||
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images
|
||||
# python3 scripts/dev-seed.py 400 # alice/bob + "Busy Room" with images (+ "Voice Lounge" call room)
|
||||
#
|
||||
# Calls: `calls` downloads livekit-server (v1.13.7 release binary) into
|
||||
# .dev-homeserver/, runs it on :7880 with key devkey, a minimal lk-jwt-service
|
||||
# clone (scripts/dev-lk-jwt.py, :8071), the real voice-limit-guard from the
|
||||
# sibling matrix repo in front of it (:8070, needs ../matrix checked out and an
|
||||
# admin user — dev-seed makes alice one), and a self-signed https server on
|
||||
# :443 serving /.well-known/matrix/client with the LiveKit focus (the client
|
||||
# autodiscovers the server NAME, not :8008). Drive it with Playwright using
|
||||
# --use-fake-device-for-media-stream and ignoreHTTPSErrors.
|
||||
#
|
||||
# Then `npm start` and log in at http://127.0.0.1:5173/login/http%3A%2F%2Flocalhost%3A8008/
|
||||
# as alice / password123.
|
||||
@@ -32,7 +42,7 @@ install() {
|
||||
python3 - "$CFG" <<'PY'
|
||||
import sys, re
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
s = s.replace(" - client\n", " - client\n - media\n", 1)
|
||||
s = s.replace(" - client\n", " - client\n - media\n - federation\n", 1)
|
||||
s += """
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
@@ -46,10 +56,17 @@ suppress_key_server_warning: true
|
||||
# URL previews (embed facades use the homeserver's cached thumbnail)
|
||||
url_preview_enabled: true
|
||||
url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7']
|
||||
# scheduled messages (MSC4140 delayed events)
|
||||
# scheduled messages (MSC4140 delayed events) + MatrixRTC transports (MSC4143) + room summaries (MSC3266)
|
||||
max_event_delay_duration: 24h
|
||||
experimental_features:
|
||||
msc4140_enabled: true
|
||||
msc4143_enabled: true
|
||||
msc3266_enabled: true
|
||||
msc4133_enabled: true
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: http://127.0.0.1:8070
|
||||
"""
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
@@ -72,10 +89,50 @@ start() {
|
||||
|
||||
stop() {
|
||||
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null || true; rm -f "$PIDFILE"; echo stopped; fi
|
||||
for f in "$DIR"/calls-*.pid; do [ -f "$f" ] && { kill "$(cat "$f")" 2>/dev/null || true; rm -f "$f"; }; done
|
||||
}
|
||||
|
||||
calls() {
|
||||
start
|
||||
local LK="$DIR/livekit-server"
|
||||
if [ ! -x "$LK" ]; then
|
||||
curl -sL "https://github.com/livekit/livekit/releases/download/v1.13.7/livekit_1.13.7_linux_amd64.tar.gz" | tar xz -C "$DIR" livekit-server
|
||||
fi
|
||||
cat > "$DIR/livekit.yaml" <<'YAML'
|
||||
port: 7880
|
||||
bind_addresses: ["127.0.0.1"]
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 50100
|
||||
use_external_ip: false
|
||||
node_ip: 127.0.0.1
|
||||
keys:
|
||||
devkey: devsecretdevsecretdevsecretdevsecret
|
||||
room:
|
||||
auto_create: true
|
||||
YAML
|
||||
local bg
|
||||
bg() { local name="$1"; shift; ( setsid nohup "$@" > "$DIR/calls-$name.log" 2>&1 < /dev/null & echo $! > "$DIR/calls-$name.pid" ); }
|
||||
bg livekit "$LK" --config "$DIR/livekit.yaml"
|
||||
bg jwt env PORT=8071 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
|
||||
if [ ! -f "$DIR/wk.crt" ]; then openssl req -x509 -newkey rsa:2048 -nodes -keyout "$DIR/wk.key" -out "$DIR/wk.crt" -days 365 -subj "/CN=localhost" 2>/dev/null; fi
|
||||
bg wellknown env CERT="$DIR/wk.crt" KEY="$DIR/wk.key" python3 "$ROOT/scripts/dev-wellknown.py"
|
||||
local GUARD="$ROOT/../matrix/livekit/voice-limit-guard.py"
|
||||
local TOKEN_FILE="$DIR/admin.token"
|
||||
if [ -f "$GUARD" ] && [ -f "$TOKEN_FILE" ]; then
|
||||
bg guard env GUARD_BIND_HOST=127.0.0.1 GUARD_BIND_PORT=8070 GUARD_UPSTREAM=http://127.0.0.1:8071 LIVEKIT_API=http://127.0.0.1:7880 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret SYNAPSE_API=http://127.0.0.1:8008 MATRIX_TOKEN="$(cat "$TOKEN_FILE")" python3 "$GUARD"
|
||||
else
|
||||
echo "voice-limit-guard not started (need ../matrix checkout and $TOKEN_FILE from dev-seed.py); pointing the focus straight at the issuer"
|
||||
bg guard env PORT=8070 LIVEKIT_KEY=devkey LIVEKIT_SECRET=devsecretdevsecretdevsecretdevsecret LIVEKIT_URL=ws://127.0.0.1:7880 SYNAPSE_API=http://127.0.0.1:8008 python3 "$ROOT/scripts/dev-lk-jwt.py"
|
||||
fi
|
||||
sleep 2
|
||||
echo "calls stack up: livekit :7880, jwt :8071, guard :8070, well-known https://localhost/.well-known/matrix/client"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start ;;
|
||||
calls) calls ;;
|
||||
stop) stop ;;
|
||||
reset) stop; rm -f "$DIR"/homeserver.db* ; rm -rf "$DIR/media_store"; echo "database wiped"; ;;
|
||||
*) echo "usage: $0 start|stop|reset" >&2; exit 2 ;;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal lk-jwt-service clone for local testing.
|
||||
POST /sfu/get {room, openid_token{access_token, matrix_server_name}, device_id}
|
||||
-> validates the OpenID token against Synapse (federation openid/userinfo),
|
||||
mints a LiveKit JWT for identity "<user_id>:<device_id>" and returns {url, jwt}.
|
||||
"""
|
||||
import base64, hashlib, hmac, json, os, time, urllib.parse, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = int(os.environ.get("PORT", "8071"))
|
||||
LK_KEY = os.environ.get("LIVEKIT_KEY", "devkey")
|
||||
LK_SECRET = os.environ.get("LIVEKIT_SECRET", "devsecretdevsecretdevsecretdevsecret")
|
||||
LK_URL = os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880")
|
||||
SYNAPSE = os.environ.get("SYNAPSE_API", "http://127.0.0.1:8008")
|
||||
|
||||
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||
def jwt(payload):
|
||||
h = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()); p = b64(json.dumps(payload).encode())
|
||||
sig = b64(hmac.new(LK_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest())
|
||||
return f"{h}.{p}.{sig}"
|
||||
|
||||
def userinfo(access_token):
|
||||
q = urllib.parse.urlencode({"access_token": access_token})
|
||||
with urllib.request.urlopen(f"{SYNAPSE}/_matrix/federation/v1/openid/userinfo?{q}", timeout=5) as r:
|
||||
return json.load(r)["sub"]
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def _cors(self):
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204); self._cors(); self.end_headers()
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
data = json.loads(self.rfile.read(n) or b"{}")
|
||||
room = data.get("room") or data.get("room_id") or ""
|
||||
oid = data.get("openid_token") or {}
|
||||
try:
|
||||
user = userinfo(oid.get("access_token", ""))
|
||||
except Exception as e: # noqa
|
||||
self.send_response(401); self._cors(); self.end_headers(); self.wfile.write(json.dumps({"errcode": "M_LOOKUP_FAILED", "error": str(e)}).encode()); return
|
||||
identity = f"{user}:{data.get('device_id', '')}"
|
||||
now = int(time.time())
|
||||
token = jwt({"iss": LK_KEY, "sub": identity, "name": user, "nbf": now - 10, "exp": now + 3600,
|
||||
"video": {"room": room, "roomJoin": True, "roomCreate": True, "canPublish": True, "canSubscribe": True, "canPublishData": True}})
|
||||
body = json.dumps({"url": LK_URL, "jwt": token}).encode()
|
||||
self.send_response(200); self._cors(); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||
print(f"issued {identity} room={room}", flush=True)
|
||||
def log_message(self, *a): pass
|
||||
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
+24
-5
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users
|
||||
and a busy unencrypted room: alice + bob, "Busy Room", N messages, one image
|
||||
every 10th message. Idempotent for the users; every run creates a new room.
|
||||
"""Seed the local dev homeserver (scripts/dev-homeserver.sh) with two users,
|
||||
a busy unencrypted room and a voice room: alice + bob, "Busy Room" (N
|
||||
messages, one image every 10th), "Voice Lounge" (org.matrix.msc3417.call with
|
||||
the call-member power level Lotus applies). Makes alice a server admin (the
|
||||
voice-limit guard reads room state through the admin API) and writes her
|
||||
token to .dev-homeserver/admin.token for `dev-homeserver.sh calls`.
|
||||
Idempotent for the users; every run creates new rooms.
|
||||
|
||||
python3 scripts/dev-seed.py [N=400]
|
||||
"""
|
||||
import json, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
|
||||
import json, os, sqlite3, struct, sys, time, urllib.error, urllib.parse, urllib.request, zlib
|
||||
|
||||
HS = "http://127.0.0.1:8008"
|
||||
PASSWORD = "password123"
|
||||
@@ -56,7 +60,22 @@ def main():
|
||||
content = {"msgtype": "m.text", "body": f"message #{i}"}
|
||||
txn += 1
|
||||
req("PUT", f"/_matrix/client/v3/rooms/{urllib.parse.quote(room)}/send/m.room.message/{txn}", content, tok)
|
||||
print(json.dumps({"room": room, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
|
||||
voice = req("POST", "/_matrix/client/v3/createRoom", {
|
||||
"name": "Voice Lounge", "preset": "public_chat",
|
||||
"creation_content": {"type": "org.matrix.msc3417.call"},
|
||||
"initial_state": [{"type": "org.matrix.msc3401.call", "state_key": "", "content": {}}],
|
||||
"power_level_content_override": {"events": {"org.matrix.msc3401.call.member": 0}},
|
||||
}, ta)["room_id"]
|
||||
req("POST", f"/_matrix/client/v3/join/{urllib.parse.quote(voice)}", {}, tb)
|
||||
# admin flag for the guard (takes effect for tokens issued after a Synapse restart-free
|
||||
# cache miss; the guard only needs it for the admin state API)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
db = os.path.join(here, "..", ".dev-homeserver", "homeserver.db")
|
||||
if os.path.exists(db):
|
||||
c = sqlite3.connect(db); c.execute("UPDATE users SET admin=1 WHERE name=?", (alice["user_id"],)); c.commit(); c.close()
|
||||
with open(os.path.join(here, "..", ".dev-homeserver", "admin.token"), "w") as f:
|
||||
f.write(ta)
|
||||
print(json.dumps({"room": room, "voice_room": voice, "alice": alice["user_id"], "bob": bob["user_id"], "count": n}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Self-signed https server on :443 serving the client well-known with the
|
||||
LiveKit focus for the local calls stack (see scripts/dev-homeserver.sh calls)."""
|
||||
import http.server, os, ssl, json
|
||||
BODY = json.dumps({"m.homeserver": {"base_url": "http://localhost:8008"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "http://127.0.0.1:8070"}]}).encode()
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith('/.well-known/matrix/client'):
|
||||
self.send_response(200); self.send_header('Content-Type','application/json'); self.send_header('Access-Control-Allow-Origin','*'); self.send_header('Content-Length',str(len(BODY))); self.end_headers(); self.wfile.write(BODY)
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
def log_message(self,*a): pass
|
||||
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 443), H)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); ctx.load_cert_chain(os.environ.get('CERT', 'wk.crt'), os.environ.get('KEY', 'wk.key')); srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
|
||||
srv.serve_forever()
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
RoomEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
|
||||
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import {
|
||||
CallEmbedContextProvider,
|
||||
@@ -33,18 +34,27 @@ import {
|
||||
useCallHangupEvent,
|
||||
useCallJoined,
|
||||
useCallThemeSync,
|
||||
useCallMemberSoundSync,
|
||||
useCallStart,
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||
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';
|
||||
import { SrOnly } from '../features/call-status/styles.css';
|
||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
@@ -112,6 +122,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
const rtcSupported = webRTCSupported();
|
||||
const canAnswer = livekitSupported && rtcSupported;
|
||||
const { room } = info;
|
||||
// [Gitea #135] Answer follows cameraOnJoin: say so, and offer the audio-only
|
||||
// alternative, rather than putting the user on video without warning.
|
||||
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const willAnswerWithCamera = info.intent === 'video' && !!cameraOnJoinPref;
|
||||
|
||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
@@ -217,6 +231,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
{willAnswerWithCamera && (
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
Answering turns your camera on.
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
@@ -233,9 +252,25 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer
|
||||
{willAnswerWithCamera ? 'Answer with camera' : 'Answer'}
|
||||
</Text>
|
||||
</Button>
|
||||
{willAnswerWithCamera && (
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => onAnswer(room, false)}
|
||||
before={<Icon size="200" src={Icons.Phone} filled />}
|
||||
disabled={!canAnswer}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
Answer without camera
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant={dm ? 'Critical' : 'Secondary'}
|
||||
@@ -277,6 +312,8 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { room } = info;
|
||||
const isVideo = info.intent === 'video';
|
||||
const [cameraOnJoinPref] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const bannerCameraOn = isVideo && !!cameraOnJoinPref;
|
||||
|
||||
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||
@@ -375,7 +412,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
@@ -384,11 +421,27 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
radii="300"
|
||||
onClick={() => onAnswer(room, isVideo)}
|
||||
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||
title={bannerCameraOn ? 'Answering turns your camera on' : undefined}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Answer
|
||||
{bannerCameraOn ? 'Answer with camera' : 'Answer'}
|
||||
</Text>
|
||||
</Button>
|
||||
{bannerCameraOn && (
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => onAnswer(room, false)}
|
||||
before={<Icon size="100" src={Icons.Phone} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Answer without camera
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
style={{ flexGrow: 1 }}
|
||||
variant={dm ? 'Critical' : 'Secondary'}
|
||||
@@ -421,6 +474,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||
const startCall = useCallStart(dm);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const { microphone, sound } = useCallPreferences();
|
||||
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
|
||||
@@ -559,6 +613,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
setCallInfo(undefined);
|
||||
}, []);
|
||||
|
||||
// [Gitea #161] Stop ringing here when the call was handled ELSEWHERE:
|
||||
// - answered on another of our devices (our own m.call.member appears),
|
||||
// - declined on another of our devices (our own RTCDecline for this ring),
|
||||
// - the caller hung up before we picked up (nobody left in the session).
|
||||
// Without this every other device kept ringing for the full lifetime.
|
||||
useEffect(() => {
|
||||
if (!callInfo) return undefined;
|
||||
const { room, refEventId } = callInfo;
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const dismiss = () =>
|
||||
setCallInfo((current) => (current?.refEventId === refEventId ? undefined : current));
|
||||
|
||||
const session = mx.matrixRTC.getRoomSession(room);
|
||||
const checkMemberships = () => {
|
||||
const { memberships } = session;
|
||||
if (memberships.some((m) => m.sender === myUserId))
|
||||
dismiss(); // answered elsewhere
|
||||
else if (memberships.length === 0) dismiss(); // caller gone
|
||||
};
|
||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
event,
|
||||
eventRoom,
|
||||
_s,
|
||||
_r,
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
}
|
||||
};
|
||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||
mx.on(RoomEvent.Timeline, onTimeline);
|
||||
// The caller's membership may not have arrived yet when the ring starts —
|
||||
// only treat "empty" as hung-up after it has had a moment to sync.
|
||||
const settle = setTimeout(checkMemberships, 5000);
|
||||
return () => {
|
||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||
mx.removeListener(RoomEvent.Timeline, onTimeline);
|
||||
clearTimeout(settle);
|
||||
};
|
||||
}, [mx, callInfo]);
|
||||
|
||||
const handleReject = useCallback(
|
||||
(room: Room, eventId: string) => {
|
||||
// Best-effort: the local UI dismisses regardless (below), but a failed
|
||||
@@ -578,16 +676,26 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
async (room: Room, video: boolean) => {
|
||||
setCallInfo(undefined);
|
||||
// [Gitea #195] Answering from the in-call banner is the one call-to-call
|
||||
// switch path. Disposing the current embed alone leaves our ghost
|
||||
// `m.call.member` in the old room for ~17 s (until the delayed leave
|
||||
// expires) — hang up explicitly and wait for the membership to clear.
|
||||
if (callEmbed?.joined) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
// Dispose it ourselves now: its HangupCall echo would otherwise land
|
||||
// after startCall() and clear the NEW embed from the atom.
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
|
||||
// forcing camera+mic+sound on — every other join path does this, and
|
||||
// Answer was skipping it, publishing the camera with no prescreen.
|
||||
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
|
||||
startCall(room, { microphone, video: cameraOnJoin && video, sound });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
|
||||
[startCall, navigateRoom, microphone, sound, cameraOnJoin, callEmbed, mx, setCallEmbed],
|
||||
);
|
||||
|
||||
if (!callInfo) return null;
|
||||
@@ -619,6 +727,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
|
||||
function CallAnnouncementRegion() {
|
||||
const announcement = useAtomValue(callAnnouncementAtom);
|
||||
return (
|
||||
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
@@ -628,8 +746,13 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
// the mobile in-call chat. Both are gated on `joined`.
|
||||
useCallHotkeys(embed, joined);
|
||||
useAfkAutoMute(joined ? embed : undefined);
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
usePttHaptics();
|
||||
useCallAnnouncements(embed, joined);
|
||||
useMutedTalkWarning(embed, joined);
|
||||
useCallThemeSync(embed);
|
||||
useCallQuality(embed);
|
||||
useCallHangupEvent(
|
||||
@@ -724,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>;
|
||||
@@ -1173,8 +1302,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||
<CallAnnouncementRegion />
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
<CallRejoin callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
</CallEmbedRefContextProvider>
|
||||
<div
|
||||
@@ -1186,7 +1317,15 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
...(callVisible && !pipMode ? wallpaperStyle : {}),
|
||||
// [Gitea #224] The fork renders EC transparent (lotusTransparent) so the
|
||||
// wallpaper shows through the full-size embed; in PiP the thing behind
|
||||
// the frame is whatever room you navigated to, so give it an opaque
|
||||
// surface instead of letting the timeline bleed through the tiles.
|
||||
...(pipMode
|
||||
? { background: color.Surface.Container }
|
||||
: callVisible
|
||||
? wallpaperStyle
|
||||
: {}),
|
||||
}}
|
||||
ref={callEmbedRef}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useSaveFile } from '../hooks/useSaveFile';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { PasswordInput } from './password-input';
|
||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||
import { copyToClipboard } from '../utils/dom';
|
||||
import { useSensitiveCopy } from '../hooks/useSensitiveCopy';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||
@@ -232,9 +232,8 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||
const [show, setShow] = useState(false);
|
||||
const saveFile = useSaveFile();
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(recoveryKey);
|
||||
};
|
||||
// [Gitea #156] The key leaves the clipboard again after 60 s, visibly.
|
||||
const { copy: handleCopy, secondsLeft } = useSensitiveCopy(recoveryKey);
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([recoveryKey], {
|
||||
@@ -272,8 +271,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button onClick={handleCopy}>
|
||||
<Text size="B400">Copy</Text>
|
||||
<Button onClick={handleCopy} aria-live="polite">
|
||||
<Text size="B400">
|
||||
{secondsLeft !== null ? `Copied · clears in ${secondsLeft} s` : 'Copy'}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button onClick={handleDownload} fill="Soft">
|
||||
<Text size="B400">Download</Text>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { TapToSendBar } from './tap-to-send/TapToSendBar';
|
||||
import { useRecentTouch } from '../hooks/useRecentTouch';
|
||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
|
||||
const sendGif = useCallback(
|
||||
(gif: RecentGif) => {
|
||||
setRecents((prev) => addRecentGif(prev, gif));
|
||||
const { url, width, height, previewUrl } = gif;
|
||||
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
|
||||
onSelect(gif.url, gif.width, gif.height);
|
||||
requestClose();
|
||||
},
|
||||
[onSelect, requestClose, setRecents],
|
||||
);
|
||||
|
||||
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
|
||||
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { wasTouch } = useRecentTouch(containerRef);
|
||||
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
|
||||
const pick = useCallback(
|
||||
(gif: RecentGif & { title?: string }) => {
|
||||
if (wasTouch() && pending?.url !== gif.url) {
|
||||
setPending(gif);
|
||||
return;
|
||||
}
|
||||
setPending(undefined);
|
||||
sendGif(gif);
|
||||
},
|
||||
[wasTouch, pending, sendGif],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(gif: IGif, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
gif.images.fixed_width_small_still?.url ??
|
||||
gif.images.downsized_still?.url ??
|
||||
gif.images.original_still?.url;
|
||||
sendGif({
|
||||
pick({
|
||||
url: r.url,
|
||||
width: Number(r.width) || 200,
|
||||
height: Number(r.height) || 200,
|
||||
previewUrl,
|
||||
title: gif.title,
|
||||
});
|
||||
},
|
||||
[sendGif],
|
||||
[pick],
|
||||
);
|
||||
|
||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||
@@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||
// narrower than 312px with a fixed 296px grid.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||
useElementSizeObserver(
|
||||
useCallback(() => containerRef.current, []),
|
||||
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<SearchBar />
|
||||
</div>
|
||||
</Box>
|
||||
{pending && (
|
||||
<TapToSendBar
|
||||
previewUrl={pending.previewUrl ?? pending.url}
|
||||
label={pending.title || 'GIF'}
|
||||
onSend={() => {
|
||||
setPending(undefined);
|
||||
sendGif(pending);
|
||||
}}
|
||||
onCancel={() => setPending(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||
>
|
||||
{showRecents && (
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
|
||||
)}
|
||||
<Grid
|
||||
key={searchKey}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { logoutClient } from '../../client/initMatrix';
|
||||
import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
@@ -11,6 +14,11 @@ import {
|
||||
VerificationStatus,
|
||||
} from '../hooks/useDeviceVerificationStatus';
|
||||
|
||||
/**
|
||||
* Ask Element Call to hang up and wait (bounded) until our own MatrixRTC
|
||||
* membership has actually been removed from the room, so the leave reaches
|
||||
* the homeserver before the client is stopped and the token is revoked.
|
||||
*/
|
||||
type LogoutDialogProps = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
@@ -26,10 +34,19 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
mx.getDeviceId() ?? undefined,
|
||||
);
|
||||
|
||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||
// everyone else sees you "in call" until it times out.
|
||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
await logoutClient(mx);
|
||||
}, [mx]),
|
||||
}, [mx, callEmbed, setCallEmbed]),
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { config, Text } from 'folds';
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
VerificationRequestContent,
|
||||
VideoContent,
|
||||
} from './message';
|
||||
import { DiffToken } from '../utils/wordDiff';
|
||||
import { EditDiffContext } from './message/content/EditDiffContext';
|
||||
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
|
||||
import { Image, MediaControl, Video } from './media';
|
||||
import { ImageViewer } from './image-viewer';
|
||||
@@ -70,8 +72,21 @@ type RenderMessageContentProps = {
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
eventId?: string;
|
||||
/** [Gitea #219] Open the room's shared media lightbox at this event. */
|
||||
onOpenImageViewer?: () => void;
|
||||
/** [Gitea #159] The event, for the undecryptable placeholder's reason + retry. */
|
||||
mEvent?: MatrixEvent;
|
||||
/** [Gitea #144] Word diff of the last edit, shown on "(edited)" hover. */
|
||||
editDiff?: DiffToken[];
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
export function RenderMessageContent({ editDiff, ...props }: RenderMessageContentProps) {
|
||||
return (
|
||||
<EditDiffContext.Provider value={editDiff}>
|
||||
<RenderMessageContentBody {...props} />
|
||||
</EditDiffContext.Provider>
|
||||
);
|
||||
}
|
||||
function RenderMessageContentBody({
|
||||
displayName,
|
||||
msgType,
|
||||
ts,
|
||||
@@ -85,7 +100,9 @@ export function RenderMessageContent({
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
onOpenImageViewer,
|
||||
mEvent,
|
||||
}: Omit<RenderMessageContentProps, 'editDiff'>) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||
@@ -241,6 +258,7 @@ export function RenderMessageContent({
|
||||
autoPlay={mediaAutoLoad}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
onOpenViewer={onOpenImageViewer}
|
||||
/>
|
||||
)}
|
||||
outlined={outlineAttachment}
|
||||
@@ -342,7 +360,7 @@ export function RenderMessageContent({
|
||||
}
|
||||
|
||||
if (msgType === 'm.bad.encrypted') {
|
||||
return <MBadEncrypted />;
|
||||
return <MBadEncrypted mEvent={mEvent} />;
|
||||
}
|
||||
|
||||
if (msgType === 'm.key.verification.request') {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Box, Overlay, OverlayBackdrop, config, color, toRem } from 'folds';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
type ActionSheetProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
'aria-label'?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #166] A bottom sheet for touch screens: slides up from the bottom,
|
||||
* swipe-down or backdrop tap dismisses. Content is whatever the caller
|
||||
* renders (quick reactions + a folds Menu, for message actions).
|
||||
*/
|
||||
export function ActionSheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
}: ActionSheetProps) {
|
||||
const [dragY, setDragY] = useState(0);
|
||||
const startY = useRef<number | null>(null);
|
||||
// The long-press that opened us ends with a touchend/click that lands
|
||||
// "outside" the sheet — ignore outside clicks for the first moments.
|
||||
const openedAt = useRef(Date.now());
|
||||
|
||||
const onTouchStart = useCallback((evt: React.TouchEvent) => {
|
||||
startY.current = evt.touches[0]?.clientY ?? null;
|
||||
}, []);
|
||||
const onTouchMove = useCallback((evt: React.TouchEvent) => {
|
||||
if (startY.current === null) return;
|
||||
const dy = (evt.touches[0]?.clientY ?? startY.current) - startY.current;
|
||||
setDragY(Math.max(0, dy));
|
||||
}, []);
|
||||
const onTouchEnd = useCallback(() => {
|
||||
const dismiss = dragY > 80;
|
||||
startY.current = null;
|
||||
setDragY(0);
|
||||
if (dismiss) onClose();
|
||||
}, [dragY, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: () => Date.now() - openedAt.current > 600,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label={ariaLabel}
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '75vh',
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
borderTopLeftRadius: toRem(16),
|
||||
borderTopRightRadius: toRem(16),
|
||||
boxShadow: '0 -8px 32px rgba(0,0,0,0.35)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
transform: dragY ? `translateY(${dragY}px)` : undefined,
|
||||
transition: dragY ? undefined : 'transform 120ms ease-out',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchEnd}
|
||||
>
|
||||
<Box justifyContent="Center" shrink="No" style={{ padding: config.space.S200 }}>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: toRem(36),
|
||||
height: toRem(4),
|
||||
borderRadius: toRem(2),
|
||||
background: color.Surface.ContainerLine,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" style={{ overflowY: 'auto' }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ActionSheet';
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
|
||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRecentTouch } from '../../hooks/useRecentTouch';
|
||||
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
|
||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||
import {
|
||||
@@ -52,7 +54,7 @@ import {
|
||||
EmojiGroup,
|
||||
EmojiBoardLayout,
|
||||
} from './components';
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
@@ -518,10 +520,27 @@ export function EmojiBoard({
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
|
||||
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
|
||||
const { wasTouch } = useRecentTouch(contentScrollRef);
|
||||
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
|
||||
const stickerUseAuthentication = useMediaAuthentication();
|
||||
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
|
||||
onStickerSelect?.(info.data, info.shortcode, info.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
|
||||
);
|
||||
setPendingSticker(undefined);
|
||||
if (close) requestClose();
|
||||
};
|
||||
|
||||
const handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||
if (!emojiInfo) return;
|
||||
if (!emojiInfo) {
|
||||
if (pendingSticker) setPendingSticker(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (emojiInfo.type === EmojiType.Emoji) {
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
@@ -533,14 +552,12 @@ export function EmojiBoard({
|
||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, {
|
||||
url: emojiInfo.data,
|
||||
shortcode: emojiInfo.shortcode,
|
||||
body: emojiInfo.label,
|
||||
}),
|
||||
);
|
||||
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
|
||||
setPendingSticker(emojiInfo);
|
||||
return;
|
||||
}
|
||||
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
};
|
||||
@@ -670,7 +687,18 @@ export function EmojiBoard({
|
||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||
</EmojiGroupHolder>
|
||||
</Box>
|
||||
<Preview previewAtom={previewAtom} />
|
||||
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
|
||||
<TapToSendBar
|
||||
previewUrl={
|
||||
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
|
||||
}
|
||||
label={pendingSticker.label}
|
||||
onSend={() => sendSticker(pendingSticker, true)}
|
||||
onCancel={() => setPendingSticker(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Preview previewAtom={previewAtom} />
|
||||
)}
|
||||
</EmojiBoardLayout>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
@@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
|
||||
import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
function formatReadTs(ts: number, hour24Clock: boolean): string {
|
||||
const timeStr = timeHourMinute(ts, hour24Clock);
|
||||
if (today(ts)) return `Today at ${timeStr}`;
|
||||
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
|
||||
const sameYear = timeYear(ts) === timeYear(Date.now());
|
||||
return sameYear
|
||||
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
|
||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
||||
}
|
||||
const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs);
|
||||
|
||||
type EventReaderItemProps = {
|
||||
room: Room;
|
||||
readerId: string;
|
||||
hour24Clock: boolean;
|
||||
prefs: TimestampPrefs;
|
||||
lotusTerminal: boolean;
|
||||
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function EventReaderItem({
|
||||
room,
|
||||
readerId,
|
||||
hour24Clock,
|
||||
lotusTerminal,
|
||||
onSelect,
|
||||
}: EventReaderItemProps) {
|
||||
function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
@@ -86,7 +73,7 @@ function EventReaderItem({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
{formatReadTs(receiptTs, prefs)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
return (
|
||||
@@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
key={readerId}
|
||||
room={room}
|
||||
readerId={readerId}
|
||||
hour24Clock={hour24Clock}
|
||||
prefs={prefs}
|
||||
lotusTerminal={lotusTerminal}
|
||||
onSelect={(event) => {
|
||||
openProfile(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
@@ -27,12 +27,37 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
saveFile(fileContent, alt);
|
||||
};
|
||||
|
||||
// [Gitea #164] Same keyboard model as the gallery lightbox: + / = zoom
|
||||
// in, - zoom out, 0 reset; double-click toggles 1× ↔ 2×. The root takes
|
||||
// focus on open so those keys (and Escape, handled by the modal) work
|
||||
// immediately instead of only after clicking inside the viewer.
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
rootRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
const handleKeyDown = (evt: React.KeyboardEvent) => {
|
||||
if (evt.key === '+' || evt.key === '=') zoomIn();
|
||||
else if (evt.key === '-') zoomOut();
|
||||
else if (evt.key === '0') setZoom(1);
|
||||
else return;
|
||||
evt.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ImageViewer, className)}
|
||||
direction="Column"
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label={alt || 'Image viewer'}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
ref={ref}
|
||||
ref={(node: HTMLDivElement | null) => {
|
||||
rootRef.current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}}
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
@@ -116,6 +141,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
alt={alt}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onDoubleClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import { Box, Icon, Icons, Text, as, toRem } from 'folds';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './Reply.css';
|
||||
import { ForwardedMeta } from '../../features/room/message/forwardContent';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
type ForwardedHeaderProps = {
|
||||
mx: MatrixClient;
|
||||
meta: ForwardedMeta;
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
/** Present when the viewer can open the original (they are in the source room). */
|
||||
onJump?: MouseEventHandler;
|
||||
};
|
||||
|
||||
/**
|
||||
* "↪ Forwarded · from <sender> in <room> · <time>" — the provenance line above
|
||||
* a forwarded message (Gitea: forwards used to look like the forwarder's own
|
||||
* words). Renders in the same visual slot and style as a reply quote so it
|
||||
* reads as message context, not as content.
|
||||
*/
|
||||
export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
|
||||
({ mx, meta, hour24Clock, dateFormatString, onJump, className, ...props }, ref) => {
|
||||
const sourceRoom = meta.room_id ? mx.getRoom(meta.room_id) : null;
|
||||
const senderName =
|
||||
(sourceRoom && getMemberDisplayName(sourceRoom, meta.sender)) ??
|
||||
mx.getUser(meta.sender)?.displayName ??
|
||||
getMxIdLocalPart(meta.sender) ??
|
||||
meta.sender;
|
||||
const ts = meta.origin_server_ts;
|
||||
const when = formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
const canJump = !!sourceRoom && !!onJump;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={canJump ? 'button' : 'div'}
|
||||
className={classNames(css.Reply, className)}
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
onClick={canJump ? onJump : undefined}
|
||||
title={canJump ? 'Jump to the original message' : undefined}
|
||||
aria-label={`Forwarded from ${senderName}${sourceRoom ? ` in ${sourceRoom.name}` : ''}, ${when}`}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Box alignItems="Center" gap="100" shrink="No" style={{ maxWidth: toRem(420) }}>
|
||||
<Icon size="100" src={Icons.ArrowGoRight} />
|
||||
<Text size="T300" priority="300" truncate>
|
||||
Forwarded from <b>{senderName}</b>
|
||||
{sourceRoom ? (
|
||||
<>
|
||||
{' in '}
|
||||
<b>{sourceRoom.name}</b>
|
||||
</>
|
||||
) : null}
|
||||
{' · '}
|
||||
{when}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { IContent, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { describeDecryptionFailure } from '../../utils/decryptionReason';
|
||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||
import { trimReplyFromBody } from '../../utils/room';
|
||||
import { MessageTextBody } from './layout';
|
||||
@@ -42,14 +46,31 @@ type CollapsibleBodyProps = {
|
||||
eventId?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
// [Gitea #152] Messages the user expanded this session. The timeline unmounts
|
||||
// rows as they scroll away, so component state alone forgot the choice;
|
||||
// module-scoped (never persisted) survives remounts and clears on reload.
|
||||
const expandedMessages = new Set<string>();
|
||||
|
||||
function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [needsCollapse, setNeedsCollapse] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [collapsed, setCollapsedState] = useState(
|
||||
() => !(eventId && expandedMessages.has(eventId)),
|
||||
);
|
||||
const setCollapsed = (next: boolean | ((c: boolean) => boolean)) => {
|
||||
setCollapsedState((c) => {
|
||||
const value = typeof next === 'function' ? next(c) : next;
|
||||
if (eventId) {
|
||||
if (value) expandedMessages.delete(eventId);
|
||||
else expandedMessages.add(eventId);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
};
|
||||
|
||||
// Reset collapsed state when the event changes (new message)
|
||||
useEffect(() => {
|
||||
setCollapsed(true);
|
||||
setCollapsedState(!(eventId && expandedMessages.has(eventId)));
|
||||
setNeedsCollapse(false);
|
||||
}, [eventId]);
|
||||
|
||||
@@ -120,11 +141,59 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MBadEncrypted() {
|
||||
type MBadEncryptedProps = {
|
||||
mEvent?: MatrixEvent;
|
||||
};
|
||||
/**
|
||||
* [Gitea #159] The undecryptable placeholder says WHY and offers the one
|
||||
* action that fixes it (set up key backup / verify this session / retry).
|
||||
* The raw SDK code stays in the tooltip for support.
|
||||
*/
|
||||
export function MBadEncrypted({ mEvent }: MBadEncryptedProps) {
|
||||
const mx = useMatrixClient();
|
||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const code = mEvent?.decryptionFailureReason ?? null;
|
||||
const reason = describeDecryptionFailure(code);
|
||||
|
||||
const handleAction = async () => {
|
||||
if (reason.action === 'setup-backup' || reason.action === 'verify-session') {
|
||||
requestSettings('devices');
|
||||
return;
|
||||
}
|
||||
if (reason.action === 'retry' && mEvent) {
|
||||
setRetrying(true);
|
||||
try {
|
||||
await mx.decryptEventIfNeeded(mEvent, { forceRedecryptIfUntrusted: true });
|
||||
} catch {
|
||||
// still undecryptable — the placeholder re-renders with the current reason
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<MessageBadEncryptedContent />
|
||||
</Text>
|
||||
<Box direction="Column" gap="100" alignItems="Start">
|
||||
<Text>
|
||||
<MessageBadEncryptedContent title={code ?? undefined} />
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{reason.text}
|
||||
</Text>
|
||||
{reason.action !== 'none' && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleAction}
|
||||
disabled={retrying}
|
||||
>
|
||||
<Text size="B300">{retrying ? 'Retrying…' : reason.actionLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
export type TimeProps = {
|
||||
compact?: boolean;
|
||||
@@ -12,8 +12,8 @@ export type TimeProps = {
|
||||
/**
|
||||
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||
*
|
||||
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||
* For older messages, it shows the date and time.
|
||||
* `compact` always shows the clock time; otherwise the shared `formatTimestamp`
|
||||
* rules apply (today → time, yesterday/this week → day word + time, else date + time).
|
||||
*
|
||||
* @param {number} ts - The timestamp to display.
|
||||
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||
@@ -23,18 +23,7 @@ export type TimeProps = {
|
||||
*/
|
||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||
|
||||
let time = '';
|
||||
if (compact) {
|
||||
time = formattedTime;
|
||||
} else if (today(ts)) {
|
||||
time = formattedTime;
|
||||
} else if (yesterday(ts)) {
|
||||
time = `Yesterday ${formattedTime}`;
|
||||
} else {
|
||||
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||
}
|
||||
const time = formatTimestamp(ts, { hour24Clock, dateFormatString }, compact ? 'time' : 'auto');
|
||||
|
||||
return (
|
||||
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { DiffToken } from '../../../utils/wordDiff';
|
||||
|
||||
/** [Gitea #144] Word diff of the message's last edit, for the "(edited)" hover. */
|
||||
export const EditDiffContext = createContext<DiffToken[] | undefined>(undefined);
|
||||
export const useEditDiff = () => useContext(EditDiffContext);
|
||||
@@ -1,5 +1,20 @@
|
||||
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
Icons,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLongPress } from '../../../hooks/useLongPress';
|
||||
import { DiffToken, countChanges } from '../../../utils/wordDiff';
|
||||
import { useEditDiff } from './EditDiffContext';
|
||||
|
||||
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
|
||||
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
|
||||
@@ -66,29 +81,157 @@ export const MessageVerificationRequestContent = as<'div', { children?: never }>
|
||||
),
|
||||
);
|
||||
|
||||
const editDiffStyle: React.CSSProperties = {
|
||||
maxWidth: '40ch',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
maxHeight: '12lh',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
/** The last edit as a word diff: removed words struck, added words bold. */
|
||||
export function EditDiffBody({ tokens, hint }: { tokens: DiffToken[]; hint: string }) {
|
||||
const { added, removed } = countChanges(tokens);
|
||||
return (
|
||||
<Box direction="Column" gap="100" style={editDiffStyle}>
|
||||
<Text size="T200" priority="300">
|
||||
Last edit
|
||||
{added > 0 && ` · +${added}`}
|
||||
{removed > 0 && ` · −${removed}`}
|
||||
</Text>
|
||||
<Text as="span" size="T300" dir="auto">
|
||||
{tokens.map((t, i) => {
|
||||
if (t.op === 'add')
|
||||
return (
|
||||
<ins key={i} style={{ textDecoration: 'none', fontWeight: 'bold' }}>
|
||||
{t.text}
|
||||
</ins>
|
||||
);
|
||||
if (t.op === 'del')
|
||||
return (
|
||||
<del key={i} style={{ opacity: config.opacity.P300 }}>
|
||||
{t.text}
|
||||
</del>
|
||||
);
|
||||
return <React.Fragment key={i}>{t.text}</React.Fragment>;
|
||||
})}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{hint}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const editedButtonStyle: React.CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* "(edited)" label. With an edit-history handler it is a button that opens
|
||||
* the viewer; when the surrounding message also provides a last-edit diff
|
||||
* (see `EditDiffContext`) hover/focus shows it as a tooltip and a touch
|
||||
* long-press shows it as a popout. [Gitea #144]
|
||||
*/
|
||||
export const MessageEditedContent = as<
|
||||
'span',
|
||||
{ children?: never; onEditHistoryClick?: () => void }
|
||||
>(({ onEditHistoryClick, ...props }, ref) =>
|
||||
onEditHistoryClick ? (
|
||||
>(({ onEditHistoryClick, ...props }, ref) => {
|
||||
const diff = useEditDiff();
|
||||
const [pressAnchor, setPressAnchor] = useState<RectCords | undefined>();
|
||||
const { coarse, handlers, suppressContextMenu } = useLongPress((x, y) => {
|
||||
if (diff) setPressAnchor({ x, y, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!pressAnchor) return undefined;
|
||||
const close = () => setPressAnchor(undefined);
|
||||
const t = window.setTimeout(close, 6000);
|
||||
window.addEventListener('touchstart', close, { passive: true });
|
||||
window.addEventListener('scroll', close, { passive: true, capture: true });
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
window.removeEventListener('touchstart', close);
|
||||
window.removeEventListener('scroll', close, { capture: true });
|
||||
};
|
||||
}, [pressAnchor]);
|
||||
|
||||
if (!onEditHistoryClick) {
|
||||
return (
|
||||
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const button = (tipRef?: React.RefCallback<HTMLElement>) => (
|
||||
<button
|
||||
ref={tipRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (suppressContextMenu.current) return;
|
||||
onEditHistoryClick();
|
||||
}}
|
||||
onContextMenu={(evt) => {
|
||||
if (suppressContextMenu.current) evt.preventDefault();
|
||||
}}
|
||||
{...handlers}
|
||||
// Keep the message's own long-press (action sheet) from firing too.
|
||||
onTouchStart={(evt) => {
|
||||
evt.stopPropagation();
|
||||
handlers.onTouchStart?.(evt);
|
||||
}}
|
||||
aria-label={diff ? 'View edit history — hover for the last change' : 'View edit history'}
|
||||
style={editedButtonStyle}
|
||||
>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditHistoryClick}
|
||||
aria-label="View edit history"
|
||||
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
</button>
|
||||
{diff ? (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
align="Start"
|
||||
delay={300}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<EditDiffBody
|
||||
tokens={diff}
|
||||
hint={coarse ? 'Tap for full history' : 'Click for full history'}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(tipRef) => button(tipRef)}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
button()
|
||||
)}
|
||||
{diff && pressAnchor && (
|
||||
<PopOut
|
||||
anchor={pressAnchor}
|
||||
position="Top"
|
||||
align="Start"
|
||||
content={
|
||||
<Tooltip>
|
||||
<EditDiffBody
|
||||
tokens={diff}
|
||||
hint={coarse ? 'Tap for full history' : 'Click for full history'}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
||||
{' (edited)'}
|
||||
</Text>
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,9 @@ export type ImageContentProps = {
|
||||
spoilerReason?: string;
|
||||
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||
renderImage: (props: RenderImageProps) => ReactNode;
|
||||
// [Gitea #219] When given, a click opens THIS instead of the built-in
|
||||
// viewer — the room timeline hands it to the shared media lightbox.
|
||||
onOpenViewer?: () => void;
|
||||
};
|
||||
export const ImageContent = as<'div', ImageContentProps>(
|
||||
(
|
||||
@@ -74,6 +77,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
spoilerReason,
|
||||
renderViewer,
|
||||
renderImage,
|
||||
onOpenViewer,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -139,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,
|
||||
@@ -182,7 +186,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
src: srcState.data,
|
||||
onLoad: handleLoad,
|
||||
onError: handleError,
|
||||
onClick: () => setViewer(true),
|
||||
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
|
||||
tabIndex: 0,
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './Time';
|
||||
export * from './MsgTypeRenderers';
|
||||
export * from './FileHeader';
|
||||
export * from './RenderBody';
|
||||
export * from './ForwardedHeader';
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -69,7 +69,7 @@ const NavItemBase = style({
|
||||
[`&:has(.${NavLink}:active)`]: {
|
||||
backgroundColor: ContainerActive,
|
||||
},
|
||||
'&[aria-selected=true]': {
|
||||
'&[data-selected=true]': {
|
||||
backgroundColor: ContainerActive,
|
||||
},
|
||||
[`&:has(.${NavLink}:focus-visible)`]: {
|
||||
|
||||
@@ -19,15 +19,13 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { InviteUserPrompt } from '../invite-user-prompt';
|
||||
import { RoomTopicViewer } from '../room-topic-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
@@ -68,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]),
|
||||
);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
@@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<Text size="T200" priority="300">
|
||||
{'Created by '}
|
||||
<b>@{creatorName}</b>
|
||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||
{` on ${format(ts, 'dateTime')}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,21 +1,49 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { zIndices } from '../../styles/zIndex';
|
||||
import { SeasonTheme } from './types';
|
||||
import { resolveSeasonTheme } from './seasonSchedule';
|
||||
import { HalloweenOverlay } from './themes/Halloween';
|
||||
import { ChristmasOverlay } from './themes/Christmas';
|
||||
import { NewYearOverlay } from './themes/NewYear';
|
||||
import { AutumnOverlay } from './themes/Autumn';
|
||||
import { AprilFoolsOverlay } from './themes/AprilFools';
|
||||
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
||||
import { ValentinesOverlay } from './themes/Valentines';
|
||||
import { StPatricksOverlay } from './themes/StPatricks';
|
||||
import { EarthDayOverlay } from './themes/EarthDay';
|
||||
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
||||
import { ArcadeOverlay } from './themes/Arcade';
|
||||
|
||||
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
|
||||
// fetched only when that season is active or previewed in Settings — the
|
||||
// date/override decision stays in the main bundle.
|
||||
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<{ reduced: boolean }> }>) =>
|
||||
React.lazy(load);
|
||||
const HalloweenOverlay = lazyOverlay(() =>
|
||||
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
|
||||
);
|
||||
const ChristmasOverlay = lazyOverlay(() =>
|
||||
import('./themes/Christmas').then((m) => ({ default: m.ChristmasOverlay })),
|
||||
);
|
||||
const NewYearOverlay = lazyOverlay(() =>
|
||||
import('./themes/NewYear').then((m) => ({ default: m.NewYearOverlay })),
|
||||
);
|
||||
const AutumnOverlay = lazyOverlay(() =>
|
||||
import('./themes/Autumn').then((m) => ({ default: m.AutumnOverlay })),
|
||||
);
|
||||
const AprilFoolsOverlay = lazyOverlay(() =>
|
||||
import('./themes/AprilFools').then((m) => ({ default: m.AprilFoolsOverlay })),
|
||||
);
|
||||
const LunarNewYearOverlay = lazyOverlay(() =>
|
||||
import('./themes/LunarNewYear').then((m) => ({ default: m.LunarNewYearOverlay })),
|
||||
);
|
||||
const ValentinesOverlay = lazyOverlay(() =>
|
||||
import('./themes/Valentines').then((m) => ({ default: m.ValentinesOverlay })),
|
||||
);
|
||||
const StPatricksOverlay = lazyOverlay(() =>
|
||||
import('./themes/StPatricks').then((m) => ({ default: m.StPatricksOverlay })),
|
||||
);
|
||||
const EarthDayOverlay = lazyOverlay(() =>
|
||||
import('./themes/EarthDay').then((m) => ({ default: m.EarthDayOverlay })),
|
||||
);
|
||||
const DeepSpaceOverlay = lazyOverlay(() =>
|
||||
import('./themes/DeepSpace').then((m) => ({ default: m.DeepSpaceOverlay })),
|
||||
);
|
||||
const ArcadeOverlay = lazyOverlay(() =>
|
||||
import('./themes/Arcade').then((m) => ({ default: m.ArcadeOverlay })),
|
||||
);
|
||||
|
||||
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
||||
// of truth, shared with the settings UI). Re-exported here for existing
|
||||
@@ -69,7 +97,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{buildOverlayContent(theme, reduced)}
|
||||
<Suspense fallback={null}>{buildOverlayContent(theme, reduced)}</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +122,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
containerType: 'inline-size',
|
||||
}}
|
||||
>
|
||||
{buildOverlayContent(theme, true)}
|
||||
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { MouseEvent, ReactNode, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LinkSafety } from '../../utils/linkSafety';
|
||||
|
||||
type SuspiciousLinkProps = {
|
||||
href: string;
|
||||
safety: LinkSafety;
|
||||
anchorProps: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #122] A link whose visible text names a different site than its
|
||||
* destination (or whose destination is a punycode host) opens a small
|
||||
* confirm instead of navigating straight away. Honest links never see this.
|
||||
*/
|
||||
export function SuspiciousLink({ href, safety, anchorProps, children }: SuspiciousLinkProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setOpen(true);
|
||||
};
|
||||
const proceed = () => {
|
||||
setOpen(false);
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
{...anchorProps}
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
data-lotus-suspicious-link
|
||||
title={`Goes to ${safety.realHost}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{open && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={{ maxWidth: 440 }}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4">
|
||||
This link doesn't go where it says
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="300"
|
||||
onClick={() => setOpen(false)}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text>
|
||||
{safety.shownHost ? (
|
||||
<>
|
||||
It shows <b>{safety.shownHost}</b> but goes to{' '}
|
||||
<b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
It goes to <b style={{ color: color.Critical.Main }}>{safety.realHost}</b>.
|
||||
</>
|
||||
)}
|
||||
{safety.punycode &&
|
||||
' The destination uses look-alike (internationalised) characters in its name.'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-all' }}>
|
||||
{href}
|
||||
</Text>
|
||||
<Box gap="200" justifyContent="End">
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => setOpen(false)}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button variant="Critical" onClick={proceed}>
|
||||
<Text size="B400">Open anyway</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SuspiciousLink';
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||
|
||||
type TapToSendBarProps = {
|
||||
previewUrl?: string;
|
||||
label: string;
|
||||
onSend: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #147] Fat-finger guard for touch screens: the first tap on a sticker
|
||||
* or GIF parks it here with a preview; "Send" (or a second tap on the same
|
||||
* item) sends it.
|
||||
*/
|
||||
export function TapToSendBar({ previewUrl, label, onSend, onCancel }: TapToSendBarProps) {
|
||||
const liveRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
liveRef.current?.focus?.();
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={liveRef}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
tabIndex={-1}
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{
|
||||
margin: `0 ${config.space.S300} ${config.space.S200}`,
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: toRem(40),
|
||||
height: toRem(40),
|
||||
objectFit: 'contain',
|
||||
borderRadius: config.radii.R300,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||
<Text size="T200" priority="300">
|
||||
Tap again to send
|
||||
</Text>
|
||||
<Text size="T300" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button size="300" variant="Primary" fill="Solid" radii="300" onClick={onSend}>
|
||||
<Text size="B300">Send</Text>
|
||||
</Button>
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
onClick={onCancel}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { describeUploadError } from '../../utils/uploadError';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
@@ -91,7 +92,7 @@ export function CompactUploadCardRenderer({
|
||||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { describeUploadError } from '../../utils/uploadError';
|
||||
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
|
||||
|
||||
type PreviewImageProps = {
|
||||
@@ -282,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,
|
||||
@@ -289,6 +296,9 @@ export function UploadCardRenderer({
|
||||
setMetadata,
|
||||
onRemove,
|
||||
onComplete,
|
||||
autoFocusCaption,
|
||||
onCaptionSubmit,
|
||||
onCaptionEscape,
|
||||
}: UploadCardRendererProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mediaConfig = useMediaConfig();
|
||||
@@ -378,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} />
|
||||
)}
|
||||
@@ -389,7 +417,7 @@ export function UploadCardRenderer({
|
||||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<Text size="T200">{describeUploadError(upload.error, allowSize)}</Text>
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||
import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { describeLastActive } from '../../utils/lastActive';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
@@ -87,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}
|
||||
@@ -110,6 +111,8 @@ type UserHeroNameProps = {
|
||||
status?: string;
|
||||
pronouns?: string;
|
||||
timezone?: string;
|
||||
/** [Gitea #150] Presence for the "Active now / Last active …" line. */
|
||||
presence?: UserPresence;
|
||||
};
|
||||
export function UserHeroName({
|
||||
displayName,
|
||||
@@ -117,7 +120,9 @@ export function UserHeroName({
|
||||
status,
|
||||
pronouns,
|
||||
timezone,
|
||||
presence,
|
||||
}: UserHeroNameProps) {
|
||||
const lastActive = describeLastActive(presence);
|
||||
const username = getMxIdLocalPart(userId);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const localTimeInfo = useLocalTime(timezone, !hour24Clock);
|
||||
@@ -158,6 +163,13 @@ export function UserHeroName({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{lastActive && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '1px', overflow: 'hidden' }}>
|
||||
<Text size="T200" style={{ opacity: 0.6 }}>
|
||||
{lastActive}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{status && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: '2px', overflow: 'hidden' }}>
|
||||
<Text
|
||||
|
||||
@@ -6,9 +6,7 @@ import { SettingTile } from '../setting-tile';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
type UserKickAlertProps = {
|
||||
reason?: string;
|
||||
@@ -16,11 +14,8 @@ type UserKickAlertProps = {
|
||||
ts?: number;
|
||||
};
|
||||
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
return (
|
||||
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||
@@ -28,11 +23,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Kicked User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{kickedBy && (
|
||||
@@ -66,11 +57,8 @@ type UserBanAlertProps = {
|
||||
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -86,11 +74,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Banned User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{bannedBy && (
|
||||
@@ -141,11 +125,8 @@ type UserInviteAlertProps = {
|
||||
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -161,11 +142,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Invited User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{invitedBy && (
|
||||
|
||||
@@ -371,6 +371,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
displayName={displayName}
|
||||
userId={userId}
|
||||
status={presence?.status}
|
||||
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
|
||||
pronouns={extProfile.pronouns}
|
||||
timezone={extProfile.timezone}
|
||||
/>
|
||||
|
||||
@@ -35,19 +35,8 @@ import { nameInitials } from '../../utils/common';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './BookmarksPanel.css';
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
||||
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
||||
@@ -110,7 +99,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
: undefined;
|
||||
// Prefer a live-resolved author name, then the stored snapshot.
|
||||
const author = senderName ?? bookmark.senderName;
|
||||
const timeAgo = formatTimeAgo(bookmark.savedAt);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const timeAgo = formatRelativeAge(bookmark.savedAt, prefs);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Spinner } from 'folds';
|
||||
import { Box, Spinner, Text, color } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import classNames from 'classnames';
|
||||
import { LiveChip } from './LiveChip';
|
||||
import * as css from './styles.css';
|
||||
@@ -14,6 +15,8 @@ import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
||||
import { MemberSpeaking } from './MemberSpeaking';
|
||||
import { clockSkewAtom } from '../../state/clockSkew';
|
||||
import { describeSkewVsServer } from '../../utils/clockSkew';
|
||||
|
||||
type CallStatusProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -26,6 +29,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
const screenSize = useScreenSize();
|
||||
const callJoined = useCallJoined(callEmbed);
|
||||
const speakers = useCallSpeakers(callEmbed);
|
||||
// [Gitea #158] Same warning as the top banner, where the user is looking during a call.
|
||||
const clockSkew = useAtomValue(clockSkewAtom);
|
||||
|
||||
const compact = screenSize === ScreenSize.Mobile;
|
||||
|
||||
@@ -51,6 +56,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
{!compact && (
|
||||
<>
|
||||
<CallRoomName room={room} />
|
||||
{clockSkew.warning && clockSkew.skewMs !== null && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: color.Warning.Main }}
|
||||
title="Fix your computer's clock — calls and encryption depend on it"
|
||||
>
|
||||
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{speakers.size > 0 && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
color,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { toastQueueAtom } from '../../../state/toast';
|
||||
import {
|
||||
cancelRedactJob,
|
||||
clearRedactJob,
|
||||
hydrateRedactJob,
|
||||
redactJobsAtom,
|
||||
runRedactJob,
|
||||
} from '../../../state/redactOwnMessagesJob';
|
||||
import { collectOwnEventIds } from '../../../utils/redactOwnMessages';
|
||||
|
||||
const TYPE_TO_CONFIRM_ABOVE = 50;
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
onStart: (leaveAfter: boolean) => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan first (live count), then confirm with the number — typing the room name
|
||||
* for > 50 — and an optional "leave the room afterwards".
|
||||
*/
|
||||
function ConfirmDialog({ onStart, requestClose }: ConfirmDialogProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const modalStyle = useModalStyle(480);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [scanError, setScanError] = useState<string>();
|
||||
const [typed, setTyped] = useState('');
|
||||
const [leaveAfter, setLeaveAfter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
setCount(null);
|
||||
collectOwnEventIds(mx, room.roomId, mx.getSafeUserId(), (n) => setCount(n), ctrl.signal)
|
||||
.then((ids) => setCount(ids.length))
|
||||
.catch((e) => {
|
||||
if (!ctrl.signal.aborted) setScanError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [mx, room.roomId]);
|
||||
|
||||
const scanning = count === null && !scanError;
|
||||
const needsTyping = (count ?? 0) > TYPE_TO_CONFIRM_ABOVE;
|
||||
const roomName = room.name ?? room.roomId;
|
||||
const canStart =
|
||||
!scanning && !scanError && (count ?? 0) > 0 && (!needsTyping || typed.trim() === roomName);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface" style={modalStyle}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text as="h2" size="H4">
|
||||
Delete all your messages
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
{scanning && (
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Spinner size="200" />
|
||||
<Text>
|
||||
Counting your messages in <b>{roomName}</b>…{' '}
|
||||
{count ? `${Number(count).toLocaleString()} so far` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{scanError && (
|
||||
<Text style={{ color: color.Critical.Main }}>
|
||||
Couldn't count your messages: {scanError}
|
||||
</Text>
|
||||
)}
|
||||
{!scanning && !scanError && (
|
||||
<>
|
||||
<Text>
|
||||
Redact <b>{(count ?? 0).toLocaleString()}</b> messages you sent in{' '}
|
||||
<b>{roomName}</b>? This cannot be undone. Redactions are visible to others as
|
||||
“message deleted”, and files you uploaded stay on the server until
|
||||
an admin purges them.
|
||||
</Text>
|
||||
{needsTyping && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Type the room name to confirm</Text>
|
||||
<Input
|
||||
variant="Background"
|
||||
size="400"
|
||||
radii="300"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.currentTarget.value)}
|
||||
placeholder={roomName}
|
||||
aria-label="Room name confirmation"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box as="label" alignItems="Center" gap="200">
|
||||
<Checkbox
|
||||
variant="Critical"
|
||||
checked={leaveAfter}
|
||||
onClick={() => setLeaveAfter((v) => !v)}
|
||||
aria-label="Leave the room afterwards"
|
||||
/>
|
||||
<Text>Leave the room afterwards</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box gap="200" justifyContent="End">
|
||||
<Button variant="Secondary" fill="Soft" onClick={requestClose}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button variant="Critical" disabled={!canStart} onClick={() => onStart(leaveAfter)}>
|
||||
<Text size="B400">Delete {count ? count.toLocaleString() : ''} messages</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #169] Room Settings tile: "Your messages in this room" with a
|
||||
* Delete all… flow. The job runs outside React (closing settings is fine),
|
||||
* shows its progress here, persists so a reload can resume, and toasts when done.
|
||||
*/
|
||||
export function DeleteMyMessages() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const store = useStore();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const jobs = useAtomValue(redactJobsAtom);
|
||||
const job = jobs[room.roomId];
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
|
||||
// A reload mid-job: offer to resume.
|
||||
useEffect(() => {
|
||||
if (!jobs[room.roomId]) hydrateRedactJob(store, room.roomId);
|
||||
}, [store, room.roomId, jobs]);
|
||||
|
||||
const start = useCallback(
|
||||
(leaveAfter: boolean, resume?: boolean) => {
|
||||
setConfirm(false);
|
||||
const resumeState = resume ? jobs[room.roomId] : undefined;
|
||||
const name = room.name ?? room.roomId;
|
||||
runRedactJob(
|
||||
store,
|
||||
mx,
|
||||
room.roomId,
|
||||
{
|
||||
leaveAfter,
|
||||
onDone: (s) =>
|
||||
setToast({
|
||||
id: `redact-done-${room.roomId}`,
|
||||
displayName: 'Lotus Chat',
|
||||
body: `Deleted ${s.redacted.toLocaleString()} of your messages in ${name}${s.skipped ? ` (${s.skipped} already gone)` : ''}.`,
|
||||
roomName: name,
|
||||
roomId: room.roomId,
|
||||
}),
|
||||
},
|
||||
resumeState,
|
||||
);
|
||||
},
|
||||
[store, mx, room, jobs, setToast],
|
||||
);
|
||||
|
||||
const running = job && (job.phase === 'collecting' || job.phase === 'redacting');
|
||||
const total = job ? job.redacted + job.skipped + job.pending.length : 0;
|
||||
const done = job ? job.redacted + job.skipped : 0;
|
||||
|
||||
let status: React.ReactNode = 'Redact everything you sent here — no moderator needed.';
|
||||
if (job?.phase === 'collecting') status = `Counting… ${job.found.toLocaleString()} found`;
|
||||
else if (job?.phase === 'redacting')
|
||||
status = `Deleting… ${done.toLocaleString()} / ${total.toLocaleString()}`;
|
||||
else if (job?.phase === 'error')
|
||||
status = `Stopped: ${job.error}. ${job.pending.length} left — you can resume.`;
|
||||
else if (job?.phase === 'cancelled' && job.pending.length > 0)
|
||||
status = `Cancelled with ${job.pending.length.toLocaleString()} left — you can resume.`;
|
||||
else if (job?.phase === 'done') status = `Deleted ${job.redacted.toLocaleString()} messages.`;
|
||||
|
||||
const resumable = job && !running && job.pending.length > 0;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Your messages in this room"
|
||||
description={status}
|
||||
after={
|
||||
<Box gap="200">
|
||||
{running && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => cancelRedactJob(room.roomId)}
|
||||
>
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Button>
|
||||
)}
|
||||
{resumable && (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => start(job.leaveAfter, true)}
|
||||
>
|
||||
<Text size="B300">Resume</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={() => clearRedactJob(store, room.roomId)}
|
||||
>
|
||||
<Text size="B300">Discard</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!running && !resumable && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setConfirm(true)}
|
||||
>
|
||||
<Text size="B300">Delete all…</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{confirm && (
|
||||
<ConfirmDialog onStart={(leave) => start(leave)} requestClose={() => setConfirm(false)} />
|
||||
)}
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './RoomRetention';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
export * from './DeleteMyMessages';
|
||||
|
||||
@@ -52,6 +52,7 @@ import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
@@ -74,6 +75,7 @@ type EncryptedRoomCachePanelProps = {
|
||||
};
|
||||
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { format } = useTimestampFormatter();
|
||||
const [loadingRooms, setLoadingRooms] = useState<Set<string>>(new Set());
|
||||
|
||||
const encryptedRooms = useMemo(
|
||||
@@ -140,7 +142,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{msgEvents.length > 0
|
||||
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
|
||||
? `${msgEvents.length} messages cached · oldest: ${format(oldest!.getTs(), 'date')}`
|
||||
: 'No messages cached yet'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
color,
|
||||
config,
|
||||
PopOut,
|
||||
toRem,
|
||||
@@ -30,18 +31,29 @@ import {
|
||||
RectCords,
|
||||
Badge,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
} from 'folds';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
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';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import {
|
||||
getDirectRoomAvatarUrl,
|
||||
getMemberName,
|
||||
getRoomAvatarUrl,
|
||||
getStateEvent,
|
||||
} from '../../utils/room';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
@@ -90,29 +102,11 @@ import { MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { EmojiBoard } from '../../components/emoji-board';
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatShortAge } from '../../utils/formatTimestamp';
|
||||
|
||||
const PREVIEW_MAX_CHARS = 48;
|
||||
|
||||
function formatDmTimestamp(ts: number): string {
|
||||
const d = dayjs(ts);
|
||||
const now = dayjs();
|
||||
const diffMinutes = now.diff(d, 'minute');
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes < 1 ? 0 : diffMinutes}m`;
|
||||
}
|
||||
const diffHours = now.diff(d, 'hour');
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
if (d.isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return d.format('D MMM');
|
||||
}
|
||||
|
||||
type RenameRoomDialogProps = {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
@@ -291,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'];
|
||||
@@ -330,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 = () => {
|
||||
@@ -512,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"
|
||||
@@ -639,6 +751,7 @@ function RoomNavItem_({
|
||||
|
||||
const roomName = useLocalRoomName(room);
|
||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
|
||||
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
||||
// so the row only re-renders when that flips (the draft atom itself is written
|
||||
@@ -670,7 +783,7 @@ function RoomNavItem_({
|
||||
}
|
||||
if (!body) return null;
|
||||
const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}…` : body;
|
||||
return { preview, time: formatDmTimestamp(ts) };
|
||||
return { preview, time: formatShortAge(ts, prefs) };
|
||||
})();
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
@@ -690,6 +803,14 @@ function RoomNavItem_({
|
||||
const optionsVisible = hover || !!menuAnchor;
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(callSession);
|
||||
const liveNames = useMemo(() => {
|
||||
const names = Array.from(
|
||||
new Set(callMembers.map((m) => m.sender).filter((u): u is string => !!u)),
|
||||
).map((u) => getMemberName(room, u));
|
||||
const shown = names.slice(0, 6);
|
||||
const more = names.length - shown.length;
|
||||
return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
|
||||
}, [callMembers, room]);
|
||||
const startCall = useCallStart(direct);
|
||||
const callEmbed = useCallEmbed();
|
||||
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
|
||||
@@ -853,11 +974,32 @@ function RoomNavItem_({
|
||||
/>
|
||||
)}
|
||||
{callMembers.length > 0 && (
|
||||
<Badge variant="Critical" fill="Solid" size="400">
|
||||
<Text as="span" size="L400" truncate>
|
||||
{callMembers.length} Live
|
||||
</Text>
|
||||
</Badge>
|
||||
// [Gitea #157] The count alone doesn't tell you whether it's your
|
||||
// squad — name the people on hover/focus (long-press on touch).
|
||||
<TooltipProvider
|
||||
position="Right"
|
||||
delay={300}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{liveNames}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(tipRef) => (
|
||||
<Badge
|
||||
ref={tipRef}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
size="400"
|
||||
tabIndex={0}
|
||||
aria-label={`${callMembers.length} in call: ${liveNames}`}
|
||||
>
|
||||
<Text as="span" size="L400" truncate>
|
||||
{callMembers.length} Live
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createDetachedTimelineSet,
|
||||
createTypesFilter,
|
||||
} from '../../utils/detachedTimeline';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,20 +29,6 @@ type StateEventType = (typeof STATE_EVENT_TYPES)[number];
|
||||
|
||||
// ── Timestamp formatting ──────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeTs(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
const d = new Date(ts);
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event description ─────────────────────────────────────────────────────────
|
||||
|
||||
function getDisplayName(mx: ReturnType<typeof useMatrixClient>, userId: string): string {
|
||||
@@ -296,6 +284,7 @@ type LogEntryProps = {
|
||||
};
|
||||
|
||||
function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
@@ -326,7 +315,7 @@ function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
{desc.text}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{formatRelativeTs(ev.getTs())}
|
||||
{formatRelativeAge(ev.getTs(), prefs)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,21 +9,10 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdatedAt(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Throttle window for re-computing stats on new timeline events - avoids
|
||||
// re-running every heatmap/list computation on every single incoming message
|
||||
// during a burst.
|
||||
@@ -74,6 +63,7 @@ type RoomInsightsProps = {
|
||||
};
|
||||
|
||||
export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
const { format } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
@@ -234,11 +224,11 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
</Text>
|
||||
{stats.oldestTs !== null && stats.newestTs !== null && (
|
||||
<Text size="T200" priority="300">
|
||||
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
|
||||
from {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Last updated {formatUpdatedAt(lastUpdated)}
|
||||
Last updated {format(lastUpdated, 'time')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RoomRetention,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
DeleteMyMessages,
|
||||
RoomVoiceLimit,
|
||||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
@@ -79,6 +80,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Text size="L400">Advanced Options</Text>
|
||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Privacy</Text>
|
||||
<DeleteMyMessages />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -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';
|
||||
@@ -35,6 +36,8 @@ import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './MediaGallery.css';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||
|
||||
@@ -54,81 +57,8 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
|
||||
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
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 formatRelativeDate(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -164,16 +94,10 @@ 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.
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type LightboxItem = {
|
||||
export type LightboxItem = {
|
||||
mxcUrl: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
mimeType?: string;
|
||||
@@ -184,6 +108,35 @@ type LightboxItem = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Images + videos of a media timeline as lightbox slots. Shared with the
|
||||
* timeline's viewer (#219) so both open the same items in the same order.
|
||||
*/
|
||||
export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem[] {
|
||||
return events
|
||||
.filter((ev) => {
|
||||
const c = ev.getContent();
|
||||
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
|
||||
// Match the grid's guard exactly: tiles without a thumb are not rendered,
|
||||
// so they must not occupy a lightbox slot either.
|
||||
return !!getThumbMxc(ev);
|
||||
})
|
||||
.map((ev) => {
|
||||
const c = ev.getContent();
|
||||
const isEnc = !!c.file;
|
||||
return {
|
||||
mxcUrl: c.file?.url ?? c.url ?? '',
|
||||
encInfo: isEnc ? c.file : undefined,
|
||||
mimeType: c.info?.mimetype,
|
||||
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
|
||||
body: c.body ?? '',
|
||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||
ts: ev.getTs(),
|
||||
eventId: ev.getId() ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function LightboxMedia({
|
||||
item,
|
||||
useAuthentication,
|
||||
@@ -280,7 +233,7 @@ function LightboxMedia({
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({
|
||||
export function Lightbox({
|
||||
items,
|
||||
initialIndex,
|
||||
useAuthentication,
|
||||
@@ -294,6 +247,7 @@ function Lightbox({
|
||||
onJump: (eventId: string) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
const item = items[index];
|
||||
const isImage = item?.msgtype === MsgType.Image;
|
||||
@@ -302,6 +256,7 @@ function Lightbox({
|
||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
||||
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||
|
||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||
@@ -336,22 +291,23 @@ function Lightbox({
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const dateStr = format(item.ts, 'date');
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
// Focus the dialog itself on open: with `initialFocus: false` focus
|
||||
// stayed on the gallery tile behind the overlay, so ←/→/Esc/+/-
|
||||
// (handled by onKeyDown below) did nothing until the user clicked
|
||||
// inside the viewer (Gitea #164).
|
||||
initialFocus: () => dialogRef.current ?? false,
|
||||
clickOutsideDeactivates: false,
|
||||
escapeDeactivates: false,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label="Media viewer"
|
||||
@@ -579,7 +535,8 @@ function GalleryTile({
|
||||
mimeType,
|
||||
nearViewport,
|
||||
);
|
||||
const relDate = formatRelativeDate(ts);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const relDate = formatRelativeAge(ts, prefs);
|
||||
|
||||
return (
|
||||
<div className={css.GalleryTileWrap}>
|
||||
@@ -689,6 +646,7 @@ type MediaGalleryProps = {
|
||||
};
|
||||
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
@@ -759,28 +717,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
}, [canLoadMore, loading, handleLoadMore]);
|
||||
|
||||
// Lightbox items (images + videos, flat)
|
||||
const lightboxItems: LightboxItem[] = events
|
||||
.filter((ev) => {
|
||||
const c = ev.getContent();
|
||||
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
|
||||
// Match the grid's guard exactly: tiles without a thumb are not rendered,
|
||||
// so they must not occupy a lightbox slot either.
|
||||
return !!getThumbMxc(ev);
|
||||
})
|
||||
.map((ev) => {
|
||||
const c = ev.getContent();
|
||||
const isEnc = !!c.file;
|
||||
return {
|
||||
mxcUrl: c.file?.url ?? c.url ?? '',
|
||||
encInfo: isEnc ? c.file : undefined,
|
||||
mimeType: c.info?.mimetype,
|
||||
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
|
||||
body: c.body ?? '',
|
||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||
ts: ev.getTs(),
|
||||
eventId: ev.getId() ?? '',
|
||||
};
|
||||
});
|
||||
const lightboxItems: LightboxItem[] = toLightboxItems(room, events);
|
||||
|
||||
// Per-tab counts for the tab labels (single pass over the loaded media)
|
||||
const tabCounts = useMemo(() => {
|
||||
@@ -1026,7 +963,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
if (!url) return null;
|
||||
const body: string = c.body || 'Voice message';
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const relDate = formatRelativeDate(mEvent.getTs());
|
||||
const relDate = formatRelativeAge(mEvent.getTs(), prefs);
|
||||
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
|
||||
// audio/ogg) so the decrypted blob actually plays.
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WidgetsPanel } from './widgets/WidgetsPanel';
|
||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
@@ -150,10 +151,16 @@ export function Room() {
|
||||
(isDesktop || (!galleryOpen && !widgetsOpen));
|
||||
// Desktop: the persisted drawer preference. Mobile: a transient panel the
|
||||
// user opened from the header menu (never the desktop default).
|
||||
// [Gitea #218] Three right-hand columns (timeline + thread/gallery/widgets
|
||||
// panel + member drawer) don't fit under ~1500 px — both composers shrank to
|
||||
// a few characters. Like Element, the member drawer yields to the content
|
||||
// panel on narrower desktops and comes back when it closes.
|
||||
const narrowDesktop = useMediaQuery('(max-width: 1500px)');
|
||||
const contentPanelOpen = showThreadPanel || showGallery || showWidgets || showThreadsList;
|
||||
const showMembers =
|
||||
!callView &&
|
||||
(isDesktop
|
||||
? isDrawer
|
||||
? isDrawer && !(narrowDesktop && contentPanelOpen)
|
||||
: mobileMembersOpen && !activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen);
|
||||
|
||||
// Leaving a room on a phone closes its member panel.
|
||||
|
||||
@@ -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';
|
||||
@@ -113,7 +114,14 @@ import {
|
||||
} from './msgContent';
|
||||
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||
import {
|
||||
Command,
|
||||
CommandError,
|
||||
SHRUG,
|
||||
TABLEFLIP,
|
||||
UNFLIP,
|
||||
useCommands,
|
||||
} from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
||||
@@ -150,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 {
|
||||
@@ -162,10 +177,21 @@ interface RoomInputProps {
|
||||
// message" handler). Threads pass a distinct name so the main timeline's handler
|
||||
// doesn't fire for the thread composer, and vice-versa.
|
||||
editableName?: string;
|
||||
// [Gitea #218] Force the one-row "+ | input | emoji | send" layout regardless
|
||||
// of viewport — the 360 px thread panel can't fit the full toolbar.
|
||||
compactLayout?: boolean;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
(
|
||||
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
|
||||
{
|
||||
editor,
|
||||
fileDropContainerRef,
|
||||
roomId,
|
||||
room,
|
||||
threadRootId,
|
||||
editableName = 'RoomInput',
|
||||
compactLayout = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
@@ -207,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();
|
||||
@@ -232,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);
|
||||
|
||||
@@ -247,7 +278,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// ten controls inline and clips the Send button behind the editor's
|
||||
// overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow
|
||||
// desktop window still has a mouse.
|
||||
const compact = isMobile || screenSize === ScreenSize.Mobile;
|
||||
const compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout;
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
// Both gates on purpose: the class covers phone-width viewports (matches
|
||||
// VoiceMessageRecorder's idle button, which lives in the same row), the
|
||||
@@ -261,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;
|
||||
@@ -357,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);
|
||||
@@ -665,16 +675,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const commandContent = commands[commandName as Command];
|
||||
if (commandContent) {
|
||||
// Fire-and-forget by design (the editor resets immediately for UX), but
|
||||
// surface a rejection instead of failing silently. NOTE: /kick and /ban
|
||||
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
|
||||
// swallows non-429 errors, so those two commands can still resolve even
|
||||
// when the underlying kick/ban failed — this catch only covers errors
|
||||
// that actually reject out of exe().
|
||||
// surface a rejection instead of failing silently. /kick and /ban
|
||||
// throw a CommandError whose message already names who and why
|
||||
// (#216); anything else gets the generic sentence.
|
||||
commandContent.exe(plainText).catch((err) => {
|
||||
console.error(`Failed to run /${commandName} command:`, err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
`The /${commandName} command failed. Please try again.`,
|
||||
err instanceof CommandError
|
||||
? err.message
|
||||
: `The /${commandName} command failed. Please try again.`,
|
||||
Icons.Warning,
|
||||
'Command failed',
|
||||
),
|
||||
@@ -1042,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>
|
||||
@@ -1167,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>
|
||||
@@ -1321,12 +1342,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{(gifOpen: boolean, setGifOpen) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
// [Gitea #147] In the compact overflow row the GIF button sits
|
||||
// near the left edge; end-aligning a 312px picker to it pushed
|
||||
// most of it off-screen. Anchor to the row instead.
|
||||
alignOffset={compact ? 0 : -44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
gifOpen
|
||||
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
? ((compact
|
||||
? gifBtnRef.current?.closest('#composer-more-actions')
|
||||
: gifBtnRef.current
|
||||
)?.getBoundingClientRect() ??
|
||||
gifBtnRef.current?.getBoundingClientRect() ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
content={
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { Lightbox, toLightboxItems } from './MediaGallery';
|
||||
|
||||
// How many media pages to walk back looking for the clicked event before
|
||||
// settling for a single-item viewer.
|
||||
const MAX_SEARCH_PAGES = 6;
|
||||
|
||||
type RoomMediaLightboxProps = {
|
||||
room: Room;
|
||||
eventId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #219] The room timeline's image viewer is the gallery lightbox,
|
||||
* opened at the clicked event: same dark backdrop, sender + date, 1/N counter,
|
||||
* ←/→ across the room's media, zoom keys, download and "jump to message".
|
||||
*
|
||||
* Items come from the same detached media timeline the gallery uses (#163).
|
||||
* It starts at the live end, so an older image may not be loaded yet: we page
|
||||
* back a bounded number of times looking for it and meanwhile show the clicked
|
||||
* image on its own, so the viewer never waits on the network to open.
|
||||
*/
|
||||
export function RoomMediaLightbox({ room, eventId, onClose }: RoomMediaLightboxProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const { events, loadMore, loading, canLoadMore } = useRoomMediaTimeline(mx, room);
|
||||
|
||||
const items = useMemo(() => toLightboxItems(room, events), [room, events]);
|
||||
const index = items.findIndex((it) => it.eventId === eventId);
|
||||
|
||||
const pagesRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (index !== -1 || loading || !canLoadMore || pagesRef.current >= MAX_SEARCH_PAGES) return;
|
||||
pagesRef.current += 1;
|
||||
loadMore();
|
||||
}, [index, loading, canLoadMore, loadMore]);
|
||||
|
||||
// Until the event is in the loaded window, show just the clicked media.
|
||||
const fallbackItems = useMemo(() => {
|
||||
const ev: MatrixEvent | undefined = room.findEventById(eventId) ?? undefined;
|
||||
return ev ? toLightboxItems(room, [ev]) : [];
|
||||
}, [room, eventId]);
|
||||
|
||||
const handleJump = useCallback(
|
||||
(id: string) => {
|
||||
onClose();
|
||||
navigateRoom(room.roomId, id);
|
||||
},
|
||||
[onClose, navigateRoom, room.roomId],
|
||||
);
|
||||
|
||||
const found = index !== -1;
|
||||
const shownItems = found ? items : fallbackItems;
|
||||
if (shownItems.length === 0) return null;
|
||||
return (
|
||||
<Lightbox
|
||||
// Remount when the full list arrives so the index and zoom reset cleanly.
|
||||
key={found ? 'room' : 'single'}
|
||||
items={shownItems}
|
||||
initialIndex={found ? index : 0}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={onClose}
|
||||
onJump={handleJump}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
Direction,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
EventTimelineSetHandlerMap,
|
||||
IContent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -53,10 +55,12 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||
import { getForwardedMeta } from './message/forwardContent';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
Reply,
|
||||
ForwardedHeader,
|
||||
MessageBase,
|
||||
MessageUnsupportedContent,
|
||||
Time,
|
||||
@@ -86,6 +90,12 @@ import {
|
||||
isMembershipChanged,
|
||||
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, 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';
|
||||
@@ -101,7 +111,8 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { inSameDay, minuteDifference } from '../../utils/time';
|
||||
import { formatDayDivider } from '../../utils/formatTimestamp';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
@@ -113,6 +124,7 @@ import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { RoomMediaLightbox } from './RoomMediaLightbox';
|
||||
import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
@@ -175,6 +187,9 @@ export const getFirstLinkedTimeline = (
|
||||
return getFirstLinkedTimeline(linkedTm, direction);
|
||||
};
|
||||
|
||||
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
|
||||
const separatedGalleries = new Set<string>();
|
||||
|
||||
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
|
||||
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
|
||||
const timelines: EventTimeline[] = [];
|
||||
@@ -464,6 +479,9 @@ 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');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
@@ -521,6 +539,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||
const [editId, setEditId] = useState<string>();
|
||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||
// [Gitea #219] Timeline images open the shared media lightbox at that event.
|
||||
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
||||
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
|
||||
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
|
||||
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -703,6 +725,34 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// [Gitea #212] Your OWN new message always comes into view — that is
|
||||
// what pressing Send means — even if the at-bottom flag went stale
|
||||
// (in-place growth like a poll gaining a row briefly pushes the anchor
|
||||
// out of the viewport) or you had scrolled up to read.
|
||||
const ownNewMessage =
|
||||
mEvt.getSender() === mx.getUserId() &&
|
||||
!mEvt.isRelation() &&
|
||||
!mEvt.isRedaction() &&
|
||||
!mEvt.isState() &&
|
||||
mEvt.getType() !== MessageEvent.Reaction;
|
||||
if (ownNewMessage) {
|
||||
if (atLiveEndRef.current) {
|
||||
setAtBottom(true);
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
setTimeline((ct) => ({
|
||||
...ct,
|
||||
range: { start: ct.range.start + 1, end: ct.range.end + 1 },
|
||||
}));
|
||||
} else {
|
||||
// Paginated back into history: jump to the live end like the
|
||||
// "Jump to Latest" button does.
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
if (!unreadInfo) {
|
||||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
@@ -865,6 +915,45 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
),
|
||||
);
|
||||
|
||||
// [Gitea #127] After a jump (reply quote, thread → main, search result) the
|
||||
// reader is stranded far up the history; remember the message that was in
|
||||
// the middle of the viewport and offer "Back to where you were".
|
||||
const [returnPoint, setReturnPoint] = useState<{ eventId: string; at: number } | null>(null);
|
||||
const captureReturnPoint = useCallback(() => {
|
||||
const scroller = scrollRef.current;
|
||||
if (!scroller) return;
|
||||
const mid = scroller.getBoundingClientRect().top + scroller.clientHeight / 2;
|
||||
const items = Array.from(scroller.querySelectorAll<HTMLElement>('[data-message-id]'));
|
||||
const hit =
|
||||
items.find((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.top <= mid && r.bottom >= mid;
|
||||
}) ?? items[items.length - 1];
|
||||
const id = hit?.getAttribute('data-message-id');
|
||||
if (id) setReturnPoint({ eventId: id, at: Date.now() });
|
||||
}, []);
|
||||
// Route-driven jumps (thread panel / search / permalink): capture before the
|
||||
// timeline is emptied by the effect below (effects run in declaration order).
|
||||
const prevEventIdRef = useRef(eventId);
|
||||
useEffect(() => {
|
||||
const prev = prevEventIdRef.current;
|
||||
prevEventIdRef.current = eventId;
|
||||
if (eventId && eventId !== prev && prev === undefined) captureReturnPoint();
|
||||
}, [eventId, captureReturnPoint]);
|
||||
// Expire after 20 s.
|
||||
useEffect(() => {
|
||||
if (!returnPoint) return undefined;
|
||||
const t = window.setTimeout(() => setReturnPoint(null), 20_000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [returnPoint]);
|
||||
const handleReturn = useCallback(() => {
|
||||
if (!returnPoint) return;
|
||||
const target = returnPoint.eventId;
|
||||
setReturnPoint(null);
|
||||
if (eventId) navigateRoom(room.roomId, target);
|
||||
else handleOpenEvent(target, false);
|
||||
}, [returnPoint, eventId, navigateRoom, room.roomId, handleOpenEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
@@ -976,9 +1065,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
async (evt) => {
|
||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||
if (!targetId) return;
|
||||
captureReturnPoint();
|
||||
handleOpenEvent(targetId);
|
||||
},
|
||||
[handleOpenEvent],
|
||||
[handleOpenEvent, captureReturnPoint],
|
||||
);
|
||||
|
||||
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
@@ -1069,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) => {
|
||||
@@ -1091,14 +1182,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<
|
||||
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
||||
[
|
||||
string,
|
||||
MatrixEvent,
|
||||
number,
|
||||
EventTimelineSet,
|
||||
boolean,
|
||||
MatrixEvent[] | undefined,
|
||||
string | undefined,
|
||||
]
|
||||
>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||
[MessageEvent.RoomMessage]: (
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapse,
|
||||
mediaGroup,
|
||||
regroupId,
|
||||
) => {
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
|
||||
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
|
||||
@@ -1131,19 +1239,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{forwardedMeta && (
|
||||
<ForwardedHeader
|
||||
mx={mx}
|
||||
meta={forwardedMeta}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||
/>
|
||||
)}
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
reactions={
|
||||
<>
|
||||
@@ -1175,21 +1294,47 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
) : mediaGroup ? (
|
||||
<MediaGroupGrid
|
||||
events={mediaGroup}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpen={(id) => {
|
||||
setLightboxGroup(mediaGroup);
|
||||
setLightboxEventId(id);
|
||||
}}
|
||||
onShowSeparately={() => {
|
||||
separatedGalleries.add(mEventId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
/>
|
||||
{regroupId && (
|
||||
<RegroupChip
|
||||
onClick={() => {
|
||||
separatedGalleries.delete(regroupId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -1199,6 +1344,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
const { replyEventId, threadRootId } = mEvent;
|
||||
const forwardedMeta = getForwardedMeta(mEvent.getContent());
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
|
||||
return (
|
||||
@@ -1224,19 +1370,30 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{forwardedMeta && (
|
||||
<ForwardedHeader
|
||||
mx={mx}
|
||||
meta={forwardedMeta}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
|
||||
/>
|
||||
)}
|
||||
{replyEventId && (
|
||||
<Reply
|
||||
room={room}
|
||||
timelineSet={timelineSet}
|
||||
replyEventId={replyEventId}
|
||||
threadRootId={threadRootId}
|
||||
onClick={handleOpenReply}
|
||||
onThreadClick={setActiveThreadId}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
reactions={
|
||||
<>
|
||||
@@ -1296,6 +1453,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={
|
||||
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
|
||||
}
|
||||
@@ -1306,6 +1464,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1847,6 +2007,84 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
// [Gitea #162] A ring (m.rtc.notification) that we neither answered nor
|
||||
// declined, and whose ring window has passed, renders as a local-only
|
||||
// "Missed call from X" line. Derived from events already in the
|
||||
// timeline; nothing is sent. Disappears if our own call membership
|
||||
// follows within the ring window (we picked up).
|
||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
if (!senderId || senderId === mx.getUserId()) return null;
|
||||
const content = mEvent.getContent();
|
||||
const mention =
|
||||
content['m.mentions']?.room ||
|
||||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
|
||||
if (!mention) return null;
|
||||
const lifetime = Math.min(
|
||||
typeof content.lifetime === 'number' ? content.lifetime : 30000,
|
||||
120000,
|
||||
);
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
const memberEventsAfter = liveEvents.filter(
|
||||
(ev) => ev.getType() === StateEvent.GroupCallMemberPrefix && ev.getTs() >= mEvent.getTs(),
|
||||
);
|
||||
// The ring is over once its window lapsed or the caller hung up.
|
||||
const callerEndTs = memberEventsAfter
|
||||
.find((ev) => ev.getSender() === senderId && !ev.getContent().application)
|
||||
?.getTs();
|
||||
if (callerEndTs === undefined && Date.now() < mEvent.getTs() + lifetime) return null;
|
||||
// "Answered" = our own join between the ring and the caller hanging up
|
||||
// (a later, separate call must not retro-actively answer this one).
|
||||
const windowEnd = Math.min(
|
||||
mEvent.getTs() + lifetime + 60_000,
|
||||
callerEndTs !== undefined ? callerEndTs + 5_000 : Infinity,
|
||||
);
|
||||
const answered = memberEventsAfter.some(
|
||||
(ev) =>
|
||||
ev.getSender() === mx.getUserId() &&
|
||||
ev.getTs() <= windowEnd &&
|
||||
!!ev.getContent().application,
|
||||
);
|
||||
if (answered) return null;
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={messageLayout === MessageLayout.Compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={false}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
iconSrc={Icons.PhoneDown}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||
Missed {content['m.call.intent'] === 'video' ? 'video ' : ''}call from{' '}
|
||||
<b>{senderName}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Event>
|
||||
);
|
||||
},
|
||||
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
|
||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
@@ -2001,30 +2239,100 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
let isPrevRendered = false;
|
||||
let newDivider = false;
|
||||
let dayDivider = false;
|
||||
const eventRenderer = (item: number) => {
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
let eventTimeline: EventTimeline | undefined;
|
||||
let baseIndex = 0;
|
||||
{
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
eventTimeline = timelineSegments[mid][2];
|
||||
baseIndex = base;
|
||||
break;
|
||||
}
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
const resolveItem = (
|
||||
item: number,
|
||||
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
|
||||
}
|
||||
}
|
||||
if (!eventTimeline) return null;
|
||||
const timelineSet = eventTimeline?.getTimelineSet();
|
||||
return undefined;
|
||||
};
|
||||
const eventAt = (
|
||||
item: number,
|
||||
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
|
||||
const seg = resolveItem(item);
|
||||
if (!seg) return undefined;
|
||||
const mEvent = getTimelineEvent(
|
||||
seg.eventTimeline,
|
||||
getTimelineRelativeIndex(item, seg.baseIndex),
|
||||
);
|
||||
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
|
||||
};
|
||||
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
|
||||
// media event we meet plans its whole run (looking both ways, so a virtual
|
||||
// window that starts mid-run still agrees), and the plan is reused for the
|
||||
// run's other members.
|
||||
const groupPlans = new Map<number, GroupPlan | null>();
|
||||
const candidateAt = (index: number): GroupCandidate | undefined => {
|
||||
const found = eventAt(index);
|
||||
if (!found) return undefined;
|
||||
const { mEvent: ev, timelineSet } = found;
|
||||
const sender = ev.getSender() ?? '';
|
||||
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
|
||||
if (
|
||||
reactionOrEditEvent(ev) ||
|
||||
ev.getType() === 'm.room.redaction' ||
|
||||
ignoredUsersSet.has(sender)
|
||||
)
|
||||
return { ...base, kind: 'skip' };
|
||||
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
|
||||
return { ...base, kind: 'skip' };
|
||||
const msgtype = ev.getContent().msgtype;
|
||||
const isMedia =
|
||||
ev.getType() === MessageEvent.RoomMessage &&
|
||||
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
|
||||
!!getThumbMxc(ev);
|
||||
if (!isMedia) return { ...base, kind: 'other' };
|
||||
const id = ev.getId() ?? '';
|
||||
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
|
||||
const hasThread =
|
||||
ev.getThread() !== undefined ||
|
||||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'media',
|
||||
hasRelation: !!ev.getContent()['m.relates_to'],
|
||||
redacted: ev.isRedacted(),
|
||||
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
|
||||
};
|
||||
};
|
||||
const mediaGroupFor = (
|
||||
item: number,
|
||||
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
|
||||
if (!groupPlans.has(item)) {
|
||||
if (candidateAt(item)?.kind !== 'media') return undefined;
|
||||
const plans = planMediaGroups(candidateAt, item);
|
||||
plans.forEach((plan, index) => groupPlans.set(index, plan));
|
||||
if (!plans.has(item)) groupPlans.set(item, null);
|
||||
}
|
||||
const plan = groupPlans.get(item);
|
||||
if (!plan) return undefined;
|
||||
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
|
||||
if (separatedGalleries.has(lastId))
|
||||
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
|
||||
if (!plan.renders) return { hidden: true };
|
||||
const events = plan.members
|
||||
.map((index) => eventAt(index)?.mEvent)
|
||||
.filter((ev): ev is MatrixEvent => !!ev);
|
||||
return { hidden: false, events };
|
||||
};
|
||||
const eventRenderer = (item: number) => {
|
||||
const resolved = resolveItem(item);
|
||||
if (!resolved) return null;
|
||||
const { eventTimeline, baseIndex } = resolved;
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||
const mEventId = mEvent?.getId();
|
||||
|
||||
@@ -2068,17 +2376,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
);
|
||||
const mediaGroup = mediaGroupFor(item);
|
||||
const eventJSX =
|
||||
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
mediaGroup?.events,
|
||||
mediaGroup?.regroup,
|
||||
);
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
|
||||
@@ -2099,11 +2411,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(mEvent.getTs())) return 'Today';
|
||||
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||
return timeDayMonthYear(mEvent.getTs());
|
||||
})()}
|
||||
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
||||
</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
@@ -2242,17 +2550,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{!atBottom && (
|
||||
{(!atBottom || returnPoint) && (
|
||||
<TimelineFloat position="Bottom">
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToLatest}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
<Box gap="200" alignItems="Center">
|
||||
{returnPoint && (
|
||||
<Chip
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleReturn}
|
||||
>
|
||||
<Text size="L400">Back to where you were</Text>
|
||||
</Chip>
|
||||
)}
|
||||
{!atBottom && (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
outlined
|
||||
before={<Icon size="50" src={Icons.ArrowBottom} />}
|
||||
onClick={handleJumpToLatest}
|
||||
>
|
||||
<Text size="L400">Jump to Latest</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
</Box>
|
||||
@@ -2263,6 +2586,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onClose={() => setEditHistoryEvent(undefined)}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && lightboxGroup && (
|
||||
<Lightbox
|
||||
items={toLightboxItems(room, lightboxGroup)}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
|
||||
)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={() => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
}}
|
||||
onJump={(id) => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
navigateRoom(room.roomId, id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && !lightboxGroup && (
|
||||
<RoomMediaLightbox
|
||||
room={room}
|
||||
eventId={lightboxEventId}
|
||||
onClose={() => setLightboxEventId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { useAtom } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
@@ -107,6 +109,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
||||
const [reportRoomOpen, setReportRoomOpen] = useState(false);
|
||||
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
|
||||
const [mobileMembers, setMobileMembers] = useAtom(mobileMembersPanelAtom);
|
||||
// [Gitea #165 / #211] Threads list and Widgets had desktop-only header
|
||||
// buttons and no mobile entry point at all.
|
||||
const [threadsList, setThreadsList] = useAtom(threadsListAtom);
|
||||
const [widgets, setWidgets] = useAtom(widgetsPanelAtom);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
@@ -226,6 +232,38 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setThreadsList(!threadsList);
|
||||
requestClose();
|
||||
}}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Thread} filled={threadsList} />}
|
||||
radii="300"
|
||||
aria-pressed={threadsList}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Threads
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setWidgets(!widgets);
|
||||
requestClose();
|
||||
}}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Category} filled={widgets} />}
|
||||
radii="300"
|
||||
aria-pressed={widgets}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Widgets
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isServerNotice && (
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
@@ -379,6 +417,54 @@ const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* [Gitea #134] One small chip that tells members a room's call rules before
|
||||
* they hit the server-side guard: "8/10" when a voice limit is set, a muted
|
||||
* camera glyph when the room is audio-only. Nothing when no rule exists; the
|
||||
* tooltip carries the words. Desktop only (the mobile header is tight).
|
||||
*/
|
||||
function CallRulesChip({ room }: { room: Room }) {
|
||||
const { current, max } = useVoiceChannelFull(room);
|
||||
const { allowCamera, allowScreenshare } = useRoomCallPolicy(room);
|
||||
const audioOnly = !allowCamera && !allowScreenshare;
|
||||
if (max <= 0 && !audioOnly) return null;
|
||||
const words = [
|
||||
max > 0 ? `Voice limit ${max} (${current} in call)` : undefined,
|
||||
audioOnly ? 'Audio-only room' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{words}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Chip
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
aria-label={words}
|
||||
tabIndex={0}
|
||||
before={audioOnly ? <Icon size="50" src={Icons.VideoCameraMute} /> : undefined}
|
||||
>
|
||||
{max > 0 && (
|
||||
<Text size="T200">
|
||||
{current}/{max}
|
||||
</Text>
|
||||
)}
|
||||
</Chip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -695,6 +781,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
|
||||
{!room.isCallRoom() &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
@@ -101,6 +102,7 @@ export function ScheduleMessageModal({
|
||||
};
|
||||
|
||||
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
@@ -124,10 +126,10 @@ export function ScheduleMessageModal({
|
||||
return;
|
||||
}
|
||||
setPreview({
|
||||
label: formatFriendlyDateTime(sendAt.getTime()),
|
||||
label: formatFriendlyDateTime(sendAt.getTime(), prefs),
|
||||
relative: formatRelativeTime(diffMs),
|
||||
});
|
||||
}, [getSendAt]);
|
||||
}, [getSendAt, prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
|
||||
@@ -6,32 +6,17 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: number): string {
|
||||
const date = new Date(sendAt);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
date.getFullYear() === tomorrow.getFullYear() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getDate() === tomorrow.getDate();
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} ${timeStr}`;
|
||||
}
|
||||
|
||||
export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const formatSendAt = (sendAt: number) => formatFriendlyDateTime(sendAt, prefs);
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -27,7 +27,8 @@ import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
|
||||
import { getToday, getYesterday } from '../../../utils/time';
|
||||
import { formatDate, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
@@ -50,6 +51,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
const [ts, setTs] = useState(() => Date.now());
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
||||
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
||||
@@ -131,7 +133,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleTimePicker}
|
||||
>
|
||||
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
|
||||
<Text size="B300">{formatTime(ts, { hour24Clock })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={timePickerCords}
|
||||
@@ -172,7 +174,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleDatePicker}
|
||||
>
|
||||
<Text size="B300">{timeDayMonthYear(ts)}</Text>
|
||||
<Text size="B300">{formatDate(ts, { hour24Clock, dateFormatString })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={datePickerCords}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { sanitizeCustomHtml } from '../../../utils/sanitize';
|
||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { formatTimestamp } from '../../../utils/formatTimestamp';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
@@ -229,11 +229,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
|
||||
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
|
||||
|
||||
const formatTs = (ts: number): string => {
|
||||
const time = timeHourMinute(ts, hour24Clock);
|
||||
const date = timeDayMonYear(ts, dateFormatString);
|
||||
return `${date} at ${time}`;
|
||||
};
|
||||
const formatTs = (ts: number): string => formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
|
||||
const originalContent = getOriginalContent(mEvent);
|
||||
|
||||
|
||||
@@ -338,35 +338,43 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
}
|
||||
|
||||
const commentBody = comment.trim();
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => {
|
||||
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
|
||||
// Encrypted destinations keep the original (possibly encrypted-attachment)
|
||||
// content; unencrypted ones get the plaintext version, or fail outright if
|
||||
// that couldn't be built — never fall back to sending the encrypted `file`.
|
||||
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
|
||||
if (fwdContent.file && !destEncrypted && !contentToSend) {
|
||||
return Promise.reject(new Error(plaintextContentError));
|
||||
}
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
|
||||
// Send the optional comment first so it reads as a note above the
|
||||
// forwarded content. The room counts as failed if either send rejects.
|
||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||
// failed) doesn't post the comment twice — only the missing forward.
|
||||
const needsComment = commentBody && !commentSentRef.current.has(id);
|
||||
const step = needsComment
|
||||
? mx
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
||||
.then(() => {
|
||||
commentSentRef.current.add(id);
|
||||
})
|
||||
: Promise.resolve();
|
||||
return step.then(sendForward);
|
||||
}),
|
||||
);
|
||||
// Rooms are sent ONE AT A TIME. matrix-js-sdk queues message sends, and
|
||||
// when one queued send fails permanently (e.g. 403 in a room you cannot
|
||||
// post to) the scheduler rejects every send still waiting in the queue —
|
||||
// so firing all rooms concurrently turned one forbidden target into
|
||||
// "Failed to forward" for all of them, with half-sent comments (Gitea #194).
|
||||
const sendToRoom = (id: string): Promise<unknown> => {
|
||||
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
|
||||
// Encrypted destinations keep the original (possibly encrypted-attachment)
|
||||
// content; unencrypted ones get the plaintext version, or fail outright if
|
||||
// that couldn't be built — never fall back to sending the encrypted `file`.
|
||||
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
|
||||
if (fwdContent.file && !destEncrypted && !contentToSend) {
|
||||
return Promise.reject(new Error(plaintextContentError));
|
||||
}
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
|
||||
// Send the optional comment first so it reads as a note above the
|
||||
// forwarded content. The room counts as failed if either send rejects.
|
||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||
// failed) doesn't post the comment twice — only the missing forward.
|
||||
const needsComment = commentBody && !commentSentRef.current.has(id);
|
||||
const step = needsComment
|
||||
? mx
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
||||
.then(() => {
|
||||
commentSentRef.current.add(id);
|
||||
})
|
||||
: Promise.resolve();
|
||||
return step.then(sendForward);
|
||||
};
|
||||
const results: PromiseSettledResult<unknown>[] = [];
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
results.push(...(await Promise.allSettled([sendToRoom(id)])));
|
||||
}
|
||||
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const Wrap = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
width: toRem(480),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Grid = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'grid',
|
||||
gap: toRem(3),
|
||||
width: '100%',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Cell = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
aspectRatio: '1 / 1',
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
selectors: {
|
||||
'&:focus-visible': {
|
||||
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
|
||||
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
transition: 'transform 150ms',
|
||||
selectors: {
|
||||
[`${Cell}:hover &`]: {
|
||||
transform: 'scale(1.03)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellBlur = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const PlayBadge = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(36),
|
||||
height: toRem(36),
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: 'white',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Footer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
marginTop: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const FooterButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'transparent',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
textDecorationColor: 'currentColor',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
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';
|
||||
|
||||
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 5–6 → 3, 7+ → 4. */
|
||||
export const gridColumns = (n: number): number => {
|
||||
if (n <= 2) return 2;
|
||||
if (n === 3) return 3;
|
||||
if (n === 4) return 2;
|
||||
if (n <= 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
/** "5 photos" / "2 videos" / "6 items". */
|
||||
export const describeGroup = (events: MatrixEvent[]): string => {
|
||||
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
|
||||
const n = events.length;
|
||||
if (videos === 0) return `${n} photos`;
|
||||
if (videos === n) return `${n} videos`;
|
||||
return `${n} items`;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
mEvent,
|
||||
load,
|
||||
onOpen,
|
||||
}: {
|
||||
mEvent: MatrixEvent;
|
||||
load: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
}) {
|
||||
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 blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
|
||||
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
|
||||
const body = typeof content.body === 'string' ? content.body : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Cell}
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={() => onOpen(mEvent.getId() ?? '')}
|
||||
>
|
||||
{blurHash && media.status !== 'ok' && (
|
||||
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
|
||||
)}
|
||||
{load && media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
|
||||
{isVideo && (
|
||||
<span className={css.PlayBadge}>
|
||||
<Icon src={Icons.Play} size="200" filled />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type MediaGroupGridProps = {
|
||||
events: MatrixEvent[];
|
||||
mediaAutoLoad: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
onShowSeparately: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #137] Several consecutive image/video events from one sender shown
|
||||
* as one grid. Each tile opens the room's shared lightbox at that event, so
|
||||
* ←/→ walk through the group (and beyond). Purely a render-time grouping.
|
||||
*/
|
||||
export function MediaGroupGrid({
|
||||
events,
|
||||
mediaAutoLoad,
|
||||
onOpen,
|
||||
onShowSeparately,
|
||||
}: MediaGroupGridProps) {
|
||||
const [load, setLoad] = useState(mediaAutoLoad);
|
||||
const columns = gridColumns(events.length);
|
||||
|
||||
return (
|
||||
<div className={css.Wrap}>
|
||||
<div
|
||||
className={css.Grid}
|
||||
role="group"
|
||||
aria-label={describeGroup(events)}
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
onClick={load ? undefined : () => setLoad(true)}
|
||||
>
|
||||
{events.map((ev) => (
|
||||
<Cell
|
||||
key={ev.getId()}
|
||||
mEvent={ev}
|
||||
load={load}
|
||||
onOpen={load ? onOpen : () => setLoad(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.Footer}>
|
||||
<Text size="T200" priority="300">
|
||||
{describeGroup(events)}
|
||||
{!load && ' · tap to load'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text
|
||||
as="button"
|
||||
size="T200"
|
||||
priority="300"
|
||||
className={css.FooterButton}
|
||||
onClick={onShowSeparately}
|
||||
>
|
||||
Show separately
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown under the last of a gallery the user split up, to put it back together. */
|
||||
export function RegroupChip({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className={css.Footer}>
|
||||
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
|
||||
Show as gallery
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,8 @@ import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
import { RemindMeDialog } from './RemindMeDialog';
|
||||
import { useLongPress } from '../../../hooks/useLongPress';
|
||||
import { ActionSheet } from '../../../components/action-sheet';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
@@ -212,7 +214,7 @@ export const MessageAllReactionItem = as<
|
||||
return (
|
||||
<>
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={open}
|
||||
@@ -1066,11 +1068,33 @@ export const Message = React.memo(
|
||||
</Box>
|
||||
);
|
||||
|
||||
// [Gitea #166] Touch: a long-press (or Android's contextmenu) opens the
|
||||
// actions as a bottom sheet instead of a hover bar + anchored popout.
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const {
|
||||
coarse: coarsePointer,
|
||||
handlers: longPressHandlers,
|
||||
suppressContextMenu,
|
||||
} = useLongPress(
|
||||
useCallback(() => {
|
||||
if (edit) return;
|
||||
// The press itself starts a text/image selection on touch browsers;
|
||||
// that is a by-product, not the user's intent — drop it.
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSheetOpen(true);
|
||||
}, [edit]),
|
||||
);
|
||||
|
||||
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) {
|
||||
// Android fires contextmenu for the same long-press we already handled.
|
||||
if (!suppressContextMenu.current) setSheetOpen(true);
|
||||
return;
|
||||
}
|
||||
setMenuAnchor({
|
||||
x: evt.clientX,
|
||||
y: evt.clientY,
|
||||
@@ -1086,6 +1110,7 @@ export const Message = React.memo(
|
||||
|
||||
const closeMenu = () => {
|
||||
setMenuAnchor(undefined);
|
||||
setSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenEmojiBoard: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
@@ -1105,6 +1130,201 @@ export const Message = React.memo(
|
||||
|
||||
const isThreadedMessage = mEvent.threadRootId !== undefined;
|
||||
|
||||
// The full action menu, shared by the desktop PopOut and the touch
|
||||
// bottom sheet (#166).
|
||||
const menuJSX = (
|
||||
<Menu data-message-menu aria-label="Message actions">
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
{canSendReaction && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.SmilePlus} />}
|
||||
radii="300"
|
||||
onClick={handleAddReactions}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Add Reaction
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{relations && (
|
||||
<MessageAllReactionItem room={room} relations={relations} onClose={closeMenu} />
|
||||
)}
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onReplyClick(evt);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Reply
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{!mEvent.isRedacted() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowRight} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setForwardOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Forward
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Star} filled={isBookmarked(mEvent.getId()!)} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
const eventId = mEvent.getId()!;
|
||||
if (isBookmarked(eventId)) {
|
||||
removeBookmark(eventId);
|
||||
} else {
|
||||
const content = mEvent.getContent();
|
||||
const body: string = (content?.body as string | undefined) ?? '';
|
||||
// For E2EE rooms useBookmarks strips the text
|
||||
// fields before persisting (account data is
|
||||
// server-readable); the panel resolves them live.
|
||||
addBookmark({
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
savedAt: Date.now(),
|
||||
previewText: body.slice(0, 120),
|
||||
roomName: room.name,
|
||||
senderName: senderDisplayName,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
{isBookmarked(mEvent.getId()!) ? 'Remove Bookmark' : 'Bookmark Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Clock} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setRemindOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Remind Me
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isThreadedMessage && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onReplyClick(evt, true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Reply in Thread
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={() => {
|
||||
onEditId(mEvent.getId());
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!hideReadReceipts && (
|
||||
<MessageReadReceiptItem
|
||||
room={room}
|
||||
eventId={mEvent.getId() ?? ''}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{showDeveloperTools && (
|
||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLotusLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />}
|
||||
</Box>
|
||||
{(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) && (
|
||||
<>
|
||||
<Line size="300" />
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Send} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).resendEvent(mEvent, room);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Retry Send
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Cross} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).cancelPendingEvent(mEvent);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Cancel Message
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
|
||||
<>
|
||||
<Line size="300" />
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
{!mEvent.isRedacted() && canDelete && (
|
||||
<MessageDeleteItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
{mEvent.getSender() !== mx.getUserId() && (
|
||||
<MessageReportItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, className, {
|
||||
@@ -1114,7 +1334,12 @@ export const Message = React.memo(
|
||||
})}
|
||||
role="article"
|
||||
aria-label={
|
||||
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||
collapse
|
||||
? messageAriaLabel(senderDisplayName, mEvent.getTs(), {
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
@@ -1234,274 +1459,7 @@ export const Message = React.memo(
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
{canSendReaction && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.SmilePlus} />}
|
||||
radii="300"
|
||||
onClick={handleAddReactions}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Add Reaction
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{relations && (
|
||||
<MessageAllReactionItem
|
||||
room={room}
|
||||
relations={relations}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onReplyClick(evt);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Reply
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{!mEvent.isRedacted() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowRight} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setForwardOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Forward
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.Star}
|
||||
filled={isBookmarked(mEvent.getId()!)}
|
||||
/>
|
||||
}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
const eventId = mEvent.getId()!;
|
||||
if (isBookmarked(eventId)) {
|
||||
removeBookmark(eventId);
|
||||
} else {
|
||||
const content = mEvent.getContent();
|
||||
const body: string =
|
||||
(content?.body as string | undefined) ?? '';
|
||||
// For E2EE rooms useBookmarks strips the text
|
||||
// fields before persisting (account data is
|
||||
// server-readable); the panel resolves them live.
|
||||
addBookmark({
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
savedAt: Date.now(),
|
||||
previewText: body.slice(0, 120),
|
||||
roomName: room.name,
|
||||
senderName: senderDisplayName,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
{isBookmarked(mEvent.getId()!)
|
||||
? 'Remove Bookmark'
|
||||
: 'Bookmark Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Clock} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setRemindOpen(true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Remind Me
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!isThreadedMessage && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onReplyClick(evt, true);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Reply in Thread
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={() => {
|
||||
onEditId(mEvent.getId());
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!hideReadReceipts && (
|
||||
<MessageReadReceiptItem
|
||||
room={room}
|
||||
eventId={mEvent.getId() ?? ''}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{showDeveloperTools && (
|
||||
<MessageSourceCodeItem
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLotusLinkItem
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
{canPinEvent && (
|
||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
</Box>
|
||||
{(mEvent.status === EventStatus.NOT_SENT ||
|
||||
mEvent.status === EventStatus.CANCELLED) && (
|
||||
<>
|
||||
<Line size="300" />
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Send} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).resendEvent(mEvent, room);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Retry Send
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Cross} />}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).cancelPendingEvent(mEvent);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Cancel Message
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{((!mEvent.isRedacted() && canDelete) ||
|
||||
mEvent.getSender() !== mx.getUserId()) && (
|
||||
<>
|
||||
<Line size="300" />
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
{!mEvent.isRedacted() && canDelete && (
|
||||
<MessageDeleteItem
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{mEvent.getSender() !== mx.getUserId() && (
|
||||
<MessageReportItem
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
{menuJSX}
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
@@ -1522,21 +1480,74 @@ export const Message = React.memo(
|
||||
</div>
|
||||
)}
|
||||
{messageLayout === MessageLayout.Compact && (
|
||||
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
||||
<CompactLayout
|
||||
before={headerJSX}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...longPressHandlers}
|
||||
>
|
||||
{msgContentJSX}
|
||||
</CompactLayout>
|
||||
)}
|
||||
{messageLayout === MessageLayout.Bubble && (
|
||||
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
||||
<BubbleLayout
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...longPressHandlers}
|
||||
>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
)}
|
||||
{messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && (
|
||||
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||
<ModernLayout
|
||||
before={avatarJSX}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...longPressHandlers}
|
||||
>
|
||||
{headerJSX}
|
||||
{msgContentJSX}
|
||||
</ModernLayout>
|
||||
)}
|
||||
{sheetOpen && (
|
||||
<ActionSheet open onClose={() => setSheetOpen(false)} aria-label="Message actions">
|
||||
{canSendReaction && (
|
||||
<Box alignItems="Center" justifyContent="Center" gap="200">
|
||||
<MessageQuickReactions
|
||||
onReaction={(key, shortcode) => {
|
||||
onReactionToggle(mEvent.getId()!, key, shortcode);
|
||||
setSheetOpen(false);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
aria-label="Add reaction"
|
||||
onClick={() => {
|
||||
setSheetOpen(false);
|
||||
// the emoji board anchors to the message; open it after the sheet is gone
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-message-id="${mEvent.getId()}"]`,
|
||||
);
|
||||
setEmojiBoardAnchor(
|
||||
el?.getBoundingClientRect() ?? {
|
||||
x: 0,
|
||||
y: window.innerHeight / 2,
|
||||
width: window.innerWidth,
|
||||
height: 0,
|
||||
},
|
||||
);
|
||||
}, 50);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.SmilePlus} size="100" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
{menuJSX}
|
||||
</ActionSheet>
|
||||
)}
|
||||
{forwardOpen && (
|
||||
<ForwardMessageDialog mEvent={mEvent} onClose={() => setForwardOpen(false)} />
|
||||
)}
|
||||
@@ -1588,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,4 +1,11 @@
|
||||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
@@ -23,6 +30,7 @@ import * as css from './styles.css';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { expandedReactionMessages, fitReactionRow } from '../../../utils/reactionOverflow';
|
||||
|
||||
export type ReactionsProps = {
|
||||
room: Room;
|
||||
@@ -42,6 +50,49 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
|
||||
);
|
||||
|
||||
// [Gitea #138] Collapse to one row + "+N" when the chips would wrap.
|
||||
// Overflowing chips stay in the DOM (invisible, clipped by max-height) so
|
||||
// the container keeps its natural width and every chip stays measurable.
|
||||
const [expanded, setExpandedState] = useState(() => expandedReactionMessages.has(mEventId));
|
||||
const setExpanded = (next: boolean) => {
|
||||
if (next) expandedReactionMessages.add(mEventId);
|
||||
else expandedReactionMessages.delete(mEventId);
|
||||
setExpandedState(next);
|
||||
};
|
||||
const [limit, setLimit] = useState<number | undefined>();
|
||||
const [rowHeight, setRowHeight] = useState<number | undefined>();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLElement>());
|
||||
const keys = reactions.map(([key]) => key).join('\u0000');
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
setLimit(fitReactionRow(widths, moreWidth, gap, rowWidth));
|
||||
setRowHeight(Math.max(0, ...chips.map((el) => el?.getBoundingClientRect().height ?? 0)));
|
||||
}, [keys]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return undefined;
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
const collapsed = !expanded && limit !== undefined;
|
||||
const hiddenCount = collapsed ? reactions.length - (limit ?? 0) : 0;
|
||||
const hiddenStyle: React.CSSProperties = { visibility: 'hidden', pointerEvents: 'none' };
|
||||
|
||||
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
@@ -56,47 +107,102 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
: {}),
|
||||
}}
|
||||
ref={(el) => {
|
||||
containerRef.current = el;
|
||||
if (typeof ref === 'function') ref(el);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
>
|
||||
{reactions.map(([key, events]) => {
|
||||
{reactions.map(([key, events], index) => {
|
||||
const rEvents = Array.from(events);
|
||||
if (rEvents.length === 0 || typeof key !== 'string') return null;
|
||||
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
|
||||
const isPressed = !!myREvent?.getRelation();
|
||||
const hidden = collapsed && index >= (limit ?? 0);
|
||||
const chip = (targetRef?: React.RefCallback<HTMLElement>) => (
|
||||
<Reaction
|
||||
ref={(el: HTMLElement | null) => {
|
||||
targetRef?.(el);
|
||||
if (el) chipRefs.current.set(key, el);
|
||||
else chipRefs.current.delete(key);
|
||||
}}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
aria-hidden={hidden || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
style={hidden ? hiddenStyle : undefined}
|
||||
useAuthentication={useAuthentication}
|
||||
/>
|
||||
);
|
||||
// The "+N" chip sits right after the last visible chip so it lands on row 1.
|
||||
const moreChip = index === (limit ?? 0) - 1 && (
|
||||
<button
|
||||
key="more"
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-expanded={false}
|
||||
aria-label={`Show ${hiddenCount} more reactions`}
|
||||
aria-hidden={!collapsed || undefined}
|
||||
tabIndex={collapsed ? undefined : -1}
|
||||
style={collapsed ? undefined : { ...hiddenStyle, position: 'absolute' }}
|
||||
>
|
||||
<Text as="span" size="T300" dir="ltr">
|
||||
{`+${collapsed ? hiddenCount : reactions.length - 1}`}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
if (hidden) return <React.Fragment key={key}>{chip()}</React.Fragment>;
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={key}
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => (
|
||||
<Reaction
|
||||
ref={targetRef}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
useAuthentication={useAuthentication}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<React.Fragment key={key}>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => chip(targetRef)}
|
||||
</TooltipProvider>
|
||||
{moreChip}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{expanded && limit !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-expanded
|
||||
aria-label="Show fewer reactions"
|
||||
>
|
||||
<Text as="span" size="T300">
|
||||
less
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={!!viewer}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useReminders } from '../../../hooks/useReminders';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
@@ -34,11 +36,11 @@ type RemindMeDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getPresets(): Array<{ label: string; ms: number }> {
|
||||
function getPresets(prefs: TimestampPrefs): Array<{ label: string; ms: number }> {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const timeLabel = formatTime(tomorrow.getTime(), prefs);
|
||||
return [
|
||||
{ label: 'In 20 minutes', ms: 20 * 60_000 },
|
||||
{ label: 'In 1 hour', ms: 60 * 60_000 },
|
||||
@@ -58,7 +60,8 @@ function defaultCustomDate(): Date {
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder, removeReminder, reminders } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const presets = useMemo(() => getPresets(prefs), [prefs]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
|
||||
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
|
||||
{formatFriendlyDateTime(r.timestamp)}
|
||||
{formatFriendlyDateTime(r.timestamp, prefs)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
@@ -193,7 +196,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={() => handleCancelExisting(r.timestamp)}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
||||
|
||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||
@@ -136,3 +136,38 @@ test('edited message forwards m.new_content', () => {
|
||||
assert.equal(content['m.new_content'], undefined);
|
||||
assert.equal(content['m.relates_to'], undefined);
|
||||
});
|
||||
|
||||
test('forward stamps io.lotus.forwarded with the original sender / time / room / event', () => {
|
||||
const mx = makeClient();
|
||||
const mEvent = makeEvent({ content: { msgtype: 'm.text', body: 'prov' } });
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
assert.ok(content);
|
||||
const meta = getForwardedMeta(content);
|
||||
assert.ok(meta);
|
||||
assert.equal(meta.sender, mEvent.getSender());
|
||||
assert.equal(meta.origin_server_ts, mEvent.getTs());
|
||||
assert.equal(meta.room_id, mEvent.getRoomId());
|
||||
assert.equal(meta.event_id, mEvent.getId());
|
||||
});
|
||||
|
||||
test('forwarding a forward keeps the ORIGINAL provenance', () => {
|
||||
const mx = makeClient();
|
||||
const original = {
|
||||
sender: '@origin:example.org',
|
||||
origin_server_ts: 1000,
|
||||
room_id: '!origin:example.org',
|
||||
event_id: '$origin:example.org',
|
||||
};
|
||||
const mEvent = makeEvent({
|
||||
content: { msgtype: 'm.text', body: 'again', 'io.lotus.forwarded': original },
|
||||
});
|
||||
const content = buildForwardContent(mx, mEvent);
|
||||
assert.ok(content);
|
||||
assert.deepEqual(getForwardedMeta(content), original);
|
||||
});
|
||||
|
||||
test('getForwardedMeta rejects malformed metadata', () => {
|
||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': 'nope' }), undefined);
|
||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
||||
assert.equal(getForwardedMeta({}), undefined);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import { IEncryptedFile } from '../../../../types/matrix/common';
|
||||
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||
* message stands alone in the target room
|
||||
*/
|
||||
/**
|
||||
* Provenance stamped onto a forwarded event so the receiving client can show
|
||||
* "Forwarded from <sender> in <room> · <time>" and jump to the original
|
||||
* (Discord-style). `room_id`/`event_id` let a recipient who is also in the
|
||||
* source room jump there; a recipient who is not just sees the sender + time
|
||||
* (the source room's NAME is deliberately not included — it is not theirs to
|
||||
* see). Other Matrix clients ignore the key and show the plain content.
|
||||
*/
|
||||
export const FORWARDED_KEY = 'io.lotus.forwarded';
|
||||
export type ForwardedMeta = {
|
||||
sender: string;
|
||||
origin_server_ts: number;
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
};
|
||||
export const getForwardedMeta = (content: Record<string, unknown>): ForwardedMeta | undefined => {
|
||||
const raw = content[FORWARDED_KEY];
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const m = raw as Partial<ForwardedMeta>;
|
||||
if (typeof m.sender !== 'string' || typeof m.origin_server_ts !== 'number') return undefined;
|
||||
return {
|
||||
sender: m.sender,
|
||||
origin_server_ts: m.origin_server_ts,
|
||||
room_id: typeof m.room_id === 'string' ? m.room_id : '',
|
||||
event_id: typeof m.event_id === 'string' ? m.event_id : '',
|
||||
};
|
||||
};
|
||||
|
||||
export function buildForwardContent(
|
||||
mx: MatrixClient,
|
||||
mEvent: MatrixEvent,
|
||||
@@ -41,6 +69,16 @@ export function buildForwardContent(
|
||||
if (typeof content.formatted_body === 'string') {
|
||||
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||
}
|
||||
|
||||
// Forwarding a forward keeps the ORIGINAL provenance rather than chaining.
|
||||
const existing = getForwardedMeta(content);
|
||||
const meta: ForwardedMeta = existing ?? {
|
||||
sender: mEvent.getSender() ?? '',
|
||||
origin_server_ts: mEvent.getTs(),
|
||||
room_id: mEvent.getRoomId() ?? '',
|
||||
event_id: eventId ?? '',
|
||||
};
|
||||
content[FORWARDED_KEY] = meta;
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user