Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 13:11:38 -04:00
jaredandClaude Opus 5 bd8c79e0e6 feat(media): consecutive photos/videos render as one gallery grid (#137)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 5m9s
Client-side only: every file is still its own standard m.image/m.video
event, so Element and friends keep seeing N plain images. In Lotus a run
of media from one sender — contiguous, ≤ 60 s apart, no reply/thread/edit
relation, up to 10 — renders once, at its last event, as a 2–4 column
grid of square thumbnails (blurhash placeholder, video play badge,
tap-to-load when media auto-load is off). A member with reactions or a
thread closes its group so those stay visible under the rendered event.

Tapping a tile opens the lightbox on just that group in send order
(←/→, zoom, download, jump). "Show separately" splits a group back into
individual messages for the session; "Show as gallery" undoes it.

Planning is lazy per render pass (utils/mediaGroups.ts, unit-tested):
the first media event met plans its whole run in both directions, so a
virtual window that starts mid-run agrees with one that starts before it.

Verified: 5 files dropped at once in an encrypted room — both sender and
recipient see one 5-tile grid with decrypted thumbnails; desktop + phone;
a reaction on photo 3 yields [1–3]+👍 and [4–5].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:48:13 -04:00
jaredandClaude Opus 5 4d4a76214a refactor(time): one timestamp formatter honouring the clock/date settings (#139)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.

utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.

Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:28:47 -04:00
jaredandClaude Opus 5 8d11a62e14 feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 4m38s
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.

useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.

Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:15:09 -04:00
jaredandClaude Opus 5 bf05751eca feat(messages): collapse reactions to one row with a +N chip (#138)
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Many distinct reactions used to wrap into a tall stack (16 reactions =
4 rows at phone width). Now only the first row is shown, ending in a
"+N" chip; tapping it expands inline and a "less" chip collapses again.
Expanded state is remembered per message for the session. Nothing changes
when the reactions already fit on one row.

Overflowing chips stay in the DOM (invisible, aria-hidden, untabbable,
clipped by max-height) so the container keeps its natural width — which
keeps shrink-to-fit bubble layout stable — and each chip stays measurable.
utils/reactionOverflow.ts holds the unit-tested fit calculation; a
ResizeObserver re-fits on width changes. "+N" is forced LTR for RTL UIs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:54:52 -04:00
jaredandClaude Opus 5 2bdb2eb4cb feat(messages): word diff of the last edit on "(edited)" hover (#144)
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Canceled after 1m37s
Hovering or focusing "(edited)" shows a tooltip with only the most recent
edit as a word diff — removed words struck, added words bold — plus a
+N/−N summary. Clicking still opens the full history viewer. On touch, a
long-press on the label shows the same diff as a popout (a plain tap opens
the viewer; the message's own long-press action sheet is not triggered).

utils/wordDiff.ts is a unit-tested LCS over words that ignores whitespace-
only changes and gives up past 400 words. Only plain-text bodies are
diffed; formatted edits fall back to the viewer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:34:38 -04:00
jaredandClaude Opus 5 4fe9c87010 feat(calls): live-call dot on the space rail (#148)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A small red dot on a space tab when any room in that space (recursively)
has an active MatrixRTC session, so a live call is visible even when you
are looking at a different space or your DMs. Hidden while the space is
selected — the room list already shows the Live badge there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:25:49 -04:00
jaredandClaude Opus 5 b69099a862 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.9
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 3m59s
Brings speakingWhileMuted in io.lotus.call_state for the 'You're muted' nudge (#117).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:17:21 -04:00
jaredandClaude Opus 5 dea1f7afc0 feat(profile): 'Active now' / 'Last active …' line from presence (#150)
One line under the handle in the user profile popover, from the presence the
SDK already receives (currently_active + last_active_ago): 'Active now',
'Active just now', 'Last active 12 min ago / 3 hours ago / yesterday / 5 days
ago / over a month ago'. Nothing is shown without presence data or for an
offline user with no timestamp (Hide Online Status users), so nothing new is
exposed; re-renders on the existing presence events, no polling. Wording
unit-tested; verified headless ('Active now' for an online member).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:16:40 -04:00
jaredandClaude Opus 5 7da91bd803 feat(timeline): 'Back to where you were' after a jump (#127)
Clicking a reply quote (or arriving via a thread-panel / search / permalink
jump, i.e. an eventId route change) remembers the message that was in the
middle of the viewport and shows a Primary chip next to Jump to Latest —
'Back to where you were' — which scrolls/navigates back to it. Nothing is
rendered normally; the chip expires after 20 s or once used. Verified headless:
reply quote → original 120 messages up is shown + chip; chip → back at the
reply, chip gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:13:29 -04:00
jaredandClaude Opus 5 2a0c409180 feat(messages): code blocks get a Wrap toggle next to Copy (#131)
Verified upstream first: the Copy chip (with a 'Copied' state) already exists
on every code block. Added the missing half: a Wrap chip that switches long
lines to pre-wrap (default stays no-wrap + horizontal scroll); the choice is
remembered for the session, not persisted. Markdown/HTML output, layout widths
and the syntax theme are untouched. Verified headless: scrollWidth 1016 →
612 px with wrapping on, 'Copied' state on copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:09:25 -04:00
jaredandClaude Opus 5 9363629ea2 feat(security): recovery key leaves the clipboard after 60 s, visibly (#156)
useSensitiveCopy: the recovery key's Copy button becomes 'Copied · clears in
60 s' and counts down; at zero the clipboard is cleared only if it still holds
the key (readText() where permitted — if the browser refuses to read, nothing
is wiped rather than risk eating something else). Any other copy made in the
app cancels the timer. No setting. Verified headless with a fake clock:
countdown ticks, clipboard emptied at 0; copying something else mid-countdown
cancels and leaves that content untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 23:06:02 -04:00
jaredandClaude Opus 5 74d8e3119b feat(security): confirm before opening a link whose text names a different site (#122)
utils/linkSafety.ts compares the registrable domain the visible text claims
(when it looks like a URL/host) with the href's; a mismatch, or a punycode
(IDN) destination, renders the anchor as SuspiciousLink, whose click opens a
confirm — "It shows matrix.lotusguild.org but goes to evil.example." with the
full URL, Cancel / Open anyway (opens in a new tab with noopener). Honest links
are untouched: same registrable domain (youtube.com text over www.youtube.com,
bbc.co.uk over news.bbc.co.uk), plain-word text, mailto:, matrix.to and Lotus
permalinks, anchors with non-text children. Comparator unit-tested (incl. a
Cyrillic-а paypal homograph); verified headless that the phish and IDN
messages are flagged, the honest ones are not, the click shows the confirm and
does not navigate, Cancel keeps you put, Open anyway opens the real target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:57:07 -04:00
jaredandClaude Opus 5 73b1d1e3a7 feat(calls): 'You're muted' nudge when talking into a muted mic (#117)
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.

Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:50:32 -04:00
jaredandClaude Opus 5 ef5d06eea3 ci(e2e): tier-3 regression suite against a Synapse the job starts itself (#220)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 4m17s
The e2e job now runs scripts/dev-homeserver.sh start + dev-seed.py before
Playwright (installing python3-venv if the runner lacks it) and stops it
afterwards. e2e/local-homeserver.spec.ts registers its own users and rooms
over the CS API and drives the built client — no prod secrets — covering the
fixes that were reproduced with scratch scripts this week: login/send/receive,
own-message scroll (#212), /kick toast (#216), upload 413 wording (#213),
forward provenance, thread panel at 1400px (#218), timeline lightbox (#219),
clock-skew banner via page.clock (#158), status save under the presence limit
(#226), long-press action sheet on a Pixel 7 emulation (#166). Skips itself
when no homeserver answers, so
> lotus-chat@4.12.7-lotus test:e2e
> playwright test

Running 16 tests using 1 worker

  ✓   1 [chromium] › e2e/boot.spec.ts:8:3 › boot › client boots to the login screen without errors (1.5s)
  ✓   2 [chromium] › e2e/boot.spec.ts:27:3 › boot › service worker script is served and registers (1.4s)
  ✓   3 [chromium] › e2e/boot.spec.ts:54:3 › boot › bundled Element Call loads in a frame (3.8s)
  -   4 [chromium] › e2e/e2ee-composer.spec.ts:67:3 › E2EE composer › logs in with a password and reaches the client
  -   5 [chromium] › e2e/e2ee-composer.spec.ts:84:3 › E2EE composer › creates a private encrypted room and sends a text message
  -   6 [chromium] › e2e/e2ee-composer.spec.ts:146:3 › E2EE composer › attaches a compressed image and it is sent encrypted
  ✓   7 [chromium] › e2e/local-homeserver.spec.ts:31:3 › local homeserver regression › logs in, opens a room, sends and receives (4.5s)
  ✓   8 [chromium] › e2e/local-homeserver.spec.ts:58:3 › local homeserver regression › your own message scrolls into view even after scrolling up (#212) (11.4s)
  ✓   9 [chromium] › e2e/local-homeserver.spec.ts:76:3 › local homeserver regression › /kick failure is reported, not swallowed (#216) (5.4s)
  ✓  10 [chromium] › e2e/local-homeserver.spec.ts:90:3 › local homeserver regression › upload failure shows a plain sentence, never the raw MatrixError (#213) (3.7s)
  ✓  11 [chromium] › e2e/local-homeserver.spec.ts:122:3 › local homeserver regression › forwarded message carries its provenance header (6.7s)
  ✓  12 [chromium] › e2e/local-homeserver.spec.ts:143:3 › local homeserver regression › thread panel: opens from the chip and yields the member drawer at 1400px (#218) (4.7s)
  ✓  13 [chromium] › e2e/local-homeserver.spec.ts:172:3 › local homeserver regression › timeline image opens the gallery lightbox (#219) (3.9s)
  ✓  14 [chromium] › e2e/local-homeserver.spec.ts:204:3 › local homeserver regression › warns when the local clock is far off the server (#158) (6.5s)
  ✓  15 [chromium] › e2e/local-homeserver.spec.ts:221:3 › local homeserver regression › status save survives the presence rate limit (#226) (12.6s)
  ✓  16 [chromium] › e2e/local-homeserver.spec.ts:265:3 › local homeserver regression › touch: long-press opens the message action sheet (#166) (5.0s)

  3 skipped
  13 passed (1.2m) still works cold. 13 pass
locally against dist + the dev homeserver in 1.8 min.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:38:39 -04:00
jaredandClaude Opus 5 7f17940d34 feat(calls): room header chip for voice limit / audio-only policy (#134)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m38s
One small Surface chip next to the header controls, only when a rule exists:
'0/10' (participants / io.lotus.voice_limit) and/or a muted-camera glyph when
the room's io.lotus.room_quality forbids camera and screenshare; the tooltip
and aria-label carry the words ('Voice limit 10 (0 in call) · Audio-only
room'). Desktop only — the mobile header is already tight. Verified headless:
appears with the rules set, disappears when they are cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:42:00 -04:00
jaredandClaude Opus 5 f5e7fb4746 fix(status): status save no longer fails on Synapse's 1-per-10s presence limit
Reported: 'Failed to save status — server may be rate limiting. Try again.'
on the first status change in a month. Root cause: Synapse rate-limits
PUT /presence/{user}/status to ONE request per 10 s per user by default
(rc_presence.per_user: per_second 0.1, burst_count 1), shared across all of
the user's devices, and our presence heartbeat (online/away on visibility and
activity changes, from every open tab/device) spends that budget — so a manual
save that lands within 10 s of a heartbeat gets a 429, which the form showed
as a dead end.

Two fixes: (1) the status save waits out Retry-After (bounded to ~25 s) via
setPresenceWithRetry instead of failing, and the error text now says what
actually happened (rate limit / server text / offline); (2) the heartbeat
dedupes — it only sends when presence or status actually changes (/sync
already keeps us online), so it stops burning the budget in the first place.
Unit-tested; reproduced headless with a routed 10 s limiter: heartbeat ok →
save 429 → retried 8 s later → saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:41:09 -04:00
jaredandClaude Opus 5 bbe91a24d8 fix(timeline): remember 'Read more' expansion per message for the session (#152)
CollapsibleBody kept its collapsed flag in component state, which the
timeline's windowed rendering throws away when a row unmounts. A module-level
Set<eventId> (never persisted, cleared on reload) now seeds the state, so
scrolling away and back, or a re-render, keeps an expanded message open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:32:15 -04:00
jaredandClaude Opus 5 f7d40460f5 paste: when the clipboard carries files, never let the HTML/URL fragment into the composer (#132)
Verified headless that pasting an image copied from a web page (bitmap +
text/html <img src alt> + text/plain URL) already produced only the upload
card and an empty composer; make that explicit with a preventDefault so a
future editor change can't regress it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:28:25 -04:00
jaredandClaude Opus 5 d2f56817b3 feat(calls): say when answering turns the camera on, offer audio-only (#135)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The only join path that skips the prescreen and can start with the camera on
is answering a video call with 'Join with Camera On' enabled. The incoming
dialog and the in-call banner now say 'Answering turns your camera on', label
the primary button 'Answer with camera' and add 'Answer without camera'.
Nothing changes for voice calls or when the setting is off. The other
no-prescreen paths already can't start on camera: the sidebar join uses a
session video pref that is only ever true after the user turned it on
themselves, and it is guarded by the same setting.

Verified headless: video call + setting on → notice + both buttons, 'Answer
without camera' joins with videoEnabled=false; voice call → plain Answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:26:31 -04:00
jaredandClaude Opus 5 33e16e85b3 feat(calls): undeafen catch-up toast (#128)
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 2m23s
On deafen the participant set is snapshotted; on undeafen it is diffed and,
only if it changed and you were deafened for at least 10 s, one auto-dismissing
toast says 'While you were deafened: Alice, Bob joined · Cole left' (names
capped at 3 + N more). Rides the membership stream that already drives the
join/leave sounds — no new subscriptions; PTT holds don't touch deafen so they
can't trigger it. Verified headless: short deafen → nothing; bob leaves during
an 11 s deafen → 'bob left'; long deafen with no change → nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:17:58 -04:00
jaredandClaude Opus 5 f23b215efa fix(calls): release a push-to-talk hold after 5 minutes (#136)
Elbow-on-the-keyboard guard: a PTT hold longer than 5 min (fixed, not a
setting) is released exactly like a keyup — pttActive off, mic muted — with a
toast 'Push to talk released after 5 minutes — press the key again to keep
talking.' A fresh press re-engages normally. One watchdog shared by the DOM
path and the desktop-global hotkey path; keydown auto-repeat no longer
restarts anything; non-PTT mode, deafen and the mic button are untouched.
Helper unit-tested with fake timers; verified headless with Playwright's clock:
held → still open at +4 min → released + toast at +5 min → re-press works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:13:14 -04:00
jaredandClaude Opus 5 821760131c chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.8
Picks up the fork change that shows the SFU token service's refusal reason
("Can't join this call — This voice channel is full.") instead of the generic
OPEN_ID_ERROR page. Verified headless with a routed 403 on /sfu/get.
(lotus.6/7 were tagged but failed CI lint; lotus.8 is the published one.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 17:09:44 -04:00
jaredandClaude Opus 5 edb4624796 feat(privacy): delete all my messages in a room (#169)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m40s
Room Settings → General → Privacy: 'Your messages in this room' with a
Delete all… flow. The confirm dialog first counts your events with a
server-side sender-filtered /messages walk (live count), then asks to confirm
with the number — typing the room name above 50 — and offers 'Leave the room
afterwards'. Files are called out as not purged by a redaction.

The job runs outside React (closing settings is fine): sequential redactEvent
with 429 back-off, 404/already-redacted skipped, progress on the tile with
Cancel, pending ids persisted per room so a reload shows Resume/Discard, a
toast when done. State events are never touched; reactions, edits and thread
replies you sent are included; encrypted rooms work the same (nothing is
decrypted). Own events need no power level, so it is purely self-service.

Unit tests cover candidate filtering, the server filter, pagination, 429/404
handling and cancel. Verified headless: 62 of bob's events (60 messages, a
reaction, a thread reply) redacted in ~34 s while alice's 10 stayed; cancel at
17/40 → reload → Resume → 'Deleted 40 messages.'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:33:33 -04:00
jaredandClaude Opus 5 d4420905e6 feat(notifications): inline reply from a browser notification (#203)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m50s
Message notifications shown through the service worker now carry a text-input
'Reply' action (Chrome desktop/Android). On notificationclick with
action==='reply' the SW sends the typed text itself — it already holds the
newest session's access token for authenticated media — as m.room.message
(threaded when the notification was for a thread), so it works with the tab in
the background or closed; a failed send shows a 'Reply not sent' notification
that opens the room. Not offered for encrypted rooms (the SW cannot encrypt).
The sender lives in swReply.ts so it is unit-tested; verified headless that
the SW notification carries actions + {roomId, threadId}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 14:12:23 -04:00
jaredandClaude Opus 5 53a80adb57 feat(mobile): long-press a message opens a bottom sheet of actions (#166)
On coarse-pointer devices the hover action bar is unreachable and iOS Safari
has no contextmenu on long-press. useLongPress (450 ms, < 10 px movement,
cancelled by scroll/lift) now opens an ActionSheet — slides up from the
bottom, drag handle, swipe-down or backdrop tap to dismiss — with the quick
reactions row + 'Add reaction' on top and the same folds Menu the desktop
popout uses (extracted to a shared menuJSX) beneath. Android's contextmenu
echo of the same press is dropped; the press-induced text/image selection is
cleared; the finger-lift's synthetic click is swallowed so it can't hit a menu
item; the sheet ignores 'outside' clicks for its first 600 ms. Desktop is
unchanged (right-click → anchored popout).

Verified with Playwright touch emulation (Pixel 7, CDP touch events):
long-press → sheet with 👍 / Add Reaction / Reply / Forward / Bookmark /
Remind Me / Reply in Thread / Copy Link / Pin / Delete; swipe-down dismisses;
a scroll gesture does not open it; tapping Reply closes it and arms the
composer. Still wants a real iPhone + Android pass before it's called done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:59:03 -04:00
jaredandClaude Opus 5 e078a2cc10 privacy: wipe the local status-message mirror on logout (#204)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 3m23s
Of the two plaintext-localStorage items in #204, composer drafts were already
swept on logout (#41); the presence status message + expiry were deliberately
kept. They are PII with an authoritative copy in server presence, so sweep
them too. The test's localStorage mock now enumerates keys like the real
Storage object, so the prefix sweeps (drafts, status) are actually exercised —
the old 'draft preserved' assertion only passed because Object.keys() saw
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:38:47 -04:00
jaredandClaude Opus 5 c921f11521 feat(calls): 'Missed call from X' line in the timeline (#162)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The ring event (m.rtc.notification) now renders — only once the ring is over
(its lifetime lapsed or the caller hung up) and only if we did not join between
the ring and the hang-up — as a red 'Missed call from bob' line with the ring's
time, in the same style as the call-membership summaries. Derived entirely from
events already in the timeline; nothing is sent; a call we answered shows
nothing extra, and a later separate call cannot retro-actively 'answer' an
earlier missed one.

Verified headless in a DM: ring ignored → line appears once bob hangs up; a
second call that alice answers adds no line and leaves the first one in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:35:02 -04:00
jaredandClaude Opus 5 0e2671891f feat(e2ee): undecryptable placeholder says why and offers the fix (#159)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
'Unable to decrypt message' now carries one sentence per matrix-js-sdk
DecryptionFailureCode (describeDecryptionFailure, unit-tested against every
code so no raw code can leak into the copy) and, where something fixes it,
one button: no key backup → 'Set up key backup'; backup exists but this
session can't open it / key withheld for an unverified session → 'Unlock key
backup' / 'Verify this session' (both open Settings → Devices via a new
settingsRequestAtom that SettingsTab consumes); backup working or unknown
session (rust-crypto re-requests keys itself) → 'Retry', which re-runs
decryptEventIfNeeded. Sender-side problems are plain text. The raw code sits
in the placeholder's tooltip for support.

Verified headless on a fresh session in the encrypted seed room: each event
shows 'Sent before you signed in here, and no key backup exists…' with
tooltip HISTORICAL_MESSAGE_NO_KEY_BACKUP; the button opens Settings → Devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:23:25 -04:00
jaredandClaude Opus 5 6f25035341 privacy: self-host the VT323 font instead of Google Fonts (#214)
CI / Build & Quality Checks (push) Failing after 1m34s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
Every page load contacted fonts.googleapis.com / fonts.gstatic.com (IP +
referrer to Google, GDPR exposure) for the Lotus Terminal font. VT323 (OFL)
now ships in public/fonts as latin + latin-ext woff2 subsets with @font-face
declarations in the existing custom-fonts.css; the three Google tags are gone
from index.html. Also deletes three unreferenced JetBrainsMono-*.woff2 files
that were actually 1.6 kB Google 404 HTML pages.

Verified headless: no third-party host on a plain load; document.fonts.load
('16px VT323') resolves from /fonts/VT323-latin.woff2. CSP tightening
(nginx + tauri) follows in their repos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:16:32 -04:00
jaredandClaude Opus 5 f111b3c9af feat(media): timeline images open the gallery lightbox at that event (#219)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.

Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:13:28 -04:00
jaredandClaude Opus 5 8b1c9fa610 fix(threads): thread panel no longer squeezes the composers (#218)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.

Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:05:10 -04:00
jaredandClaude Opus 5 8658ec05c3 perf: lazy-load seasonal overlays as their own chunks (#167)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
All 11 seasonal overlays (particles + vanilla-extract keyframes) were in the
main JS and CSS for everyone year-round. Each is now a React.lazy chunk fetched
only when that season is active (auto date or override) or previewed in
Settings → Appearance; the schedule/override decision stays in the main bundle.

check-bundle-size: largest chunk 332.4 kB → 320.1 kB gzip; main CSS 68.4 kB →
56.5 kB; total gzip 1551.6 kB → 1561.3 kB (per-chunk overhead, only paid when
a season is on). Verified headless: no theme chunk requested at startup with
'auto' out of season; the Settings grid fetches the 11 previews; picking
Halloween renders the full-screen overlay (20 particle nodes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:59:28 -04:00
jaredandClaude Opus 5 2e7915d086 feat(a11y): screen-reader announcements for call events (#168)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A visually hidden aria-live=polite region (rendered by CallEmbedProvider so it
outlives the embed) announces joins/leaves — batched over 1.5 s: 'alice
joined', 'alice and bob joined', '3 people joined' — your own mute/unmute,
deafen/undeafen and screenshare start/stop, and 'Call ended'. Nothing visible,
nothing audible for anyone else, no setting. Verified headless by observing
the region: bob joined → You are muted → … → bob left → Call ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:55:01 -04:00
jaredandClaude Opus 5 84c906fe33 feat: warn when the local clock is far off the homeserver's (#158)
Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.

Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.

UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.

Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:52:03 -04:00
jaredandClaude Opus 5 464951edf4 fix(timeline): your own new message always scrolls into view (#212)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 3m1s
Auto-scroll on a live event only ran while the at-bottom flag was true, so a
stale flag (in-place growth like a poll gaining a row briefly pushes the anchor
out of view; or simply having scrolled up) left your own just-sent message
below the fold behind 'Jump to Latest'. An own non-relation, non-state,
non-reaction event now always brings the live end into view: advance the
range + smooth scroll when the window is at the live end, otherwise reset to
the live timeline like the Jump to Latest button. Others' messages keep the
current behaviour.

Verified headless: scrolled up 600 px → Send → message visible, no Jump pill
(before: Jump pill, message off-screen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:37:54 -04:00
jaredandClaude Opus 5 6df160a7bf fix(upload): plain-language upload failure text instead of the raw MatrixError (#213)
The upload card printed the SDK's toString — 'MatrixError: [413] nope
(http://<hs>/_matrix/media/v3/upload?filename=…)'. describeUploadError() maps
the common cases to one sentence: 413/M_TOO_LARGE → 'This file is larger than
the server allows (limit N)' using m.upload.size when known, 429 → 'Slow down —
try again in a moment.', 401/403 → 'The server refused this upload: <server
text>', 5xx/transport after the retry loop → 'Couldn't reach the server. Check
your connection and retry.', other 4xx → the server's own sentence, URL
stripped. Both card renderers use it; the raw error is still console.warn-ed
by uploadContent for debugging. Unit-tested; verified headless with routed
413/403/503 responses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:31:26 -04:00
jaredandClaude Opus 5 60076a48d0 fix(commands): /kick and /ban failures are no longer silent (#216)
rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.

Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:27:15 -04:00
jaredandClaude Opus 5 a475531b2b feat(calls): 'N Live' pill names who is in the call on hover/focus (#157)
CI / Build & Quality Checks (push) Successful in 1m51s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 1m37s
Tooltip (folds TooltipProvider, 300 ms) on the room-list Live badge listing the
call members' display names — deduped per user, capped at 6 + 'and N more' —
plus an aria-label with the same text so keyboard/screen-reader users get it
too (the badge is focusable). Names come from the same MatrixRTC memberships
that produce the count; no new data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:18:20 -04:00
jaredandClaude Opus 5 34574178a9 feat(calls): toast when a room admin turns off screen sharing / camera mid-call (#223)
Re-measured the revoke on the current stack (guard fix + fork lotus.4): the
SFU drops the sharer's tracks and EC's own button, the captured tracks and our
control bar all follow within ~2-4 s — the 30 s lag is gone. What remained was
that the button simply vanished with no explanation, so a call-lifetime hook
now toasts 'Screen sharing was turned off by a room admin.' (or the camera
variant) when a track ends while the room policy forbids it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:12:25 -04:00
jaredandClaude Opus 5 070a1ea012 fix(calls): hang up explicitly when answering another call while in one (#195)
Answering from the in-call banner is the only call-to-call switch path; it
started the new embed and let the atom dispose the old one, which just removed
the iframe — our m.call.member in the old room lingered ~17 s (until the
delayed leave expired), so everyone there still saw us in the call.

hangupAndWait moves out of LogoutDialog into plugins/call/hangup.ts and
handleAnswer now hangs up, waits for our membership to clear (bounded 4 s),
disposes the old embed itself (its HangupCall echo would otherwise land after
startCall and clear the NEW embed from the atom — seen in testing), then joins
the new call. Measured headless: old membership gone in 1 s, new call live
with both participants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 01:04:26 -04:00
jaredandClaude Opus 5 bbcbdad55a refactor(calls): drop the deafen iframe-DOM fallback now that the fork owns it (#209)
CallControl.setSound()/applyScreenshareAudioMuted() no longer poke <audio>.muted
inside the EC frame, and the per-membership re-apply (useCallMemberSoundSync)
is gone: with the pin at 0.25.0-lotus.4 the fork applies deafen and the
screenshare-audio mute through its own renderer, including for late joiners.
Verified headless: deafen set before a second participant joins mutes their
track on arrival; deafen/undeafen leaves the screenshare-audio mute in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:53:24 -04:00
jaredandClaude Opus 5 a9e0b893be fix(calls): opaque backdrop for the PiP call window (#224)
The fork renders EC transparent so the room wallpaper shows through the
full-size embed; in PiP the element behind the frame is whatever room you
navigated to, so the timeline bled through the participant tiles. The PiP
container now gets the surface colour (the full embed keeps the wallpaper).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:45:38 -04:00
jaredandClaude Opus 5 68ce13f081 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.4
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m3s
Picks up the fork fix where "Mute Screenshare Audio" was undone whenever the
sharer stopped and re-shared (EC's own volume controls wrote volume 1 through
the same setter). The mute now rides the renderer's `muted` prop like deafen
does. Docs: fork feature table gains the deafen/screenshare-audio row; the
soundboard + quality rows are marked verified end-to-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:16:49 -04:00
jaredandClaude Opus 5 70620d4b43 fix(calls): read the EC iframe document defensively when the widget failed to load
When the Element Call frame's navigation fails (offline, blocked) it becomes a
cross-origin error page and `iframe.contentWindow.document` throws a
SecurityError. Every DOM-driven call hook (CallControl selectors, deafen DOM
fallback, useCallSpeakers/useRemoteAllMuted observers, CallEmbed theme
injection) read it unguarded, so the moment the 25 s load watchdog fired the
page logged a burst of uncaught SecurityErrors. Reproduced headless by aborting
the EC index request. One `getCallDocument()` helper now treats "can't read" as
"not loaded yet"; the watchdog overlay itself was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:15:37 -04:00
jaredandClaude Opus 5 4e455ae42e feat(calls): debounce join/leave cues for a flapping participant (#145)
Verified first on the local calls stack: a participant who left and rejoined
~3 s later played "leave" then "join" every time. Cues now go through
createCallSoundDebouncer (per USER, not per device):

- a leave cue waits 5 s; if the same user is back before it fires, the leave is
  cancelled and no join cue is played either;
- a join cue is suppressed for a user who left < 60 s ago;
- same-kind cues within 250 ms collapse, so a batch of leaves still sounds once;
- a second device of someone already present is not a new arrival, and a
  device switch is quiet.

Only the sound is debounced; membership UI is unchanged. Sound style, volume
and PTT interplay untouched (the style is read at play time). Timers are
injected — unit-tested with a manual clock. Re-ran the flap scenario headless:
join → flap → (silence) → real leave → one "leave" 5 s later → rejoin within
60 s → silence.

Also enables msc4133 (custom profile fields → in-call avatar decorations) on
the dev Synapse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 00:15:34 -04:00
jaredandClaude Opus 5 30fd22a5c5 fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s
Root cause of the 'went deaf while typing' reports: the deafen key (default
M) and Cinny's type-anywhere-to-focus-the-composer both listen on window,
so the first letter of a message typed after clicking the timeline toggled
deafen and was swallowed (reproduced: typing 'mom' → deafened, composer
shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off
switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles
where no composer is on screen — typing wins; (3) such keys are never
bound system-wide on desktop — only F-keys, numpad and the lock/navigation
cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app
can't deafen you. Verified in live calls: M still toggles in the call
view, is ignored on a chat screen, switch off disables it entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 23:15:00 -04:00
jaredandClaude Opus 5 da2d7a7d4f dev: one-command local calls stack (LiveKit + JWT issuer + voice-limit guard + https well-known) and a seeded voice room
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m22s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:29:47 -04:00
jaredandClaude Opus 5 50b4e2c16c fix(calls): incoming ring stops on every device once answered or declined elsewhere, or when the caller hangs up (#161)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m39s
The incoming-call dialog only went away on Ignore/Answer/Reject on THAT
device or when the notification lifetime expired, so a DM call answered
on the desktop kept the phone ringing for up to two minutes, and a caller
who gave up left everyone ringing. While a ring is showing we now watch
the room's MatrixRTC session and timeline: our own membership from any
device (answered elsewhere), our own RTCDecline for this ring (declined
elsewhere), or an empty session after it has settled (caller hung up)
all dismiss it. Verified with two alice devices + bob on the local
LiveKit stack: answer elsewhere → dismissed; decline elsewhere →
dismissed; caller End → both dialogs gone in 0.5 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:27:13 -04:00
jaredandClaude Opus 5 230d147ec1 fix(calls): logging out mid-call hangs up first so no ghost MatrixRTC membership is left behind (#29)
Logout stopped the client with the call still joined; the m.call.member
state (expires 4 h) stayed and everyone saw the user 'in call'. The
logout dialog now sends HangupCall and waits (≤4 s) until our own
membership is gone from the room's RTC session before stopping the
client. Verified on the local LiveKit stack: membership count 1 → 0,
logout completes in ~2 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 22:13:45 -04:00
jaredandClaude Opus 5 9fefd14944 fix(calls): undeafen restores the microphone it muted (#173)
Deafen muted the mic (correct) but undeafen left you muted, so every
deafen cycle silently turned into a mute. Remember whether the mic was on
when deafening and turn it back on when undeafening (Discord semantics).
Verified in a real two-party LiveKit call on the local stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:58:39 -04:00
jaredandClaude Opus 5 53823f5466 fix(media viewer): lightbox focuses itself on open (keys worked only after a click); timeline viewer gets +/-/0 keys, double-click zoom and dialog semantics (#164)
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m19s
Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the
tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked
inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label,
focus landed on the <img>; now mirrors the lightbox (+ = / - / 0,
double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file
name, focuses on open).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:29:24 -04:00
jaredandClaude Opus 5 f2f498425b fix(threads): drop the fallback root quote on every thread reply; add Threads + Widgets to the mobile room menu (#165, #211)
Inside the thread panel each reply rendered a 'Thread ↩ <root author> <root
text…>' quote because the spec's fallback reply relation (is_falling_back
+ m.in_reply_to root) was treated like a real reply — noise on every row
when the root is already pinned at the top. Genuine reply-to-a-reply
quotes are kept. The Threads list and Widgets panel had desktop-only
header buttons and no way to open them on a phone; both are now in the
mobile ⋮ menu next to Members / Media Gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:22:02 -04:00
jaredandClaude Opus 5 5fc90feef9 feat(forward): Discord-style provenance — 'Forwarded from <sender> in <room> · <time>' with jump to the original
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
Forwarded events carried no origin at all; they read as the forwarder's own
words. buildForwardContent now stamps io.lotus.forwarded (sender, ts,
room_id, event_id; re-forwards keep the original stamp) and the main and
thread timelines render a reply-style header above the message that jumps
to the original when the viewer is in the source room (sender + time only
otherwise — the source room's name is not leaked). Unit-tested; verified
end to end with Playwright (header text, event content, jump, re-forward).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:07:33 -04:00
jaredandClaude Opus 5 6460d0569c dev: register the dev service worker as an ES module so it actually loads
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 2m53s
vite-plugin-pwa's dev-sw.js imports workbox as a module; registering it
as a classic script failed with 'script evaluation failed', leaving the
dev client with no SW — authenticated media 401'd (broken images in every
dev screenshot) and SW notification routing was untestable. Production
sw.js is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:59:02 -04:00
jaredandClaude Opus 5 b38621861b fix(forward): one forbidden target no longer fails the whole multi-room forward (#194 P6-3)
All targets were sent concurrently through matrix-js-sdk's message queue;
when the send to a room you cannot post to failed with 403 the scheduler
clearQueue()'d every send still waiting, so 'Send to 3 rooms' with one
read-only room reported 'Failed to forward' for all three and left a
half-sent comment in the first. Rooms are now sent one at a time.
Verified: 'Forwarded to 2/3. Failed: Read Only Room.' and both good rooms
receive comment + forwarded message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:49:33 -04:00
jaredandClaude Opus 5 1a8fa5cd4c a11y(typing): empty typing state announced 'undefined, undefined, undefined and -3 others are typing' (#187)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 2m37s
RoomViewTyping's live region fell into the >3-names branch when nobody
was typing, so screen readers heard that string on every room load and
whenever typing stopped. Empty list now announces nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:27:23 -04:00
jaredandClaude Opus 5 2e8244dc67 a11y: SettingTile labels its switch/select with the tile title (#185)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Every settings toggle was an icon-only role=switch with no accessible
name (36 axe 'button-name' criticals in User Settings, 3 in Room
Settings). SettingTile now gives the title an id and points any unlabelled
switch/input/select in its 'after' slot at it via aria-labelledby.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:19:54 -04:00
jaredandClaude Opus 5 e9d419f84c a11y: give every icon-only sidebar avatar button and the message sender avatar an accessible name (#185)
axe-core flagged 18 'button-name' criticals on the room view: the space
tabs, Home/Direct/Inbox/Search/Saved/Explore/Add Space/User Settings/
Unverified sidebar buttons, and each message's avatar button had no text
for screen readers. Labels mirror the existing tooltips; message avatars
read '<name>, open profile'. Room view now has zero button-name findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:17:32 -04:00
jaredandClaude Opus 5 e0b1c50155 chore(dev): dev homeserver serves URL previews (embed facade thumbnails) and installs the url-preview extra
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:11:30 -04:00
jaredandClaude Opus 5 14b6849f6e fix(search): "Clear cached index" was undone immediately by the active search re-persisting its in-memory rows (#184 O4)
CI / Build & Quality Checks (push) Successful in 1m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 33s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m2s
Clearing bumps cacheVersion so the search re-runs, and the re-run wrote
the scanned rows straight back to IndexedDB, so the index was never empty
while the button was visible. Skip persistence for that one re-run.
Verified: IDB stores go to 0/0 after Clear; logout still deletes the DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 18:34:55 -04:00
jaredandClaude Opus 5 22371f8156 fix(composer): no LaTeX conversion inside a typed markdown fence or backtick span (#184 O3)
In markdown mode each paragraph line is serialised before parseBlockMD
joins them, so $x$ inside a ``` fence became data-mx-maths markup
inside the resulting <pre><code> (rendered as math in a code block).
Track fence state across lines and skip math for fenced lines and for
backtick code spans. Unit tests added; verified in the browser.

Also: scripts/dev-homeserver.sh enables MSC4140 delayed events so
scheduled messages work locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 18:26:15 -04:00
jaredandClaude Opus 5 58a716c734 fix(editor): opening a thread on a pristine composer crashed the app — give each Slate editor its own initial value
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m24s
CustomEditor passed one module-level initialValue array to every <Slate>.
slate-react keys its node→path weak maps by node identity, so mounting
the thread composer re-mapped the shared nodes to the new editor and the
main composer threw "Unable to find the path for Slate node" on its next
render, taking the whole client to the error boundary. Anything that had
already edited the main editor (typing, a restored draft) replaced its
nodes and masked the bug, which is why it was intermittent.

Reproduced with Playwright (fresh login → room → click "N replies"):
crashed every time; typing one character first avoided it. Fixed by
creating the initial value per instance (useState).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 18:14:05 -04:00
jaredandClaude Opus 5 6d63c34b2c fix(upload): retry on dropped connections — the SDK reports XHR network failures as AbortError, which we treated as a user cancel (#172)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m6s
matrix-js-sdk rejects an upload whose XHR ends with status 0 (offline,
connection reset, DNS) with DOMException('AbortError') to mimic fetch,
the same name mx.cancelUpload() produces. isRetryableUploadError bailed
on any AbortError, so the one failure class the retry loop was built
for was never retried. Decide by our own cancel AbortSignal instead.

Verified with Playwright routing the upload endpoint: 502 → network drop
→ ok now completes in 3 attempts (1 s, 2 s back-off) and the image sends;
413 still fails fast after 1 attempt; persistent 503 gives up after 4.
Unit tests in utils/uploadRetry.test.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 17:58:15 -04:00
jaredandClaude Opus 5 4c671fbf3a fix(mobile): member list no longer opens on top of every room on phones (#175)
Room.tsx showed the MembersDrawer on phone widths whenever the persisted
desktop preference isPeopleDrawer was true — which is the default — so a
new mobile session opened every room behind a full-screen member list.
Mobile now uses a transient mobileMembersPanelAtom (header ⋮ → Members
toggles it, the drawer's X closes it, leaving the room resets it); the
desktop drawer keeps using the setting. Verified with Playwright at 400px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 17:45:43 -04:00
jaredandClaude Opus 5 9d84f9153a fix(invite): the header Invite dialog's QR was still fetched from api.qrserver.com — render it locally (#192)
a899d7d3 moved the Room Settings share QR to qrcode.react but the
InviteUserPrompt (header ⋮ → Invite, Room Intro, space menu) kept the
third-party <img>, which leaked the room link and — since the prod CSP
img-src no longer allows that host — rendered as a broken image. Same
QRCodeSVG + white quiet zone as RoomShareInvite. Verified with
Playwright: SVG present, no remote <img>, zero external requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:47:37 -04:00
240 changed files with 10511 additions and 1217 deletions
+42 -2
View File
@@ -132,11 +132,27 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
runs-on: ubuntu-latest
steps:
# [matrix #9] Debounce: a Tauri build takes ~30 min on the shared runner,
# so at most one bump per DEBOUNCE_MIN. Commits that land inside the
# window are picked up by cinny-desktop's nightly catch-up workflow (or a
# manual dispatch of it) — the desktop cadence no longer tracks every web
# commit. The bump is also skipped when nothing changed.
- name: Bump cinny submodule
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEBOUNCE_MIN: '60'
run: |
CINNY_SHA="${{ github.sha }}"
LAST=$(curl -fsSL -H "Authorization: token $TOKEN" \
"https://code.lotusguild.org/api/v1/repos/LotusGuild/cinny-desktop/commits?sha=main&limit=1&stat=false&verification=false&files=false" \
| python3 -c 'import sys,json; c=json.load(sys.stdin); print(c[0]["commit"]["committer"]["date"] if c else "")' 2>/dev/null || true)
if [ -n "$LAST" ]; then
AGE=$(python3 -c "import sys,datetime; d=datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')); print(int((datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()//60))" "$LAST")
if [ "$AGE" -lt "$DEBOUNCE_MIN" ]; then
echo "Last desktop bump was ${AGE} min ago (< ${DEBOUNCE_MIN}); skipping — the nightly catch-up will pick this up."
exit 0
fi
fi
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
cd desktop
git config user.email "ci@lotusguild.org"
@@ -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
View File
@@ -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
View File
@@ -79,7 +79,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.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
View File
@@ -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):
+1 -1
View File
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.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.
+4
View File
@@ -1,6 +1,10 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
root * /path/to/cinny/dist
# [Gitea #155] PWA share target: the service worker answers this POST; if it
# isn't controlling the page yet, land on /share instead of a 405.
redir /share-target /share 303
try_files {path} /index.html
file_server
+7
View File
@@ -26,6 +26,13 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
# [Gitea #155] PWA share target. The service worker normally answers this
# POST itself; if it isn't controlling the page yet, land on /share
# (the shared files are lost, but nothing 405s).
location = /share-target {
return 303 /share;
}
location / {
root /opt/cinny/dist/;
+125
View File
@@ -0,0 +1,125 @@
import { test, expect, Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import {
createRoom,
hsReachable,
loginUI,
openRoom,
ensureUser,
sendText,
uniq,
TestUser,
} from './localHs';
// [Gitea #222] Machine-checkable accessibility on every push. Two parts:
// 1. axe-core over the main surfaces, failing on critical/serious findings.
// Colour-contrast is reported but not gated: several hits are generated
// avatar colours and portal false positives (see the issue for the list).
// 2. Accessibility-tree snapshots of the composer, message menu, thread
// panel and settings nav, so a lost name/role/live-region shows up as a
// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`.
// A real screen-reader pass still needs a human.
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
async function auditPage(page: Page, label: string) {
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const describe = (v: (typeof results.violations)[number]) =>
`[${v.impact}] ${v.id}: ${v.help}\n${v.nodes
.slice(0, 5)
.map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`)
.join('\n')}`;
const contrast = results.violations.filter((v) => v.id === 'color-contrast');
if (contrast.length) {
// eslint-disable-next-line no-console
console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`);
}
const gated = results.violations.filter(
(v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'),
);
expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]);
}
test.describe('accessibility', () => {
test('login page passes axe @webkit', async ({ page }) => {
await page.goto('/');
await expect(page.getByLabel('Username or email')).toBeVisible();
await auditPage(page, 'login');
});
test.describe('signed in', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'no local homeserver');
alice = await ensureUser(uniq('e2e_a11y_'));
room = await createRoom(alice, 'A11y Room');
const root = await sendText(alice, room, 'thread root for a11y');
await sendText(alice, room, 'a reply', {
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 850 });
await loginUI(page, alice);
await openRoom(page, room);
await expect(page.getByText('thread root for a11y')).toBeVisible();
});
test('room timeline + composer', async ({ page }) => {
await auditPage(page, 'room');
await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({
name: 'composer-editor.aria.yml',
});
await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({
name: 'composer-send.aria.yml',
});
});
test('message options menu', async ({ page }) => {
const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' });
await msg.hover();
await msg.getByRole('button', { name: 'More options' }).click();
const menu = page.locator('[data-message-menu]').first();
await expect(menu).toBeVisible();
await auditPage(page, 'message menu');
await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' });
});
test('thread panel', async ({ page }) => {
await page
.locator('[data-message-item]', { hasText: 'thread root for a11y' })
.getByText(/1 reply/)
.click();
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
await auditPage(page, 'thread panel');
await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({
name: 'thread-panel.aria.yml',
});
});
test('user settings', async ({ page }) => {
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await expect(dialog).toBeVisible();
await auditPage(page, 'settings');
await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({
name: 'settings-nav.aria.yml',
});
});
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
await expect(page.getByRole('dialog').first()).toBeVisible();
await auditPage(page, 'room settings');
});
});
});
@@ -0,0 +1,2 @@
- textbox "Send a message...":
- paragraph: Send a message...
@@ -0,0 +1,2 @@
- button "Send message":
- img
@@ -0,0 +1,39 @@
- button "Add Reaction":
- text: ''
- img
- button "Reply":
- text: ''
- img
- button "Forward":
- text: ''
- img
- button "Bookmark Message":
- text: ''
- img
- button "Remind Me":
- text: ''
- img
- button "Edit Message":
- text: ''
- img
- button "Read Receipts":
- text: ''
- img
- button "Copy Text":
- text: ''
- img
- button "Translate":
- text: ''
- img
- button "Copy Link":
- text: ''
- img
- button "Copy Lotus Link":
- text: ''
- img
- button "Pin Message":
- text: ''
- img
- button "Delete":
- text: ''
- img
@@ -0,0 +1,22 @@
- navigation "Settings sections":
- button "General" [pressed]:
- img
- paragraph: General
- button "Account":
- img
- paragraph: Account
- button "Notifications":
- img
- paragraph: Notifications
- button "Devices":
- img
- paragraph: Devices
- button "Emojis & Stickers":
- img
- paragraph: Emojis & Stickers
- button "Developer Tools":
- img
- paragraph: Developer Tools
- button "About":
- img
- paragraph: About
@@ -0,0 +1,31 @@
- complementary "Thread":
- paragraph: Thread
- paragraph: A11y Room
- button "Thread notifications":
- img
- button "Close thread":
- img
- log "Thread timeline":
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: thread root for a11y
- paragraph: 1 reply
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: a reply
- button "More actions":
- img
- textbox "Send a message...":
- paragraph: Send a message...
- button "Insert sticker":
- img
- button "Insert emoji":
- img
- button "Send message":
- img
+2 -2
View File
@@ -5,7 +5,7 @@ import { collectConsole } from './helpers';
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
test.describe('boot', () => {
test('client boots to the login screen without errors', async ({ page }) => {
test('client boots to the login screen without errors @webkit @ios', async ({ page }) => {
const consoleLog = collectConsole(page);
await page.goto('/');
@@ -24,7 +24,7 @@ test.describe('boot', () => {
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
});
test('service worker script is served and registers', async ({ page }) => {
test('service worker script is served and registers @webkit @ios', async ({ page }) => {
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status(), 'GET /sw.js').toBe(200);
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
+14 -1
View File
@@ -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))),
],
};
+285
View File
@@ -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
View File
@@ -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)}`;
-3
View File
@@ -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" />
+22 -7
View File
@@ -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
View File
@@ -12,13 +12,13 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68",
"check:eslint": "eslint src/* --max-warnings 49",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"test:e2e:install": "playwright install chromium webkit",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -107,7 +107,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.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
View File
@@ -25,7 +25,16 @@ export default defineConfig({
trace: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// [Gitea #221] WebKit as the Safari/iOS proxy. It runs the subset tagged
// @webkit / @ios (boot, composer round-trip, thread panel, lightbox): it
// catches WebKit-only breakage (CSS, dvh, IndexedDB, media decode) that
// Chromium emulation can't, but does not emulate the iOS keyboard or the
// Home-Screen install flow.
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: /@webkit/ },
{ name: 'iphone', use: { ...devices['iPhone 14'] }, grep: /@ios/ },
],
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: BASE_URL,
-11
View File
@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
-11
View File
@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
-11
View File
@@ -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>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
Binary file not shown.
Binary file not shown.
+22
View File
@@ -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;
}
+16
View File
@@ -69,6 +69,22 @@
}
],
"categories": ["social", "communication", "productivity"],
"share_target": {
"action": "./share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"]
}
]
}
},
"shortcuts": [
{
"name": "New Message",
+67 -3
View File
@@ -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.
@@ -23,7 +33,7 @@ install() {
python3 -m venv --without-pip "$VENV"
curl -sS https://bootstrap.pypa.io/get-pip.py -o "$DIR/get-pip.py"
"$VENV/bin/python" "$DIR/get-pip.py" -q
"$VENV/bin/pip" install -q matrix-synapse
"$VENV/bin/pip" install -q "matrix-synapse[url-preview]"
fi
if [ ! -f "$CFG" ]; then
(cd "$DIR" && "$VENV/bin/python" -m synapse.app.homeserver \
@@ -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
@@ -43,6 +53,20 @@ rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_secon
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
max_upload_size: 50M
suppress_key_server_warning: true
# URL previews (embed facades use the homeserver's cached thumbnail)
url_preview_enabled: true
url_preview_ip_range_blacklist: ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '100.64.0.0/10', '169.254.0.0/16', '::1/128', 'fe80::/10', 'fc00::/7']
# scheduled messages (MSC4140 delayed events) + MatrixRTC transports (MSC4143) + room summaries (MSC3266)
max_event_delay_duration: 24h
experimental_features:
msc4140_enabled: true
msc4143_enabled: true
msc3266_enabled: true
msc4133_enabled: true
matrix_rtc:
transports:
- type: livekit
livekit_service_url: http://127.0.0.1:8070
"""
open(p, "w").write(s)
PY
@@ -65,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 ;;
+52
View File
@@ -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
View File
@@ -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__":
+15
View File
@@ -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()
+148 -9
View File
@@ -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>
+36 -5
View File
@@ -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}
+1 -1
View File
@@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src,
+18 -1
View File
@@ -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;
+2
View File
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<Modal
size="500"
variant="Background"
role="dialog"
aria-modal="true"
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+22 -4
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './ActionSheet';
+3 -1
View File
@@ -67,7 +67,9 @@ export const EditorTextarea = style([
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
// [Gitea #222] folds' Placeholder opacity (0.5) lands at ~2.3:1 on the
// composer surface; P300 keeps it visibly secondary at AA contrast.
opacity: config.opacity.P300,
pointerEvents: 'none',
userSelect: 'none',
},
+8 -1
View File
@@ -23,7 +23,13 @@ import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
const initialValue: CustomElement[] = [
// One FRESH value per editor instance. slate-react keys its node→path weak
// maps by node object identity, so a module-level constant shared by every
// <Slate> (main composer + thread composer + message editor) makes the second
// mount hijack the first editor's nodes and the first editor throws "Unable to
// find the path for Slate node" on its next render — the app-wide crash when
// opening a thread on a pristine composer (Gitea #165 / #184).
const createInitialValue = (): CustomElement[] => [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
@@ -92,6 +98,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref,
) => {
const [initialValue] = useState(createInitialValue);
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[],
+24
View File
@@ -65,3 +65,27 @@ test('no math conversion inside a code block', () => {
test('a message with no math is unchanged', () => {
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
});
// Gitea #184 O3 — markdown mode: fences and backtick spans typed as plain
// paragraphs are literal too (the block markdown parser only sees the fence
// after the lines are joined, so math must be skipped while serialising them).
const MD_OPTS = { ...OPTS, allowInlineMarkdown: true, allowBlockMarkdown: true };
test('markdown: no math conversion inside a typed ``` fence', () => {
const paragraphs = [
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('$x$ literal')]),
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('after $y$')]),
];
const out = toMatrixCustomHTML(paragraphs, MD_OPTS);
assert.ok(out.includes('<pre'), 'fence became a code block');
assert.ok(out.includes('$x$ literal'), 'code block content stays literal');
assert.ok(out.includes('data-mx-maths="y"'), 'math after the fence still converts');
});
test('markdown: no math conversion inside a backtick span, math outside still converts', () => {
const out = toMatrixCustomHTML(el(BlockType.Paragraph, [txt('use `$x$` and $y$')]), MD_OPTS);
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
assert.ok(out.includes('data-mx-maths="y"'));
});
+20
View File
@@ -36,6 +36,18 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
// applied inside inline code. Non-math text recurses with allowMath off so it
// still gets the normal marks + inline-markdown treatment.
if (opts.allowMath && !node.code) {
// Markdown inline code spans (`…`) are literal too: apply math only to the
// text between them (Gitea #184 O3 — `$x$` inside backticks stayed math).
if (opts.allowInlineMarkdown && /`[^`]*`/.test(node.text)) {
return node.text
.split(/(`+[^`]*`+)/)
.map((part) =>
part.startsWith('`')
? textToCustomHtml({ ...node, text: part }, { ...opts, allowMath: false })
: textToCustomHtml({ ...node, text: part }, opts),
)
.join('');
}
const segments = splitMathSegments(node.text);
if (segments.some((seg) => seg.type !== 'text')) {
return segments
@@ -128,12 +140,20 @@ export const toMatrixCustomHTML = (
opts: OutputOptions,
): string => {
let markdownLines = '';
// Inside a markdown ``` fence every line is literal: no `$…$` math conversion
// (the fence is only recognised by parseBlockMD after the lines are joined,
// which is too late — Gitea #184 O3).
let inFence = false;
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
const isFenceLine = /^\s*```/.test(toPlainText(n, false));
const literal = inFence || isFenceLine;
if (isFenceLine) inFence = !inFence;
const line = toMatrixCustomHTML(n, {
...opts,
allowInlineMarkdown: false,
allowBlockMarkdown: false,
allowMath: opts.allowMath && !literal,
})
.replace(/<br\/>$/, '\n')
.replace(/^(\\*)&gt;/, '$1>');
+39 -11
View File
@@ -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>
@@ -33,6 +33,7 @@ import {
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import { QRCodeSVG } from 'qrcode.react';
import { stopPropagation } from '../../utils/keyboard';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import {
@@ -237,13 +238,25 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(inviteUrl)}`}
alt="QR code for room invite link"
width={180}
height={180}
style={{ display: 'block', borderRadius: config.radii.R300 }}
/>
{/* Generated locally (qrcode.react) like RoomShareInvite — the
old api.qrserver.com <img> leaked the room link to a third
party and is blocked by the prod CSP img-src anyway
(Gitea #192). White quiet-zone so it scans on any theme. */}
<Box
style={{
padding: config.space.S200,
background: '#ffffff',
borderRadius: config.radii.R300,
lineHeight: 0,
}}
>
<QRCodeSVG
value={inviteUrl}
size={164}
level="M"
title="QR code for room invite link"
/>
</Box>
<Text
size="T200"
style={{ opacity: 0.6, wordBreak: 'break-all', textAlign: 'center' }}
@@ -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>
);
}
+7 -3
View File
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import * as css from './Reply.css';
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
<i>Original message not available</i>
</Text>
) : (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
</Box>
)}
</ReplyLayout>
</Box>
@@ -0,0 +1,75 @@
import React from 'react';
import { Icon, Icons, config, toRem } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { MessageEvent } from '../../../types/matrix/room';
const SIZE = 36;
/** Whether a reply quote for this event should carry a thumbnail. */
export const hasReplyMedia = (mEvent: MatrixEvent | null | undefined): boolean => {
if (!mEvent || mEvent.isRedacted()) return false;
if (mEvent.getType() === MessageEvent.Sticker) return true;
const msgtype = mEvent.getContent().msgtype;
return msgtype === MsgType.Image || msgtype === MsgType.Video;
};
/**
* [Gitea #151] 36 px thumbnail in a reply quote for an image/video/sticker.
* Uses the event's own thumbnail (decrypting it for E2EE media), never the
* full-size file.
*/
export function ReplyMediaThumb({ mEvent }: { mEvent: MatrixEvent }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType);
return (
<span
aria-hidden
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(SIZE),
height: toRem(SIZE),
flexShrink: 0,
borderRadius: config.radii.R300,
overflow: 'hidden',
background: 'rgba(127, 127, 127, 0.15)',
}}
>
{media.status === 'ok' ? (
<img
src={media.url}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Icon size="100" src={isVideo ? Icons.Play : Icons.Photo} />
)}
{isVideo && media.status === 'ok' && (
<Icon
size="50"
src={Icons.Play}
filled
style={{ position: 'absolute', color: 'white', filter: 'drop-shadow(0 0 2px black)' }}
/>
)}
</span>
);
}
+4 -15
View File
@@ -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>
+1
View File
@@ -8,3 +8,4 @@ export * from './Time';
export * from './MsgTypeRenderers';
export * from './FileHeader';
export * from './RenderBody';
export * from './ForwardedHeader';
+29 -10
View File
@@ -9,16 +9,35 @@ export const NavItem = as<
{
highlight?: boolean;
} & css.RoomSelectorVariants
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
{...props}
ref={ref}
>
{children}
</AsNavItem>
));
>(
(
{
as: AsNavItem = 'div',
className,
highlight,
variant,
radii,
children,
'aria-selected': selected,
...props
},
ref,
) => (
// [Gitea #222] Callers pass `aria-selected`, but that attribute is only
// valid on option/tab/row roles; on a plain div axe flags it as critical.
// Keep the prop for callers and styling, expose the state as aria-current.
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-selected={selected === true || selected === 'true' ? true : undefined}
aria-current={selected === true || selected === 'true' ? 'page' : undefined}
{...props}
ref={ref}
>
{children}
</AsNavItem>
),
);
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
({ className, ...props }, ref) => (
+1 -1
View File
@@ -69,7 +69,7 @@ const NavItemBase = style({
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
'&[data-selected=true]': {
backgroundColor: ContainerActive,
},
[`&:has(.${NavLink}:focus-visible)`]: {
+3 -5
View File
@@ -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>
+42 -14
View File
@@ -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>
);
}
@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect, useId, useRef } from 'react';
import { Box, Text } from 'folds';
import { BreakWord } from '../../styles/Text.css';
@@ -10,12 +10,35 @@ type SettingTileProps = {
children?: ReactNode;
};
export function SettingTile({ title, description, before, after, children }: SettingTileProps) {
const titleId = useId();
const afterRef = useRef<HTMLDivElement>(null);
// [Gitea #185] The control in `after` is almost always an icon-only Switch
// (or a bare select/input) whose only visible label is this tile's title.
// Point it at the title so screen readers announce "Show Notifications,
// switch, on" instead of "switch, on". Explicit labels are left alone.
useEffect(() => {
if (!title || !afterRef.current) return;
afterRef.current
.querySelectorAll<HTMLElement>('[role="switch"], input, select, textarea, [role="combobox"]')
.forEach((el) => {
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby')) return;
if (
el.tagName === 'INPUT' &&
el.id &&
afterRef.current?.querySelector(`label[for="${el.id}"]`)
)
return;
el.setAttribute('aria-labelledby', titleId);
});
});
return (
<Box alignItems="Center" gap="300">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
<Text className={BreakWord} size="T300">
<Text id={titleId} className={BreakWord} size="T300">
{title}
</Text>
)}
@@ -26,7 +49,11 @@ export function SettingTile({ title, description, before, after, children }: Set
)}
{children}
</Box>
{after && <Box shrink="No">{after}</Box>}
{after && (
<Box shrink="No" ref={afterRef}>
{after}
</Box>
)}
</Box>
);
}
@@ -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&apos;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 && (
+13 -1
View File
@@ -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}
/>
+4 -14
View File
@@ -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} />
+19 -1
View File
@@ -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 />
+1 -1
View File
@@ -41,7 +41,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={(evt: any) =>
onClick={(evt: React.MouseEvent) =>
openUserProfile(
room.roomId,
undefined,
@@ -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&apos;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
&ldquo;message deleted&rdquo;, 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>
@@ -246,8 +248,15 @@ export function MessageSearch({
const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
// After a clear, the active search re-runs (cacheVersion) and would persist
// the in-memory rows straight back — skip that one write so "Clear cached
// index" actually leaves the disk empty until the next new search.
const skipPersistOnceRef = useRef(false);
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
clearSearchCache().then(() => {
skipPersistOnceRef.current = true;
setCacheVersion((v) => v + 1);
});
}, []);
// The rooms actually in scope for this search (mirrors server-side logic)
@@ -277,12 +286,15 @@ export function MessageSearch({
return undefined;
}
let cancelled = false;
const persist = !skipPersistOnceRef.current;
skipPersistOnceRef.current = false;
searchLocalMessages({
term: msgSearchParams.term ?? '',
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
persist,
}).then((result) => {
if (!cancelled) setLocalResult(result);
});
@@ -18,6 +18,12 @@ export type LocalSearchParams = {
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
/**
* Set false to run the scan without writing it back to IndexedDB used
* right after "Clear cached index" so the clear is not undone by the
* re-run of the active search (Gitea #184 O4).
*/
persist?: boolean;
};
export type LocalSearchResult = {
@@ -149,6 +155,7 @@ export const useLocalMessageSearch = () => {
senders,
fromTs,
toTs,
persist = true,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -302,7 +309,7 @@ export const useLocalMessageSearch = () => {
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && rowsToPersist.length > 0) {
if (cacheEnabled && persist && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
}
+172 -30
View File
@@ -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>
+50 -113
View File
@@ -17,7 +17,7 @@ import {
color,
config,
} from 'folds';
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
@@ -26,7 +26,8 @@ import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../utils/mediaThumb';
import { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
@@ -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');
+8 -1
View File
@@ -31,6 +31,8 @@ import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room';
@@ -79,6 +81,8 @@ type MemberDrawerHeaderProps = {
};
function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const setMobileMembers = useSetAtom(mobileMembersPanelAtom);
const screenSize = useScreenSizeContext();
return (
<Header className={css.MembersDrawerHeader} variant="Background" size="600">
@@ -104,7 +108,10 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
ref={triggerRef}
variant="Background"
aria-label="Close member list"
onClick={() => setPeopleDrawer(false)}
onClick={() => {
if (screenSize === ScreenSize.Mobile) setMobileMembers(false);
else setPeopleDrawer(false);
}}
>
<Icon src={Icons.Cross} />
</IconButton>
+18 -3
View File
@@ -2,14 +2,16 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery';
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';
@@ -46,6 +48,7 @@ export function Room() {
const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const [mobileMembersOpen, setMobileMembersOpen] = useAtom(mobileMembersPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const threadsListOpen = useAtomValue(threadsListAtom);
const setThreadsListOpen = useSetAtom(threadsListAtom);
@@ -146,10 +149,22 @@ export function Room() {
threadsListOpen &&
!activeThreadId &&
(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 &&
isDrawer &&
(isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen));
(isDesktop
? isDrawer && !(narrowDesktop && contentPanelOpen)
: mobileMembersOpen && !activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen);
// Leaving a room on a phone closes its member panel.
useEffect(() => () => setMobileMembersOpen(false), [room.roomId, setMobileMembersOpen]);
return (
<PowerLevelsContextProvider value={powerLevels}>
+73 -44
View File
@@ -95,7 +95,8 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
@@ -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}
/>
);
}
+441 -92
View File
@@ -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)}
/>
)}
</>
);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
{joinState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement room!'}
</Text>
)}
</Box>
+92 -4
View File
@@ -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';
@@ -75,6 +77,7 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -105,7 +108,11 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
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);
@@ -196,13 +203,13 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setPeopleDrawer(!peopleDrawer);
setMobileMembers(!mobileMembers);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.User} filled={peopleDrawer} />}
after={<Icon size="100" src={Icons.User} filled={mobileMembers} />}
radii="300"
aria-pressed={peopleDrawer}
aria-pressed={mobileMembers}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Members
@@ -225,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}
@@ -378,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();
@@ -694,6 +781,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
+6 -1
View File
@@ -38,7 +38,12 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
// a `role="status"` region added to the DOM together with its first text
// is not reliably announced by some screen readers.
let typingAnnouncement = '';
if (typingNames.length === 1) {
if (typingNames.length === 0) {
// nobody typing — keep the region mounted but silent (the final `else`
// below used to announce "undefined, undefined, undefined and -3
// others are typing" on every room load, Gitea #187)
typingAnnouncement = '';
} else if (typingNames.length === 1) {
typingAnnouncement = `${typingNames[0]} is typing`;
} else if (typingNames.length === 2) {
typingAnnouncement = `${typingNames[0]} and ${typingNames[1]} are typing`;
@@ -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);

Some files were not shown because too many files have changed in this diff Show More