Compare commits

...
24 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
127 changed files with 4430 additions and 560 deletions
+21 -2
View File
@@ -132,11 +132,27 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
runs-on: ubuntu-latest
steps:
# [matrix #9] Debounce: a Tauri build takes ~30 min on the shared runner,
# so at most one bump per DEBOUNCE_MIN. Commits that land inside the
# window are picked up by cinny-desktop's nightly catch-up workflow (or a
# manual dispatch of it) — the desktop cadence no longer tracks every web
# commit. The bump is also skipped when nothing changed.
- name: Bump cinny submodule
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEBOUNCE_MIN: '60'
run: |
CINNY_SHA="${{ github.sha }}"
LAST=$(curl -fsSL -H "Authorization: token $TOKEN" \
"https://code.lotusguild.org/api/v1/repos/LotusGuild/cinny-desktop/commits?sha=main&limit=1&stat=false&verification=false&files=false" \
| python3 -c 'import sys,json; c=json.load(sys.stdin); print(c[0]["commit"]["committer"]["date"] if c else "")' 2>/dev/null || true)
if [ -n "$LAST" ]; then
AGE=$(python3 -c "import sys,datetime; d=datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')); print(int((datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()//60))" "$LAST")
if [ "$AGE" -lt "$DEBOUNCE_MIN" ]; then
echo "Last desktop bump was ${AGE} min ago (< ${DEBOUNCE_MIN}); skipping — the nightly catch-up will pick this up."
exit 0
fi
fi
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
cd desktop
git config user.email "ci@lotusguild.org"
@@ -275,8 +291,11 @@ jobs:
sleep $((attempt * 15))
done
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
# [Gitea #221] WebKit too — Playwright's Safari/iOS proxy for the tagged
# subset (see playwright.config.ts projects). `--with-deps` pulls the
# GTK/GStreamer libraries WebKit needs on the runner.
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium webkit
- name: Build
run: npm run build
+1 -1
View File
@@ -79,7 +79,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.9`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.9`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.11`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.11`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
+4
View File
@@ -57,6 +57,10 @@ Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They bo
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual.
**Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too.
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
+1 -1
View File
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.9`, upstream base v0.25.0, source at
(`@lotusguild/element-call-embedded` `0.25.0-lotus.11`, upstream base v0.25.0, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
+4
View File
@@ -1,6 +1,10 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld {
root * /path/to/cinny/dist
# [Gitea #155] PWA share target: the service worker answers this POST; if it
# isn't controlling the page yet, land on /share instead of a 405.
redir /share-target /share 303
try_files {path} /index.html
file_server
+7
View File
@@ -26,6 +26,13 @@ server {
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
# [Gitea #155] PWA share target. The service worker normally answers this
# POST itself; if it isn't controlling the page yet, land on /share
# (the shared files are lost, but nothing 405s).
location = /share-target {
return 303 /share;
}
location / {
root /opt/cinny/dist/;
+125
View File
@@ -0,0 +1,125 @@
import { test, expect, Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import {
createRoom,
hsReachable,
loginUI,
openRoom,
ensureUser,
sendText,
uniq,
TestUser,
} from './localHs';
// [Gitea #222] Machine-checkable accessibility on every push. Two parts:
// 1. axe-core over the main surfaces, failing on critical/serious findings.
// Colour-contrast is reported but not gated: several hits are generated
// avatar colours and portal false positives (see the issue for the list).
// 2. Accessibility-tree snapshots of the composer, message menu, thread
// panel and settings nav, so a lost name/role/live-region shows up as a
// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`.
// A real screen-reader pass still needs a human.
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
async function auditPage(page: Page, label: string) {
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const describe = (v: (typeof results.violations)[number]) =>
`[${v.impact}] ${v.id}: ${v.help}\n${v.nodes
.slice(0, 5)
.map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`)
.join('\n')}`;
const contrast = results.violations.filter((v) => v.id === 'color-contrast');
if (contrast.length) {
// eslint-disable-next-line no-console
console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`);
}
const gated = results.violations.filter(
(v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'),
);
expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]);
}
test.describe('accessibility', () => {
test('login page passes axe @webkit', async ({ page }) => {
await page.goto('/');
await expect(page.getByLabel('Username or email')).toBeVisible();
await auditPage(page, 'login');
});
test.describe('signed in', () => {
let alice: TestUser;
let room: string;
test.beforeAll(async () => {
test.skip(!(await hsReachable()), 'no local homeserver');
alice = await ensureUser(uniq('e2e_a11y_'));
room = await createRoom(alice, 'A11y Room');
const root = await sendText(alice, room, 'thread root for a11y');
await sendText(alice, room, 'a reply', {
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 850 });
await loginUI(page, alice);
await openRoom(page, room);
await expect(page.getByText('thread root for a11y')).toBeVisible();
});
test('room timeline + composer', async ({ page }) => {
await auditPage(page, 'room');
await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({
name: 'composer-editor.aria.yml',
});
await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({
name: 'composer-send.aria.yml',
});
});
test('message options menu', async ({ page }) => {
const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' });
await msg.hover();
await msg.getByRole('button', { name: 'More options' }).click();
const menu = page.locator('[data-message-menu]').first();
await expect(menu).toBeVisible();
await auditPage(page, 'message menu');
await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' });
});
test('thread panel', async ({ page }) => {
await page
.locator('[data-message-item]', { hasText: 'thread root for a11y' })
.getByText(/1 reply/)
.click();
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
await auditPage(page, 'thread panel');
await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({
name: 'thread-panel.aria.yml',
});
});
test('user settings', async ({ page }) => {
await page.getByRole('button', { name: 'User Settings' }).click();
const dialog = page.getByRole('dialog').first();
await expect(dialog).toBeVisible();
await auditPage(page, 'settings');
await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({
name: 'settings-nav.aria.yml',
});
});
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
await expect(page.getByRole('dialog').first()).toBeVisible();
await auditPage(page, 'room settings');
});
});
});
@@ -0,0 +1,2 @@
- textbox "Send a message...":
- paragraph: Send a message...
@@ -0,0 +1,2 @@
- button "Send message":
- img
@@ -0,0 +1,39 @@
- button "Add Reaction":
- text: ''
- img
- button "Reply":
- text: ''
- img
- button "Forward":
- text: ''
- img
- button "Bookmark Message":
- text: ''
- img
- button "Remind Me":
- text: ''
- img
- button "Edit Message":
- text: ''
- img
- button "Read Receipts":
- text: ''
- img
- button "Copy Text":
- text: ''
- img
- button "Translate":
- text: ''
- img
- button "Copy Link":
- text: ''
- img
- button "Copy Lotus Link":
- text: ''
- img
- button "Pin Message":
- text: ''
- img
- button "Delete":
- text: ''
- img
@@ -0,0 +1,22 @@
- navigation "Settings sections":
- button "General" [pressed]:
- img
- paragraph: General
- button "Account":
- img
- paragraph: Account
- button "Notifications":
- img
- paragraph: Notifications
- button "Devices":
- img
- paragraph: Devices
- button "Emojis & Stickers":
- img
- paragraph: Emojis & Stickers
- button "Developer Tools":
- img
- paragraph: Developer Tools
- button "About":
- img
- paragraph: About
@@ -0,0 +1,31 @@
- complementary "Thread":
- paragraph: Thread
- paragraph: A11y Room
- button "Thread notifications":
- img
- button "Close thread":
- img
- log "Thread timeline":
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: thread root for a11y
- paragraph: 1 reply
- article:
- button /e2e_a11y_\w+, open profile/:
- img
- button /e2e_a11y_\w+/
- time: /\d+:\d+ (AM|PM)/
- text: a reply
- button "More actions":
- img
- textbox "Send a message...":
- paragraph: Send a message...
- button "Insert sticker":
- img
- button "Insert emoji":
- img
- button "Send message":
- img
+2 -2
View File
@@ -5,7 +5,7 @@ import { collectConsole } from './helpers';
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
test.describe('boot', () => {
test('client boots to the login screen without errors', async ({ page }) => {
test('client boots to the login screen without errors @webkit @ios', async ({ page }) => {
const consoleLog = collectConsole(page);
await page.goto('/');
@@ -24,7 +24,7 @@ test.describe('boot', () => {
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
});
test('service worker script is served and registers', async ({ page }) => {
test('service worker script is served and registers @webkit @ios', async ({ page }) => {
const swResponse = await page.request.get('/sw.js');
expect(swResponse.status(), 'GET /sw.js').toBe(200);
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
+10 -1
View File
@@ -18,6 +18,11 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
// autodiscovery probes https://localhost/, which is either not listening
// (CI) or a self-signed dev server (local calls stack).
/ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i,
// WebKit's spellings of the same discovery failures (#221).
/Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i,
// Also a fetch cut short by our own navigation (e.g. the crypto wasm while
// the test moves from /home to a room) — WebKit words that the same way.
/due to access control checks/i,
// React devtools hint in production bundles.
/Download the React DevTools/i,
];
@@ -45,8 +50,12 @@ export function collectConsole(page: Page): ConsoleCollector {
return {
errors,
pageErrors,
// WebKit surfaces handled fetch failures (well-known probes) as page
// errors rather than console lines, so the allowlist applies to both.
unexpected: () => [
...pageErrors.map((m) => `pageerror: ${m}`),
...pageErrors
.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m)))
.map((m) => `pageerror: ${m}`),
...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))),
],
};
+3 -3
View File
@@ -28,7 +28,7 @@ test.describe('local homeserver regression', () => {
bob = await ensureUser(uniq('e2e_bob_'));
});
test('logs in, opens a room, sends and receives', async ({ page }) => {
test('logs in, opens a room, sends and receives @webkit @ios', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] });
await joinRoom(bob, room);
@@ -140,7 +140,7 @@ test.describe('local homeserver regression', () => {
await expect(page.getByText('the original message')).toBeVisible();
});
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218)', async ({
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218) @webkit', async ({
browser,
}) => {
const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } });
@@ -169,7 +169,7 @@ test.describe('local homeserver regression', () => {
await ctx.close();
});
test('timeline image opens the gallery lightbox (#219)', async ({ page }) => {
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
+22 -7
View File
@@ -80,7 +80,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -172,6 +173,19 @@
"@babel/runtime": "^7.0.0"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.13.0"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2681,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.9",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.9/element-call-embedded-0.25.0-lotus.9.tgz",
"integrity": "sha512-+mIrBA4SywAmb00EYgThyyVqDLShICjC2Mh8GR13NAWo/u4nPY+j4rMhZidfOJMdFTF5eaMHigX6SE0lHZazZA==",
"version": "0.25.0-lotus.11",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.11/element-call-embedded-0.25.0-lotus.11.tgz",
"integrity": "sha512-wLsVsEBLZ4UjhAdLhhk5BmAap1kZ4x8KKirOm2Ie+DbtM34GCv30oUFmQnUmVdO4by3EQAmNK+v+vYyIwq4DOg==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
@@ -4847,10 +4861,11 @@
}
},
"node_modules/axe-core": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
+4 -3
View File
@@ -12,13 +12,13 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68",
"check:eslint": "eslint src/* --max-warnings 49",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install chromium",
"test:e2e:install": "playwright install chromium webkit",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -107,7 +107,8 @@
"workbox-precaching": "7.4.1"
},
"devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+10 -1
View File
@@ -25,7 +25,16 @@ export default defineConfig({
trace: 'retain-on-failure',
...devices['Desktop Chrome'],
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// [Gitea #221] WebKit as the Safari/iOS proxy. It runs the subset tagged
// @webkit / @ios (boot, composer round-trip, thread panel, lightbox): it
// catches WebKit-only breakage (CSS, dvh, IndexedDB, media decode) that
// Chromium emulation can't, but does not emulate the iOS keyboard or the
// Home-Screen install flow.
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: /@webkit/ },
{ name: 'iphone', use: { ...devices['iPhone 14'] }, grep: /@ios/ },
],
webServer: {
command: `npx vite preview --port ${PORT} --strictPort`,
url: BASE_URL,
+16
View File
@@ -69,6 +69,22 @@
}
],
"categories": ["social", "communication", "productivity"],
"share_target": {
"action": "./share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"]
}
]
}
},
"shortcuts": [
{
"name": "New Message",
+14
View File
@@ -47,6 +47,10 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
import { useCallEndedToast } from '../hooks/useCallEndedToast';
import { useCallRejoin } from '../hooks/useCallRejoin';
import { usePttHaptics } from '../hooks/usePttHaptics';
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
import { callAnnouncementAtom } from '../state/callAnnouncement';
@@ -744,6 +748,9 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useAfkAutoMute(joined ? embed : undefined);
useCallJoinLeaveSounds(embed);
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
usePttHaptics();
useCallAnnouncements(embed, joined);
useMutedTalkWarning(embed, joined);
useCallThemeSync(embed);
@@ -840,6 +847,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
type CallEmbedProviderProps = {
children?: ReactNode;
};
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
useCallRejoin(callEmbed, joined);
return null;
}
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callEmbed = useAtomValue(callEmbedAtom);
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
@@ -1292,6 +1305,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
<CallAnnouncementRegion />
<CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
<CallRejoin callEmbed={callEmbed} joined={joined} />
{children}
</CallEmbedRefContextProvider>
<div
+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,
+2
View File
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
<Modal
size="500"
variant="Background"
role="dialog"
aria-modal="true"
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+3 -1
View File
@@ -67,7 +67,9 @@ export const EditorTextarea = style([
export const EditorPlaceholderContainer = style([
DefaultReset,
{
opacity: config.opacity.Placeholder,
// [Gitea #222] folds' Placeholder opacity (0.5) lands at ~2.3:1 on the
// composer surface; P300 keeps it visibly secondary at AA contrast.
opacity: config.opacity.P300,
pointerEvents: 'none',
userSelect: 'none',
},
+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(
@@ -6,7 +6,7 @@ import * as css from './Reply.css';
import { ForwardedMeta } from '../../features/room/message/forwardContent';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import { formatTimestamp } from '../../utils/formatTimestamp';
type ForwardedHeaderProps = {
mx: MatrixClient;
@@ -32,11 +32,7 @@ export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
getMxIdLocalPart(meta.sender) ??
meta.sender;
const ts = meta.origin_server_ts;
const when = today(ts)
? timeHourMinute(ts, hour24Clock)
: yesterday(ts)
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
const when = formatTimestamp(ts, { hour24Clock, dateFormatString });
const canJump = !!sourceRoom && !!onJump;
return (
+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}>
@@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@@ -143,7 +143,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,
+29 -10
View File
@@ -9,16 +9,35 @@ export const NavItem = as<
{
highlight?: boolean;
} & css.RoomSelectorVariants
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
{...props}
ref={ref}
>
{children}
</AsNavItem>
));
>(
(
{
as: AsNavItem = 'div',
className,
highlight,
variant,
radii,
children,
'aria-selected': selected,
...props
},
ref,
) => (
// [Gitea #222] Callers pass `aria-selected`, but that attribute is only
// valid on option/tab/row roles; on a plain div axe flags it as critical.
// Keep the prop for callers and styling, expose the state as aria-current.
<AsNavItem
className={classNames(css.NavItem({ variant, radii }), className)}
data-highlight={highlight}
data-selected={selected === true || selected === 'true' ? true : undefined}
aria-current={selected === true || selected === 'true' ? 'page' : undefined}
{...props}
ref={ref}
>
{children}
</AsNavItem>
),
);
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
({ className, ...props }, ref) => (
+1 -1
View File
@@ -69,7 +69,7 @@ const NavItemBase = style({
[`&:has(.${NavLink}:active)`]: {
backgroundColor: ContainerActive,
},
'&[aria-selected=true]': {
'&[data-selected=true]': {
backgroundColor: ContainerActive,
},
[`&:has(.${NavLink}:focus-visible)`]: {
+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>
@@ -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>
);
}
@@ -283,6 +283,12 @@ type UploadCardRendererProps = {
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
/** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */
autoFocusCaption?: boolean;
/** Enter in the caption field: send the board (and any composer text). */
onCaptionSubmit?: () => void;
/** Escape in the caption field: hand focus back to the composer. */
onCaptionEscape?: () => void;
};
export function UploadCardRenderer({
isEncrypted,
@@ -290,6 +296,9 @@ export function UploadCardRenderer({
setMetadata,
onRemove,
onComplete,
autoFocusCaption,
onCaptionSubmit,
onCaptionEscape,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
@@ -379,9 +388,27 @@ export function UploadCardRenderer({
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
autoFocus={autoFocusCaption}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onCaptionSubmit?.();
} else if (e.key === 'Escape') {
e.preventDefault();
onCaptionEscape?.();
}
}}
/>
)}
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
{metadata.metadataStripped && (
<Box alignItems="Center" gap="100" style={{ marginTop: config.space.S100 }}>
<Icon size="50" src={Icons.Shield} style={{ color: color.Success.Main }} />
<Text size="T200" priority="300">
Photo metadata removed (location, camera, time)
</Text>
</Box>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
+1 -1
View File
@@ -88,7 +88,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
<Modal
size="500"
className={ModalMobileFull}
onContextMenu={(evt: any) => evt.stopPropagation()}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
<ImageViewer
src={viewAvatar}
@@ -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 && (
+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} />
+1 -1
View File
@@ -41,7 +41,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
className={css.CallMemberCard}
variant="SurfaceVariant"
radii="500"
onClick={(evt: any) =>
onClick={(evt: React.MouseEvent) =>
openUserProfile(
room.roomId,
undefined,
@@ -40,10 +40,15 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { describeRoomVisibility } from '../../../utils/roomVisibilityLine';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
@@ -138,7 +143,19 @@ export function RoomProfileEdit({
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
false,
);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -419,6 +436,14 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
const name = useRoomName(room);
const topic = useRoomTopic(room);
const joinRule = useRoomJoinRule(room);
// [Gitea #133] One line, no controls: encryption · join rule · history.
const historyVisibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
const visibilityLine = describeRoomVisibility({
encrypted: room.hasEncryptionStateEvent(),
joinRule: joinRule?.join_rule,
historyVisibility: historyVisibilityEvent?.getContent<{ history_visibility?: string }>()
.history_visibility,
});
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
@@ -459,6 +484,9 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
<Text className={BreakWord} size="H5">
{name ?? 'Unknown'}
</Text>
<Text size="T200" priority="300" className={BreakWord}>
{visibilityLine}
</Text>
{topic && (
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
{topic.format === 'org.matrix.custom.html' &&
@@ -65,6 +65,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
<Input
key={maxUsers}
name="limitInput"
aria-label="Voice channel participant limit"
defaultValue={maxUsers}
type="number"
min={0}
@@ -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>
+130 -24
View File
@@ -23,6 +23,7 @@ import {
Overlay,
OverlayBackdrop,
OverlayCenter,
color,
config,
PopOut,
toRem,
@@ -37,9 +38,13 @@ import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import 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';
@@ -97,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;
@@ -298,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'];
@@ -337,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 = () => {
@@ -519,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"
@@ -646,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
@@ -677,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) => {
@@ -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">
+11 -88
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,12 +94,6 @@ function getSenderName(room: Room, userId: string): string {
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
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 ──────────────────────────────────────────────────────────────────
@@ -323,6 +247,7 @@ export function Lightbox({
onJump: (eventId: string) => void;
}) {
const [index, setIndex] = useState(initialIndex);
const { format } = useTimestampFormatter();
const item = items[index];
const isImage = item?.msgtype === MsgType.Image;
@@ -366,11 +291,7 @@ export 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 />}>
@@ -614,7 +535,8 @@ function GalleryTile({
mimeType,
nearViewport,
);
const relDate = formatRelativeDate(ts);
const { prefs } = useTimestampFormatter();
const relDate = formatRelativeAge(ts, prefs);
return (
<div className={css.GalleryTileWrap}>
@@ -724,6 +646,7 @@ type MediaGalleryProps = {
};
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const { prefs } = useTimestampFormatter();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
@@ -1040,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');
+46 -35
View File
@@ -95,7 +95,8 @@ import {
createUploadFamilyObserverAtom,
} from '../../state/upload';
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
import { safeFile } from '../../utils/mimeTypes';
import { filesToUploadItems } from '../../utils/uploadItems';
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
@@ -157,6 +158,13 @@ const EmojiBoard = React.lazy(() =>
);
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */
const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean =>
!!target &&
file instanceof File &&
file.name === target.name &&
file.lastModified === target.lastModified;
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
interface RoomInputProps {
@@ -225,6 +233,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const replyUserID = replyDraft?.userId;
// [Gitea #151] The quoted event, for a media thumbnail in the draft preview.
const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined;
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const creatorsTag = useRoomCreatorsTag();
@@ -250,6 +260,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
selectedFiles.map((f) => f.file),
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
// [Gitea #129] The file whose caption input should take focus once its card mounts.
// (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.)
const autoFocusCaptionRef = useRef<File | undefined>(undefined);
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
@@ -279,6 +292,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
@@ -375,39 +389,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
}),
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
}),
);
}
// [Gitea #129] One image into an empty composer: land in its caption
// field so "here's the bug" is one motion (Enter sends, Esc returns).
const focusCaption =
files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor);
setSelectedFiles({
type: 'PUT',
item: fileItems,
item: await filesToUploadItems(room, files, stripImageMetadata),
});
if (focusCaption) autoFocusCaptionRef.current = files[0];
},
[setSelectedFiles, room],
[setSelectedFiles, room, stripImageMetadata, editor],
);
const pickFile = useFilePicker(handleFiles, true);
const handleFilePaste = useFilePasteHandler(handleFiles);
@@ -1060,6 +1052,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
autoFocusCaption={isAutoFocusTarget(
fileItem.originalFile,
autoFocusCaptionRef.current,
)}
onCaptionSubmit={submit}
onCaptionEscape={() => ReactEditor.focus(editor)}
/>
))}
</UploadBoardContent>
@@ -1185,9 +1183,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
<ReplyMediaThumb mEvent={replyDraftEvent} />
)}
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</Box>
</ReplyLayout>
</Box>
</Box>
@@ -1339,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={
+203 -59
View File
@@ -19,6 +19,7 @@ import {
IContent,
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
Room,
RoomEvent,
@@ -90,6 +91,11 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, 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';
@@ -105,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';
@@ -180,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[] = [];
@@ -469,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');
@@ -528,6 +541,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
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);
@@ -1144,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) => {
@@ -1166,10 +1182,26 @@ 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;
@@ -1262,24 +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}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
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}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
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>
);
@@ -2184,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();
@@ -2251,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;
@@ -2282,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>
@@ -2461,7 +2586,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
{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}
+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>
@@ -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);
@@ -0,0 +1,118 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const Wrap = style([
DefaultReset,
{
display: 'block',
width: toRem(480),
maxWidth: '100%',
},
]);
export const Grid = style([
DefaultReset,
{
display: 'grid',
gap: toRem(3),
width: '100%',
borderRadius: config.radii.R400,
overflow: 'hidden',
},
]);
export const Cell = style([
DefaultReset,
{
position: 'relative',
aspectRatio: '1 / 1',
minWidth: 0,
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:focus-visible': {
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
},
]);
export const CellImg = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
transition: 'transform 150ms',
selectors: {
[`${Cell}:hover &`]: {
transform: 'scale(1.03)',
},
},
},
]);
export const CellBlur = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
},
]);
export const PlayBadge = style([
DefaultReset,
{
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
color: 'white',
pointerEvents: 'none',
},
]);
export const Footer = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginTop: config.space.S100,
},
]);
export const FooterButton = style([
DefaultReset,
{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'inherit',
textDecoration: 'underline',
textDecorationColor: 'transparent',
selectors: {
'&:hover, &:focus-visible': {
textDecorationColor: 'currentColor',
},
},
},
]);
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { Icon, Icons, Spinner, Text } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useDecryptedMediaUrl } from '../../../hooks/useDecryptedMediaUrl';
import { getThumbMxc } from '../../../utils/mediaThumb';
import { validBlurHash } from '../../../utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import * as css from './MediaGroupGrid.css';
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 56 → 3, 7+ → 4. */
export const gridColumns = (n: number): number => {
if (n <= 2) return 2;
if (n === 3) return 3;
if (n === 4) return 2;
if (n <= 6) return 3;
return 4;
};
/** "5 photos" / "2 videos" / "6 items". */
export const describeGroup = (events: MatrixEvent[]): string => {
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
const n = events.length;
if (videos === 0) return `${n} photos`;
if (videos === n) return `${n} videos`;
return `${n} items`;
};
function Cell({
mEvent,
load,
onOpen,
}: {
mEvent: MatrixEvent;
load: boolean;
onOpen: (eventId: string) => void;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
const body = typeof content.body === 'string' ? content.body : '';
return (
<button
type="button"
className={css.Cell}
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={() => onOpen(mEvent.getId() ?? '')}
>
{blurHash && media.status !== 'ok' && (
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
)}
{load && media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
{isVideo && (
<span className={css.PlayBadge}>
<Icon src={Icons.Play} size="200" filled />
</span>
)}
</button>
);
}
type MediaGroupGridProps = {
events: MatrixEvent[];
mediaAutoLoad: boolean;
onOpen: (eventId: string) => void;
onShowSeparately: () => void;
};
/**
* [Gitea #137] Several consecutive image/video events from one sender shown
* as one grid. Each tile opens the room's shared lightbox at that event, so
* / walk through the group (and beyond). Purely a render-time grouping.
*/
export function MediaGroupGrid({
events,
mediaAutoLoad,
onOpen,
onShowSeparately,
}: MediaGroupGridProps) {
const [load, setLoad] = useState(mediaAutoLoad);
const columns = gridColumns(events.length);
return (
<div className={css.Wrap}>
<div
className={css.Grid}
role="group"
aria-label={describeGroup(events)}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
onClick={load ? undefined : () => setLoad(true)}
>
{events.map((ev) => (
<Cell
key={ev.getId()}
mEvent={ev}
load={load}
onOpen={load ? onOpen : () => setLoad(true)}
/>
))}
</div>
<div className={css.Footer}>
<Text size="T200" priority="300">
{describeGroup(events)}
{!load && ' · tap to load'}
</Text>
<Text size="T200" priority="300">
·
</Text>
<Text
as="button"
size="T200"
priority="300"
className={css.FooterButton}
onClick={onShowSeparately}
>
Show separately
</Text>
</div>
</div>
);
}
/** Shown under the last of a gallery the user split up, to put it back together. */
export function RegroupChip({ onClick }: { onClick: () => void }) {
return (
<div className={css.Footer}>
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
Show as gallery
</Text>
</div>
);
}
+12 -7
View File
@@ -214,7 +214,7 @@ export const MessageAllReactionItem = as<
return (
<>
<Overlay
onContextMenu={(evt: any) => {
onContextMenu={(evt: React.MouseEvent) => {
evt.stopPropagation();
}}
open={open}
@@ -1087,7 +1087,7 @@ export const Message = React.memo(
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as any).tagName;
const tag = (evt.target as HTMLElement | null)?.tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
if (coarsePointer) {
@@ -1133,7 +1133,7 @@ export const Message = React.memo(
// The full action menu, shared by the desktop PopOut and the touch
// bottom sheet (#166).
const menuJSX = (
<Menu>
<Menu data-message-menu aria-label="Message actions">
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
@@ -1155,7 +1155,7 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
onReplyClick(evt);
closeMenu();
}}
@@ -1232,7 +1232,7 @@ export const Message = React.memo(
after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300"
data-event-id={mEvent.getId()}
onClick={(evt: any) => {
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
onReplyClick(evt, true);
closeMenu();
}}
@@ -1334,7 +1334,12 @@ export const Message = React.memo(
})}
role="article"
aria-label={
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
collapse
? messageAriaLabel(senderDisplayName, mEvent.getTs(), {
hour24Clock,
dateFormatString,
})
: undefined
}
tabIndex={0}
space={messageSpacing}
@@ -1594,7 +1599,7 @@ export const Event = React.memo(
const stateEvent = typeof mEvent.getStateKey() === 'string';
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName;
const tag = (evt.target as HTMLElement | null)?.tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
setMenuAnchor({
+137 -31
View File
@@ -1,4 +1,11 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import React, {
MouseEventHandler,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
Box,
Modal,
@@ -23,6 +30,7 @@ import * as css from './styles.css';
import { ReactionViewer } from '../reaction-viewer';
import { stopPropagation } from '../../../utils/keyboard';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { expandedReactionMessages, fitReactionRow } from '../../../utils/reactionOverflow';
export type ReactionsProps = {
room: Room;
@@ -42,6 +50,49 @@ export const Reactions = as<'div', ReactionsProps>(
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
);
// [Gitea #138] Collapse to one row + "+N" when the chips would wrap.
// Overflowing chips stay in the DOM (invisible, clipped by max-height) so
// the container keeps its natural width and every chip stays measurable.
const [expanded, setExpandedState] = useState(() => expandedReactionMessages.has(mEventId));
const setExpanded = (next: boolean) => {
if (next) expandedReactionMessages.add(mEventId);
else expandedReactionMessages.delete(mEventId);
setExpandedState(next);
};
const [limit, setLimit] = useState<number | undefined>();
const [rowHeight, setRowHeight] = useState<number | undefined>();
const containerRef = useRef<HTMLDivElement | null>(null);
const moreRef = useRef<HTMLButtonElement | null>(null);
const chipRefs = useRef(new Map<string, HTMLElement>());
const keys = reactions.map(([key]) => key).join('\u0000');
const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const rowWidth = container.getBoundingClientRect().width;
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
setLimit(fitReactionRow(widths, moreWidth, gap, rowWidth));
setRowHeight(Math.max(0, ...chips.map((el) => el?.getBoundingClientRect().height ?? 0)));
}, [keys]);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') return undefined;
const ro = new ResizeObserver(() => measure());
ro.observe(container);
return () => ro.disconnect();
}, [measure]);
const collapsed = !expanded && limit !== undefined;
const hiddenCount = collapsed ? reactions.length - (limit ?? 0) : 0;
const hiddenStyle: React.CSSProperties = { visibility: 'hidden', pointerEvents: 'none' };
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
evt.preventDefault();
@@ -56,47 +107,102 @@ export const Reactions = as<'div', ReactionsProps>(
gap="200"
wrap="Wrap"
{...props}
ref={ref}
style={{
...props.style,
...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped.
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
: {}),
}}
ref={(el) => {
containerRef.current = el;
if (typeof ref === 'function') ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
>
{reactions.map(([key, events]) => {
{reactions.map(([key, events], index) => {
const rEvents = Array.from(events);
if (rEvents.length === 0 || typeof key !== 'string') return null;
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
const isPressed = !!myREvent?.getRelation();
const hidden = collapsed && index >= (limit ?? 0);
const chip = (targetRef?: React.RefCallback<HTMLElement>) => (
<Reaction
ref={(el: HTMLElement | null) => {
targetRef?.(el);
if (el) chipRefs.current.set(key, el);
else chipRefs.current.delete(key);
}}
data-reaction-key={key}
aria-pressed={isPressed}
key={key}
mx={mx}
reaction={key}
count={events.size}
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
onContextMenu={handleViewReaction}
aria-disabled={!canSendReaction}
aria-hidden={hidden || undefined}
tabIndex={hidden ? -1 : undefined}
style={hidden ? hiddenStyle : undefined}
useAuthentication={useAuthentication}
/>
);
// The "+N" chip sits right after the last visible chip so it lands on row 1.
const moreChip = index === (limit ?? 0) - 1 && (
<button
key="more"
ref={moreRef}
type="button"
className={css.ReactionsMore}
onClick={() => setExpanded(true)}
aria-expanded={false}
aria-label={`Show ${hiddenCount} more reactions`}
aria-hidden={!collapsed || undefined}
tabIndex={collapsed ? undefined : -1}
style={collapsed ? undefined : { ...hiddenStyle, position: 'absolute' }}
>
<Text as="span" size="T300" dir="ltr">
{`+${collapsed ? hiddenCount : reactions.length - 1}`}
</Text>
</button>
);
if (hidden) return <React.Fragment key={key}>{chip()}</React.Fragment>;
return (
<TooltipProvider
key={key}
position="Top"
tooltip={
<Tooltip style={{ maxWidth: toRem(200) }}>
<Text className={css.ReactionsTooltipText} size="T300">
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
</Text>
</Tooltip>
}
>
{(targetRef) => (
<Reaction
ref={targetRef}
data-reaction-key={key}
aria-pressed={isPressed}
key={key}
mx={mx}
reaction={key}
count={events.size}
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
onContextMenu={handleViewReaction}
aria-disabled={!canSendReaction}
useAuthentication={useAuthentication}
/>
)}
</TooltipProvider>
<React.Fragment key={key}>
<TooltipProvider
position="Top"
tooltip={
<Tooltip style={{ maxWidth: toRem(200) }}>
<Text className={css.ReactionsTooltipText} size="T300">
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
</Text>
</Tooltip>
}
>
{(targetRef) => chip(targetRef)}
</TooltipProvider>
{moreChip}
</React.Fragment>
);
})}
{expanded && limit !== undefined && (
<button
type="button"
className={css.ReactionsMore}
onClick={() => setExpanded(false)}
aria-expanded
aria-label="Show fewer reactions"
>
<Text as="span" size="T300">
less
</Text>
</button>
)}
{reactions.length > 0 && (
<Overlay
onContextMenu={(evt: any) => {
onContextMenu={(evt: React.MouseEvent) => {
evt.stopPropagation();
}}
open={!!viewer}
@@ -16,6 +16,8 @@ import {
OverlayCenter,
Text,
} from 'folds';
import { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { stopPropagation } from '../../../utils/keyboard';
import { useReminders } from '../../../hooks/useReminders';
import { useModalStyle } from '../../../hooks/useModalStyle';
@@ -34,11 +36,11 @@ type RemindMeDialogProps = {
onClose: () => void;
};
function getPresets(): Array<{ label: string; ms: number }> {
function getPresets(prefs: TimestampPrefs): Array<{ label: string; ms: number }> {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0);
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const timeLabel = formatTime(tomorrow.getTime(), prefs);
return [
{ label: 'In 20 minutes', ms: 20 * 60_000 },
{ label: 'In 1 hour', ms: 60 * 60_000 },
@@ -58,7 +60,8 @@ function defaultCustomDate(): Date {
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder, removeReminder, reminders } = useReminders();
const presets = useMemo(() => getPresets(), []);
const { prefs } = useTimestampFormatter();
const presets = useMemo(() => getPresets(prefs), [prefs]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customOpen, setCustomOpen] = useState(false);
@@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
{formatFriendlyDateTime(r.timestamp)}
{formatFriendlyDateTime(r.timestamp, prefs)}
</Text>
<IconButton
size="300"
@@ -193,7 +196,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
variant="SurfaceVariant"
fill="None"
onClick={() => handleCancelExisting(r.timestamp)}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
+23 -1
View File
@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, config, toRem } from 'folds';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
export const MessageBase = style({
position: 'relative',
@@ -45,6 +45,7 @@ export const MessageMenuItemText = style({
});
export const ReactionsContainer = style({
position: 'relative',
selectors: {
'&:empty': {
display: 'none',
@@ -52,6 +53,27 @@ export const ReactionsContainer = style({
},
});
/** [Gitea #138] "+N" / "less" chip at the end of a collapsed reaction row. */
export const ReactionsMore = style([
FocusOutline,
{
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
padding: `${toRem(2)} ${config.space.S200}`,
color: color.SurfaceVariant.OnContainer,
backgroundColor: 'transparent',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
},
},
]);
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
@@ -177,6 +177,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
return (
<Box
as="aside"
aria-label="Thread"
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
+3 -10
View File
@@ -3,9 +3,7 @@ import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { MobileTouchTarget } from '../../../styles/mobile.css';
import { useThreadSummary } from '../../../hooks/useThreadSummary';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadSummaryProps = {
@@ -15,17 +13,12 @@ type ThreadSummaryProps = {
};
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const { format } = useTimestampFormatter();
if (!summary || summary.count === 0) return null;
const { count, latestTs } = summary;
const latestStr =
latestTs !== undefined
? today(latestTs)
? timeHourMinute(latestTs, hour24Clock)
: timeDayMonthYear(latestTs)
: undefined;
const latestStr = latestTs !== undefined ? format(latestTs) : undefined;
return (
<Box style={{ marginTop: config.space.S200 }}>
@@ -76,13 +76,9 @@ import { RoomMediaLightbox } from '../RoomMediaLightbox';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
import {
inSameDay,
minuteDifference,
timeDayMonthYear,
today,
yesterday,
} from '../../../utils/time';
import { inSameDay, minuteDifference } from '../../../utils/time';
import { formatDayDivider } from '../../../utils/formatTimestamp';
import { tick } from '../../../utils/haptics';
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
@@ -271,6 +267,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
const { navigateRoom } = useRoomNavigate();
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -578,6 +575,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const rShortcode =
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply.
@@ -587,7 +585,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
getReactionContent(targetEventId, key, rShortcode),
);
},
[mx, room, thread, getRelationTimelineSet],
[mx, room, thread, getRelationTimelineSet, hapticFeedback],
);
const handleEdit = useCallback(
@@ -910,11 +908,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
<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>
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
@@ -27,6 +27,8 @@ import {
isThreadSort,
} from '../../../utils/threadList';
import { useRoomThreads } from '../../../hooks/useRoomThreads';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { formatRelativeAge } from '../../../utils/formatTimestamp';
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
// localStorage synchronously so the chosen filter/sort apply on first render.
@@ -55,19 +57,6 @@ const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
const MAX_PARTICIPANTS = 5;
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();
}
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
function SegButton({
label,
@@ -115,6 +104,7 @@ type ThreadRowProps = {
onOpen: (threadId: string) => void;
};
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
const { prefs } = useTimestampFormatter();
const rootEvent = thread.rootEvent;
const rootSender = rootEvent?.getSender() ?? '';
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
@@ -126,7 +116,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
const extra = participants.length - MAX_PARTICIPANTS;
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
typeof lastTs === 'number' ? `, last reply ${formatRelativeAge(lastTs, prefs)}` : ''
}`;
return (
@@ -175,7 +165,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
<Icon size="50" src={Icons.Thread} />
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
{count} {count === 1 ? 'reply' : 'replies'}
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
</Text>
<Box shrink="No" alignItems="Center">
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
+2 -2
View File
@@ -153,7 +153,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
{menuItems.map((item) => (
<MenuItem
key={item.name}
@@ -174,7 +174,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Text>
</MenuItem>
))}
</div>
</nav>
</PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
<UseStateProvider initial={false}>
+1 -1
View File
@@ -159,7 +159,7 @@ export function About({ requestClose }: AboutProps) {
>
<SettingTile
title="Clear Cache & Reload"
description="Clear all your locally stored data and reload from server."
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
after={
<Button
onClick={() => clearCacheAndReload(mx)}
+14 -1
View File
@@ -47,6 +47,7 @@ import { UserAvatar } from '../../../components/user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { nameInitials } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
import { useFilePicker } from '../../../hooks/useFilePicker';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { stopPropagation } from '../../../utils/keyboard';
@@ -89,7 +90,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
return undefined;
}, [imageFile]);
const pickFile = useFilePicker(setImageFile, false);
// [Gitea #109] Avatars go through the same metadata strip as messages.
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
const pickFile = useFilePicker(
useCallback(
(file: File) => {
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
setImageFile(r.file),
);
},
[stripImageMetadata],
),
false,
);
const handleRemoveUpload = useCallback(() => {
setImageFile(undefined);
@@ -20,15 +20,13 @@ import FocusTrap from 'focus-trap-react';
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { BreakWord } from '../../../styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
export function DeviceTilePlaceholder() {
return (
@@ -43,20 +41,14 @@ export function DeviceTilePlaceholder() {
}
function DeviceActiveTime({ ts }: { ts: number }) {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const { format } = useTimestampFormatter();
return (
<Text className={BreakWord} size="T200">
<Text size="Inherit" as="span" priority="300">
{'Last activity: '}
</Text>
<>
{today(ts) && 'Today'}
{yesterday(ts) && 'Yesterday'}
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
{timeHourMinute(ts, hour24Clock)}
</>
{format(ts)}
</Text>
);
}
@@ -121,6 +121,8 @@ import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { isBindableCallKey } from '../../../utils/callKeybind';
import { StorageUsage } from './StorageUsage';
import { hapticsSupported } from '../../../utils/haptics';
/**
* P5-47 opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -1442,6 +1444,7 @@ function Privacy() {
'warnOnUnverifiedDevices',
);
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
return (
<Box direction="Column" gap="100">
@@ -1453,6 +1456,13 @@ function Privacy() {
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Remove Photo Metadata Before Sending"
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Typing & Read Receipts"
@@ -1643,6 +1653,8 @@ function Calls() {
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
@@ -1938,6 +1950,28 @@ function Calls() {
direction="Column"
gap="400"
>
<SettingTile
title="After a Restart"
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
after={
<SettingsSelect<'ask' | 'auto' | 'off'>
value={callRejoin}
onChange={setCallRejoin}
options={[
{ value: 'ask', label: 'Ask' },
{ value: 'auto', label: 'Rejoin automatically' },
{ value: 'off', label: 'Do nothing' },
]}
/>
}
/>
{hapticsSupported() && (
<SettingTile
title="Haptic Feedback"
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
/>
)}
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
@@ -2717,6 +2751,7 @@ export function General({ requestClose }: GeneralProps) {
<Editor />
<Messages />
<Privacy />
<StorageUsage />
<SettingsSyncSection />
<Calls />
<AppUpdates />
@@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Text } from 'folds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { bytesToSize } from '../../../utils/common';
import {
percentOf,
readStorageUsage,
requestPersistentStorage,
StorageUsage as Usage,
storageUsageSupported,
} from '../../../utils/storageUsage';
/**
* [Gitea #120] One tile: how much this device stores for Lotus, whether the
* browser has promised to keep it, and a way to ask. Hidden entirely when
* `navigator.storage.estimate()` is unavailable rather than showing zeros.
*/
export function StorageUsage() {
const [usage, setUsage] = useState<Usage | undefined>();
const [requesting, setRequesting] = useState(false);
const refresh = useCallback(() => {
readStorageUsage()
.then(setUsage)
.catch(() => setUsage(undefined));
}, []);
useEffect(() => {
if (storageUsageSupported()) refresh();
}, [refresh]);
if (!usage) return null;
const handlePersist = async () => {
setRequesting(true);
await requestPersistentStorage();
setRequesting(false);
refresh();
};
const breakdown: string[] = [];
if (typeof usage.indexedDB === 'number')
breakdown.push(
`${bytesToSize(usage.indexedDB)} in IndexedDB (sync cache, encryption keys, search index)`,
);
if (typeof usage.caches === 'number')
breakdown.push(`${bytesToSize(usage.caches)} offline app files`);
return (
<Box direction="Column" gap="100">
<Text size="L400">Storage</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={`${bytesToSize(usage.usage)} used on this device`}
description={
<Box direction="Column" gap="100">
<Text size="T200">
{percentOf(usage.usage, usage.quota)}% of the {bytesToSize(usage.quota)} the browser
allows this site.
{breakdown.length > 0 && ` ${breakdown.join(' · ')}.`}
</Text>
<Text size="T200">
Images and videos you have viewed are kept in the browser&apos;s own cache, which it
manages and empties itself; they are not counted here. Encryption keys are never
cleared from this page losing them makes older encrypted messages unreadable on
this device. Cached search results can be cleared from Message Search.
</Text>
</Box>
}
after={
usage.persisted === undefined ? undefined : (
<Box direction="Column" alignItems="End" gap="100">
<Badge
variant={usage.persisted ? 'Success' : 'Warning'}
fill="Soft"
radii="300"
size="400"
>
<Text size="L400">{usage.persisted ? 'Protected' : 'May be evicted'}</Text>
</Badge>
{!usage.persisted && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={requesting}
onClick={handlePersist}
>
<Text size="B300">Keep my data</Text>
</Button>
)}
</Box>
)
}
/>
</SequenceCard>
</Box>
);
}
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { useAtomValue, useSetAtom } from 'jotai';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
@@ -139,6 +140,7 @@ const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }>
// Cross-platform "pause notifications" — sets a snooze instant that the
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
function PauseNotifications() {
const { prefs } = useTimestampFormatter();
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
// While paused, tick so the status flips to "on" the moment the snooze lapses.
@@ -155,7 +157,7 @@ function PauseNotifications() {
? 'Notifications are on.'
: snoozeUntil >= SNOOZE_INDEFINITE
? 'Paused until you resume.'
: `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`;
: `Paused until ${formatFriendlyDateTime(snoozeUntil, prefs)}.`;
return (
<SettingTile
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useCallHangupEvent } from './useCallEmbed';
import { toastQueueAtom } from '../state/toast';
import { describeCallEnd } from '../utils/callSummary';
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
// before Element Call's hangup echo; give a late one this long before toasting.
const SUMMARY_GRACE_MS = 400;
/**
* [Gitea #143] "Call ended · 41 min · connection was good" in the existing
* toast style when a call you were in ends. Duration comes from our own
* join clock; the quality readout from the fork, if it arrived.
*/
export function useCallEndedToast(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const fired = useRef(false);
useEffect(() => {
fired.current = false;
}, [embed]);
useCallHangupEvent(embed, () => {
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
fired.current = true;
const durationMs = Date.now() - embed.joinedAt;
const toast = () =>
setToast({
id: `call-ended-${Date.now()}`,
displayName: 'Lotus Chat',
body: describeCallEnd(durationMs, embed.lastSummary),
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
if (embed.lastSummary) toast();
else setTimeout(toast, SUMMARY_GRACE_MS);
});
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc';
import { useAtomValue, useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { ElementWidgetActions } from '../plugins/call/types';
import { CallEmbed, useCallControlState, useClientWidgetApiEvent } from '../plugins/call';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
import { useCallStart } from './useCallEmbed';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { toastQueueAtom, dismissToastAtom } from '../state/toast';
import { mDirectAtom } from '../state/mDirectList';
import {
CALL_SESSION_HEARTBEAT_MS,
clearCallSession,
decideRejoin,
readCallSession,
writeCallSession,
} from '../utils/callRejoin';
const TOAST_ID = 'call-rejoin';
/** How long after the first sync to wait for the RTC session to report members. */
const MEMBERSHIP_WAIT_MS = 10_000;
const isSynced = (mx: MatrixClient): boolean => {
const state = mx.getSyncState();
return state === SyncState.Prepared || state === SyncState.Syncing;
};
/**
* [Gitea #118] Keep a heartbeat record of the voice room you are in, clear
* it on a deliberate hangup, and on the next start put you back (or ask)
* when the call is still going and the record is fresh.
*/
export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): void {
const mx = useMatrixClient();
const [mode] = useSetting(settingsAtom, 'callRejoinAfterRestart');
const { microphone, video } = useCallControlState(embed?.control);
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
const directs = useAtomValue(mDirectAtom);
const { microphone: prefMic, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const startRoomCall = useCallStart(false);
const startDmCall = useCallStart(true);
// --- record while joined
useEffect(() => {
if (!embed || !joined) return undefined;
const deviceId = mx.getDeviceId() ?? '';
const write = () =>
writeCallSession({
roomId: embed.roomId,
deviceId,
joinedAt: embed.joinedAt ?? Date.now(),
lastSeen: Date.now(),
microphone,
video,
});
write();
const timer = window.setInterval(write, CALL_SESSION_HEARTBEAT_MS);
return () => window.clearInterval(timer);
}, [embed, joined, mx, microphone, video]);
// --- deliberate hangup clears it
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.HangupCall, clearCallSession);
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.Close, clearCallSession);
// --- startup: offer / perform a rejoin once
// The startup effect must run exactly once and outlive re-renders (its
// membership wait spans several seconds), so it reads these through a ref.
const latest = useRef({
mode,
prefMic,
sound,
cameraOnJoin,
directs,
startRoomCall,
startDmCall,
});
latest.current = { mode, prefMic, sound, cameraOnJoin, directs, startRoomCall, startDmCall };
const checkedRef = useRef(false);
const [synced, setSynced] = useState(() => isSynced(mx));
useSyncState(
mx,
useCallback(() => {
if (isSynced(mx)) setSynced(true);
}, [mx]),
);
useEffect(() => {
// Wait for the first sync so the room and its call memberships are known;
// deciding earlier would read an empty session and wrongly drop the record.
if (checkedRef.current || embed || !synced) return undefined;
checkedRef.current = true;
const record = readCallSession();
if (!record) return undefined;
const room = mx.getRoom(record.roomId);
if (!room) {
clearCallSession();
return undefined;
}
const session = mx.matrixRTC.getRoomSession(room);
const rejoin = () => {
const l = latest.current;
const pref = {
microphone: record.microphone ?? l.prefMic,
video: l.cameraOnJoin && record.video,
sound: l.sound,
};
dismissToast(TOAST_ID);
clearCallSession();
try {
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
} catch {
/* no embed container yet — the user can join from the room */
}
};
let done = false;
let timer: number | undefined;
const evaluate = (final: boolean) => {
if (done) return;
const memberships = session.memberships;
const decision = decideRejoin({
record,
mode: latest.current.mode,
now: Date.now(),
myDeviceId: mx.getDeviceId() ?? '',
roomJoined: room.getMyMembership() === 'join',
ownMemberDevices: memberships
.filter((m) => m.userId === mx.getUserId())
.map((m) => m.deviceId),
memberCount: memberships.length,
});
// The RTC session fills in a moment after sync; keep waiting while it is
// empty unless this is the final (timed-out) look.
if (decision.action === 'none' && memberships.length === 0 && !final) return;
done = true;
if (timer !== undefined) window.clearTimeout(timer);
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (decision.action === 'none') {
clearCallSession();
return;
}
if (decision.action === 'auto') {
rejoin();
return;
}
setToast({
id: TOAST_ID,
iconSrc: Icons.Phone,
displayName: 'Rejoin voice?',
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
roomName: room.name ?? '',
roomId: room.roomId,
sticky: true,
onClick: rejoin,
onDismiss: clearCallSession,
});
};
const onChange = () => evaluate(false);
session.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
timer = window.setTimeout(() => evaluate(true), MEMBERSHIP_WAIT_MS);
evaluate(false);
return () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
if (timer !== undefined) window.clearTimeout(timer);
};
}, [embed, synced, mx, setToast, dismissToast]);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { IEncryptedFile } from '../../types/matrix/common';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../utils/matrix';
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
}
let cancelled = false;
setState({ status: 'loading' });
const run = async () => {
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
if (!httpUrl) throw new Error('bad url');
if (encInfo) {
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
);
const blobUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(blobUrl);
return;
}
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
prevBlobUrl.current = blobUrl;
setState({ status: 'ok', url: blobUrl });
} else {
setState({ status: 'ok', url: httpUrl });
}
};
run().catch(() => {
if (!cancelled) setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
},
[],
);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { pttActiveAtom } from './useCallHotkeys';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { tick } from '../utils/haptics';
/**
* [Gitea #125] One observer for every push-to-talk path (keyboard, global
* hotkey, on-screen chip): a tick on engage and on release.
*/
export function usePttHaptics(): void {
const active = useAtomValue(pttActiveAtom);
const [enabled] = useSetting(settingsAtom, 'hapticFeedback');
const prev = useRef(active);
useEffect(() => {
if (prev.current === active) return;
prev.current = active;
tick(active ? 'ptt-on' : 'ptt-off', enabled);
}, [active, enabled]);
}
+36
View File
@@ -0,0 +1,36 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useMediaQuery } from './useMediaQuery';
const RECENT_MS = 700;
/**
* [Gitea #147] Tells whether a click was produced by a finger. Records the
* last touch inside `ref`; `wasTouch()` is true for a short window after it.
* Keyboard activation and screen-reader activation (TalkBack/VoiceOver send a
* bare click, no touch events) report false, so they keep one-step behaviour.
* Always false on devices without a coarse pointer.
*/
export function useRecentTouch(ref: RefObject<HTMLElement | null>) {
const coarse = useMediaQuery('(pointer: coarse)');
const last = useRef(0);
// Listen on the document (the ref'd element may be remounted, e.g. on a tab
// switch) and only count touches that land inside it.
useEffect(() => {
if (!coarse) return undefined;
const mark = (evt: TouchEvent) => {
const el = ref.current;
if (el && evt.target instanceof Node && !el.contains(evt.target)) return;
last.current = Date.now();
};
document.addEventListener('touchstart', mark, { passive: true, capture: true });
document.addEventListener('touchend', mark, { passive: true, capture: true });
return () => {
document.removeEventListener('touchstart', mark, { capture: true });
document.removeEventListener('touchend', mark, { capture: true });
};
}, [ref, coarse]);
const wasTouch = useCallback(() => coarse && Date.now() - last.current < RECENT_MS, [coarse]);
return { coarse, wasTouch };
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string | undefined => {
switch (kind) {
case 'ended':
return 'Screen sharing stopped — the shared window was closed.';
case 'no-frames':
return 'Your screen share is showing nothing — the shared window may be minimised or hidden.';
case 'alone':
return "Still sharing? You've been sharing your screen for 30 minutes with nobody else in the call.";
default:
return undefined;
}
};
/**
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
* closed, no frames for a while, or a long share with nobody watching.
*/
export function useScreenshareNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
useEffect(
() =>
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
'io.lotus.screenshare_notice',
(evt) => {
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
if (!body) return;
setToast({
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
iconSrc: Icons.ScreenShare,
displayName: 'Screen share',
body,
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
sticky: evt.detail?.data?.kind === 'alone',
});
},
),
[embed, setToast],
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useMemo } from 'react';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import {
TimestampStyle,
formatShortAge,
formatTimestamp,
TimestampPrefs,
} from '../utils/formatTimestamp';
/**
* [Gitea #139] Timestamp formatting bound to the user's clock/date settings.
* `format(ts)` is the everyday "auto" style; pass a style for the others.
*/
export function useTimestampFormatter() {
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const prefs = useMemo<TimestampPrefs>(
() => ({ hour24Clock, dateFormatString }),
[hour24Clock, dateFormatString],
);
const format = useCallback(
(ts: number, style: TimestampStyle = 'auto') => formatTimestamp(ts, prefs, style),
[prefs],
);
const shortAge = useCallback((ts: number) => formatShortAge(ts, prefs), [prefs]);
return { format, shortAge, prefs };
}
+13
View File
@@ -63,10 +63,14 @@ import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { getFallbackSession } from '../state/sessions';
import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
@@ -380,6 +384,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</React.Suspense>
}
/>
{/* [Gitea #155] PWA share-target landing page. */}
<Route
path={SHARE_PAGE_PATH}
element={
<React.Suspense fallback={null}>
<Share />
</React.Suspense>
}
/>
<Route
path={INBOX_PATH}
element={
+97 -3
View File
@@ -73,6 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
RoomsNotificationPreferences,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
@@ -80,6 +81,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
type HomeMenuProps = {
requestClose: () => void;
@@ -217,6 +219,73 @@ function HomeEmpty() {
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
type HomeCustomSectionProps = {
section: RoomSection;
closed: boolean;
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
filterQuery: string;
selectedRoomId?: string;
roomsWithUnreadSet: Set<string>;
notificationPreferences: RoomsNotificationPreferences;
};
/** [Gitea #108] One collapsible u.* section; mirrors the Favorites block. */
function HomeCustomSection({
section,
closed,
onCategoryClick,
filterQuery,
selectedRoomId,
roomsWithUnreadSet,
notificationPreferences,
}: HomeCustomSectionProps) {
const mx = useMatrixClient();
const categoryId = makeNavCategoryId('home', section.tag);
const items = useMemo(() => {
// Open: the tag's own order. Closed: only unread/selected, by activity.
const base = closed
? [...section.rooms]
.sort(factoryRoomIdByActivity(mx))
.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId)
: section.rooms;
if (!filterQuery.trim()) return base;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return base.filter((rId) => {
const localName = localNames.rooms[rId];
const matrixName = mx.getRoom(rId)?.name ?? '';
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closed}
data-category-id={categoryId}
onClick={onCategoryClick}
>
{section.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
{items.map((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
})}
</NavCategory>
);
}
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
@@ -260,21 +329,34 @@ export function Home() {
// once some unrelated event (e.g. an unread-count change) forces a re-render.
const roomTagsVersion = useRoomTagsVersion(mx);
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
const favs: string[] = [];
const low: string[] = [];
const others: string[] = [];
// [Gitea #108] u.* tags → custom sections. A room in a section leaves the
// plain "Rooms" list but keeps its Favorite / Low Priority placement.
const { sections, sectioned } = deriveRoomSections(
rooms.map((rId) => {
const room = mx.getRoom(rId);
return { roomId: rId, name: room?.name ?? rId, tags: room?.tags };
}),
);
rooms.forEach((rId) => {
const room = mx.getRoom(rId);
if (room?.tags?.['m.favourite']) {
favs.push(rId);
} else if (room?.tags?.['m.lowpriority']) {
low.push(rId);
} else {
} else if (!sectioned.has(rId)) {
others.push(rId);
}
});
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
return {
favoriteRooms: favs,
lowPriorityRooms: low,
otherRooms: others,
customSections: sections,
};
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
// this memo to re-run whenever any room's tags change.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -536,6 +618,18 @@ export function Home() {
</div>
</NavCategory>
)}
{customSections.map((section) => (
<HomeCustomSection
key={section.tag}
section={section}
closed={closedCategories.has(makeNavCategoryId('home', section.tag))}
onCategoryClick={handleCategoryClick}
filterQuery={filterQuery}
selectedRoomId={selectedRoomId}
roomsWithUnreadSet={roomsWithUnreadSet}
notificationPreferences={notificationPreferences}
/>
))}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
+250
View File
@@ -0,0 +1,250 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
Icons,
Input,
MenuItem,
Scroll,
Spinner,
Text,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue, useStore } from 'jotai';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { mDirectAtom } from '../../../state/mDirectList';
import {
roomIdToMsgDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
} from '../../../state/room/roomInputDrafts';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { filesToUploadItems } from '../../../utils/uploadItems';
import {
clearSharedPayload,
readSharedPayload,
shareDraftText,
SharePayload,
} from '../../../../swShare';
import { BlockType } from '../../../components/editor';
import { bytesToSize } from '../../../utils/common';
import { getHomePath } from '../../pathUtils';
type Shared = { payload: SharePayload; files: File[] };
/**
* [Gitea #155] Landing page for the PWA share target: what was shared, pick
* a room, and the files land on that room's composer upload board (text/url
* as the draft) the user still presses Send.
*/
export function Share() {
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom } = useRoomNavigate();
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
useEffect(() => {
readSharedPayload()
.then((s) => setShared(s ?? null))
.catch(() => setShared(null));
}, []);
const rooms = useMemo(() => {
const all = mx
.getRooms()
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
const q = query.trim().toLowerCase();
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
}, [mx, query]);
// Written straight into the room's draft atoms so its composer shows the
// upload board + text as soon as it mounts.
const store = useStore();
const pick = useCallback(
async (room: Room) => {
if (!shared || busy) return;
setBusy(true);
try {
if (shared.files.length) {
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
type: 'PUT',
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
});
}
const text = shareDraftText(shared.payload);
if (text) {
store.set(roomIdToMsgDraftAtomFamily(room.roomId), [
{ type: BlockType.Paragraph, children: [{ text }] },
]);
}
await clearSharedPayload();
navigateRoom(room.roomId);
} finally {
setBusy(false);
}
},
[shared, busy, navigateRoom, store, stripImageMetadata],
);
return (
<Page>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Send} />
<Text size="H3" truncate>
Share to Lotus Chat
</Text>
</Box>
</PageHeader>
<Box grow="Yes" direction="Column">
<Scroll hideTrack visibility="Hover">
<PageContent>
{shared === undefined && (
<Box justifyContent="Center" style={{ padding: config.space.S500 }}>
<Spinner size="400" />
</Box>
)}
{shared === null && (
<Box direction="Column" gap="300" alignItems="Start">
<Text size="T300">
Nothing was shared, or it has already been placed in a room.
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => navigate(getHomePath())}
>
<Text size="B300">Go home</Text>
</Button>
</Box>
)}
{shared && (
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Shared</Text>
{shared.files.map((f) => (
<Box key={f.name} alignItems="Center" gap="200">
<Icon
size="100"
src={f.type.startsWith('image/') ? Icons.Photo : Icons.File}
/>
<Text size="T300" truncate>
{f.name}
</Text>
<Text size="T200" priority="300">
{bytesToSize(f.size)}
</Text>
</Box>
))}
{shareDraftText(shared.payload) && (
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{shareDraftText(shared.payload)}
</Text>
)}
</Box>
<Box direction="Column" gap="200">
<Text size="L400">Send to</Text>
<Input
size="400"
variant="Background"
radii="400"
placeholder="Search rooms"
value={query}
onChange={(evt) => setQuery(evt.currentTarget.value)}
autoFocus
/>
<Box direction="Column" gap="100">
{rooms.slice(0, 60).map((room) => (
<ShareRoomRow
key={room.roomId}
room={room}
dm={directs.has(room.roomId)}
useAuthentication={useAuthentication}
disabled={busy}
onPick={pick}
/>
))}
{rooms.length === 0 && (
<Text size="T300" priority="300">
No rooms match.
</Text>
)}
</Box>
</Box>
</Box>
)}
</PageContent>
</Scroll>
</Box>
</Page>
);
}
function ShareRoomRow({
room,
dm,
useAuthentication,
disabled,
onPick,
}: {
room: Room;
dm: boolean;
useAuthentication: boolean;
disabled: boolean;
onPick: (room: Room) => void;
}) {
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
return (
<MenuItem
size="300"
radii="300"
variant="Background"
disabled={disabled}
onClick={() => onPick(room)}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
)}
/>
</Avatar>
}
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
Direct Message
</Text>
)}
</Box>
</MenuItem>
);
}
+1 -1
View File
@@ -378,7 +378,7 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
<Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement space!'}
</Text>
)}
</Box>
+19
View File
@@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState {
public resendForkState(): void {
this.sendDeafenState();
this.sendQuality();
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
}
@@ -277,6 +278,24 @@ export class CallControl extends EventEmitter implements CallControlState {
// P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the
// fork's handler only exists once joined; onCallJoined() re-sends the current
// state so a pre-join deafen is not lost.
// [Gitea #119] Output device (headset ↔ speakers) chosen from the host's
// call bar; the fork applies it with mediaDevices.audioOutput.select().
private _audioOutputId: string | undefined;
public get audioOutputId(): string | undefined {
return this._audioOutputId;
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
}
private sendDeafenState(): void {
if (!this.joined) return;
this.call.transport
+29 -1
View File
@@ -49,6 +49,14 @@ export interface LotusCallParticipant {
speakingWhileMuted?: boolean;
}
/** [Gitea #143] The fork's one-shot io.lotus.call_summary payload. */
export interface LotusCallSummary {
durationMs: number;
reconnects: number;
poorMs: number;
verdict: 'good' | 'fair' | 'poor' | 'unknown';
}
export class CallEmbed {
private mx: MatrixClient;
@@ -60,6 +68,12 @@ export class CallEmbed {
public joined = false;
/** [Gitea #143] When the first JoinCall landed, for the hangup readout. */
public joinedAt: number | undefined;
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
public lastSummary: LotusCallSummary | undefined;
// C-M4: set once dispose() has run so the hangup fallback timer can tell
// whether the embed was already torn down by the normal Close/Hangup echo.
public disposed = false;
@@ -354,7 +368,7 @@ export class CallEmbed {
return this.listenEvent('preparing', callback);
}
public onPreparingError(callback: (error: any) => void) {
public onPreparingError(callback: (error: unknown) => void) {
return this.listenEvent('error:preparing', callback);
}
@@ -390,6 +404,19 @@ export class CallEmbed {
this.forkStateRequestListeners.forEach((l) => l());
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_summary', (evt) => {
const data = (evt.detail as { data?: Partial<LotusCallSummary> } | undefined)?.data;
if (data && typeof data.durationMs === 'number') {
this.lastSummary = {
durationMs: data.durationMs,
reconnects: data.reconnects ?? 0,
poorMs: data.poorMs ?? 0,
verdict: data.verdict ?? 'unknown',
};
}
}),
);
this.disposables.push(
this.listenAction('io.lotus.call_state', (evt) => {
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
@@ -537,6 +564,7 @@ export class CallEmbed {
return;
}
this.joined = true;
this.joinedAt = Date.now();
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
this.control.forceState(this.initialState);
}
+2
View File
@@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs';
import { clearRecentStickers } from './recentStickers';
import { clearNavToActivePathStore } from './navToActivePath';
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
import { clearCallSession } from '../utils/callRejoin';
/**
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
@@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs();
clearRecentStickers();
clearMsgDrafts();
clearCallSession();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId);
};
+2
View File
@@ -15,6 +15,8 @@ export type TUploadMetadata = {
compressImage?: boolean;
/** Cached compression result (populated in the background when compressImage is set to true) */
compressionResult?: CompressionResult | null;
/** [Gitea #109] EXIF/XMP/IPTC was removed from this image before upload. */
metadataStripped?: boolean;
};
export type TUploadItem = {
+10
View File
@@ -268,6 +268,13 @@ export interface Settings {
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
// send, and from links rendered in the timeline. Local only.
stripTrackingParams: boolean;
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
// uploads without re-encoding. Default on.
stripImageMetadata: boolean;
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
// [Gitea #125] Vibration ticks on PTT press/release and reactions (Android only).
hapticFeedback: boolean;
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
// so other devices pick them up. Device-local itself (utils/settingsSync).
@@ -390,6 +397,9 @@ const defaultSettings: Settings = {
warnOnUnverifiedDevices: false,
stripTrackingParams: true,
stripImageMetadata: true,
callRejoinAfterRestart: 'ask',
hapticFeedback: true,
settingsSync: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

+9 -7
View File
@@ -2,27 +2,29 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import dayjs from 'dayjs';
import { messageAriaLabel } from './a11y';
import { timeDayMonthYear, timeHourMinute } from './time';
test('messageAriaLabel composes sender, date and time (24h)', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Alice', ts, true),
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
messageAriaLabel('Alice', ts, { hour24Clock: true, dateFormatString: 'D MMM YYYY' }),
'Alice, 1 Jul 2026 14:30',
);
});
test('messageAriaLabel honours the 12-hour clock preference', () => {
test('messageAriaLabel honours the 12-hour clock and date-format preferences', () => {
const ts = dayjs('2026-07-01T14:30:00').valueOf();
assert.equal(
messageAriaLabel('Bob', ts, false),
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
messageAriaLabel('Bob', ts, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }),
'Bob, 07/01/2026 02:30 PM',
);
});
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
const ts = dayjs('2026-07-01T09:05:00').valueOf();
const label = messageAriaLabel('@user:example.org', ts, true);
const label = messageAriaLabel('@user:example.org', ts, {
hour24Clock: true,
dateFormatString: '',
});
assert.ok(label.startsWith('@user:example.org, '));
assert.ok(!label.includes('<'));
});
+5 -5
View File
@@ -1,4 +1,4 @@
import { timeDayMonthYear, timeHourMinute } from './time';
import { TimestampPrefs, formatTimestamp } from './formatTimestamp';
/**
* Builds a plain-text accessible label for a message row, used when the
@@ -7,8 +7,8 @@ import { timeDayMonthYear, timeHourMinute } from './time';
*
* @param sender - Sender display name (already resolved to a human string).
* @param ts - Message origin timestamp in milliseconds.
* @param hour24Clock - Whether to format the time using a 24-hour clock.
* @returns A label such as `Alice, 1 July 2026 14:30`.
* @param prefs - The user's clock/date preferences.
* @returns A label such as `Alice, 1 Jul 2026 14:30`.
*/
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
export const messageAriaLabel = (sender: string, ts: number, prefs: TimestampPrefs): string =>
`${sender}, ${formatTimestamp(ts, prefs, 'dateTime')}`;
+56
View File
@@ -0,0 +1,56 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
const base: RejoinContext = {
record: {
roomId: '!r',
deviceId: 'DEV',
joinedAt: 900_000,
lastSeen: 1_000_000,
microphone: true,
video: false,
},
mode: 'ask',
now: 1_060_000,
myDeviceId: 'DEV',
roomJoined: true,
ownMemberDevices: [],
memberCount: 2,
};
describe('decideRejoin', () => {
it('asks by default when the call is still going', () => {
assert.deepEqual(decideRejoin(base), { action: 'ask', roomId: '!r' });
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
});
it('does nothing when off, stale, another device, or no record', () => {
assert.deepEqual(decideRejoin({ ...base, mode: 'off' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, record: undefined }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, now: 1_000_000 + CALL_REJOIN_MAX_AGE_MS + 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, myDeviceId: 'OTHER' }), { action: 'none' });
assert.deepEqual(decideRejoin({ ...base, roomJoined: false }), { action: 'none' });
});
it('skips when we already rejoined from another device', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['PHONE'], memberCount: 2 }), {
action: 'none',
});
});
it('skips when only our own stale membership is left', () => {
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 1 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: [], memberCount: 0 }), {
action: 'none',
});
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 2 }), {
action: 'ask',
roomId: '!r',
});
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* [Gitea #118] Remember the voice room you were in so a crash, update or
* reload can put you back. Device-local (localStorage), never synced;
* cleared on a deliberate hangup and on logout.
*/
export const CALL_SESSION_KEY = 'lotus-call-session';
/** A record older than this (by last heartbeat) is stale — the call is over. */
export const CALL_REJOIN_MAX_AGE_MS = 10 * 60_000;
export const CALL_SESSION_HEARTBEAT_MS = 30_000;
export type CallSessionRecord = {
roomId: string;
deviceId: string;
joinedAt: number;
/** Refreshed while joined so a crash leaves a recent timestamp behind. */
lastSeen: number;
microphone: boolean;
video: boolean;
};
export type CallRejoinMode = 'ask' | 'auto' | 'off';
export const readCallSession = (): CallSessionRecord | undefined => {
try {
const raw = localStorage.getItem(CALL_SESSION_KEY);
if (!raw) return undefined;
const r = JSON.parse(raw) as Partial<CallSessionRecord>;
if (typeof r.roomId !== 'string' || typeof r.deviceId !== 'string') return undefined;
return {
roomId: r.roomId,
deviceId: r.deviceId,
joinedAt: Number(r.joinedAt) || 0,
lastSeen: Number(r.lastSeen) || 0,
microphone: r.microphone !== false,
video: r.video === true,
};
} catch {
return undefined;
}
};
export const writeCallSession = (record: CallSessionRecord): void => {
try {
localStorage.setItem(CALL_SESSION_KEY, JSON.stringify(record));
} catch {
/* quota / private mode */
}
};
export const clearCallSession = (): void => {
try {
localStorage.removeItem(CALL_SESSION_KEY);
} catch {
/* ignore */
}
};
export type RejoinContext = {
record: CallSessionRecord | undefined;
mode: CallRejoinMode;
now: number;
myDeviceId: string;
/** Whether the room still exists and we are joined to it. */
roomJoined: boolean;
/** Device ids of our own live call memberships in that room (other tabs/devices). */
ownMemberDevices: string[];
/** Number of call memberships in the room, ours included. */
memberCount: number;
};
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
/** Pure: whether startup should offer (or perform) a rejoin. */
export function decideRejoin(ctx: RejoinContext): RejoinDecision {
const { record, mode, now, myDeviceId } = ctx;
if (!record || mode === 'off') return { action: 'none' };
if (record.deviceId !== myDeviceId) return { action: 'none' };
if (now - record.lastSeen > CALL_REJOIN_MAX_AGE_MS) return { action: 'none' };
if (!ctx.roomJoined) return { action: 'none' };
// Already back in from another device/tab — don't double-join.
if (ctx.ownMemberDevices.some((d) => d !== myDeviceId)) return { action: 'none' };
// Nobody there any more (our own stale membership doesn't count).
const others = ctx.memberCount - ctx.ownMemberDevices.length;
if (others <= 0) return { action: 'none' };
return { action: mode, roomId: record.roomId };
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { describeCallDuration, describeCallEnd } from './callSummary';
describe('describeCallDuration', () => {
it('picks the unit', () => {
assert.equal(describeCallDuration(40_000), '40 s');
assert.equal(describeCallDuration(41 * 60_000), '41 min');
assert.equal(describeCallDuration(65 * 60_000), '1 h 05 min');
});
});
describe('describeCallEnd', () => {
const min = 60_000;
it('without a summary shows just the duration', () => {
assert.equal(describeCallEnd(41 * min), 'Call ended · 41 min');
});
it('good / unknown', () => {
assert.equal(
describeCallEnd(41 * min, {
durationMs: 41 * min,
reconnects: 1,
poorMs: 0,
verdict: 'good',
}),
'Call ended · 41 min · connection was good',
);
assert.equal(
describeCallEnd(2 * min, {
durationMs: 2 * min,
reconnects: 0,
poorMs: 0,
verdict: 'unknown',
}),
'Call ended · 2 min',
);
});
it('reconnects and poor spells', () => {
assert.equal(
describeCallEnd(41 * min, {
durationMs: 41 * min,
reconnects: 3,
poorMs: 0,
verdict: 'fair',
}),
'Call ended · 41 min · 3 reconnects',
);
assert.equal(
describeCallEnd(12 * min, {
durationMs: 12 * min,
reconnects: 1,
poorMs: 4 * min,
verdict: 'poor',
}),
'Call ended · 12 min · 1 reconnect, connection was poor for 4 min',
);
assert.equal(
describeCallEnd(12 * min, {
durationMs: 12 * min,
reconnects: 0,
poorMs: 2_000,
verdict: 'fair',
}),
'Call ended · 12 min · connection was fair',
);
});
});

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