ui: forward dialog avatars, poll TDS, delivery icon, caption focus, boot hint
CI / Build & Quality Checks (push) Failing after 5m46s

ForwardMessageDialog:
- Room list now shows small avatars (48px crop) + DM label beneath room name
- Forward is now async: spinner overlay while in-flight, '✓ Forwarded' only
  shown after sendEvent resolves; error clears sending state so user can retry
- Search bar hidden in success state for cleaner confirmation view

DeliveryStatus:
- QUEUED state used  emoji breaking the ASCII/terminal aesthetic; changed
  to ⟳ matching the SENDING/ENCRYPTING icon

PollContent:
- Added data-poll-content + data-poll-answer + data-selected attributes so
  TDS CSS can override inline styles without JS branching
- Added data-poll-content-label on the ◉ Poll header
- TDS dark: answers get cyan dim bg/border, selected gets orange highlight
  with subtle box-shadow; hover brightens border; label uses cyan glow
- TDS light: equivalent blue/orange variants

Caption input:
- Marked with data-caption-input; focus-visible ring added in index.css
  (blue for default, dark-theme dark blue) and lotus-terminal.css.ts
  (orange glow for TDS dark, orange for TDS light)

Boot sequence:
- Added '[ ESC ] skip' hint at bottom-right of overlay so users know
  they can dismiss it without waiting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:01:13 -04:00
parent dd5cede31d
commit df99038ad6
7 changed files with 202 additions and 30 deletions
@@ -216,6 +216,7 @@ export function PollContent({
return ( return (
<Box <Box
data-poll-content
direction="Column" direction="Column"
gap="200" gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }} style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
@@ -223,6 +224,7 @@ export function PollContent({
<Box <Box
alignItems="Center" alignItems="Center"
gap="100" gap="100"
data-poll-content-label
style={{ style={{
fontSize: '0.68rem', fontSize: '0.68rem',
fontWeight: 700, fontWeight: 700,
@@ -251,6 +253,8 @@ export function PollContent({
<button <button
key={id} key={id}
type="button" type="button"
data-poll-answer
data-selected={selected}
onClick={canVote ? () => handleVote(id) : undefined} onClick={canVote ? () => handleVote(id) : undefined}
style={{ style={{
padding: '7px 12px', padding: '7px 12px',
@@ -188,6 +188,7 @@ export function UploadCardRenderer({
placeholder="Add a caption… (optional)" placeholder="Add a caption… (optional)"
value={metadata.caption ?? ''} value={metadata.caption ?? ''}
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })} onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
data-caption-input
style={{ style={{
marginTop: '6px', marginTop: '6px',
width: '100%', width: '100%',
@@ -199,6 +200,7 @@ export function UploadCardRenderer({
color: 'var(--text-primary)', color: 'var(--text-primary)',
outline: 'none', outline: 'none',
boxSizing: 'border-box', boxSizing: 'border-box',
transition: 'border-color 0.15s, box-shadow 0.15s',
}} }}
/> />
)} )}
@@ -1,6 +1,7 @@
import React, { ChangeEvent, useState } from 'react'; import React, { ChangeEvent, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { import {
Avatar,
Box, Box,
config, config,
Input, Input,
@@ -11,11 +12,69 @@ import {
OverlayBackdrop, OverlayBackdrop,
OverlayCenter, OverlayCenter,
Scroll, Scroll,
Spinner,
Text, Text,
} from 'folds'; } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { mDirectAtom } from '../../../state/mDirectList';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
type RoomRowProps = {
room: Room;
dm: boolean;
useAuthentication: boolean;
onClick: () => void;
sending: boolean;
};
function RoomRow({ room, dm, useAuthentication, onClick, sending }: RoomRowProps) {
const mx = useMatrixClient();
const avatarMxc = room.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
: undefined;
return (
<MenuItem
size="300"
radii="300"
onClick={onClick}
disabled={sending}
before={
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={room.name}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="100"
joinRule={room.getJoinRule()}
filled
/>
)}
/>
</Avatar>
}
>
<Box direction="Column">
<Text size="T300" truncate>
{room.name}
</Text>
{dm && (
<Text size="T200" priority="300" truncate>
Direct Message
</Text>
)}
</Box>
</MenuItem>
);
}
type Props = { type Props = {
mEvent: MatrixEvent; mEvent: MatrixEvent;
@@ -24,7 +83,10 @@ type Props = {
export function ForwardMessageDialog({ mEvent, onClose }: Props) { export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null); const [sentTo, setSentTo] = useState<string | null>(null);
const allRooms = mx const allRooms = mx
@@ -36,14 +98,23 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())) ? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
: allRooms; : allRooms;
const forward = (roomId: string, roomName: string) => { const forward = useCallback(
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() }; async (room: Room) => {
delete fwdContent['m.relates_to']; if (sending) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any setSending(true);
(mx as any).sendEvent(roomId, mEvent.getType(), fwdContent); const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
setSentTo(roomName); delete fwdContent['m.relates_to'];
setTimeout(onClose, 1200); try {
}; // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
setSentTo(room.name);
setTimeout(onClose, 1400);
} catch {
setSending(false);
}
},
[mx, mEvent, onClose, sending],
);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -59,7 +130,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Modal <Modal
size="400" size="400"
style={{ style={{
maxHeight: '440px', maxHeight: '480px',
borderRadius: config.radii.R500, borderRadius: config.radii.R500,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -72,15 +143,17 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
style={{ padding: config.space.S400, paddingBottom: config.space.S200 }} style={{ padding: config.space.S400, paddingBottom: config.space.S200 }}
> >
<Text size="H5">Forward message</Text> <Text size="H5">Forward message</Text>
<Input {!sentTo && (
variant="Background" <Input
size="400" variant="Background"
radii="400" size="400"
outlined radii="400"
placeholder="Search rooms…" outlined
value={query} placeholder="Search rooms…"
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} value={query}
/> onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
)}
</Box> </Box>
<Line size="300" /> <Line size="300" />
{sentTo ? ( {sentTo ? (
@@ -88,6 +161,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
grow="Yes" grow="Yes"
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
gap="300"
style={{ padding: config.space.S400 }} style={{ padding: config.space.S400 }}
> >
<Text size="T300"> Forwarded to {sentTo}</Text> <Text size="T300"> Forwarded to {sentTo}</Text>
@@ -97,16 +171,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Scroll size="300" hideTrack visibility="Hover"> <Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{filtered.slice(0, 60).map((room) => ( {filtered.slice(0, 60).map((room) => (
<MenuItem <RoomRow
key={room.roomId} key={room.roomId}
size="300" room={room}
radii="300" dm={directs.has(room.roomId)}
onClick={() => forward(room.roomId, room.name)} useAuthentication={useAuthentication}
> onClick={() => forward(room)}
<Text size="T300" truncate> sending={sending}
{room.name} />
</Text>
</MenuItem>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<Box <Box
@@ -121,6 +193,20 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
)} )}
</Box> </Box>
</Scroll> </Scroll>
{sending && (
<Box
alignItems="Center"
justifyContent="Center"
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.18)',
borderRadius: config.radii.R500,
}}
>
<Spinner variant="Secondary" size="400" />
</Box>
)}
</Box> </Box>
)} )}
</Modal> </Modal>
+1 -1
View File
@@ -96,7 +96,7 @@ function DeliveryStatus({
label = 'Failed to send'; label = 'Failed to send';
colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main; colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
} else if (status === EventStatus.QUEUED) { } else if (status === EventStatus.QUEUED) {
icon = ''; icon = '';
label = 'Queued'; label = 'Queued';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main; colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main;
} else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) { } else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) {
+11
View File
@@ -141,6 +141,17 @@ textarea {
word-spacing: inherit; word-spacing: inherit;
} }
/* Caption input focus ring — replaces stripped outline */
[data-caption-input]:focus-visible {
border-color: rgba(0, 100, 200, 0.55);
box-shadow: 0 0 0 2px rgba(0, 100, 200, 0.18);
}
.dark-theme [data-caption-input]:focus-visible,
.butter-theme [data-caption-input]:focus-visible {
border-color: rgba(100, 160, 255, 0.60);
box-shadow: 0 0 0 2px rgba(100, 160, 255, 0.18);
}
audio:not([controls]) { audio:not([controls]) {
display: none !important; display: none !important;
} }
+17
View File
@@ -55,8 +55,25 @@ export function runLotusBootSequence(force = false): void {
'line-height:1.7', 'line-height:1.7',
'white-space:pre-wrap', 'white-space:pre-wrap',
'overflow:hidden', 'overflow:hidden',
'flex:1',
].join(';'); ].join(';');
overlay.appendChild(pre); overlay.appendChild(pre);
const escHint = document.createElement('div');
escHint.style.cssText = [
'position:absolute',
'bottom:1.5rem',
'right:2rem',
'font-size:0.68rem',
'color:rgba(0,255,136,0.35)',
"font-family:'JetBrains Mono','Courier New',monospace",
'letter-spacing:0.08em',
'user-select:none',
'pointer-events:none',
].join(';');
escHint.textContent = '[ ESC ] skip';
overlay.appendChild(escHint);
document.body.appendChild(overlay); document.body.appendChild(overlay);
const dismiss = (): void => { const dismiss = (): void => {
+52
View File
@@ -878,3 +878,55 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ._13tt0gb6:
background: 'rgba(0,98,184,0.08) !important' as any, background: 'rgba(0,98,184,0.08) !important' as any,
color: '#0062b8 !important' as any, color: '#0062b8 !important' as any,
}); });
// ── Poll card TDS ─────────────────────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer]`, {
background: 'rgba(0,212,255,0.04) !important' as any,
border: '1px solid rgba(0,212,255,0.22) !important' as any,
color: '#c4d9ee !important' as any,
transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s',
});
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer]:hover`, {
background: 'rgba(0,212,255,0.08) !important' as any,
borderColor: 'rgba(0,212,255,0.40) !important' as any,
boxShadow: '0 0 8px rgba(0,212,255,0.08)',
});
globalStyle(
`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-selected="true"]`,
{
background: 'rgba(255,107,0,0.10) !important' as any,
border: '1px solid rgba(255,107,0,0.55) !important' as any,
boxShadow: '0 0 10px rgba(255,107,0,0.10)',
},
);
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, {
color: 'rgba(0,212,255,0.60) !important' as any,
opacity: '1 !important' as any,
textShadow: '0 0 8px rgba(0,212,255,0.25)',
});
// light TDS
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer]`, {
background: 'rgba(0,98,184,0.04) !important' as any,
border: '1px solid rgba(0,98,184,0.22) !important' as any,
color: '#111827 !important' as any,
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-selected="true"]`,
{
background: 'rgba(196,78,0,0.08) !important' as any,
border: '1px solid rgba(196,78,0,0.50) !important' as any,
},
);
// ── Caption input TDS focus ring ──────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} [data-caption-input]:focus-visible`, {
borderColor: 'rgba(255,107,0,0.65) !important' as any,
boxShadow: '0 0 0 2px rgba(255,107,0,0.14), 0 0 6px rgba(255,107,0,0.10) !important' as any,
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-caption-input]:focus-visible`,
{
borderColor: 'rgba(196,78,0,0.60) !important' as any,
boxShadow: '0 0 0 2px rgba(196,78,0,0.12) !important' as any,
},
);