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:
@@ -49,7 +49,8 @@ import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
|||||||
import { mxcUrlToHttp } from '../utils/matrix';
|
import { mxcUrlToHttp } from '../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
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 { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
@@ -119,13 +120,19 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
|
|
||||||
const playSound = useCallback(() => {
|
const playSound = useCallback(() => {
|
||||||
const audioElement = audioRef.current;
|
const audioElement = audioRef.current;
|
||||||
audioElement?.play();
|
audioElement?.play().catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info.notificationType === 'ring') {
|
if (info.notificationType === 'ring') {
|
||||||
playSound();
|
playSound();
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [playSound, info.notificationType]);
|
}, [playSound, info.notificationType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,7 +157,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||||
<Text size="T200" align="Center">
|
<Text size="T200" align="Center">
|
||||||
{info.sender}
|
{getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender}
|
||||||
</Text>
|
</Text>
|
||||||
<Box direction="Column" gap="500" alignItems="Center">
|
<Box direction="Column" gap="500" alignItems="Center">
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
@@ -287,7 +294,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const refEventId = relation?.event_id;
|
const refEventId = relation?.event_id;
|
||||||
|
|
||||||
const mention =
|
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) {
|
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import * as css from './EventReaders.css';
|
import * as css from './EventReaders.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
@@ -24,6 +24,19 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
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 = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -34,9 +47,12 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 openProfile = useOpenUserRoomProfile();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
const getName = (userId: string) =>
|
const getName = (userId: string) =>
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
@@ -48,9 +64,14 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
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">
|
<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>
|
</Box>
|
||||||
<IconButton size="300" onClick={requestClose}>
|
<IconButton size="300" onClick={requestClose}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
@@ -63,16 +84,9 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
const name = getName(readerId);
|
const name = getName(readerId);
|
||||||
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxcUrl
|
const avatarUrl = avatarMxcUrl
|
||||||
? mx.mxcUrlToHttp(
|
? mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ?? undefined
|
||||||
avatarMxcUrl,
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
'crop',
|
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
useAuthentication
|
|
||||||
)
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -92,16 +106,28 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={readerId}
|
userId={readerId}
|
||||||
src={avatarUrl ?? undefined}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="T400" truncate>
|
<Box direction="Column" grow="Yes">
|
||||||
{name}
|
<Text size="T400" truncate>
|
||||||
</Text>
|
{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>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
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 FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-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';
|
||||||
|
|
||||||
@@ -24,15 +27,17 @@ export function ReadReceiptAvatars({
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
const displayed = userIds.slice(0, MAX_DISPLAY);
|
const displayed = userIds.slice(0, MAX_DISPLAY);
|
||||||
const extra = userIds.length - MAX_DISPLAY;
|
const extra = userIds.length - MAX_DISPLAY;
|
||||||
const tooltipNames = userIds
|
const tooltipNames =
|
||||||
.slice(0, 5)
|
userIds
|
||||||
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
|
.slice(0, 5)
|
||||||
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
|
||||||
|
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,58 +61,65 @@ export function ReadReceiptAvatars({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
title={tooltipNames}
|
title={tooltipNames}
|
||||||
|
aria-label={tooltipNames}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '1px 0 0',
|
padding: 0,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginTop: '4px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '3px',
|
gap: '4px',
|
||||||
marginLeft: 'auto',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||||
{displayed.map((userId, i) => {
|
<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 =
|
const name =
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
|
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
? mx.mxcUrlToHttp(avatarMxc, 32, 32, 'crop', undefined, false, useAuthentication) ??
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
|
||||||
undefined
|
|
||||||
: undefined;
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<span
|
<StackedAvatar
|
||||||
key={userId}
|
key={userId}
|
||||||
title={name}
|
title={name}
|
||||||
style={{
|
variant="SurfaceVariant"
|
||||||
marginLeft: i === 0 ? 0 : -5,
|
size="200"
|
||||||
display: 'block',
|
radii="Pill"
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: '1.5px solid var(--bg-surface)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Avatar size="100">
|
<UserAvatar
|
||||||
<UserAvatar
|
userId={userId}
|
||||||
userId={userId}
|
src={avatarUrl}
|
||||||
src={avatarUrl}
|
alt={name}
|
||||||
alt={name}
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
/>
|
||||||
/>
|
</StackedAvatar>
|
||||||
</Avatar>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{extra > 0 && (
|
||||||
|
<Text
|
||||||
|
size="T100"
|
||||||
|
style={{ paddingLeft: '4px', color: color.SurfaceVariant.OnContainer }}
|
||||||
|
>
|
||||||
|
+{extra}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{extra > 0 && (
|
|
||||||
<Text size="T100" style={{ opacity: 0.6 }}>
|
|
||||||
+{extra}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
|||||||
{ value: 'tactical', label: 'Tactical' },
|
{ value: 'tactical', label: 'Tactical' },
|
||||||
{ value: 'circuit', label: 'Circuit' },
|
{ value: 'circuit', label: 'Circuit' },
|
||||||
{ value: 'hexgrid', label: 'Hex Grid' },
|
{ value: 'hexgrid', label: 'Hex Grid' },
|
||||||
|
{ value: 'waves', label: 'Waves' },
|
||||||
|
{ value: 'neon', label: 'Neon Grid' },
|
||||||
|
{ value: 'aurora', label: 'Aurora' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DARK: Record<ChatBackground, CSSProperties> = {
|
const DARK: Record<ChatBackground, CSSProperties> = {
|
||||||
@@ -147,6 +150,39 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
||||||
backgroundSize: '29px 50px',
|
backgroundSize: '29px 50px',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Flowing sine-wave lines
|
||||||
|
waves: {
|
||||||
|
backgroundColor: '#080c18',
|
||||||
|
backgroundImage: [
|
||||||
|
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
|
||||||
|
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
|
||||||
|
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Neon cyberpunk grid — orange/cyan TDS colors
|
||||||
|
neon: {
|
||||||
|
backgroundColor: '#020408',
|
||||||
|
backgroundImage: [
|
||||||
|
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Aurora borealis — flowing gradient bands
|
||||||
|
aurora: {
|
||||||
|
backgroundColor: '#030810',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.08) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.08) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse at 50% 10%, rgba(120,0,255,0.06) 0%, transparent 50%)',
|
||||||
|
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.06) 0%, transparent 50%)',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||||
@@ -272,6 +308,36 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
backgroundImage: 'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
||||||
backgroundSize: '29px 50px',
|
backgroundSize: '29px 50px',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
waves: {
|
||||||
|
backgroundColor: '#eef3ff',
|
||||||
|
backgroundImage: [
|
||||||
|
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
|
||||||
|
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
|
||||||
|
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
neon: {
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
backgroundImage: [
|
||||||
|
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
||||||
|
},
|
||||||
|
|
||||||
|
aurora: {
|
||||||
|
backgroundColor: '#f4faf8',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.09) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.09) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse at 50% 10%, rgba(100,0,200,0.07) 0%, transparent 50%)',
|
||||||
|
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.07) 0%, transparent 50%)',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
|
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
new File([blob], 'image.gif', { type: 'image/gif' }),
|
new File([blob], 'image.gif', { type: 'image/gif' }),
|
||||||
{ type: 'image/gif', name: 'image.gif', includeFilename: false }
|
{ type: 'image/gif', name: 'image.gif', includeFilename: false }
|
||||||
);
|
);
|
||||||
const mxcUrl = (uploadRes as any).content_uri;
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (!mxcUrl) return;
|
if (!mxcUrl) return;
|
||||||
mx.sendMessage(roomId, {
|
mx.sendMessage(roomId, {
|
||||||
msgtype: MsgType.Image,
|
msgtype: MsgType.Image,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||||
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page ref={roomViewRef} style={getChatBg(chatBackground, isDark)}>
|
<Page ref={roomViewRef} style={getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark)}>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<RoomTimeline
|
<RoomTimeline
|
||||||
key={roomId}
|
key={roomId}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
import { Room, RoomEvent } from 'matrix-js-sdk';
|
import { Room, RoomEvent, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { reactionOrEditEvent } from '../utils/room';
|
||||||
|
|
||||||
|
// Receipts can land on reaction/edit events which RoomTimeline skips (renders null).
|
||||||
|
// Walk backwards from the receipt event to find the nearest event that IS rendered.
|
||||||
|
function nearestRenderableId(liveEvents: MatrixEvent[], evtId: string): string | null {
|
||||||
|
const idx = liveEvents.findIndex(e => e.getId() === evtId);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
for (let i = idx; i >= 0; i--) {
|
||||||
|
const e = liveEvents[i];
|
||||||
|
if (!reactionOrEditEvent(e)) return e.getId() ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, string[]>();
|
||||||
|
const liveEvents = room.getLiveTimeline().getEvents();
|
||||||
for (const member of room.getJoinedMembers()) {
|
for (const member of room.getJoinedMembers()) {
|
||||||
if (member.userId === myUserId) continue;
|
if (member.userId === myUserId) continue;
|
||||||
const evtId = room.getEventReadUpTo(member.userId);
|
const evtId = room.getEventReadUpTo(member.userId);
|
||||||
if (!evtId) continue;
|
if (!evtId) continue;
|
||||||
const arr = map.get(evtId);
|
const targetId = nearestRenderableId(liveEvents, evtId);
|
||||||
|
if (!targetId) continue;
|
||||||
|
const arr = map.get(targetId);
|
||||||
if (arr) arr.push(member.userId);
|
if (arr) arr.push(member.userId);
|
||||||
else map.set(evtId, [member.userId]);
|
else map.set(targetId, [member.userId]);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -22,9 +38,17 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPositions(computePositions(room, myUserId));
|
setPositions(computePositions(room, myUserId));
|
||||||
const onReceipt = () => setPositions(computePositions(room, myUserId));
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const onReceipt = (): void => {
|
||||||
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
setPositions(computePositions(room, myUserId));
|
||||||
|
debounceTimer = null;
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
room.on(RoomEvent.Receipt, onReceipt);
|
room.on(RoomEvent.Receipt, onReceipt);
|
||||||
return () => {
|
return () => {
|
||||||
|
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||||
room.removeListener(RoomEvent.Receipt, onReceipt);
|
room.removeListener(RoomEvent.Receipt, onReceipt);
|
||||||
};
|
};
|
||||||
}, [room, myUserId]);
|
}, [room, myUserId]);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type DateFormat =
|
|||||||
| 'YYYY-MM-DD'
|
| 'YYYY-MM-DD'
|
||||||
| '';
|
| '';
|
||||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||||
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid';
|
export type ChatBackground = 'none' | 'blueprint' | 'carbon' | 'stars' | 'topographic' | 'herringbone' | 'crosshatch' | 'chevron' | 'polka' | 'triangles' | 'plaid' | 'tactical' | 'circuit' | 'hexgrid' | 'waves' | 'neon' | 'aurora';
|
||||||
export enum MessageLayout {
|
export enum MessageLayout {
|
||||||
Modern = 0,
|
Modern = 0,
|
||||||
Compact = 1,
|
Compact = 1,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function resetBootSequence(): void {
|
|||||||
|
|
||||||
export function runLotusBootSequence(force = false): void {
|
export function runLotusBootSequence(force = false): void {
|
||||||
if (!force && sessionStorage.getItem(STORAGE_KEY)) return;
|
if (!force && sessionStorage.getItem(STORAGE_KEY)) return;
|
||||||
|
if (document.getElementById('lt-boot')) return;
|
||||||
sessionStorage.setItem(STORAGE_KEY, '1');
|
sessionStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
@@ -58,11 +59,25 @@ export function runLotusBootSequence(force = false): void {
|
|||||||
overlay.appendChild(pre);
|
overlay.appendChild(pre);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const dismiss = (): void => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
overlay.style.transition = 'opacity 0.4s ease';
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => overlay.remove(), 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Escape') dismiss();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let text = '';
|
let text = '';
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (i >= BOOT_MESSAGES.length) {
|
if (i >= BOOT_MESSAGES.length) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
overlay.style.transition = 'opacity 0.5s ease';
|
overlay.style.transition = 'opacity 0.5s ease';
|
||||||
overlay.style.opacity = '0';
|
overlay.style.opacity = '0';
|
||||||
|
|||||||
@@ -325,8 +325,8 @@ globalStyle(`body.${lotusTerminalBodyClass} kbd`, {
|
|||||||
boxShadow: '0 1px 3px rgba(0,0,0,0.6)',
|
boxShadow: '0 1px 3px rgba(0,0,0,0.6)',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tooltip / title popups: if browser renders them, style if possible
|
// Tooltip / title popups: abbr only — avoid cursor:help on buttons/links
|
||||||
globalStyle(`body.${lotusTerminalBodyClass} [title]`, {
|
globalStyle(`body.${lotusTerminalBodyClass} abbr[title]`, {
|
||||||
textDecoration: 'underline dotted rgba(0,212,255,0.35)',
|
textDecoration: 'underline dotted rgba(0,212,255,0.35)',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
});
|
});
|
||||||
@@ -354,6 +354,32 @@ globalStyle(`body.${lotusTerminalBodyClass}`, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Reaction chips (emoji reactions on messages) ────────────────────────────
|
||||||
|
globalStyle(`body.${lotusTerminalBodyClass} button[data-reaction-key]`, {
|
||||||
|
backgroundColor: 'rgba(0,212,255,0.06)',
|
||||||
|
border: '1px solid rgba(0,212,255,0.22)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: 'rgba(0,212,255,0.85)',
|
||||||
|
transition: 'background 0.12s, border-color 0.12s, box-shadow 0.12s',
|
||||||
|
});
|
||||||
|
globalStyle(`body.${lotusTerminalBodyClass} button[data-reaction-key]:hover`, {
|
||||||
|
backgroundColor: 'rgba(0,212,255,0.13)',
|
||||||
|
borderColor: 'rgba(0,212,255,0.45)',
|
||||||
|
boxShadow: '0 0 8px rgba(0,212,255,0.18)',
|
||||||
|
});
|
||||||
|
// Own reaction (aria-pressed = true) → orange accent
|
||||||
|
globalStyle(`body.${lotusTerminalBodyClass} button[data-reaction-key][aria-pressed="true"]`, {
|
||||||
|
backgroundColor: 'rgba(255,107,0,0.12)',
|
||||||
|
border: '1px solid rgba(255,107,0,0.38)',
|
||||||
|
color: 'rgba(255,140,0,0.90)',
|
||||||
|
});
|
||||||
|
globalStyle(`body.${lotusTerminalBodyClass} button[data-reaction-key][aria-pressed="true"]:hover`, {
|
||||||
|
backgroundColor: 'rgba(255,107,0,0.20)',
|
||||||
|
borderColor: 'rgba(255,107,0,0.60)',
|
||||||
|
boxShadow: '0 0 8px rgba(255,107,0,0.22)',
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// LIGHT MODE — TDS "daylight reading" variant
|
// LIGHT MODE — TDS "daylight reading" variant
|
||||||
// html[data-theme="light"] + body.lotusTerminalBodyClass
|
// html[data-theme="light"] + body.lotusTerminalBodyClass
|
||||||
@@ -575,3 +601,28 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} img:hover`,
|
|||||||
outline: '1px solid rgba(0,98,184,0.28)',
|
outline: '1px solid rgba(0,98,184,0.28)',
|
||||||
boxShadow: '0 1px 8px rgba(0,98,184,0.08)',
|
boxShadow: '0 1px 8px rgba(0,98,184,0.08)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reaction chips — light TDS
|
||||||
|
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} button[data-reaction-key]`, {
|
||||||
|
backgroundColor: 'rgba(0,98,184,0.06)',
|
||||||
|
border: '1px solid rgba(0,98,184,0.22)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#0062b8',
|
||||||
|
transition: 'background 0.12s, border-color 0.12s, box-shadow 0.12s',
|
||||||
|
});
|
||||||
|
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} button[data-reaction-key]:hover`, {
|
||||||
|
backgroundColor: 'rgba(0,98,184,0.12)',
|
||||||
|
borderColor: 'rgba(0,98,184,0.42)',
|
||||||
|
boxShadow: '0 0 7px rgba(0,98,184,0.16)',
|
||||||
|
});
|
||||||
|
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} button[data-reaction-key][aria-pressed="true"]`, {
|
||||||
|
backgroundColor: 'rgba(196,78,0,0.10)',
|
||||||
|
border: '1px solid rgba(196,78,0,0.35)',
|
||||||
|
color: '#c44e00',
|
||||||
|
});
|
||||||
|
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} button[data-reaction-key][aria-pressed="true"]:hover`, {
|
||||||
|
backgroundColor: 'rgba(196,78,0,0.18)',
|
||||||
|
borderColor: 'rgba(196,78,0,0.55)',
|
||||||
|
boxShadow: '0 0 7px rgba(196,78,0,0.18)',
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user