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 (
<Box
data-poll-content
direction="Column"
gap="200"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
@@ -223,6 +224,7 @@ export function PollContent({
<Box
alignItems="Center"
gap="100"
data-poll-content-label
style={{
fontSize: '0.68rem',
fontWeight: 700,
@@ -251,6 +253,8 @@ export function PollContent({
<button
key={id}
type="button"
data-poll-answer
data-selected={selected}
onClick={canVote ? () => handleVote(id) : undefined}
style={{
padding: '7px 12px',
@@ -188,6 +188,7 @@ export function UploadCardRenderer({
placeholder="Add a caption… (optional)"
value={metadata.caption ?? ''}
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
data-caption-input
style={{
marginTop: '6px',
width: '100%',
@@ -199,6 +200,7 @@ export function UploadCardRenderer({
color: 'var(--text-primary)',
outline: 'none',
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 {
Avatar,
Box,
config,
Input,
@@ -11,11 +12,69 @@ import {
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
} 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 { 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 = {
mEvent: MatrixEvent;
@@ -24,7 +83,10 @@ type Props = {
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const [query, setQuery] = useState('');
const [sending, setSending] = useState(false);
const [sentTo, setSentTo] = useState<string | null>(null);
const allRooms = mx
@@ -36,14 +98,23 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
: allRooms;
const forward = (roomId: string, roomName: string) => {
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
delete fwdContent['m.relates_to'];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).sendEvent(roomId, mEvent.getType(), fwdContent);
setSentTo(roomName);
setTimeout(onClose, 1200);
};
const forward = useCallback(
async (room: Room) => {
if (sending) return;
setSending(true);
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
delete fwdContent['m.relates_to'];
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 (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -59,7 +130,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Modal
size="400"
style={{
maxHeight: '440px',
maxHeight: '480px',
borderRadius: config.radii.R500,
display: 'flex',
flexDirection: 'column',
@@ -72,15 +143,17 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
style={{ padding: config.space.S400, paddingBottom: config.space.S200 }}
>
<Text size="H5">Forward message</Text>
<Input
variant="Background"
size="400"
radii="400"
outlined
placeholder="Search rooms…"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
{!sentTo && (
<Input
variant="Background"
size="400"
radii="400"
outlined
placeholder="Search rooms…"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
/>
)}
</Box>
<Line size="300" />
{sentTo ? (
@@ -88,6 +161,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
grow="Yes"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S400 }}
>
<Text size="T300"> Forwarded to {sentTo}</Text>
@@ -97,16 +171,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
<Scroll size="300" hideTrack visibility="Hover">
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
{filtered.slice(0, 60).map((room) => (
<MenuItem
<RoomRow
key={room.roomId}
size="300"
radii="300"
onClick={() => forward(room.roomId, room.name)}
>
<Text size="T300" truncate>
{room.name}
</Text>
</MenuItem>
room={room}
dm={directs.has(room.roomId)}
useAuthentication={useAuthentication}
onClick={() => forward(room)}
sending={sending}
/>
))}
{filtered.length === 0 && (
<Box
@@ -121,6 +193,20 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
)}
</Box>
</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>
)}
</Modal>
+1 -1
View File
@@ -96,7 +96,7 @@ function DeliveryStatus({
label = 'Failed to send';
colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
} else if (status === EventStatus.QUEUED) {
icon = '';
icon = '';
label = 'Queued';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.45)' : color.Secondary.Main;
} else if (status === EventStatus.SENDING || status === EventStatus.ENCRYPTING) {
+11
View File
@@ -141,6 +141,17 @@ textarea {
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]) {
display: none !important;
}
+17
View File
@@ -55,8 +55,25 @@ export function runLotusBootSequence(force = false): void {
'line-height:1.7',
'white-space:pre-wrap',
'overflow:hidden',
'flex:1',
].join(';');
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);
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,
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,
},
);