Compare commits
7 Commits
8c711f5f4a
...
ffa490e767
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa490e767 | |||
| 8ac42cdbad | |||
| 1b4c6cab6d | |||
| 176d5d0bb7 | |||
| 3df95adc52 | |||
| a6bf4eb7e7 | |||
| baa12823f7 |
+11
-11
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user