Compare commits

...
20 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 e7e6d44a31 feat(image-viewer): scroll wheel to zoom
CI / Trigger Desktop Build (push) Successful in 18s
CI / Build & Quality Checks (push) Successful in 10m44s
The timeline image viewer only zoomed via the -/+ buttons and the % toggle. Add
scroll-to-zoom (wheel up = in, down = out) for parity with the media-gallery
lightbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:58:21 -04:00
jaredandClaude Opus 4.8 829525506c fix(image-viewer): pan tracks the cursor 1:1 when zoomed
The timeline image viewer's transform applied translate inside scale(), so
dragging a zoomed image moved it by zoom x the cursor distance (panning outran
the pointer). Divide the pan offset by zoom so it tracks 1:1 - matching the
media-gallery lightbox fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:57:33 -04:00
jaredandClaude Opus 4.8 dcad282749 fix(jump-to-time): friendly message when homeserver lacks MSC3030
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:22:43 -04:00
jaredandClaude Opus 4.8 4d78d427d2 fix(message): gate "Copy Text" to textual message types
Review noted media without a caption has a filename body, so Copy Text showed
and copied the filename. Gate to m.text/m.emote/m.notice so it only appears for
actual text messages (matching the intended behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:58 -04:00
jaredandClaude Opus 4.8 12aa49f054 feat(message): add "Copy Text" context-menu action
The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:17:52 -04:00
jaredandClaude Opus 4.8 df8afe4410 fix(location): wrap long MSC3488 descriptions
Constrain the rendered location description width and break long tokens so an
oversized description from another client can't overflow the message bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:14:09 -04:00
jaredandClaude Opus 4.8 ea6e89ae56 feat(location): render MSC3488-only locations (uri + description)
The m.location renderer only read the legacy top-level geo_uri, so a location
from a client that sends only the MSC3488 shape (uri under
org.matrix.msc3488.location / m.location) showed as broken. Fall back to that
uri, and display an MSC3488 description above the coordinates when present.
Closes the consume-side gap noted in review of the send-side MSC3488 change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:12:01 -04:00
jaredandClaude Opus 4.8 75b94bd39f fix(night-light): warn when schedule start equals end
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:09:33 -04:00
jaredandClaude Opus 4.8 2d8bc487e5 feat(location): send MSC3488-compliant shared-location events
Location sharing sent only a legacy geo_uri with a "Location: geo:..." body, so
other clients often rendered it as plain text. Include the MSC3488 blocks
(org.matrix.msc3488.location/asset/ts + m.ts) alongside geo_uri and a readable
body, so Element and others render a proper location pin. Local rendering is
unchanged (still reads geo_uri).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:08:32 -04:00
jaredandClaude Opus 4.8 cf4ee6618c feat(night-light): optional auto-on schedule (time window)
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles itself on/off
automatically (the overlay re-checks every minute; no reload). Overnight windows
that wrap midnight (e.g. 21:00 -> 07:00) are handled.

Window logic is the pure, unit-tested isWithinTimeWindow/parseHHMM in
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:05:56 -04:00
jaredandClaude Opus 4.8 23950a6797 fix(invite): robust QR filename + encode-failure feedback
From review: sanitizeFilename now denylists only path-hostile + control chars
(preserving Unicode room names instead of collapsing CJK/emoji names to "room"),
converts whitespace to dashes, trims separator runs, and caps length. Also
surface an inline error when canvas.toBlob returns null so a failed PNG encode
isn't a silent no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:03:04 -04:00
jaredandClaude Opus 4.8 a1107015fd feat(invite): download the room QR code as a PNG
The invite QR was display-only. Add a "Download QR" button that exports it as a
PNG via an offscreen high-resolution (1024px, spec 4-module quiet zone)
QRCodeCanvas + canvas.toBlob, saved through useSaveFile (filename from the room
name) with the standard download toast. The visible code stays an SVG so it
renders crisply at any size/theme.

Also corrects the stale LOTUS_FEATURES note (the QR is generated locally via
qrcode.react, not api.qrserver.com).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:57:19 -04:00
jaredandClaude Opus 4.8 4a3a80df9f fix(reminders): make cancel optimistic, no contradictory error
The shared account-data store removes optimistically with no rollback, so the
reminder row vanished the instant Cancel was clicked. Showing a "could not
cancel" error beside the already-gone row (and it reappearing on next sync) was
self-contradictory. Match the removeBookmark convention: fire-and-forget
optimistic removal, no inline error. Drops the now-moot cancelling busy-guard
and uses a collision-safe React key for same-minute custom reminders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:56:22 -04:00
jaredandClaude Opus 4.8 7d67cdeef0 feat(reminders): view and cancel a message's existing reminders
There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.

- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
  (Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.

Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:11 -04:00
jaredandClaude Opus 4.8 b6725a6ee3 feat(reminders): custom date/time option in Remind Me
The Remind Me dialog only offered four fixed presets, so you couldn't set a
reminder for an arbitrary time. Add a "Custom time…" option that reveals date +
time pickers (validated >= 1 minute in the future) and sets the reminder at that
absolute timestamp.

Also extract the local date/time <input> helpers (toLocalDate, toLocalTime,
parseLocalDateTime, pickerInputStyle) into a shared, unit-tested
utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an
inline copy) — identical output, now covered by tests.

Documents the previously-undocumented Message Reminders feature in
LOTUS_FEATURES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:45:43 -04:00
jaredandClaude Opus 4.8 23e264d179 fix(bookmarks): drop unused senderId from stored bookmark
Review noted the 500-entry io.lotus.bookmarks blob approaches the ~65KB event
limit. The senderId I stored was never read back (the panel re-resolves the
live sender via the event and uses the senderName snapshot otherwise), so it
was dead payload. Keep only senderName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:40:39 -04:00
jaredandClaude Opus 4.8 e01b87b214 feat(bookmarks): show who wrote each saved message
Saved-message cards showed the room and time but not the author, so in a busy
room you couldn't tell who said it without jumping. Now each card shows
"{sender} - {time ago}":

- Bookmark gains optional senderId/senderName (snapshotted at save time in
  Message.tsx from the already-computed sender display name); optional so
  existing stored bookmarks stay valid.
- The panel re-resolves the author's current display name live from the event
  when the room is joined, falling back to the stored snapshot for left rooms.
- Search now also matches the author name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:37:12 -04:00
jaredandClaude Opus 4.8 f9af363dd6 fix(media-gallery): harden download filename + zoom/pan from review
- mediaFilename: only skip appending an extension when the body already ends in
  a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
  gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
  dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:28:27 -04:00
jaredandClaude Opus 4.8 8a92821a48 feat(media-gallery): zoom & pan images in the lightbox
The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):

- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
  % chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item

Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:23:14 -04:00
jaredandClaude Opus 4.8 68a88e84b8 feat(media-gallery): download images/videos from the viewer and grid tiles
The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:

- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile

Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:18:55 -04:00
20 changed files with 838 additions and 111 deletions
+19 -3
View File
@@ -294,6 +294,7 @@ A warm orange overlay rendered over the entire UI to reduce blue light emission.
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
- Orange tint color with configurable opacity
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:0007:00.
- Settings persisted via the standard Lotus settings store
---
@@ -682,6 +683,10 @@ The indicator is hidden once the server confirms the event (when the internal st
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
### Copy Message Text
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
### Draft Persistence
- Composer drafts are stored in `localStorage` keyed by `roomId`
@@ -721,7 +726,9 @@ Images and videos can be sent with a caption. The caption and media are sent as
### Location Sharing
`m.location` events render an inline map tile using the coordinates from the event content.
`m.location` events render an inline map tile using the coordinates from the event content. The renderer reads the top-level `geo_uri` and **falls back to the MSC3488 `org.matrix.msc3488.location`/`m.location` `uri`**, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 `description`, if present, is shown above the coordinates.
Sharing your location (composer → location button) sends an **MSC3488-compliant** `m.location` event: the legacy `geo_uri` plus the `org.matrix.msc3488.location` (uri), `org.matrix.msc3488.asset` (`m.self`), and `org.matrix.msc3488.ts`/`m.ts` blocks, and a human-readable `body`. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.
### Deleted Message Placeholders
@@ -733,6 +740,7 @@ Redacted events display "This message has been deleted" along with the redaction
- Maximum of 500 bookmarked entries
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
- Live-renders edits/redactions, text search, jump-to-message, and remove
- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name.
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
- Hook: `src/app/hooks/useBookmarks.ts`
@@ -745,6 +753,13 @@ Redacted events display "This message has been deleted" along with the redaction
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
- Utilities in `src/app/utils/scheduledMessages.ts`
### Message Reminders
- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
- `RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).
### File Upload Compression (opt-in)
- Implemented in `UploadCardRenderer.tsx`
@@ -1094,7 +1109,8 @@ Persists via the `homeRoomSort` setting.
`RoomShareInvite.tsx` provides a shareable invite UI:
- 160×160px QR code generated via `api.qrserver.com`
- 160×160px QR code generated locally via `qrcode.react` (`QRCodeSVG`) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme
- **Download QR**: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin) `QRCodeCanvas` + `canvas.toBlob`, saved through `useSaveFile` (filename derived from the room name) with the standard download toast
- "Copy Link" button to copy the `matrix.to` URI
- Also accessible via a toggle button (⊞) in the Invite modal
@@ -1107,7 +1123,7 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
`MediaGallery.tsx` — a right-side drawer for browsing room media.
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next)
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or /+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
@@ -81,12 +81,21 @@ export const ImageViewer = as<'div', ImageViewerProps>(
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
onWheel={(evt) => {
if (evt.deltaY < 0) zoomIn();
else if (evt.deltaY > 0) zoomOut();
}}
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
// translate runs inside scale(), so divide by zoom to make a dragged
// pixel move the image one screen pixel (1:1 with the cursor) rather
// than `zoom` pixels.
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
pan.translateY / zoom
}px)`,
}}
src={src}
alt={alt}
@@ -514,7 +514,19 @@ type MLocationProps = {
};
export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation();
const geoUri = content.geo_uri;
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
// location block so events from clients that only send the new shape (uri under
// org.matrix.msc3488.location / m.location) still render instead of appearing
// broken.
const msc3488 = (content['org.matrix.msc3488.location'] ?? content['m.location']) as
| { uri?: string; description?: string }
| undefined;
const geoUri =
typeof content.geo_uri === 'string'
? content.geo_uri
: typeof msc3488?.uri === 'string'
? msc3488.uri
: undefined;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
@@ -522,6 +534,7 @@ export function MLocation({ content }: MLocationProps) {
const lat = parseFloat(location.latitude);
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
@@ -542,6 +555,11 @@ export function MLocation({ content }: MLocationProps) {
loading="lazy"
sandbox="allow-scripts"
/>
{description && (
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
{description}
</Text>
)}
<Text size="T300" priority="300">
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
</Text>
+24 -6
View File
@@ -37,7 +37,7 @@ import { MessageDeletedContent } from '../../components/message/content/Fallback
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { getRoomAvatarUrl, getMemberName } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
@@ -102,9 +102,11 @@ type BookmarkItemProps = {
onRemove: (eventId: string) => void;
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
preview?: ReactNode;
// Live-resolved author name; falls back to the stored snapshot when absent.
senderName?: string;
};
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: BookmarkItemProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
@@ -112,6 +114,9 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
// Prefer a live-resolved author name, then the stored snapshot.
const author = senderName ?? bookmark.senderName;
const timeAgo = formatTimeAgo(bookmark.savedAt);
return (
<Box
@@ -137,8 +142,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
<Text size="T200" priority="300" truncate>
{author ? `${author} · ${timeAgo}` : timeAgo}
</Text>
</Box>
<IconButton
@@ -199,7 +204,19 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
}
}
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
// Resolve the author's current display name from the live event when available.
const liveSender = liveEvent?.getSender();
const senderName = liveSender ? getMemberName(room, liveSender) : undefined;
return (
<BookmarkItem
bookmark={bookmark}
onJump={onJump}
onRemove={onRemove}
preview={preview}
senderName={senderName}
/>
);
}
type RoomGroupHeaderProps = {
@@ -320,7 +337,8 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query),
bk.roomName.toLowerCase().includes(query) ||
(bk.senderName?.toLowerCase().includes(query) ?? false),
),
[bookmarks, query],
);
+30
View File
@@ -52,8 +52,38 @@ export const MediaGalleryGrid = style({
gap: config.space.S100,
});
// Wraps a tile + its floating download button so the download control is a
// sibling of the tile <button> (never nested inside it — that would be invalid
// interactive-in-interactive markup). The wrapper is the grid cell; the tile
// button fills it via aspect-ratio.
export const GalleryTileWrap = style({
position: 'relative',
display: 'flex',
});
export const GalleryTileDownload = style({
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
zIndex: 1,
opacity: 0,
transition: 'opacity 100ms ease-in-out',
selectors: {
[`${GalleryTileWrap}:hover &, ${GalleryTileWrap}:focus-within &`]: {
opacity: 1,
},
},
// Touch devices have no hover; keep the control reachable there.
'@media': {
'(hover: none)': {
opacity: 1,
},
},
});
export const GalleryTile = style({
position: 'relative',
width: '100%',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
+205 -47
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box,
Button,
Chip,
Header,
Icon,
IconButton,
@@ -20,6 +21,8 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -131,6 +134,22 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
// A sensible download filename: prefer the event body/filename; if it has no
// plausible extension already, append one derived from the mimetype so the
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
// so "Screenshot 2024.01.05" still gets a real extension appended.
function hasFileExtension(name: string): boolean {
const dot = name.lastIndexOf('.');
if (dot <= 0 || dot === name.length - 1) return false;
return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
}
function mediaFilename(body: string, mimeType?: string): string {
const name = body.trim() || 'media';
if (hasFileExtension(name)) return name;
const ext = mimeType ? mimeTypeToExt(mimeType) : '';
return ext ? `${name}.${ext}` : name;
}
function monthLabel(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}
@@ -167,9 +186,19 @@ type LightboxItem = {
function LightboxMedia({
item,
useAuthentication,
zoom,
pan,
cursor,
onMouseDown,
onImageDoubleClick,
}: {
item: LightboxItem;
useAuthentication: boolean;
zoom: number;
pan: Pan;
cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
@@ -222,12 +251,24 @@ function LightboxMedia({
<img
src={media.url}
alt={item.body}
draggable={false}
onMouseDown={onMouseDown}
onDoubleClick={onImageDoubleClick}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 120px)',
objectFit: 'contain',
borderRadius: config.radii.R300,
display: 'block',
cursor,
// translate is nested inside scale(), so it runs in scaled space —
// divide by zoom so a dragged pixel moves the image one screen pixel
// (1:1 with the cursor) rather than `zoom` pixels.
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
pan.translateY / zoom
}px)`,
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
willChange: 'transform',
}}
/>
))}
@@ -250,6 +291,20 @@ function Lightbox({
}) {
const [index, setIndex] = useState(initialIndex);
const item = items[index];
const isImage = item?.msgtype === MsgType.Image;
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
// Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
useEffect(() => {
setZoom(1);
}, [index, setZoom]);
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
const next = useCallback(
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
@@ -260,11 +315,21 @@ function Lightbox({
if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
else if (e.key === 'Escape') onClose();
else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
else if (isImage && e.key === '-') zoomOut();
else if (isImage && e.key === '0') setZoom(1);
},
[prev, next, onClose],
[prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
if (!isImage) return;
if (e.deltaY < 0) zoomIn();
else if (e.deltaY > 0) zoomOut();
},
[isImage, zoomIn, zoomOut],
);
const item = items[index];
if (!item) return null;
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
@@ -318,6 +383,62 @@ function Lightbox({
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
{isImage && (
<Box
shrink="No"
alignItems="Center"
gap="100"
role="group"
aria-label="Zoom controls"
>
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom out"
onClick={zoomOut}
disabled={zoom <= 0.2}
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom in"
onClick={zoomIn}
disabled={zoom >= 5}
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
</Box>
)}
{item.mxcUrl && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Download</Text>
</Tooltip>
}
>
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
/>
</span>
)}
</TooltipProvider>
)}
{item.eventId && (
<TooltipProvider
position="Bottom"
@@ -364,6 +485,7 @@ function Lightbox({
grow="Yes"
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
@@ -386,6 +508,11 @@ function Lightbox({
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
zoom={zoom}
pan={pan}
cursor={cursor}
onMouseDown={onMouseDown}
onImageDoubleClick={toggleZoom}
/>
</Box>
{index < items.length - 1 && (
@@ -417,6 +544,10 @@ function GalleryTile({
ts,
useAuthentication,
onClick,
downloadUrl,
downloadEncInfo,
downloadMimeType,
downloadFilename,
}: {
mxcUrl: string;
encInfo?: IEncryptedFile;
@@ -427,6 +558,10 @@ function GalleryTile({
ts: number;
useAuthentication: boolean;
onClick: () => void;
downloadUrl?: string;
downloadEncInfo?: IEncryptedFile;
downloadMimeType?: string;
downloadFilename: string;
}) {
const mx = useMatrixClient();
const tileRef = useRef<HTMLButtonElement>(null);
@@ -442,55 +577,71 @@ function GalleryTile({
const relDate = formatRelativeDate(ts);
return (
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text
size="T200"
truncate
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
<div className={css.GalleryTileWrap}>
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
className={css.GalleryTile}
>
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text
size="T200"
truncate
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
>
{body}
</Text>
</Box>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text
size="T200"
truncate
style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}
>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text>
</div>
</div>
)}
</button>
{downloadUrl && (
<div className={css.GalleryTileDownload}>
<FileDownloadButton
filename={downloadFilename}
url={downloadUrl}
mimeType={downloadMimeType ?? 'application/octet-stream'}
encInfo={downloadEncInfo}
/>
</div>
)}
</button>
</div>
);
}
@@ -783,6 +934,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
// Guard before incrementing: skipped tiles must not consume a slot
if (!thumbMxc) return null;
const idx = flatIdx++;
// Full-resolution source for download (not the thumb).
const fullMxc: string | undefined = c.file?.url ?? c.url;
const bodyStr: string = c.body ?? '';
return (
<GalleryTile
key={mEvent.getId()}
@@ -790,11 +944,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
encInfo={thumbEnc}
mimeType={thumbMime}
isVideo={isVideo}
body={c.body ?? ''}
body={bodyStr}
sender={getSenderName(room, mEvent.getSender() ?? '')}
ts={mEvent.getTs()}
useAuthentication={useAuthentication}
onClick={() => setLightboxIndex(idx)}
downloadUrl={fullMxc}
downloadEncInfo={isEnc ? c.file : undefined}
downloadMimeType={info?.mimetype}
downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
/>
);
})}
+9 -1
View File
@@ -251,10 +251,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setLocating(false);
const { latitude, longitude } = pos.coords;
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
const ts = Date.now();
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Location: ${geoUri}`,
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
'org.matrix.msc3488.location': { uri: geoUri },
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
} as any);
},
(err) => {
+5 -31
View File
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
import { toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle } from '../../utils/datetimeInput';
interface ScheduleMessageModalProps {
roomId: string;
@@ -65,32 +66,6 @@ function formatSendAt(sendAt: Date): string {
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
}
function toLocalDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function toLocalTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme.
const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({
background: c.SurfaceVariant.Container,
color: c.SurfaceVariant.OnContainer,
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
borderRadius: cfg.radii.R300,
padding: `${cfg.space.S200} ${cfg.space.S300}`,
fontSize: '0.875rem',
width: '100%',
boxSizing: 'border-box',
outline: 'none',
fontFamily: 'inherit',
// Hint browser to render the calendar/clock popup in dark mode
colorScheme: 'dark',
});
export function ScheduleMessageModal({
roomId,
initialBody,
@@ -119,11 +94,10 @@ export function ScheduleMessageModal({
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
const getSendAt = useCallback((): Date | null => {
if (!dateValue || !timeValue) return null;
const dt = new Date(`${dateValue}T${timeValue}:00`);
return Number.isNaN(dt.getTime()) ? null : dt;
}, [dateValue, timeValue]);
const getSendAt = useCallback(
(): Date | null => parseLocalDateTime(dateValue, timeValue),
[dateValue, timeValue],
);
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
@@ -234,7 +234,9 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
</Box>
{timestampState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{timestampState.error.message}
{timestampState.error.errcode === 'M_UNRECOGNIZED'
? "Your homeserver doesn't support jumping to a date or time (MSC3030)."
: timestampState.error.message}
</Text>
)}
<Button
+45
View File
@@ -56,6 +56,7 @@ import {
getMemberAvatarMxc,
getMemberName,
sendStateEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
@@ -411,6 +412,47 @@ export const MessageCopyLinkItem = as<
);
});
// Copies the message's plain-text body (reply fallback stripped) to the
// clipboard. Renders nothing for events without a usable text body (e.g. media
// without a caption), so the caller can list it unconditionally.
export const MessageCopyTextItem = as<
'button',
{
mEvent: MatrixEvent;
onClose?: () => void;
}
>(({ mEvent, onClose, ...props }, ref) => {
const content = mEvent.getContent();
// Text-only: for media the `body` is the filename (or caption). We don't want
// "Copy Text" to copy a filename, so gate to textual message types.
const msgtype = content.msgtype;
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
const rawBody = typeof content.body === 'string' ? content.body : '';
const body = trimReplyFromBody(rawBody).trim();
if (!isTextual || !body) return null;
const handleCopy = () => {
copyToClipboard(body);
onClose?.();
};
return (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Text} />}
radii="300"
onClick={handleCopy}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
Copy Text
</Text>
</MenuItem>
);
});
export const MessagePinItem = as<
'button',
{
@@ -1190,6 +1232,7 @@ export const Message = React.memo(
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
senderName: senderDisplayName,
});
}
closeMenu();
@@ -1283,6 +1326,7 @@ export const Message = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
@@ -1512,6 +1556,7 @@ export const Event = React.memo(
onClose={closeMenu}
/>
)}
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
@@ -19,6 +19,13 @@ import {
import { stopPropagation } from '../../../utils/keyboard';
import { useReminders } from '../../../hooks/useReminders';
import { useModalStyle } from '../../../hooks/useModalStyle';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
pickerInputStyle,
formatFriendlyDateTime,
} from '../../../utils/datetimeInput';
type RemindMeDialogProps = {
roomId: string;
@@ -40,14 +47,51 @@ function getPresets(): Array<{ label: string; ms: number }> {
];
}
// Default custom pick: 1 hour from now, rounded up to the nearest 5 minutes.
function defaultCustomDate(): Date {
const d = new Date(Date.now() + 60 * 60 * 1000);
d.setSeconds(0, 0);
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
return d;
}
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
const { addReminder, removeReminder, reminders } = useReminders();
const presets = useMemo(() => getPresets(), []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const def = useMemo(() => defaultCustomDate(), []);
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState(() => toLocalTime(def));
const dateInputRef = useRef<HTMLInputElement>(null);
const handlePick = async (ms: number) => {
// Reminders already set on this message (soonest first) — so the user can see
// and cancel them instead of silently stacking duplicates.
const existing = useMemo(
() =>
reminders
.filter((r) => r.eventId === eventId)
.sort((a, b) => a.timestamp - b.timestamp),
[reminders, eventId],
);
// Move focus into the revealed date input for keyboard/SR users.
useEffect(() => {
if (customOpen) dateInputRef.current?.focus();
}, [customOpen]);
const handleCancelExisting = (timestamp: number) => {
// Optimistic, matching removeBookmark: the shared account-data store drops
// the reminder locally at once (no rollback) and re-syncs from the server.
// We deliberately show no inline error — the store has no rollback path, so a
// failed write simply reappears on the next sync rather than leaving a stale
// "couldn't cancel" message beside an already-vanished row.
removeReminder(eventId, timestamp).catch(() => undefined);
};
const commit = async (timestamp: number) => {
if (busy) return;
setBusy(true);
setError(null);
@@ -55,7 +99,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
await addReminder({
roomId,
eventId,
timestamp: Date.now() + ms,
timestamp,
message: previewText || 'Reminder',
});
onClose();
@@ -65,6 +109,23 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
}
};
const handlePick = (ms: number) => commit(Date.now() + ms);
const customDate = parseLocalDateTime(dateValue, timeValue);
const customValid = !!customDate && customDate.getTime() - Date.now() >= 60_000;
const handleCustom = () => {
if (!customDate) {
setError('Please select a valid date and time.');
return;
}
if (customDate.getTime() - Date.now() < 60_000) {
setError('Reminder time must be at least 1 minute in the future.');
return;
}
commit(customDate.getTime());
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -111,6 +172,40 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
<Line size="300" />
</>
)}
{existing.length > 0 && (
<>
<Box
direction="Column"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
<Text size="L400" priority="300" style={{ paddingLeft: config.space.S200 }}>
{existing.length === 1 ? 'Reminder set' : 'Reminders set'}
</Text>
{existing.map((r, idx) => (
// Composite key: two custom reminders on one message can share
// a minute-precision timestamp; index keeps React keys unique.
<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)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
fill="None"
onClick={() => handleCancelExisting(r.timestamp)}
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
))}
</Box>
<Line size="300" style={{ marginTop: config.space.S200 }} />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{presets.map((p) => (
<Button
@@ -127,6 +222,77 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
</Text>
</Button>
))}
{customOpen ? (
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="remind-date" size="T200" priority="400">
Date
</Text>
<input
ref={dateInputRef}
id="remind-date"
type="date"
value={dateValue}
min={toLocalDate(new Date())}
disabled={busy}
onChange={(e) => {
setDateValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="remind-time" size="T200" priority="400">
Time
</Text>
<input
id="remind-time"
type="time"
value={timeValue}
disabled={busy}
onChange={(e) => {
setTimeValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>
</Box>
{!customValid && (dateValue || timeValue) && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
)}
<Button
size="300"
variant="Primary"
radii="300"
disabled={busy || !customValid}
onClick={handleCustom}
>
<Text size="B300">Set reminder</Text>
</Button>
</Box>
) : (
<Button
size="300"
variant="Secondary"
fill="None"
radii="300"
disabled={busy}
onClick={() => {
setError(null);
setCustomOpen(true);
}}
before={<Icon src={Icons.Clock} size="100" />}
>
<Text size="B300">Custom time</Text>
</Button>
)}
{error && (
<Text
size="T200"
+70 -15
View File
@@ -96,6 +96,7 @@ import {
useThemes,
} from '../../../hooks/useTheme';
import { stopPropagation } from '../../../utils/keyboard';
import { pickerInputStyle } from '../../../utils/datetimeInput';
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
@@ -435,6 +436,12 @@ function Appearance() {
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
const [nightLightSchedule, setNightLightSchedule] = useSetting(
settingsAtom,
'nightLightSchedule',
);
const [nightLightStart, setNightLightStart] = useSetting(settingsAtom, 'nightLightStart');
const [nightLightEnd, setNightLightEnd] = useSetting(settingsAtom, 'nightLightEnd');
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
settingsAtom,
'glassmorphismSidebar',
@@ -567,23 +574,71 @@ function Appearance() {
{nightLightEnabled && (
<Box
direction="Column"
gap="100"
gap="300"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
<Text size="T200" priority="300">
Intensity: {nightLightOpacity}%
</Text>
<input
aria-label="Night light intensity"
type="range"
min={5}
max={80}
value={nightLightOpacity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
<Box direction="Column" gap="100">
<Text size="T200" priority="300">
Intensity: {nightLightOpacity}%
</Text>
<input
aria-label="Night light intensity"
type="range"
min={5}
max={80}
value={nightLightOpacity}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNightLightOpacity(parseInt(e.target.value, 10))
}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
</Box>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Box direction="Column">
<Text size="T300">Schedule</Text>
<Text size="T200" priority="300">
Only tint during the hours below
</Text>
</Box>
<Switch
variant="Primary"
value={nightLightSchedule}
onChange={setNightLightSchedule}
/>
</Box>
{nightLightSchedule && (
<Box gap="200">
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="night-light-start" size="T200" priority="400">
From
</Text>
<input
id="night-light-start"
type="time"
value={nightLightStart}
onChange={(e) => setNightLightStart(e.target.value)}
style={pickerInputStyle(color, config)}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1 }}>
<Text as="label" htmlFor="night-light-end" size="T200" priority="400">
To
</Text>
<input
id="night-light-end"
type="time"
value={nightLightEnd}
onChange={(e) => setNightLightEnd(e.target.value)}
style={pickerInputStyle(color, config)}
/>
</Box>
</Box>
)}
{nightLightSchedule && nightLightStart === nightLightEnd && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Start and end times are the same, so the tint never turns on. Set different times.
</Text>
)}
</Box>
)}
</SequenceCard>
+6
View File
@@ -8,6 +8,12 @@ export type Bookmark = {
savedAt: number;
previewText: string;
roomName: string;
// Author display name, snapshotted at save time. Optional for backward
// compatibility with bookmarks stored before attribution was added; the panel
// re-resolves a live name when the room is joined and falls back to this
// snapshot otherwise. (We deliberately don't store the sender MXID — it's never
// read back and would only grow the 500-entry account-data blob.)
senderName?: string;
};
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
+18 -1
View File
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import {
@@ -23,6 +23,7 @@ import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
@@ -128,7 +129,23 @@ function DesktopChrome({ children }: { children: ReactNode }) {
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
const scheduled = settings.nightLightEnabled && settings.nightLightSchedule;
// When scheduled, re-evaluate the active window every minute so the overlay
// turns on/off automatically at the start/end times without a reload.
const [, forceTick] = useState(0);
useEffect(() => {
if (!scheduled) return undefined;
const id = setInterval(() => forceTick((n) => n + 1), 60_000);
return () => clearInterval(id);
}, [scheduled]);
if (!settings.nightLightEnabled) return null;
if (
settings.nightLightSchedule &&
!isWithinTimeWindow(settings.nightLightStart, settings.nightLightEnd)
) {
return null;
}
return (
<div
aria-hidden="true"
+6
View File
@@ -220,6 +220,9 @@ export interface Settings {
nightLightEnabled: boolean;
nightLightOpacity: number;
nightLightSchedule: boolean;
nightLightStart: string;
nightLightEnd: string;
glassmorphismSidebar: boolean;
@@ -329,6 +332,9 @@ const defaultSettings: Settings = {
pttMode: false,
pttKey: 'Space',
nightLightSchedule: false,
nightLightStart: '21:00',
nightLightEnd: '07:00',
nightLightEnabled: false,
nightLightOpacity: 30,
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
formatFriendlyDateTime,
} from './datetimeInput';
// All helpers work in the LOCAL timezone. We construct Dates with the local
// component constructor so the assertions hold regardless of where tests run.
test('toLocalDate: zero-pads month and day', () => {
assert.equal(toLocalDate(new Date(2026, 0, 5, 9, 3)), '2026-01-05');
assert.equal(toLocalDate(new Date(2026, 11, 25, 0, 0)), '2026-12-25');
});
test('toLocalTime: zero-pads hours and minutes (24h)', () => {
assert.equal(toLocalTime(new Date(2026, 0, 5, 9, 3)), '09:03');
assert.equal(toLocalTime(new Date(2026, 0, 5, 23, 59)), '23:59');
assert.equal(toLocalTime(new Date(2026, 0, 5, 0, 0)), '00:00');
});
test('toLocalDate/toLocalTime round-trip through parseLocalDateTime', () => {
const d = new Date(2026, 5, 15, 14, 30, 0, 0);
const parsed = parseLocalDateTime(toLocalDate(d), toLocalTime(d));
assert.ok(parsed);
assert.equal(parsed.getTime(), d.getTime());
});
test('parseLocalDateTime: returns null for missing parts', () => {
assert.equal(parseLocalDateTime('', '09:00'), null);
assert.equal(parseLocalDateTime('2026-01-05', ''), null);
assert.equal(parseLocalDateTime('', ''), null);
});
test('parseLocalDateTime: returns null for an invalid combination', () => {
assert.equal(parseLocalDateTime('not-a-date', '09:00'), null);
assert.equal(parseLocalDateTime('2026-13-40', '09:00'), null);
});
test('parseLocalDateTime: valid input yields the local wall-clock time', () => {
const parsed = parseLocalDateTime('2026-01-05', '09:03');
assert.ok(parsed);
assert.equal(parsed.getFullYear(), 2026);
assert.equal(parsed.getMonth(), 0);
assert.equal(parsed.getDate(), 5);
assert.equal(parsed.getHours(), 9);
assert.equal(parsed.getMinutes(), 3);
});
test('formatFriendlyDateTime: uses Today/Tomorrow/date prefixes', () => {
const now = new Date(2026, 0, 5, 10, 0).getTime();
const laterToday = new Date(2026, 0, 5, 15, 30).getTime();
const tomorrow = new Date(2026, 0, 6, 9, 0).getTime();
const nextWeek = new Date(2026, 0, 12, 9, 0).getTime();
assert.ok(formatFriendlyDateTime(laterToday, now).startsWith('Today at '));
assert.ok(formatFriendlyDateTime(tomorrow, now).startsWith('Tomorrow at '));
const other = formatFriendlyDateTime(nextWeek, now);
assert.ok(!other.startsWith('Today'));
assert.ok(!other.startsWith('Tomorrow'));
assert.ok(other.includes(' at '));
});
test('formatFriendlyDateTime: Tomorrow rolls over month/year boundaries', () => {
const nye = new Date(2026, 11, 31, 23, 0).getTime();
const jan1 = new Date(2027, 0, 1, 9, 0).getTime();
assert.ok(formatFriendlyDateTime(jan1, nye).startsWith('Tomorrow at '));
});
+62
View File
@@ -0,0 +1,62 @@
import { CSSProperties } from 'react';
import { color as foldsColor, config as foldsConfig } from 'folds';
const pad = (n: number): string => String(n).padStart(2, '0');
// Format a Date as the value string expected by <input type="date"> in the
// user's LOCAL timezone. (toISOString would shift to UTC and can land on the
// wrong calendar day.)
export function toLocalDate(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
// Format a Date as the value string expected by <input type="time"> (local, HH:mm).
export function toLocalTime(date: Date): string {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// Parse the local date + time <input> values back into a Date. Returns null when
// either value is missing or the combination is invalid (e.g. a partial entry).
export function parseLocalDateTime(dateValue: string, timeValue: string): Date | null {
if (!dateValue || !timeValue) return null;
const dt = new Date(`${dateValue}T${timeValue}:00`);
return Number.isNaN(dt.getTime()) ? null : dt;
}
// Human-friendly absolute time: "Today at 3:00 PM", "Tomorrow at 9:00 AM", or
// "1/5/2026 at 3:00 PM". `now` is injectable so the relative-day logic is testable.
export function formatFriendlyDateTime(ts: number, now: number = Date.now()): string {
const date = new Date(ts);
const nowDate = new Date(now);
const sameDay = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const tomorrow = new Date(nowDate);
tomorrow.setDate(tomorrow.getDate() + 1);
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (sameDay(date, nowDate)) return `Today at ${timeStr}`;
if (sameDay(date, tomorrow)) return `Tomorrow at ${timeStr}`;
return `${date.toLocaleDateString()} at ${timeStr}`;
}
// Shared style for date/time <input>s — matches the app's surface tokens and
// hints a dark-mode calendar/clock popup via colorScheme.
export function pickerInputStyle(
c: typeof foldsColor = foldsColor,
cfg: typeof foldsConfig = foldsConfig,
): CSSProperties {
return {
background: c.SurfaceVariant.Container,
color: c.SurfaceVariant.OnContainer,
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
borderRadius: cfg.radii.R300,
padding: `${cfg.space.S200} ${cfg.space.S300}`,
fontSize: '0.875rem',
width: '100%',
boxSizing: 'border-box',
outline: 'none',
fontFamily: 'inherit',
colorScheme: 'dark',
};
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseHHMM, isWithinTimeWindow } from './timeWindow';
const at = (h: number, m: number): Date => new Date(2026, 0, 5, h, m);
test('parseHHMM: valid values', () => {
assert.equal(parseHHMM('00:00'), 0);
assert.equal(parseHHMM('07:30'), 7 * 60 + 30);
assert.equal(parseHHMM('23:59'), 23 * 60 + 59);
});
test('parseHHMM: malformed values return null', () => {
assert.equal(parseHHMM(''), null);
assert.equal(parseHHMM('7:30'), null);
assert.equal(parseHHMM('24:00'), null);
assert.equal(parseHHMM('12:60'), null);
assert.equal(parseHHMM('abc'), null);
});
test('isWithinTimeWindow: same-day window [09:00, 17:00)', () => {
assert.equal(isWithinTimeWindow('09:00', '17:00', at(8, 59)), false);
assert.equal(isWithinTimeWindow('09:00', '17:00', at(9, 0)), true);
assert.equal(isWithinTimeWindow('09:00', '17:00', at(16, 59)), true);
assert.equal(isWithinTimeWindow('09:00', '17:00', at(17, 0)), false); // end exclusive
});
test('isWithinTimeWindow: overnight window [21:00, 07:00) wraps midnight', () => {
assert.equal(isWithinTimeWindow('21:00', '07:00', at(20, 59)), false);
assert.equal(isWithinTimeWindow('21:00', '07:00', at(21, 0)), true);
assert.equal(isWithinTimeWindow('21:00', '07:00', at(23, 59)), true);
assert.equal(isWithinTimeWindow('21:00', '07:00', at(0, 0)), true);
assert.equal(isWithinTimeWindow('21:00', '07:00', at(6, 59)), true);
assert.equal(isWithinTimeWindow('21:00', '07:00', at(7, 0)), false); // end exclusive
});
test('isWithinTimeWindow: zero-length window is never active', () => {
assert.equal(isWithinTimeWindow('08:00', '08:00', at(8, 0)), false);
assert.equal(isWithinTimeWindow('08:00', '08:00', at(12, 0)), false);
});
test('isWithinTimeWindow: malformed inputs are inactive', () => {
assert.equal(isWithinTimeWindow('', '07:00', at(3, 0)), false);
assert.equal(isWithinTimeWindow('21:00', 'nope', at(3, 0)), false);
});
+22
View File
@@ -0,0 +1,22 @@
// Parse an "HH:mm" (24h) string into minutes-since-midnight, or null if malformed.
export function parseHHMM(value: string): number | null {
const m = /^(\d{2}):(\d{2})$/.exec(value);
if (!m) return null;
const hours = Number(m[1]);
const minutes = Number(m[2]);
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
// Is `now`'s wall-clock time inside the [start, end) window? The window may wrap
// past midnight (start > end, e.g. 21:00 → 07:00). A zero-length window
// (start === end) is treated as never active. Malformed inputs → false.
export function isWithinTimeWindow(start: string, end: string, now: Date = new Date()): boolean {
const s = parseHHMM(start);
const e = parseHHMM(end);
if (s === null || e === null || s === e) return false;
const cur = now.getHours() * 60 + now.getMinutes();
if (s < e) return cur >= s && cur < e;
// Overnight wrap: active from start until midnight, then midnight until end.
return cur >= s || cur < e;
}