Compare commits
7 Commits
| 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
|
### 9. Modal Float-Style Responsiveness
|
||||||
|
|
||||||
- **File:** Multiple modal files
|
- **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.
|
- **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`.
|
- **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`.
|
||||||
- **Remaining:** Apply `useModalStyle` to: `DeviceVerificationSetup`, `UIAFlowOverlay`, `JoinAddressPrompt`, `JoinRulesSwitcher`, `RoomNotificationSwitcher`, and others that render floating dialogs.
|
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
|
||||||
|
|
||||||
### 10. Composer Keyboard Obscurity
|
### 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 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 |
|
| 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) |
|
| 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 |
|
| 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 | 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 |
|
| 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 | `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 🏗️ 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 |
|
| 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 | 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 |
|
| 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 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 |
|
| 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` | OPEN |
|
| 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` | OPEN |
|
| 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.
|
**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.
|
**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.
|
**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`.
|
**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 React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
Header,
|
Header,
|
||||||
@@ -287,9 +288,10 @@ type DeviceVerificationSetupProps = {
|
|||||||
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
const [recoveryKey, setRecoveryKey] = useState<string>();
|
const [recoveryKey, setRecoveryKey] = useState<string>();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref}>
|
<Dialog ref={ref} style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -324,9 +326,10 @@ type DeviceVerificationResetProps = {
|
|||||||
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
const [reset, setReset] = useState(false);
|
const [reset, setReset] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref}>
|
<Dialog ref={ref} style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
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 FocusTrap from 'focus-trap-react';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
|
const modalStyle = useModalStyle(560);
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -19,7 +21,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" variant="Background">
|
<Modal size="500" variant="Background" style={modalStyle}>
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -232,7 +232,13 @@ export function RenderMessageContent({
|
|||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
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 (
|
return (
|
||||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} aria-label="View thread" />
|
||||||
)}
|
)}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
as="button"
|
as="button"
|
||||||
|
aria-label="Jump to original message"
|
||||||
userColor={usernameColor}
|
userColor={usernameColor}
|
||||||
username={
|
username={
|
||||||
sender && (
|
sender && (
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const MessageEditedContent = as<
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditHistoryClick}
|
onClick={onEditHistoryClick}
|
||||||
|
aria-label="View edit history"
|
||||||
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||||
>
|
>
|
||||||
<Text as="span" size="T200" priority="300">
|
<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 {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -31,6 +31,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -85,6 +86,9 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -113,11 +117,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{typeof blurHash === 'string' && !load && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UserAvatar } from '../user-avatar';
|
|||||||
import { StackedAvatar } from '../stacked-avatar';
|
import { StackedAvatar } from '../stacked-avatar';
|
||||||
import { EventReaders } from '../event-readers';
|
import { EventReaders } from '../event-readers';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
const MAX_DISPLAY = 5;
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export function ReadReceiptAvatars({
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const modalStyle = useModalStyle(360);
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ export function ReadReceiptAvatars({
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal variant="Surface" size="300">
|
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
@@ -17,6 +18,7 @@ export const RoomTopicViewer = as<
|
|||||||
}
|
}
|
||||||
>(({ name, topic, requestClose, className, ...props }, ref) => {
|
>(({ name, topic, requestClose, className, ...props }, ref) => {
|
||||||
const topicStr = typeof topic === 'string' ? topic : topic.topic;
|
const topicStr = typeof topic === 'string' ? topic : topic.topic;
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const isFormatted =
|
const isFormatted =
|
||||||
typeof topic !== 'string' &&
|
typeof topic !== 'string' &&
|
||||||
topic.format === 'org.matrix.custom.html' &&
|
topic.format === 'org.matrix.custom.html' &&
|
||||||
@@ -28,6 +30,7 @@ export const RoomTopicViewer = as<
|
|||||||
flexHeight
|
flexHeight
|
||||||
className={classNames(css.ModalFlex, className)}
|
className={classNames(css.ModalFlex, className)}
|
||||||
aria-labelledby="room-topic-title"
|
aria-labelledby="room-topic-title"
|
||||||
|
style={modalStyle}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
|
|||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { rateLimitedActions } from '../../utils/matrix';
|
import { rateLimitedActions } from '../../utils/matrix';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||||
limit: 500,
|
limit: 500,
|
||||||
@@ -72,6 +73,7 @@ type AddExistingModalProps = {
|
|||||||
};
|
};
|
||||||
export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
|
export function AddExistingModal({ parentId, space, requestClose }: AddExistingModalProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="300">
|
<Modal size="300" style={modalStyle}>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useRoom } from '../../../hooks/useRoom';
|
|||||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
|
|
||||||
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ type RoomEncryptionProps = {
|
|||||||
export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId());
|
const canEnable = permissions.stateEvent(StateEvent.RoomEncryption, mx.getSafeUserId());
|
||||||
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
|
const content = useStateEvent(room, StateEvent.RoomEncryption)?.getContent<{
|
||||||
@@ -111,7 +113,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||||||
import { creatorsSupported } from '../../../utils/matrix';
|
import { creatorsSupported } from '../../../utils/matrix';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
|
|
||||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const capabilities = useCapabilities();
|
const capabilities = useCapabilities();
|
||||||
const roomVersions = capabilities['m.room_versions'];
|
const roomVersions = capabilities['m.room_versions'];
|
||||||
@@ -93,7 +95,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { CreateRoomType } from '../../components/create-room/types';
|
import { CreateRoomType } from '../../components/create-room/types';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
type CreateRoomModalProps = {
|
type CreateRoomModalProps = {
|
||||||
state: CreateRoomModalState;
|
state: CreateRoomModalState;
|
||||||
@@ -31,6 +32,7 @@ type CreateRoomModalProps = {
|
|||||||
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||||
const { spaceId, type } = state;
|
const { spaceId, type } = state;
|
||||||
const closeDialog = useCloseCreateRoomModal();
|
const closeDialog = useCloseCreateRoomModal();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
@@ -48,7 +50,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="300" flexHeight>
|
<Modal size="300" flexHeight style={modalStyle}>
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../../state/hooks/createSpaceModal';
|
} from '../../state/hooks/createSpaceModal';
|
||||||
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
type CreateSpaceModalProps = {
|
type CreateSpaceModalProps = {
|
||||||
state: CreateSpaceModalState;
|
state: CreateSpaceModalState;
|
||||||
@@ -30,6 +31,7 @@ type CreateSpaceModalProps = {
|
|||||||
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
||||||
const { spaceId } = state;
|
const { spaceId } = state;
|
||||||
const closeDialog = useCloseCreateSpaceModal();
|
const closeDialog = useCloseCreateSpaceModal();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
@@ -47,7 +49,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="300" flexHeight>
|
<Modal size="300" flexHeight style={modalStyle}>
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -20,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
|
||||||
type GalleryTab = 'image' | 'video' | 'file';
|
type GalleryTab = 'image' | 'video' | 'file';
|
||||||
|
|
||||||
@@ -45,11 +47,13 @@ function useDecryptedMediaUrl(
|
|||||||
encInfo: IEncryptedFile | undefined,
|
encInfo: IEncryptedFile | undefined,
|
||||||
useAuthentication: boolean,
|
useAuthentication: boolean,
|
||||||
mimeType?: string,
|
mimeType?: string,
|
||||||
|
enabled = true,
|
||||||
): DecryptState {
|
): DecryptState {
|
||||||
const [state, setState] = useState<DecryptState>({ status: 'loading' });
|
const [state, setState] = useState<DecryptState>({ status: 'loading' });
|
||||||
const prevBlobUrl = useRef<string | null>(null);
|
const prevBlobUrl = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
if (!mxcUrl) {
|
if (!mxcUrl) {
|
||||||
setState({ status: 'error' });
|
setState({ status: 'error' });
|
||||||
return;
|
return;
|
||||||
@@ -84,7 +88,7 @@ function useDecryptedMediaUrl(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
|
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -367,12 +371,15 @@ function GalleryTile({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
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 [hovered, setHovered] = useState(false);
|
||||||
const relDate = formatRelativeDate(ts);
|
const relDate = formatRelativeDate(ts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={tileRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -525,6 +532,8 @@ type MediaGalleryProps = {
|
|||||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
const [tab, setTab] = useState<GalleryTab>('image');
|
const [tab, setTab] = useState<GalleryTab>('image');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -644,9 +653,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: '320px',
|
width: isMobile ? '100%' : '320px',
|
||||||
zIndex: 500,
|
zIndex: 500,
|
||||||
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
|
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { config, toRem } from 'folds';
|
|||||||
|
|
||||||
export const MembersDrawer = style({
|
export const MembersDrawer = style({
|
||||||
width: toRem(266),
|
width: toRem(266),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MembersDrawerHeader = style({
|
export const MembersDrawerHeader = style({
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ export function Room() {
|
|||||||
<CallChatView />
|
<CallChatView />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!callView && screenSize === ScreenSize.Desktop && isDrawer && (
|
{!callView && isDrawer && (
|
||||||
<>
|
<>
|
||||||
|
{screenSize === ScreenSize.Desktop && (
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
)}
|
||||||
<MembersDrawer key={room.roomId} room={room} members={members} />
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -80,10 +80,14 @@ import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
|||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
requestClose: () => void;
|
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 mx = useMatrixClient();
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
@@ -99,6 +103,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const [reportRoomOpen, setReportRoomOpen] = useState(false);
|
const [reportRoomOpen, setReportRoomOpen] = useState(false);
|
||||||
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
|
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
|
||||||
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
@@ -186,6 +191,38 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
Saved Messages
|
Saved Messages
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</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 && (
|
{!isServerNotice && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
@@ -288,7 +325,8 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
</Box>
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type CallMenuProps = {
|
type CallMenuProps = {
|
||||||
onVoiceCall: () => void;
|
onVoiceCall: () => void;
|
||||||
@@ -783,7 +821,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RoomMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
<RoomMenu
|
||||||
|
room={room}
|
||||||
|
requestClose={() => setMenuAnchor(undefined)}
|
||||||
|
galleryOpen={galleryOpen}
|
||||||
|
onToggleGallery={() => setGalleryOpen((v) => !v)}
|
||||||
|
/>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,8 +41,12 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
}, [dismiss, toast.id]);
|
}, [dismiss, toast.id]);
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
|
if (toast.onClick) {
|
||||||
|
toast.onClick();
|
||||||
|
} else {
|
||||||
// window.location.hash setter auto-prepends '#', so values must not include it
|
// window.location.hash setter auto-prepends '#', so values must not include it
|
||||||
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
|
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
|
||||||
|
}
|
||||||
dismiss(toast.id);
|
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 { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
|
|
||||||
function isInQuietHours(start: string, end: string): boolean {
|
function isInQuietHours(start: string, end: string): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -429,6 +430,46 @@ function ReminderMonitor() {
|
|||||||
return null;
|
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() {
|
function LotusDenoiseFeature() {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
@@ -465,6 +506,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
<ReminderMonitor />
|
<ReminderMonitor />
|
||||||
|
<TauriUpdateFeature />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ToastNotif = {
|
|||||||
roomName: string;
|
roomName: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
hashPath?: string; // overrides window.location.hash navigation when set
|
hashPath?: string; // overrides window.location.hash navigation when set
|
||||||
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseAtom = atom<ToastNotif[]>([]);
|
const baseAtom = atom<ToastNotif[]>([]);
|
||||||
|
|||||||
@@ -364,7 +364,8 @@ export const rateLimitedActions = async <T, R = void>(
|
|||||||
return;
|
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;
|
actionInterval = waitMS * 1.5;
|
||||||
await sleepForMs(waitMS);
|
await sleepForMs(waitMS);
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user