Compare commits

...

7 Commits

Author SHA1 Message Date
jared ffa490e767 fix(mobile): make media gallery and members panel accessible on mobile
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 9s
MediaGallery: fixed panel now goes full-width (100%) on mobile instead
of the inaccessible 320px right sidebar. Added 'Media Gallery' MenuItem
to RoomMenu (visible only on mobile) so users can open it from the
More Options (···) button.

MembersDrawer: removed ScreenSize.Desktop gate in Room.tsx so it now
renders on mobile too. CSS media query (≤750px) makes it position:fixed
inset:0 width:100% on mobile instead of the 266px desktop sidebar.
Added 'Members' MenuItem to RoomMenu for mobile access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:21:58 -04:00
jared 8ac42cdbad perf(gallery): gate tile decryption until near-viewport
Adds enabled=true param to useDecryptedMediaUrl in MediaGallery.tsx.
GalleryTile now uses useNearViewport(300px) — decryption is deferred
until the tile approaches the viewport, preventing burst of 100
concurrent decrypt/fetch calls when a pagination batch loads.

Blob revocation was already correct (no actual leak); this fixes the
load-burst performance issue. Full windowing virtualization deferred.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:16:15 -04:00
jared 1b4c6cab6d fix(a11y): add missing aria-labels to message buttons
- FallbackContent: '(edited)' button now has aria-label='View edit history'
- Reply: ThreadIndicator as=button gets aria-label='View thread'
- Reply: ReplyLayout as=button gets aria-label='Jump to original message'

Addresses WCAG 2.1 SC 4.1.2 (Name, Role, Value) for icon-only buttons
and polymorphic div-as-button patterns in the message timeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:14:01 -04:00
jared 176d5d0bb7 fix(mobile): apply useModalStyle to remaining dialog files (Bug #9)
Completes the mobile fullscreen modal pass — adds useModalStyle to
DeviceVerificationSetup, DeviceVerificationReset, AddExistingModal,
RoomEncryption prompt, RoomUpgradeDialog, Modal500, ReadReceiptAvatars,
and RoomTopicViewer. All floating Dialog/Modal components now go
fullscreen on mobile (≤750px). UIAFlowOverlay was already fullscreen
via <Overlay>; JoinRulesSwitcher/RoomNotificationSwitcher are dropdowns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:11:24 -04:00
jared 3df95adc52 perf(media): defer image/video decryption until near-viewport (P5-5)
Creates useNearViewport hook (IntersectionObserver, 200px rootMargin,
one-shot disconnect after first trigger). ImageContent and VideoContent
now gate loadSrc() on nearViewport — when autoPlay is enabled, encrypted
media is not decrypted until the element is within 200px of the visible
area, reducing initial page load cost on long timelines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:56:34 -04:00
jared a6bf4eb7e7 feat(tauri): proactive update notifications via toast (P5-40)
TauriUpdateFeature component in ClientNonUIFeatures checks for updates
on mount and every 12h (skips if checked within the window). On update
available, fires a Lotus toast: "Lotus Chat vX.Y.Z is ready to install."
Clicking the toast calls install(). No-op on web (isTauri guard).

Also adds optional onClick to ToastNotif type and wires it in
LotusToastContainer so custom click handlers can skip hash navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 15:39:23 -04:00
jared baa12823f7 fix: exponential backoff for 429 retries, cover thumbnails, 2 more modal dialogs
- matrix.ts: rateLimitedActions fallback delay now uses capped exponential
  backoff (min(1000 * 2^n, 30s)) instead of flat 3000ms when server omits
  Retry-After; server header still takes precedence
- RenderMessageContent: add objectFit:cover + 100% fill to video thumbnail
  <Image> so thumbnails fill their container without letterboxing (P5-6)
- CreateRoomModal, CreateSpaceModal: apply useModalStyle(480) for fullscreen
  on mobile
- LOTUS_BUGS: mark usePan memory leak + httpStatus check as FALSE POSITIVE;
  mark rateLimitedActions backoff as FIXED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 15:32:17 -04:00
25 changed files with 220 additions and 42 deletions
+11 -11
View File
@@ -82,10 +82,10 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 9. Modal Float-Style Responsiveness
- **File:** Multiple modal files
- **Status:** **PARTIALLY FIXED ⚠️ UNTESTED**applied to 7 modals; ~13 remaining modal files still need the hook applied
- **Status:** **FIXED ⚠️ UNTESTED**needs verification by opening each modal on a real mobile device
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`.
- **Remaining:** Apply `useModalStyle` to: `DeviceVerificationSetup`, `UIAFlowOverlay`, `JoinAddressPrompt`, `JoinRulesSwitcher`, `RoomNotificationSwitcher`, and others that render floating dialogs.
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
### 10. Composer Keyboard Obscurity
@@ -119,18 +119,18 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :----- |
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | OPEN |
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | OPEN |
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | OPEN |
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | OPEN |
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | OPEN |
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
## 🏗️ Architectural & Hygiene Audit
@@ -183,10 +183,10 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | OPEN |
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | OPEN |
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | OPEN |
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | OPEN |
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | OPEN |
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | OPEN |
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | OPEN |
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | OPEN — emoji content is already screen-reader-accessible via alt text; parent caller would need to set aria-label per reaction |
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
---
+2 -2
View File
@@ -278,7 +278,7 @@ Themes:
---
### [ ] P5-5 · Intersection-Based Lazy Loading
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
@@ -349,7 +349,7 @@ Themes:
---
### [ ] P5-40 · Desktop — Proactive Update Notifications (Tauri)
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
@@ -1,4 +1,5 @@
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import { useModalStyle } from '../hooks/useModalStyle';
import {
Dialog,
Header,
@@ -287,9 +288,10 @@ type DeviceVerificationSetupProps = {
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
({ onCancel }, ref) => {
const [recoveryKey, setRecoveryKey] = useState<string>();
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref}>
<Dialog ref={ref} style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -324,9 +326,10 @@ type DeviceVerificationResetProps = {
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
({ onCancel }, ref) => {
const [reset, setReset] = useState(false);
const modalStyle = useModalStyle(480);
return (
<Dialog ref={ref}>
<Dialog ref={ref} style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+3 -1
View File
@@ -2,12 +2,14 @@ import React, { ReactNode } from 'react';
import FocusTrap from 'focus-trap-react';
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import { stopPropagation } from '../utils/keyboard';
import { useModalStyle } from '../hooks/useModalStyle';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
};
export function Modal500({ requestClose, children }: Modal500Props) {
const modalStyle = useModalStyle(560);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -19,7 +21,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Background">
<Modal size="500" variant="Background" style={modalStyle}>
{children}
</Modal>
</FocusTrap>
+7 -1
View File
@@ -232,7 +232,13 @@ export function RenderMessageContent({
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
<Image
alt={body}
title={body}
src={src}
loading="lazy"
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
/>
)}
/>
)
+2 -1
View File
@@ -103,10 +103,11 @@ export const Reply = as<'div', ReplyProps>(
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} aria-label="View thread" />
)}
<ReplyLayout
as="button"
aria-label="Jump to original message"
userColor={usernameColor}
username={
sender && (
@@ -75,6 +75,7 @@ export const MessageEditedContent = as<
<button
type="button"
onClick={onEditHistoryClick}
aria-label="View edit history"
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
>
<Text as="span" size="T200" priority="300">
@@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
type RenderViewerProps = {
src: string;
@@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>(
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const sentinelRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(sentinelRef);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>(
};
useEffect(() => {
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
}, [autoPlay, nearViewport, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -1,4 +1,4 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import {
Badge,
Box,
@@ -32,6 +32,7 @@ import {
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { useNearViewport } from '../../../hooks/useNearViewport';
type RenderVideoProps = {
title: string;
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
const [error, setError] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const sentinelRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(sentinelRef);
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
};
useEffect(() => {
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
}, [autoPlay, nearViewport, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
{typeof blurHash === 'string' && !load && (
<BlurhashCanvas
style={{ width: '100%', height: '100%' }}
@@ -12,6 +12,7 @@ import { UserAvatar } from '../user-avatar';
import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
const MAX_DISPLAY = 5;
@@ -28,6 +29,7 @@ export function ReadReceiptAvatars({
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360);
if (userIds.length === 0) return null;
@@ -51,7 +53,7 @@ export function ReadReceiptAvatars({
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal variant="Surface" size="300" style={modalStyle}>
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
</Modal>
</FocusTrap>
@@ -1,6 +1,7 @@
import React from 'react';
import parse from 'html-react-parser';
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
import { useModalStyle } from '../../hooks/useModalStyle';
import classNames from 'classnames';
import Linkify from 'linkify-react';
import * as css from './style.css';
@@ -17,6 +18,7 @@ export const RoomTopicViewer = as<
}
>(({ name, topic, requestClose, className, ...props }, ref) => {
const topicStr = typeof topic === 'string' ? topic : topic.topic;
const modalStyle = useModalStyle(480);
const isFormatted =
typeof topic !== 'string' &&
topic.format === 'org.matrix.custom.html' &&
@@ -28,6 +30,7 @@ export const RoomTopicViewer = as<
flexHeight
className={classNames(css.ModalFlex, className)}
aria-labelledby="room-topic-title"
style={modalStyle}
{...props}
ref={ref}
>
@@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
import { useModalStyle } from '../../hooks/useModalStyle';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@@ -72,6 +73,7 @@ type AddExistingModalProps = {
};
export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
const mx = useMatrixClient();
const modalStyle = useModalStyle(480);
const useAuthentication = useMediaAuthentication();
const alive = useAlive();
@@ -188,7 +190,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300">
<Modal size="300" style={modalStyle}>
<Box grow="Yes" direction="Column">
<Header
size="500"
@@ -28,6 +28,7 @@ import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { useModalStyle } from '../../../hooks/useModalStyle';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
@@ -37,6 +38,7 @@ type RoomEncryptionProps = {
export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const mx = useMatrixClient();
const room = useRoom();
const modalStyle = useModalStyle(480);
const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId());
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
@@ -111,7 +113,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -39,12 +39,14 @@ import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
import { useModalStyle } from '../../../hooks/useModalStyle';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const creators = useRoomCreators(room);
const modalStyle = useModalStyle(480);
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
@@ -93,7 +95,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Dialog variant="Surface" style={modalStyle}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@@ -24,6 +24,7 @@ import {
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
import { useModalStyle } from '../../hooks/useModalStyle';
type CreateRoomModalProps = {
state: CreateRoomModalState;
@@ -31,6 +32,7 @@ type CreateRoomModalProps = {
function CreateRoomModal({ state }: CreateRoomModalProps) {
const { spaceId, type } = state;
const closeDialog = useCloseCreateRoomModal();
const modalStyle = useModalStyle(480);
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
@@ -48,7 +50,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Modal size="300" flexHeight style={modalStyle}>
<Box direction="Column">
<Header
size="500"
@@ -23,6 +23,7 @@ import {
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -30,6 +31,7 @@ type CreateSpaceModalProps = {
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
const { spaceId } = state;
const closeDialog = useCloseCreateSpaceModal();
const modalStyle = useModalStyle(480);
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
@@ -47,7 +49,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" flexHeight>
<Modal size="300" flexHeight style={modalStyle}>
<Box direction="Column">
<Header
size="500"
+13 -4
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNearViewport } from '../../hooks/useNearViewport';
import {
Box,
Button,
@@ -20,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
type GalleryTab = 'image' | 'video' | 'file';
@@ -45,11 +47,13 @@ function useDecryptedMediaUrl(
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
enabled = true,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!enabled) return undefined;
if (!mxcUrl) {
setState({ status: 'error' });
return;
@@ -84,7 +88,7 @@ function useDecryptedMediaUrl(
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
useEffect(
() => () => {
@@ -367,12 +371,15 @@ function GalleryTile({
onClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType);
const tileRef = useRef<HTMLButtonElement>(null);
const nearViewport = useNearViewport(tileRef, 300);
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType, nearViewport);
const [hovered, setHovered] = useState(false);
const relDate = formatRelativeDate(ts);
return (
<button
ref={tileRef}
type="button"
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick}
@@ -525,6 +532,8 @@ type MediaGalleryProps = {
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
@@ -644,9 +653,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
top: 0,
right: 0,
bottom: 0,
width: '320px',
width: isMobile ? '100%' : '320px',
zIndex: 500,
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
@@ -3,6 +3,14 @@ import { config, toRem } from 'folds';
export const MembersDrawer = style({
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const MembersDrawerHeader = style({
+4 -2
View File
@@ -78,9 +78,11 @@ export function Room() {
<CallChatView />
</>
)}
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
{!callView && isDrawer && (
<>
<Line variant="Background" direction="Vertical" size="300" />
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
+46 -3
View File
@@ -80,10 +80,14 @@ import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
room: Room;
requestClose: () => void;
galleryOpen?: boolean;
onToggleGallery?: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose }, ref) => {
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, requestClose, galleryOpen, onToggleGallery }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -99,6 +103,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -186,6 +191,38 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
Saved Messages
</Text>
</MenuItem>
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setPeopleDrawer(!peopleDrawer);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.User} filled={peopleDrawer} />}
radii="300"
aria-pressed={peopleDrawer}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Members
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && onToggleGallery && (
<MenuItem
onClick={() => {
onToggleGallery();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Photo} filled={galleryOpen} />}
radii="300"
aria-pressed={galleryOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Media Gallery
</Text>
</MenuItem>
)}
{!isServerNotice && (
<MenuItem
onClick={handleInvite}
@@ -288,7 +325,8 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</Box>
</Menu>
);
});
},
);
type CallMenuProps = {
onVoiceCall: () => void;
@@ -783,7 +821,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
<RoomMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
galleryOpen={galleryOpen}
onToggleGallery={() => setGalleryOpen((v) => !v)}
/>
</FocusTrap>
}
/>
@@ -41,8 +41,12 @@ function ToastCard({ toast }: ToastCardProps) {
}, [dismiss, toast.id]);
const handleCardClick = () => {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
if (toast.onClick) {
toast.onClick();
} else {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
}
dismiss(toast.id);
};
+30
View File
@@ -0,0 +1,30 @@
import { RefObject, useEffect, useState } from 'react';
/**
* Returns true once the observed element has come within `margin` pixels of
* the viewport. Disconnects the observer after the first intersection so there
* is no ongoing overhead.
*/
export function useNearViewport(ref: RefObject<Element | null>, margin = 200): boolean {
const [triggered, setTriggered] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || triggered) return undefined;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setTriggered(true);
observer.disconnect();
}
},
{ rootMargin: `${margin}px` },
);
observer.observe(el);
return () => observer.disconnect();
}, [ref, margin, triggered]);
return triggered;
}
@@ -37,6 +37,7 @@ import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -429,6 +430,46 @@ function ReminderMonitor() {
return null;
}
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
const setToast = useSetAtom(toastQueueAtom);
const firedRef = useRef<string | null>(null);
useEffect(() => {
if (!isTauri) return;
const runCheck = () => {
const last = Number(localStorage.getItem(TAURI_UPDATE_LAST_CHECK_KEY) ?? '0');
if (Date.now() - last < TAURI_UPDATE_CHECK_INTERVAL) return;
localStorage.setItem(TAURI_UPDATE_LAST_CHECK_KEY, String(Date.now()));
check();
};
runCheck();
const interval = setInterval(runCheck, TAURI_UPDATE_CHECK_INTERVAL);
return () => clearInterval(interval);
}, [isTauri, check]);
useEffect(() => {
if (status.state !== 'available') return;
if (firedRef.current === status.version) return;
firedRef.current = status.version;
setToast({
id: `tauri-update-${status.version}`,
displayName: 'Update Available',
body: `Lotus Chat ${status.version} is ready to install.`,
roomName: 'System',
roomId: '',
onClick: install,
});
}, [status, setToast, install]);
return null;
}
function LotusDenoiseFeature() {
const setToast = useSetAtom(toastQueueAtom);
@@ -465,6 +506,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />
<TauriUpdateFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
{children}
+1
View File
@@ -8,6 +8,7 @@ export type ToastNotif = {
roomName: string;
roomId: string;
hashPath?: string; // overrides window.location.hash navigation when set
onClick?: () => void; // custom click handler; skips hash navigation when set
};
const baseAtom = atom<ToastNotif[]>([]);
+2 -1
View File
@@ -364,7 +364,8 @@ export const rateLimitedActions = async <T, R = void>(
return;
}
const waitMS = err.getRetryAfterMs() ?? 3000;
// Respect server Retry-After header; fall back to capped exponential backoff.
const waitMS = err.getRetryAfterMs() ?? Math.min(1000 * 2 ** retryCount, 30_000);
actionInterval = waitMS * 1.5;
await sleepForMs(waitMS);
retryCount += 1;