feat: reaction TDS styling, debounce read receipts, Escape to skip boot, type fixes

- lotus-terminal.css.ts: add reaction chip styles for dark + light TDS modes
  (cyan border/bg for unselected, orange accent for own/pressed reactions)
- useRoomReadPositions: debounce receipt handler at 150ms (M-3)
- lotus-boot.ts: Escape key skips boot animation (I-3)
- RoomInput.tsx: replace (uploadRes as any) with typed assertion (M-7)
- CallEmbedProvider: call mention detection, audio cleanup, display name (C-1, C-2, M-5)
- EventReaders: timestamps in seen-by modal, filter self, TDS styling
- ReadReceiptAvatars: StackedAvatar pill, TDS visual treatment
- chatBackground: add waves/neon/aurora backgrounds
- RoomView: auto-apply tactical bg when TDS active and bg is none
- settings: extend ChatBackground union type
This commit is contained in:
root
2026-05-16 01:34:20 -04:00
parent 6648ec68a2
commit 4249150100
10 changed files with 270 additions and 68 deletions
+11 -4
View File
@@ -49,7 +49,8 @@ import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getStateEvent } from '../utils/room';
import { getStateEvent, getMemberDisplayName } from '../utils/room';
import { getMxIdLocalPart } from '../utils/matrix';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -119,13 +120,19 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
audioElement?.play().catch(() => undefined);
}, []);
useEffect(() => {
if (info.notificationType === 'ring') {
playSound();
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
};
}, [playSound, info.notificationType]);
useEffect(() => {
@@ -150,7 +157,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{info.sender}
{getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
@@ -287,7 +294,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const refEventId = relation?.event_id;
const mention =
content['m.mentions'].room || content['m.mentions'].user_ids?.includes(mx.getSafeUserId());
content['m.mentions']?.room || content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
@@ -16,7 +16,7 @@ import {
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar';
@@ -24,6 +24,19 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
function formatReadTs(ts: number, hour24Clock: boolean): string {
const timeStr = timeHourMinute(ts, hour24Clock);
if (today(ts)) return `Today at ${timeStr}`;
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
const sameYear = timeYear(ts) === timeYear(Date.now());
return sameYear
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
}
export type EventReadersProps = {
room: Room;
@@ -34,9 +47,12 @@ export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId);
const myUserId = mx.getUserId();
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
@@ -48,9 +64,14 @@ export const EventReaders = as<'div', EventReadersProps>(
{...props}
ref={ref}
>
<Header className={css.Header} variant="Surface" size="600">
<Header
className={css.Header}
variant="Surface"
size="600"
style={lotusTerminal ? { borderBottom: '1px solid rgba(0,212,255,0.30)', boxShadow: '0 2px 12px rgba(0,212,255,0.08)' } : undefined}
>
<Box grow="Yes">
<Text size="H3">Seen by</Text>
<Text size="H3" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
@@ -63,16 +84,9 @@ export const EventReaders = as<'div', EventReadersProps>(
const name = getName(readerId);
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication
)
? mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ?? undefined
: undefined;
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
return (
<MenuItem
@@ -92,16 +106,28 @@ export const EventReaders = as<'div', EventReadersProps>(
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T400" truncate>
{name}
</Text>
<Box direction="Column" grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
{receiptTs !== undefined && (
<Text
size="T200"
style={lotusTerminal
? { color: '#FFB300', textShadow: '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)', fontSize: '0.72rem' }
: { opacity: 0.6 }}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
)}
</Box>
</MenuItem>
);
})}
@@ -1,12 +1,15 @@
import React, { useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { Avatar, Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text } from 'folds';
import { Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, color } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar';
import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
@@ -24,15 +27,17 @@ export function ReadReceiptAvatars({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
if (userIds.length === 0) return null;
const displayed = userIds.slice(0, MAX_DISPLAY);
const extra = userIds.length - MAX_DISPLAY;
const tooltipNames = userIds
.slice(0, 5)
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
const tooltipNames =
userIds
.slice(0, 5)
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
return (
<>
@@ -56,58 +61,65 @@ export function ReadReceiptAvatars({
type="button"
onClick={() => setOpen(true)}
title={tooltipNames}
aria-label={tooltipNames}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '1px 0 0',
padding: 0,
marginLeft: 'auto',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '3px',
marginLeft: 'auto',
gap: '4px',
}}
>
<span style={{ display: 'flex', alignItems: 'center' }}>
{displayed.map((userId, i) => {
{/* Pill wrapper ensures visibility on any wallpaper/background */}
<span
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: lotusTerminal ? 'rgba(0,212,255,0.07)' : color.SurfaceVariant.Container,
border: lotusTerminal ? '1px solid rgba(0,212,255,0.30)' : '1px solid transparent',
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
borderRadius: '999px',
padding: '2px 6px 2px 2px',
gap: '0px',
}}
>
{displayed.map((userId) => {
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mx.mxcUrlToHttp(avatarMxc, 32, 32, 'crop', undefined, false, useAuthentication) ??
undefined
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
return (
<span
<StackedAvatar
key={userId}
title={name}
style={{
marginLeft: i === 0 ? 0 : -5,
display: 'block',
width: 16,
height: 16,
borderRadius: '50%',
border: '1.5px solid var(--bg-surface)',
overflow: 'hidden',
flexShrink: 0,
}}
variant="SurfaceVariant"
size="200"
radii="Pill"
>
<Avatar size="100">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</span>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{extra > 0 && (
<Text
size="T100"
style={{ paddingLeft: '4px', color: color.SurfaceVariant.OnContainer }}
>
+{extra}
</Text>
)}
</span>
{extra > 0 && (
<Text size="T100" style={{ opacity: 0.6 }}>
+{extra}
</Text>
)}
</button>
</>
);