Compare commits
20
Commits
82eb65b822
...
e7e6d44a31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7e6d44a31 | ||
|
|
829525506c | ||
|
|
dcad282749 | ||
|
|
4d78d427d2 | ||
|
|
12aa49f054 | ||
|
|
df8afe4410 | ||
|
|
ea6e89ae56 | ||
|
|
75b94bd39f | ||
|
|
2d8bc487e5 | ||
|
|
cf4ee6618c | ||
|
|
23950a6797 | ||
|
|
a1107015fd | ||
|
|
4a3a80df9f | ||
|
|
7d67cdeef0 | ||
|
|
b6725a6ee3 | ||
|
|
23e264d179 | ||
|
|
e01b87b214 | ||
|
|
f9af363dd6 | ||
|
|
8a92821a48 | ||
|
|
68a88e84b8 |
+19
-3
@@ -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:00–07: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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 '));
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user