fix(ui): settings modal sizing regression + 17 more folds audit findings
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560), forcing maxWidth 560px and squishing the two-pane Settings layout (folds size="500" is ~50rem). Restore desktop width to the folds recipe while keeping mobile fullscreen. N-series fixes: - N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button> - N28 composer char counter: drop undefined --tc-surface-low + opacity, use priority="300" and config.space token - N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button pattern matching (edited) link - N41 UserPrivateNotes "Saving..." now shows a folds <Spinner> - N43 Night Light slider: add accentColor; label opacity -> priority - N44 mention-highlight Reset: bare <button> -> folds <Button> (drops undefined --border-interactive-normal); Boot button kept (TDS-only) - N45 SelectTheme trigger variant -> Secondary to match SettingsSelect - N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/ Headphone/File) - N54/N57 PiP overlay badges + fullscreen button: token discipline (config.radii/space, folds Text); dark scrim kept for video legibility - N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem insets, no hardcoded size overrides) - N62 unverified-device banner: 3px left-accent -> standard border via color.Warning.ContainerLine; drop opacity hacks - N65 Edit History: real "Load more" pagination (accumulate next_batch, de-dupe by id, re-sort by ts) replacing passive text - N66 search date fields: raw <input type="date"> -> folds <Input> - N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals) - N73 Pending Requests header uses css.MembersGroupLabel - N74 remove raw em-sized emoji <span> in RoomNavItem name - N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA) Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -435,18 +435,19 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
||||
const localUserId = mx.getSafeUserId();
|
||||
const localDisplayName = getMxIdLocalPart(localUserId) ?? localUserId;
|
||||
|
||||
// Dark translucent scrim is intentional: these badges overlay arbitrary
|
||||
// video, so a theme surface token would not guarantee legibility.
|
||||
const badgeStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
zIndex: 3,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 7px',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
gap: config.space.S100,
|
||||
pointerEvents: 'none',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
};
|
||||
@@ -456,21 +457,25 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
||||
{localMicMuted && (
|
||||
<div
|
||||
aria-label={`Your microphone is muted (${localDisplayName})`}
|
||||
title={`Your microphone is muted`}
|
||||
style={{ ...badgeStyle, bottom: '8px', left: '8px', color: color.Critical.Main }}
|
||||
title="Your microphone is muted"
|
||||
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
|
||||
>
|
||||
<Icon size="100" src={Icons.MicMute} filled />
|
||||
<span style={{ fontSize: '11px', fontWeight: 600 }}>You</span>
|
||||
<Icon size="100" src={Icons.MicMute} filled style={{ color: color.Critical.Main }} />
|
||||
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
|
||||
You
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{allRemoteMuted && (
|
||||
<div
|
||||
aria-label="All other participants are muted"
|
||||
title="All other participants are muted"
|
||||
style={{ ...badgeStyle, top: '8px', right: '8px', color: color.Warning.Main }}
|
||||
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
|
||||
>
|
||||
<Icon size="50" src={Icons.MicMute} />
|
||||
<span style={{ fontSize: '11px' }}>All muted</span>
|
||||
<Icon size="50" src={Icons.MicMute} style={{ color: color.Warning.Main }} />
|
||||
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
|
||||
All muted
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -925,19 +930,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
onClick={(e) => { e.stopPropagation(); handlePipFullscreen(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePipFullscreen();
|
||||
}}
|
||||
style={{
|
||||
// Dark scrim is intentional for legibility over arbitrary video.
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 7px',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
@@ -953,8 +962,8 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -2,14 +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';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
|
||||
type Modal500Props = {
|
||||
requestClose: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
const modalStyle = useModalStyle(560);
|
||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -21,7 +21,25 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" variant="Background" style={modalStyle}>
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||
// squish the two-pane settings layout.
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 0,
|
||||
overflow: 'hidden auto',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||
import { trimReplyFromBody } from '../../utils/room';
|
||||
@@ -94,15 +94,21 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
)}
|
||||
</div>
|
||||
{needsCollapse && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
style={{ marginTop: '4px' }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
marginTop: config.space.S100,
|
||||
}}
|
||||
>
|
||||
<Text size="B300">{collapsed ? 'Read more ↓' : 'Show less ↑'}</Text>
|
||||
</Button>
|
||||
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
||||
{collapsed ? 'Read more ↓' : 'Show less ↑'}
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -756,7 +756,9 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
||||
// by it, and below modals (9999) so dialogs are never obscured.
|
||||
zIndex: 9997,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -237,11 +237,20 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Box justifyContent="SpaceBetween" alignItems="Center">
|
||||
<Box justifyContent="SpaceBetween" alignItems="Center" gap="200">
|
||||
<Text size="L400">Private Note</Text>
|
||||
<Text size="T200" priority="400">
|
||||
{saving ? 'Saving…' : charsLeft < 100 ? `${charsLeft} left` : ''}
|
||||
</Text>
|
||||
{saving ? (
|
||||
<Box alignItems="Center" gap="100" shrink="No">
|
||||
<Spinner variant="Success" fill="Solid" size="100" />
|
||||
<Text size="T200" priority="400">
|
||||
Saving…
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="T200" priority="400">
|
||||
{charsLeft < 100 ? `${charsLeft} left` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<textarea
|
||||
value={draft}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Input,
|
||||
Badge,
|
||||
RectCords,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -591,36 +590,26 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">From</Text>
|
||||
<input
|
||||
<Input
|
||||
type="date"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
value={fromDate}
|
||||
max={toDate || undefined}
|
||||
onChange={(e) => handleFrom(e.target.value)}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: 'inherit',
|
||||
fontSize: '0.82rem',
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">To</Text>
|
||||
<input
|
||||
<Input
|
||||
type="date"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
value={toDate}
|
||||
min={fromDate || undefined}
|
||||
onChange={(e) => handleTo(e.target.value)}
|
||||
style={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: 'inherit',
|
||||
fontSize: '0.82rem',
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{hasRange && (
|
||||
|
||||
@@ -720,23 +720,7 @@ function RoomNavItem_({
|
||||
<Box as="span" direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||
{(() => {
|
||||
const emojiMatch = roomName.match(
|
||||
/^(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s*/u,
|
||||
);
|
||||
const emojiPrefix = emojiMatch?.[0] ?? '';
|
||||
const nameRest = emojiPrefix ? roomName.slice(emojiPrefix.length) : roomName;
|
||||
return (
|
||||
<>
|
||||
{emojiPrefix && (
|
||||
<span style={{ fontSize: '1.15em', lineHeight: 1 }}>
|
||||
{emojiPrefix.trim()}
|
||||
</span>
|
||||
)}
|
||||
{emojiPrefix ? ` ${nameRest}` : roomName}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{roomName}
|
||||
</Text>
|
||||
{hasLocalName && (
|
||||
<Icon
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
|
||||
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
@@ -32,7 +32,7 @@ function SectionHeader({ label }: { label: string }) {
|
||||
|
||||
// ── Stat tile ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatTile({ emoji, count, label }: { emoji: string; count: number; label: string }) {
|
||||
function StatTile({ icon, count, label }: { icon: IconSrc; count: number; label: string }) {
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
@@ -47,7 +47,7 @@ function StatTile({ emoji, count, label }: { emoji: string; count: number; label
|
||||
background: color.Surface.Container,
|
||||
}}
|
||||
>
|
||||
<Text size="H4">{emoji}</Text>
|
||||
<Icon src={icon} size="300" />
|
||||
<Text size="H4" style={{ fontWeight: 700 }}>
|
||||
{count}
|
||||
</Text>
|
||||
@@ -274,10 +274,14 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
<Box direction="Column" gap="200">
|
||||
<SectionHeader label="Media Shared" />
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<StatTile emoji="🖼️" count={stats.mediaCounts.image} label="Images" />
|
||||
<StatTile emoji="🎬" count={stats.mediaCounts.video} label="Videos" />
|
||||
<StatTile emoji="🎵" count={stats.mediaCounts.audio} label="Audio" />
|
||||
<StatTile emoji="📎" count={stats.mediaCounts.file} label="Files" />
|
||||
<StatTile icon={Icons.Photo} count={stats.mediaCounts.image} label="Images" />
|
||||
<StatTile
|
||||
icon={Icons.VideoCamera}
|
||||
count={stats.mediaCounts.video}
|
||||
label="Videos"
|
||||
/>
|
||||
<StatTile icon={Icons.Headphone} count={stats.mediaCounts.audio} label="Audio" />
|
||||
<StatTile icon={Icons.File} count={stats.mediaCounts.file} label="Files" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -414,11 +414,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
|
||||
{knockMembers.length > 0 && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text
|
||||
style={{ padding: `${config.space.S100} ${config.space.S200}` }}
|
||||
size="L400"
|
||||
priority="300"
|
||||
>
|
||||
<Text className={css.MembersGroupLabel} size="L400">
|
||||
Pending Requests
|
||||
</Text>
|
||||
{knockMembers.map((knockMember) => {
|
||||
|
||||
@@ -866,15 +866,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.Warning.Container,
|
||||
borderLeft: `3px solid ${color.Warning.Main}`,
|
||||
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
src={Icons.Shield}
|
||||
style={{ color: color.Warning.OnContainer, opacity: 0.8, flexShrink: 0 }}
|
||||
style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="T200" style={{ color: color.Warning.OnContainer, opacity: 0.9 }}>
|
||||
<Text size="T200" style={{ color: color.Warning.OnContainer }}>
|
||||
{roomUnverifiedDeviceCount}{' '}
|
||||
{roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
|
||||
room
|
||||
@@ -1159,14 +1159,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{charCount > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{
|
||||
color: 'var(--tc-surface-low)',
|
||||
padding: '0 4px',
|
||||
padding: `0 ${config.space.S100}`,
|
||||
alignSelf: 'center',
|
||||
userSelect: 'none',
|
||||
minWidth: '2rem',
|
||||
textAlign: 'right',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{charCount}
|
||||
|
||||
@@ -741,45 +741,38 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<div style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label={
|
||||
pendingKnocks.length > 0
|
||||
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
|
||||
: 'Toggle member list'
|
||||
}
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label={
|
||||
pendingKnocks.length > 0
|
||||
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
|
||||
: 'Toggle member list'
|
||||
}
|
||||
>
|
||||
{pendingKnocks.length > 0 && (
|
||||
<Badge
|
||||
aria-hidden
|
||||
variant="Warning"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
size="200"
|
||||
size="400"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
right: '-2px',
|
||||
right: toRem(3),
|
||||
top: toRem(3),
|
||||
pointerEvents: 'none',
|
||||
fontSize: '9px',
|
||||
minWidth: '14px',
|
||||
height: '14px',
|
||||
padding: '0 3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
|
||||
<Text as="span" size="L400">
|
||||
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Box, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||
@@ -106,24 +106,24 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
}}
|
||||
>
|
||||
{/* Tray header */}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="0"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
as="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
||||
before={<Icon src={Icons.Clock} size="50" />}
|
||||
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Clock} size="50" />
|
||||
<Text size="T200" style={{ flex: 1, fontWeight: 600 }}>
|
||||
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
|
||||
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />
|
||||
</Box>
|
||||
</Button>
|
||||
|
||||
{/* Tray items */}
|
||||
{expanded && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { ReactNode, useCallback, useEffect } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import parse from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -40,11 +41,6 @@ type EditHistoryResponse = {
|
||||
next_batch?: string;
|
||||
};
|
||||
|
||||
type EditHistoryData = {
|
||||
events: MatrixEvent[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type EditHistoryModalProps = {
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
@@ -100,48 +96,71 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
const eventId = mEvent.getId();
|
||||
const roomId = room.roomId;
|
||||
|
||||
const [historyState, fetchHistory] = useAsyncCallback<EditHistoryData, unknown, []>(
|
||||
useCallback(async () => {
|
||||
if (!eventId) return { events: [], hasMore: false };
|
||||
// Accumulated, de-duplicated edits across paginated fetches.
|
||||
const [edits, setEdits] = useState<MatrixEvent[]>([]);
|
||||
const [nextBatch, setNextBatch] = useState<string | undefined>(undefined);
|
||||
|
||||
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
|
||||
const token = mx.getAccessToken();
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50`;
|
||||
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||
const rawEvents = res.chunk ?? [];
|
||||
const events = await Promise.all(
|
||||
rawEvents
|
||||
.filter(isRawEditEvent)
|
||||
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
||||
.map(async (raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
const evt = new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
if (evt.isEncrypted()) {
|
||||
await mx.decryptEventIfNeeded(evt);
|
||||
}
|
||||
return evt;
|
||||
}),
|
||||
);
|
||||
const parseRawEvents = useCallback(
|
||||
(rawEvents: Array<Record<string, unknown>>): Promise<MatrixEvent[]> =>
|
||||
Promise.all(
|
||||
rawEvents.filter(isRawEditEvent).map(async (raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
const evt = new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
if (evt.isEncrypted()) {
|
||||
await mx.decryptEventIfNeeded(evt);
|
||||
}
|
||||
return evt;
|
||||
}),
|
||||
),
|
||||
[room, roomId, mEvent, mx],
|
||||
);
|
||||
|
||||
return { events, hasMore: !!res.next_batch };
|
||||
}, [mx, roomId, eventId, room, mEvent]),
|
||||
const [historyState, fetchHistory] = useAsyncCallback<void, unknown, [string | undefined]>(
|
||||
useCallback(
|
||||
async (from?: string) => {
|
||||
if (!eventId) return;
|
||||
|
||||
// Relations API lives at /_matrix/client/v1/ (not v3); use raw fetch to avoid SDK prefix
|
||||
const token = mx.getAccessToken();
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
const fromParam = from ? `&from=${encodeURIComponent(from)}` : '';
|
||||
const url = `${baseUrl}/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId)}/m.replace?limit=50${fromParam}`;
|
||||
const fetchRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||
const newEvents = await parseRawEvents(res.chunk ?? []);
|
||||
|
||||
// Merge with prior pages, de-dupe by event id, sort chronologically so
|
||||
// page ordering across batches is always correct.
|
||||
setEdits((prev) => {
|
||||
const byId = new Map<string, MatrixEvent>();
|
||||
[...prev, ...newEvents].forEach((evt) => {
|
||||
const id = evt.getId();
|
||||
if (id) byId.set(id, evt);
|
||||
});
|
||||
return Array.from(byId.values()).sort((a, b) => a.getTs() - b.getTs());
|
||||
});
|
||||
setNextBatch(res.next_batch);
|
||||
},
|
||||
[mx, roomId, eventId, parseRawEvents],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory().catch(() => undefined);
|
||||
fetchHistory(undefined).catch(() => undefined);
|
||||
}, [fetchHistory]);
|
||||
|
||||
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
|
||||
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
|
||||
|
||||
const formatTs = (ts: number): string => {
|
||||
const time = timeHourMinute(ts, hour24Clock);
|
||||
const date = timeDayMonYear(ts, dateFormatString);
|
||||
@@ -195,7 +214,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
paddingBottom: config.space.S700,
|
||||
}}
|
||||
>
|
||||
{historyState.status === AsyncStatus.Loading && (
|
||||
{initialLoading && (
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
@@ -204,12 +223,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
<Spinner size="200" />
|
||||
</Box>
|
||||
)}
|
||||
{historyState.status === AsyncStatus.Error && (
|
||||
{historyState.status === AsyncStatus.Error && edits.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
Failed to load edit history.
|
||||
</Text>
|
||||
)}
|
||||
{historyState.status === AsyncStatus.Success && (
|
||||
{!initialLoading && historyState.status !== AsyncStatus.Error && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="Center">
|
||||
@@ -223,11 +242,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{historyState.data.events.map((editEvt, index) => (
|
||||
{edits.map((editEvt, index) => (
|
||||
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="L400">
|
||||
{index === historyState.data.events.length - 1
|
||||
{index === edits.length - 1
|
||||
? `Edit ${index + 1} (current)`
|
||||
: `Edit ${index + 1}`}
|
||||
</Text>
|
||||
@@ -244,17 +263,27 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{historyState.data.events.length === 0 && (
|
||||
{edits.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
No edit history found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{historyState.data.hasMore && (
|
||||
{nextBatch && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Text size="T200" priority="300">
|
||||
Showing the 50 most recent edits
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
disabled={loadingMore}
|
||||
before={
|
||||
loadingMore ? <Spinner size="100" variant="Secondary" /> : undefined
|
||||
}
|
||||
onClick={() => fetchHistory(nextBatch).catch(() => undefined)}
|
||||
>
|
||||
<Text size="B300">Load more</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -2,14 +2,14 @@ import React, { useMemo } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
color,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
MenuItem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
@@ -42,7 +42,6 @@ function getPresets(): Array<{ label: string; ms: number }> {
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
@@ -66,18 +65,12 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remind-me-title"
|
||||
direction="Column"
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: color.Other.Shadow,
|
||||
...modalStyle,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
variant="Surface"
|
||||
@@ -109,14 +102,21 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
)}
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{presets.map((p) => (
|
||||
<MenuItem key={p.label} size="300" radii="300" onClick={() => handlePick(p.ms)}>
|
||||
<Text size="T300" truncate>
|
||||
<Button
|
||||
key={p.label}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => handlePick(p.ms)}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{p.label}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
|
||||
@@ -123,7 +123,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
@@ -549,7 +549,7 @@ function Appearance() {
|
||||
gap="100"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<Text size="T200" style={{ opacity: 0.7 }}>
|
||||
<Text size="T200" priority="300">
|
||||
Intensity: {nightLightOpacity}%
|
||||
</Text>
|
||||
<input
|
||||
@@ -560,7 +560,7 @@ function Appearance() {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNightLightOpacity(parseInt(e.target.value, 10))
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -655,21 +655,16 @@ function Appearance() {
|
||||
}}
|
||||
/>
|
||||
{mentionHighlightColor && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setMentionHighlightColor('')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<Text size="B300">Reset</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user