chore: prettier format all files, brotli, Sentry release tagging, CI gates

Prettier: auto-formatted 103 files to fix baseline. Prettier check in CI
  is now a hard gate (removed continue-on-error).

Brotli: installed libnginx-mod-http-brotli-filter/static. Enabled in nginx
  with brotli_static on for pre-compressed assets and comp_level 6.

Sentry releases: deploy script now exports VITE_APP_VERSION=<git-short-sha>
  before building so each Sentry release maps to an exact commit.
  CI also passes github.sha as VITE_APP_VERSION.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lotus Bot
2026-05-21 20:49:33 -04:00
parent 408fc1b846
commit 42b9cc2b64
105 changed files with 2749 additions and 1850 deletions
+238 -57
View File
@@ -137,7 +137,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
if (remaining <= 0) { onIgnore(); return; }
if (remaining <= 0) {
onIgnore();
return;
}
const id = setTimeout(onIgnore, remaining);
return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]);
@@ -157,7 +160,9 @@ 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">
{getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender}
{getMemberDisplayName(info.room, info.sender) ??
getMxIdLocalPart(info.sender) ??
info.sender}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
@@ -294,7 +299,8 @@ 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;
}
@@ -410,10 +416,19 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const { navigateRoom } = useRoomNavigate();
const pipDragRef = React.useRef<{
startX: number; startY: number; origLeft: number; origTop: number; dragged: boolean;
startX: number;
startY: number;
origLeft: number;
origTop: number;
dragged: boolean;
} | null>(null);
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => () => { activeDragCleanupRef.current?.(); }, []);
React.useEffect(
() => () => {
activeDragCleanupRef.current?.();
},
[]
);
// Track previous pipMode to only reset position when first entering pip (not on callVisible changes)
const prevPipModeRef = React.useRef(false);
@@ -422,16 +437,35 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el) return;
if (pipMode) {
if (!prevPipModeRef.current) {
el.style.top = 'auto'; el.style.left = 'auto';
el.style.bottom = '72px'; el.style.right = '16px';
el.style.width = '280px'; el.style.height = '158px';
el.style.borderRadius = '12px'; el.style.overflow = 'hidden';
el.style.zIndex = '99'; el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.top = 'auto';
el.style.left = 'auto';
el.style.bottom = '72px';
el.style.right = '16px';
el.style.width = '280px';
el.style.height = '158px';
el.style.borderRadius = '12px';
el.style.overflow = 'hidden';
el.style.zIndex = '99';
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.55)';
el.style.border = '1px solid rgba(255,255,255,0.1)';
}
el.style.visibility = 'visible';
} else {
['top','left','bottom','right','width','height','borderRadius','overflow','zIndex','boxShadow','border'].forEach(p => { (el.style as any)[p] = ''; });
[
'top',
'left',
'bottom',
'right',
'width',
'height',
'borderRadius',
'overflow',
'zIndex',
'boxShadow',
'border',
].forEach((p) => {
(el.style as any)[p] = '';
});
el.style.visibility = callVisible ? '' : 'hidden';
}
prevPipModeRef.current = pipMode;
@@ -444,30 +478,66 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el) return;
const l = parseFloat(el.style.left);
const t = parseFloat(el.style.top);
if (!isNaN(l)) el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
if (!isNaN(t)) el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
if (!isNaN(l))
el.style.left = `${Math.max(0, Math.min(l, window.innerWidth - el.offsetWidth))}px`;
if (!isNaN(t))
el.style.top = `${Math.max(0, Math.min(t, window.innerHeight - el.offsetHeight))}px`;
};
window.addEventListener('resize', onPipWindowResize);
return () => window.removeEventListener('resize', onPipWindowResize);
}, [pipMode, callEmbedRef]);
const handlePipMouseDown = (e: React.MouseEvent) => {
const el = callEmbedRef.current; if (!el) return;
const el = callEmbedRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
pipDragRef.current = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
pipDragRef.current = {
startX: e.clientX,
startY: e.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onMove = (ev: MouseEvent) => {
if (!pipDragRef.current || !el) return;
const dx = ev.clientX - pipDragRef.current.startX, dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx)+Math.abs(dy) > 5) { pipDragRef.current.dragged = true; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }
const dx = ev.clientX - pipDragRef.current.startX,
dy = ev.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) {
pipDragRef.current.dragged = true;
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
}
if (pipDragRef.current.dragged) {
el.style.left = `${Math.max(0, Math.min(window.innerWidth-el.offsetWidth, pipDragRef.current.origLeft+dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight-el.offsetHeight, pipDragRef.current.origTop+dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
el.style.left = `${Math.max(
0,
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx)
)}px`;
el.style.top = `${Math.max(
0,
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy)
)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; activeDragCleanupRef.current = null; setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0); };
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
activeDragCleanupRef.current = null;
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
activeDragCleanupRef.current = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const handlePipTouchStart = (e: React.TouchEvent) => {
@@ -475,50 +545,115 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (!el || e.touches.length !== 1) return;
const touch = e.touches[0];
const rect = el.getBoundingClientRect();
pipDragRef.current = { startX: touch.clientX, startY: touch.clientY, origLeft: rect.left, origTop: rect.top, dragged: false };
pipDragRef.current = {
startX: touch.clientX,
startY: touch.clientY,
origLeft: rect.left,
origTop: rect.top,
dragged: false,
};
const onTouchMove = (ev: TouchEvent) => {
if (!pipDragRef.current || !el || ev.touches.length !== 1) return;
ev.preventDefault();
const t = ev.touches[0];
const dx = t.clientX - pipDragRef.current.startX;
const dy = t.clientY - pipDragRef.current.startY;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5) pipDragRef.current.dragged = true;
if (!pipDragRef.current.dragged && Math.abs(dx) + Math.abs(dy) > 5)
pipDragRef.current.dragged = true;
if (pipDragRef.current.dragged) {
el.style.left = `${Math.max(0, Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx))}px`;
el.style.top = `${Math.max(0, Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy))}px`;
el.style.right = 'auto'; el.style.bottom = 'auto';
el.style.left = `${Math.max(
0,
Math.min(window.innerWidth - el.offsetWidth, pipDragRef.current.origLeft + dx)
)}px`;
el.style.top = `${Math.max(
0,
Math.min(window.innerHeight - el.offsetHeight, pipDragRef.current.origTop + dy)
)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
activeDragCleanupRef.current = null;
setTimeout(() => { if (pipDragRef.current) pipDragRef.current.dragged = false; }, 0);
setTimeout(() => {
if (pipDragRef.current) pipDragRef.current.dragged = false;
}, 0);
};
activeDragCleanupRef.current = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
activeDragCleanupRef.current = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); };
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
};
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation(); e.preventDefault();
const el = callEmbedRef.current; if (!el) return;
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
normaliseToTopLeft(el);
const sx = e.clientX, sy = e.clientY, sw = el.offsetWidth, sh = el.offsetHeight;
const sl = parseFloat(el.style.left), st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none';
const sx = e.clientX,
sy = e.clientY,
sw = el.offsetWidth,
sh = el.offsetHeight;
const sl = parseFloat(el.style.left),
st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX-sx, dy = ev.clientY-sy;
let w = sw, h = sh, l = sl, t = st;
if (corner==='se'){w=sw+dx;h=sh+dy;} if (corner==='sw'){w=sw-dx;h=sh+dy;l=sl+sw-Math.max(PIP_MIN_W,w);}
if (corner==='ne'){w=sw+dx;h=sh-dy;t=st+sh-Math.max(PIP_MIN_H,h);} if (corner==='nw'){w=sw-dx;h=sh-dy;l=sl+sw-Math.max(PIP_MIN_W,w);t=st+sh-Math.max(PIP_MIN_H,h);}
w=Math.max(PIP_MIN_W,Math.min(w,window.innerWidth)); h=Math.max(PIP_MIN_H,Math.min(h,window.innerHeight));
l=Math.max(0,Math.min(l,window.innerWidth-PIP_MIN_W)); t=Math.max(0,Math.min(t,window.innerHeight-PIP_MIN_H));
el.style.width=`${w}px`; el.style.height=`${h}px`; el.style.left=`${l}px`; el.style.top=`${t}px`;
const dx = ev.clientX - sx,
dy = ev.clientY - sy;
let w = sw,
h = sh,
l = sl,
t = st;
if (corner === 'se') {
w = sw + dx;
h = sh + dy;
}
if (corner === 'sw') {
w = sw - dx;
h = sh + dy;
l = sl + sw - Math.max(PIP_MIN_W, w);
}
if (corner === 'ne') {
w = sw + dx;
h = sh - dy;
t = st + sh - Math.max(PIP_MIN_H, h);
}
if (corner === 'nw') {
w = sw - dx;
h = sh - dy;
l = sl + sw - Math.max(PIP_MIN_W, w);
t = st + sh - Math.max(PIP_MIN_H, h);
}
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.left = `${l}px`;
el.style.top = `${t}px`;
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; activeDragCleanupRef.current = null; };
activeDragCleanupRef.current = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.style.cursor=''; document.body.style.userSelect=''; };
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
activeDragCleanupRef.current = null;
};
activeDragCleanupRef.current = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
return (
@@ -548,25 +683,71 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
aria-label="Return to call"
onMouseDown={handlePipMouseDown}
onTouchStart={handlePipTouchStart}
onClick={() => { if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId); }}
onClick={() => {
if (!pipDragRef.current?.dragged) navigateRoom(callEmbed.roomId);
}}
onKeyDown={(e) => e.key === 'Enter' && navigateRoom(callEmbed.roomId)}
style={{ position:'absolute', inset:0, zIndex:1, background:'transparent', cursor:'grab', display:'flex', alignItems:'flex-start', justifyContent:'flex-end', padding:'6px' }}
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
cursor: 'grab',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '6px',
}}
>
<div style={{ background:'rgba(0,0,0,0.65)', backdropFilter:'blur(4px)', borderRadius:'6px', padding:'3px 8px', color:'#fff', fontSize:'11px', fontWeight:600, pointerEvents:'none' }}>
<div
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
borderRadius: '6px',
padding: '3px 8px',
color: '#fff',
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
}}
>
Return to call
</div>
</div>
{(['se','sw','ne','nw'] as Corner[]).map((corner) => {
const s = corner.includes('s'); const e2 = corner.includes('e');
const dots = [[2,2],[2,7],[7,2]].map(([a,b]) => ({
position:'absolute' as const, width:4, height:4, borderRadius:'50%',
background:'rgba(255,255,255,0.45)',
[s?'bottom':'top']:a, [e2?'right':'left']:b,
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
[2, 2],
[2, 7],
[7, 2],
].map(([a, b]) => ({
position: 'absolute' as const,
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255,255,255,0.45)',
[s ? 'bottom' : 'top']: a,
[e2 ? 'right' : 'left']: b,
}));
return (
<div key={corner} onMouseDown={(ev) => handleResizeMouseDown(ev, corner)} onClick={(ev) => ev.stopPropagation()}
style={{ position:'absolute', width:'18px', height:'18px', [s?'bottom':'top']:0, [e2?'right':'left']:0, cursor:`${corner}-resize`, zIndex:2 }}>
{dots.map((style, i) => <div key={i} style={style} />)}
<div
key={corner}
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
onClick={(ev) => ev.stopPropagation()}
style={{
position: 'absolute',
width: '18px',
height: '18px',
[s ? 'bottom' : 'top']: 0,
[e2 ? 'right' : 'left']: 0,
cursor: `${corner}-resize`,
zIndex: 2,
}}
>
{dots.map((style, i) => (
<div key={i} style={style} />
))}
</div>
);
})}
+9 -2
View File
@@ -259,9 +259,16 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text as="h2" size="H4">Device Verification</Text>
<Text as="h2" size="H4">
Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel} aria-label="Cancel verification">
<IconButton
size="300"
radii="300"
onClick={handleCancel}
aria-label="Cancel verification"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -299,7 +299,9 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Setup Device Verification</Text>
<Text as="h2" size="H4">
Setup Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -334,7 +336,9 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Reset Device Verification</Text>
<Text as="h2" size="H4">
Reset Device Verification
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
+20 -13
View File
@@ -8,7 +8,6 @@ import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
@@ -34,23 +33,27 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}>
{lotusTerminal && (
<div style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
userSelect: 'none',
}}>
<div
style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
userSelect: 'none',
}}
>
// GIF_SEARCH
</div>
)}
<Box style={{ padding: '8px 8px 4px' }}>
<SearchBar style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }} />
</Box>
<div style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}>
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
<Grid
key={searchKey}
fetchGifs={fetchGifs}
@@ -108,7 +111,11 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
style={containerStyle}
>
<SearchContextManager apiKey={apiKey} initialTerm="">
<GifPickerInner onSelect={onSelect} requestClose={requestClose} lotusTerminal={!!lotusTerminal} />
<GifPickerInner
onSelect={onSelect}
requestClose={requestClose}
lotusTerminal={!!lotusTerminal}
/>
</SearchContextManager>
</Box>
</FocusTrap>
+3 -1
View File
@@ -43,7 +43,9 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Logout</Text>
<Text as="h2" size="H4">
Logout
</Text>
</Box>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
+33 -13
View File
@@ -22,7 +22,8 @@ export function RoomSkeleton() {
`;
const shimmer = {
background: 'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
background:
'linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-shine) 50%, var(--skeleton-base) 75%)',
backgroundSize: '800px 100%',
animation: `shimmer-${id} 1.6s ease-in-out infinite`,
borderRadius: '4px',
@@ -32,16 +33,18 @@ export function RoomSkeleton() {
<>
<style>{shimmerKeyframes}</style>
<div
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
// CSS vars resolve against both light and dark themes automatically
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties}
style={
{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
overflow: 'hidden',
// CSS vars resolve against both light and dark themes automatically
'--skeleton-base': 'color-mix(in srgb, currentColor 8%, transparent)',
'--skeleton-shine': 'color-mix(in srgb, currentColor 15%, transparent)',
} as React.CSSProperties
}
>
{/* Header — matches PageHeader size="600" (56px) */}
<div
@@ -56,7 +59,15 @@ export function RoomSkeleton() {
}}
>
{/* Avatar */}
<div style={{ ...shimmer, width: '32px', height: '32px', borderRadius: '50%', flexShrink: 0 }} />
<div
style={{
...shimmer,
width: '32px',
height: '32px',
borderRadius: '50%',
flexShrink: 0,
}}
/>
{/* Room name */}
<div style={{ ...shimmer, width: '140px', height: '16px' }} />
{/* Spacer */}
@@ -70,7 +81,16 @@ export function RoomSkeleton() {
<div style={{ flex: 1, overflowY: 'hidden', padding: '16px 0' }}>
{MESSAGES.map((msg, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i} style={{ display: 'flex', gap: '12px', padding: '4px 16px', alignItems: 'flex-start', marginBottom: msg.showAvatar ? '8px' : '2px' }}>
<div
key={i}
style={{
display: 'flex',
gap: '12px',
padding: '4px 16px',
alignItems: 'flex-start',
marginBottom: msg.showAvatar ? '8px' : '2px',
}}
>
{/* Avatar — only shown on first message in a group */}
<div style={{ width: '36px', flexShrink: 0 }}>
{msg.showAvatar && (
+11 -2
View File
@@ -23,7 +23,11 @@ export function EditorPreview() {
return (
<>
<IconButton variant="SurfaceVariant" aria-label="Open editor preview" onClick={() => setOpen(!open)}>
<IconButton
variant="SurfaceVariant"
aria-label="Open editor preview"
onClick={() => setOpen(!open)}
>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
@@ -58,7 +62,12 @@ export function EditorPreview() {
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Insert emoji">
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Insert emoji"
>
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300" aria-label="Send">
+32 -30
View File
@@ -279,44 +279,42 @@ export function Toolbar() {
<MarkButton
format={MarkType.Bold}
icon={Icons.Bold}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`}
label="Bold"
/>}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} label="Bold" />}
/>
<MarkButton
format={MarkType.Italic}
icon={Icons.Italic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`}
label="Italic"
/>}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} label="Italic" />}
/>
<MarkButton
format={MarkType.Underline}
icon={Icons.Underline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`}
label="Underline"
/>}
tooltip={
<BtnTooltip text="Underline" shortCode={`${modKey} + U`} label="Underline" />
}
/>
<MarkButton
format={MarkType.StrikeThrough}
icon={Icons.Strike}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`}
label="Strikethrough"
/>}
tooltip={
<BtnTooltip
text="Strike Through"
shortCode={`${modKey} + S`}
label="Strikethrough"
/>
}
/>
<MarkButton
format={MarkType.Code}
icon={Icons.Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`}
label="Inline code"
/>}
tooltip={
<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} label="Inline code" />
}
/>
<MarkButton
format={MarkType.Spoiler}
icon={Icons.EyeBlind}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`}
label="Spoiler"
/>}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} label="Spoiler" />}
/>
</Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
@@ -325,30 +323,34 @@ export function Toolbar() {
<BlockButton
format={BlockType.BlockQuote}
icon={Icons.BlockQuote}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`}
label="Block quote"
/>}
tooltip={
<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} label="Block quote" />
}
/>
<BlockButton
format={BlockType.CodeBlock}
icon={Icons.BlockCode}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`}
label="Code block"
/>}
tooltip={
<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} label="Code block" />
}
/>
<BlockButton
format={BlockType.OrderedList}
icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`}
label="Ordered list"
/>}
tooltip={
<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} label="Ordered list" />
}
/>
<BlockButton
format={BlockType.UnorderedList}
icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`}
label="Unordered list"
/>}
tooltip={
<BtnTooltip
text="Unordered List"
shortCode={`${modKey} + 8`}
label="Unordered list"
/>
}
/>
<HeadingBlockButton />
</Box>
@@ -68,10 +68,30 @@ export const EventReaders = as<'div', EventReadersProps>(
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}
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" style={lotusTerminal ? { color: '#00D4FF', textShadow: '0 0 6px rgba(0,212,255,0.45)', letterSpacing: '0.05em' } : undefined}>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} aria-label="Close">
<Icon src={Icons.Cross} />
@@ -120,9 +140,15 @@ export const EventReaders = as<'div', EventReadersProps>(
{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 }}
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>
@@ -80,7 +80,9 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Join with Address</Text>
<Text as="h2" size="H4">
Join with Address
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -66,7 +66,9 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="leave-room-dialog-title">Leave Room</Text>
<Text as="h2" size="H4" id="leave-room-dialog-title">
Leave Room
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -66,7 +66,9 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Leave Space</Text>
<Text as="h2" size="H4">
Leave Space
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
+7 -1
View File
@@ -48,7 +48,13 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={downloading ? 'Downloading...' : hasError ? 'Download failed, click to retry' : 'Download file'}
aria-label={
downloading
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
@@ -394,7 +394,9 @@ export function MLocation({ content }: MLocationProps) {
const lat = parseFloat(location.latitude);
const lon = parseFloat(location.longitude);
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${lat - 0.004},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
lat - 0.004
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
return (
<Box direction="Column" alignItems="Start" gap="200">
+1 -2
View File
@@ -29,8 +29,7 @@ export const Reaction = as<
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
alt={reaction}
/>
) : (
@@ -8,7 +8,9 @@ export const MessageDeletedContent = as<'div', { children?: never; reason?: stri
({ reason, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Delete} />
<i>{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}</i>
<i>
{reason ? `This message has been deleted — ${reason}` : 'This message has been deleted'}
</i>
</Box>
)
);
@@ -105,9 +105,9 @@ export function PollContent({
const mx = useMatrixClient();
const isStable = !!content['m.poll'];
const poll = (
content['m.poll'] ?? content['org.matrix.msc3381.poll.start']
) as PollData | undefined;
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
| PollData
| undefined;
const [votes, setVotes] = useState<VoteState>(() => {
if (!roomId || !eventId) return { counts: new Map(), myVote: null, total: 0 };
@@ -259,7 +259,9 @@ export function PollContent({
padding: '7px 12px',
borderRadius: '8px',
background: selected ? 'var(--bg-surface-active)' : 'var(--bg-surface-low)',
border: `1px solid ${selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'}`,
border: `1px solid ${
selected ? 'var(--text-primary)' : 'var(--bg-surface-border)'
}`,
fontSize: '0.88rem',
lineHeight: 1.4,
textAlign: 'left',
@@ -281,23 +283,21 @@ export function PollContent({
position: 'absolute',
inset: 0,
width: `${pct}%`,
background: selected
? 'rgba(255,255,255,0.10)'
: 'rgba(255,255,255,0.05)',
background: selected ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.05)',
borderRadius: '8px',
pointerEvents: 'none',
}}
/>
)}
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}>
<span
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
>
<span style={{ flexGrow: 1 }}>{text}</span>
{selected && (
<span style={{ opacity: 0.8, fontSize: '1rem', flexShrink: 0 }}></span>
)}
{total > 0 && (
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>
{pct}%
</span>
<span style={{ opacity: 0.6, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
)}
</span>
</button>
@@ -79,7 +79,9 @@ export function ReadReceiptAvatars({
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: lotusTerminal ? 'rgba(0,212,255,0.07)' : color.SurfaceVariant.Container,
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',
@@ -88,8 +90,7 @@ export function ReadReceiptAvatars({
}}
>
{displayed.map((userId) => {
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined
+3 -1
View File
@@ -18,7 +18,9 @@ function DummyErrorDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
+12 -3
View File
@@ -37,9 +37,16 @@ function EmailErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
<Text as="label" htmlFor="retryEmailInput" size="L400" style={{ paddingTop: config.space.S400 }}>
<Text
as="label"
htmlFor="retryEmailInput"
size="L400"
style={{ paddingTop: config.space.S400 }}
>
Email
</Text>
<Input
@@ -141,7 +148,9 @@ export function EmailStageDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">Verification Request Sent</Text>
<Text as="h2" size="H4">
Verification Request Sent
</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
{errorCode && (
@@ -43,7 +43,9 @@ export function PasswordStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Account Password</Text>
<Text as="h2" size="H4">
Account Password
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -35,9 +35,16 @@ function RegistrationTokenErrorDialog({
gap="400"
>
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
<Text as="label" htmlFor="retryTokenInput" size="L400" style={{ paddingTop: config.space.S400 }}>
<Text
as="label"
htmlFor="retryTokenInput"
size="L400"
style={{ paddingTop: config.space.S400 }}
>
Registration Token
</Text>
<Input
+3 -1
View File
@@ -54,7 +54,9 @@ export function SSOStage({
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">SSO Login</Text>
<Text as="h2" size="H4">
SSO Login
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
+3 -1
View File
@@ -18,7 +18,9 @@ function TermsErrorDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text as="h2" size="H4">{title}</Text>
<Text as="h2" size="H4">
{title}
</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
@@ -4,7 +4,13 @@ import { Box, as } from 'folds';
import * as css from './UrlPreview.css';
export const UrlPreview = as<'div'>(({ className, ...props }, ref) => (
<Box shrink="No" data-url-preview="" className={classNames(css.UrlPreview, className)} {...props} ref={ref} />
<Box
shrink="No"
data-url-preview=""
className={classNames(css.UrlPreview, className)}
{...props}
ref={ref}
/>
));
export const UrlPreviewImg = as<'img'>(({ className, alt, ...props }, ref) => (
@@ -68,7 +68,9 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Self Demotion</Text>
<Text as="h2" size="H4">
Self Demotion
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -118,7 +120,9 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Shared Power</Text>
<Text as="h2" size="H4">
Shared Power
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
<Icon src={Icons.Cross} />
@@ -198,7 +198,9 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">Add Existing</Text>
<Text as="h2" size="H4">
Add Existing
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
+52 -28
View File
@@ -62,7 +62,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => { microphoneRef.current = microphone; }, [microphone]);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
@@ -165,8 +167,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setPttActive(false);
}
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pttMode, pttKey, callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@@ -184,21 +186,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center"
alignItems="Center"
>
{pttMode && (
lotusTerminal ? (
<Box style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}>
<Text size="T200" style={{ color: pttActive ? '#00FF88' : '#FF6B00', fontWeight: 700, letterSpacing: '0.08em', fontFamily: 'JetBrains Mono, monospace' }}>
{pttMode &&
(lotusTerminal ? (
<Box
style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'rgba(0,255,136,0.18)' : 'rgba(255,107,0,0.12)',
border: `1px solid ${pttActive ? 'rgba(0,255,136,0.55)' : 'rgba(255,107,0,0.35)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
<Text
size="T200"
style={{
color: pttActive ? '#00FF88' : '#FF6B00',
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
}}
>
{pttActive ? '● LIVE' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Box>
@@ -221,8 +233,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
)
)}
))}
{shareConfirm && (
<Box
style={{
@@ -242,17 +253,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>Share your screen?</Text>
<Text size="T200" style={{ opacity: 0.75 }}>Your screen will be visible to all participants in this call.</Text>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button
size="300" variant="Success" fill="Solid" radii="300"
onClick={() => { callEmbed.control.toggleScreenshare(); setShareConfirm(false); }}
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300" variant="Secondary" fill="Soft" radii="300" outlined
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
@@ -281,9 +306,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => screenshare
? callEmbed.control.toggleScreenshare()
: setShareConfirm(true)
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
</Box>
+3 -1
View File
@@ -108,7 +108,9 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
onClick={() => onToggle()}
outlined
disabled={disabled}
aria-label={disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'}
aria-label={
disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'
}
aria-pressed={enabled}
style={disabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
>
+4 -1
View File
@@ -75,7 +75,10 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
</Box>
<Box grow="Yes" direction="Column" gap="200">
{micDenied && (
<Text size="T200" style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}>
<Text
size="T200"
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
>
Microphone access is blocked. Enable it in your browser settings to join.
</Text>
)}
@@ -121,9 +121,16 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Enable Encryption</Text>
<Text as="h2" size="H4">
Enable Encryption
</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300" aria-label="Cancel">
<IconButton
size="300"
onClick={() => setPrompt(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -103,7 +103,9 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
<Text as="h2" size="H4">
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
@@ -58,7 +58,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
}}
>
<Box grow="Yes">
<Text as="h2" size="H4">New Space</Text>
<Text as="h2" size="H4">
New Space
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog} aria-label="Close">
+3 -1
View File
@@ -22,7 +22,9 @@ export function LobbyHero() {
const name = useRoomName(space);
const topic = useRoomTopic(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<PageHero
+4 -2
View File
@@ -147,7 +147,8 @@ const DARK: Record<ChatBackground, CSSProperties> = {
// True pointy-top hexagonal grid via SVG data URI
hexgrid: {
backgroundColor: '#060c14',
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',
},
@@ -305,7 +306,8 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
hexgrid: {
backgroundColor: '#f4f8ff',
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',
},
+1 -1
View File
@@ -440,4 +440,4 @@ function RoomNavItem_({
</NavItem>
);
}
export const RoomNavItem = React.memo(RoomNavItem_);
export const RoomNavItem = React.memo(RoomNavItem_);
@@ -117,7 +117,11 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+6 -1
View File
@@ -41,7 +41,12 @@ export function CallChatView() {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose} aria-label="Close call chat">
<IconButton
ref={triggerRef}
variant="Surface"
onClick={handleClose}
aria-label="Close call chat"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+68 -31
View File
@@ -30,7 +30,9 @@ import {
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })));
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker }))
);
import { useClientConfig } from '../../hooks/useClientConfig';
import {
CustomEditor,
@@ -57,7 +59,9 @@ import {
getMentions,
} from '../../components/editor';
import { EmojiBoardTab } from '../../components/emoji-board/types';
const EmojiBoard = React.lazy(() => import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })));
const EmojiBoard = React.lazy(() =>
import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard }))
);
import { UseStateProvider } from '../../components/UseStateProvider';
import {
TUploadContent,
@@ -143,7 +147,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const alive = useAlive();
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const replyUserID = replyDraft?.userId;
@@ -467,8 +471,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
async (gifUrl: string, w: number, h: number) => {
try {
// Only fetch from trusted Giphy CDN domains
const allowed = ['media.giphy.com', 'i.giphy.com', 'media0.giphy.com',
'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com'];
const allowed = [
'media.giphy.com',
'i.giphy.com',
'media0.giphy.com',
'media1.giphy.com',
'media2.giphy.com',
'media3.giphy.com',
'media4.giphy.com',
];
const { hostname } = new URL(gifUrl);
if (!allowed.includes(hostname)) return;
@@ -695,24 +706,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={
<React.Suspense fallback={null}><EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/></React.Suspense>
<React.Suspense fallback={null}>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab((t) => {
if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return t;
});
}}
/>
</React.Suspense>
}
>
{!hideStickerBtn && (
@@ -765,11 +778,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
: undefined
}
content={
<React.Suspense fallback={null}><GifPicker
apiKey={gifApiKey}
onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)}
/></React.Suspense>
<React.Suspense fallback={null}>
<GifPicker
apiKey={gifApiKey}
onSelect={handleGifSelect}
requestClose={() => setGifOpen(false)}
/>
</React.Suspense>
}
>
<IconButton
@@ -798,7 +813,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</UseStateProvider>
)}
{gifError && (
<Text size="T100" style={{ color: 'var(--tc-danger-normal)', padding: '2px 6px', alignSelf: 'center', whiteSpace: 'nowrap' }}>
<Text
size="T100"
style={{
color: 'var(--tc-danger-normal)',
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
}}
>
{gifError}
</Text>
)}
@@ -811,14 +834,28 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
title="Share location"
>
{locating ? (
<Text size="T200" style={{ fontWeight: 800, fontSize: '10px', letterSpacing: '0.04em', lineHeight: 1 }}>
<Text
size="T200"
style={{
fontWeight: 800,
fontSize: '10px',
letterSpacing: '0.04em',
lineHeight: 1,
}}
>
...
</Text>
) : (
<Icon src={Icons.Pin} size="100" />
)}
</IconButton>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300" aria-label="Send message">
<IconButton
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Send message"
>
<Icon src={Icons.Send} />
</IconButton>
</>
+277 -147
View File
@@ -637,7 +637,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt.
const _roomId = mEvt.getRoomId();
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
if (_roomId) requestAnimationFrame(() => markAsRead(mx, _roomId, hideActivity));
}
if (!document.hasFocus() && !unreadInfo) {
@@ -673,7 +673,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) => {
const evtTimeline = getEventTimeline(room, evtId);
const absoluteIndex =
evtTimeline && getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId);
evtTimeline &&
getEventIdAbsoluteIndex(timelineRef.current.linkedTimelines, evtTimeline, evtId);
if (typeof absoluteIndex === 'number') {
const scrolled = scrollToItem(absoluteIndex, {
@@ -1242,7 +1243,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getType() === 'm.poll.start' ||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
)
return <PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />;
return (
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
);
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
return (
<Text>
@@ -1371,7 +1378,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
)}
</Message>
);
@@ -1424,7 +1435,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<PollContent content={mEvent.getContent()} roomId={room.roomId} eventId={mEvent.getId() ?? undefined} />
<PollContent
content={mEvent.getContent()}
roomId={room.roomId}
eventId={mEvent.getId() ?? undefined}
/>
)}
</Message>
);
@@ -1608,12 +1623,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Lock}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{' enabled end-to-end encryption'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Lock}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{' enabled end-to-end encryption'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1623,14 +1664,45 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const joinRule = mEvent.getContent<{ join_rule?: string }>().join_rule ?? 'unknown';
const ruleLabel: Record<string, string> = { public: 'public', invite: 'invite-only', knock: 'knock', restricted: 'restricted' };
const ruleLabel: Record<string, string> = {
public: 'public',
invite: 'invite-only',
knock: 'knock',
restricted: 'restricted',
};
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Settings}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{` set room join rule to ${ruleLabel[joinRule] ?? joinRule}`}
</Text>
</Box>
}
/>
</Event>
);
@@ -1641,12 +1713,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const access = mEvent.getContent<{ guest_access?: string }>().guest_access ?? 'unknown';
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Settings}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Settings}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{access === 'can_join' ? ' allowed guest access' : ' disabled guest access'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1657,12 +1755,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const alias = mEvent.getContent<{ alias?: string }>().alias;
const timeJSX = (
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event key={mEvent.getId()} data-message-item={item} data-message-id={mEventId} room={room} mEvent={mEvent} highlight={highlighted} messageSpacing={messageSpacing} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} hideReadReceipts={hideActivity} showDeveloperTools={showDeveloperTools}>
<EventContent messageLayout={messageLayout} time={timeJSX} iconSrc={Icons.Hash}
content={<Box grow="Yes" direction="Column"><Text size="T300" priority="300"><b>{senderName}</b>{alias ? ` set room address to ${alias}` : ' removed room address'}</Text></Box>}
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
<b>{senderName}</b>
{alias ? ` set room address to ${alias}` : ' removed room address'}
</Text>
</Box>
}
/>
</Event>
);
@@ -1831,9 +1955,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) { hi = mid - 1; }
else if (item >= base + len) { lo = mid + 1; }
else { eventTimeline = timelineSegments[mid][2]; baseIndex = base; break; }
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
eventTimeline = timelineSegments[mid][2];
baseIndex = base;
break;
}
}
}
if (!eventTimeline) return null;
@@ -1935,131 +2065,131 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return (
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">Jump to Unread</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<Text size="L400">Jump to Unread</Text>
</Chip>
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">Mark as Read</Text>
</Chip>
</TimelineFloat>
)}
<Scroll ref={scrollRef} visibility="Hover">
<Box
direction="Column"
justifyContent="End"
style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</TimelineFloat>
)}
</Box>
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
}}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>
{!atBottom && (
<TimelineFloat position="Bottom">
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.ArrowBottom} />}
onClick={handleJumpToLatest}
>
<Text size="L400">Jump to Latest</Text>
</Chip>
</TimelineFloat>
)}
</Box>
</ReadPositionsContext.Provider>
);
}
+3 -4
View File
@@ -56,8 +56,6 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
return true;
};
export function RoomView({ eventId }: { eventId?: string }) {
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
@@ -98,8 +96,9 @@ export function RoomView({ eventId }: { eventId?: string }) {
)
);
const chatBgStyle = useMemo(
() => getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
const chatBgStyle = useMemo(
() =>
getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
[chatBackground, lotusTerminal, isDark]
);
+19 -7
View File
@@ -533,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick} aria-label="Search">
<IconButton
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
aria-label="Search"
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
@@ -596,11 +601,13 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</FocusTrap>
}
/>
{!room.isCallRoom() && livekitSupported && rtcSupported && hasCallPermission &&
(direct || (room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && (
<CallButton />
)}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
@@ -616,7 +623,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle} aria-label="Toggle member list">
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
+7 -1
View File
@@ -117,7 +117,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</>
)}
</Text>
<IconButton title="Drop Typing Status" aria-label="Drop typing status" size="300" radii="Pill" onClick={handleDropAll}>
<IconButton
title="Drop Typing Status"
aria-label="Drop typing status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>
@@ -57,7 +57,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
>
<Modal
size="400"
style={{ maxHeight: '440px', borderRadius: config.radii.R500, display: 'flex', flexDirection: 'column' }}
style={{
maxHeight: '440px',
borderRadius: config.radii.R500,
display: 'flex',
flexDirection: 'column',
}}
>
<Box
direction="Column"
@@ -89,11 +94,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
) : (
<Box grow="Yes" style={{ minHeight: 0 }}>
<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) => (
<MenuItem
key={room.roomId}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -141,7 +141,11 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
@@ -185,7 +185,12 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300" aria-label="Cancel">
<IconButton
size="300"
onClick={() => setAlertRemove(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+49 -21
View File
@@ -34,7 +34,13 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { ChatBackground, DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import {
ChatBackground,
DateFormat,
MessageLayout,
MessageSpacing,
settingsAtom,
} from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -313,7 +319,10 @@ function Appearance() {
const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
const [monochromeMode, setMonochromeMode] = useSetting(settingsAtom, 'monochromeMode');
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(settingsAtom, 'perMessageProfiles');
const [perMessageProfiles, setPerMessageProfiles] = useSetting(
settingsAtom,
'perMessageProfiles'
);
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
return (
@@ -359,7 +368,12 @@ function Appearance() {
<SettingTile title="Page Zoom" after={<PageZoomInput />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="200">
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<SettingTile
title="Chat Background"
description="Pattern applied behind the message timeline."
@@ -373,7 +387,9 @@ function Appearance() {
<SettingTile
title="Show Profile on Every Message"
description="Display avatar and name on each message instead of grouping consecutive messages."
after={<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />}
after={
<Switch variant="Primary" value={perMessageProfiles} onChange={setPerMessageProfiles} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
@@ -385,7 +401,10 @@ function Appearance() {
{lotusTerminal && (
<button
type="button"
onClick={() => { resetBootSequence(); runLotusBootSequence(true); }}
onClick={() => {
resetBootSequence();
runLotusBootSequence(true);
}}
title="Replay boot sequence"
style={{
background: 'transparent',
@@ -808,10 +827,12 @@ function Editor() {
);
}
function Calls() {
const [cameraOnJoin, setCameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
const [callNoiseSuppression, setCallNoiseSuppression] = useSetting(
settingsAtom,
'callNoiseSuppression'
);
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [listeningForKey, setListeningForKey] = useState(false);
@@ -843,7 +864,8 @@ function Calls() {
window.addEventListener('keydown', onKey, true);
}, [listeningForKey, setPttKey]);
const keyLabel = (code: string) => code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
const keyLabel = (code: string) =>
code === 'Space' ? 'Space' : code.replace('Key', '').replace('Digit', '');
return (
<Box direction="Column" gap="100">
@@ -859,10 +881,21 @@ function Calls() {
<SettingTile
title="Noise Suppression"
description="Apply AI noise suppression to filter background noise during calls (powered by Element Call)."
after={<Switch variant="Primary" value={callNoiseSuppression} onChange={setCallNoiseSuppression} />}
after={
<Switch
variant="Primary"
value={callNoiseSuppression}
onChange={setCallNoiseSuppression}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column" gap="400">
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Push to Talk"
description="Mute your microphone by default. Hold the PTT key to speak."
@@ -882,9 +915,7 @@ function Calls() {
onClick={handleKeyBind}
style={{ minWidth: '90px' }}
>
<Text size="B300">
{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}
</Text>
<Text size="B300">{listeningForKey ? 'Press a key…' : keyLabel(pttKey)}</Text>
</Button>
}
/>
@@ -894,7 +925,6 @@ function Calls() {
);
}
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const theme = useTheme();
@@ -915,18 +945,16 @@ function ChatBgGrid() {
height: toRem(50),
borderRadius: toRem(8),
cursor: 'pointer',
border: chatBackground === opt.value
? '2px solid #980000'
: '2px solid rgba(128,128,128,0.25)',
border:
chatBackground === opt.value
? '2px solid #980000'
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark),
}}
/>
<Text
size="T200"
style={chatBackground === opt.value ? { color: '#980000' } : undefined}
>
<Text size="T200" style={chatBackground === opt.value ? { color: '#980000' } : undefined}>
{opt.label}
</Text>
</Box>
@@ -120,7 +120,14 @@ function KeywordCross({ pushRule }: PushRulesProps) {
const removing = removeState.status === AsyncStatus.Loading;
return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing} aria-label="Remove keyword">
<IconButton
onClick={remove}
size="300"
radii="Pill"
variant="Secondary"
disabled={removing}
aria-label="Remove keyword"
>
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton>
);
@@ -117,7 +117,11 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background" aria-label="Close settings">
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
+22 -4
View File
@@ -53,10 +53,19 @@ export const createCallEmbed = (
MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
const initialAudio = forceAudioOff ? false : pref?.microphone ?? true;
const initialVideo = pref?.video ?? false;
const widget = CallEmbed.getWidget(mx, room, intent, themeKind, noiseSuppression, initialAudio, initialVideo);
const controlState = pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
const widget = CallEmbed.getWidget(
mx,
room,
intent,
themeKind,
noiseSuppression,
initialAudio,
initialVideo
);
const controlState =
pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
const embed = new CallEmbed(mx, room, widget, container, controlState);
@@ -77,7 +86,16 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref, callNoiseSuppression ?? true, !!pttMode);
const callEmbed = createCallEmbed(
mx,
room,
dm,
theme.kind,
container,
pref,
callNoiseSuppression ?? true,
!!pttMode
);
setCallEmbed(callEmbed);
},
+6 -3
View File
@@ -4,7 +4,10 @@ import { useState } from 'react';
export function useForceUpdate() {
const [data, setData] = useState(null);
return [data, function forceUpdateHook() {
setData({});
}];
return [
data,
function forceUpdateHook() {
setData({});
},
];
}
+8 -5
View File
@@ -55,11 +55,14 @@ export const usePan = (active: boolean) => {
}, [active]);
// Clean up document listeners if component unmounts during an active drag
useEffect(() => () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(
() => () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[]
);
return {
pan,
+1 -3
View File
@@ -23,9 +23,7 @@ function computePositions(room: Room, myUserId: string): Map<string, string[]> {
const map = new Map<string, string[]>();
const liveEvents = room.getLiveTimeline().getEvents();
// Build O(1) index once instead of O(T) findIndex per member
const eventIndex = new Map<string, number>(
liveEvents.map((e, i) => [e.getId() ?? '', i])
);
const eventIndex = new Map<string, number>(liveEvents.map((e, i) => [e.getId() ?? '', i]));
for (const member of room.getJoinedMembers()) {
if (member.userId === myUserId) continue;
const evtId = room.getEventReadUpTo(member.userId);
+13 -2
View File
@@ -1,7 +1,13 @@
import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, darkTheme, lotusTerminalLightTheme, lotusTerminalTheme, silverTheme } from '../../colors.css';
import {
butterTheme,
darkTheme,
lotusTerminalLightTheme,
lotusTerminalTheme,
silverTheme,
} from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@@ -45,7 +51,12 @@ export const LotusTerminalTheme: Theme = {
export const LotusTerminalLightTheme: Theme = {
id: 'lotus-terminal-light-theme',
kind: ThemeKind.Light,
classNames: ['lotus-terminal-light-theme', lotusTerminalLightTheme, onLightFontWeight, 'prism-light'],
classNames: [
'lotus-terminal-light-theme',
lotusTerminalLightTheme,
onLightFontWeight,
'prism-light',
],
};
export const useThemes = (): Theme[] => {
+120 -33
View File
@@ -10,10 +10,12 @@ import {
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
const AuthLayout = React.lazy(() => import('./auth').then(m => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then(m => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then(m => ({ default: m.Register })));
const ResetPassword = React.lazy(() => import('./auth').then(m => ({ default: m.ResetPassword })));
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
const ResetPassword = React.lazy(() =>
import('./auth').then((m) => ({ default: m.ResetPassword }))
);
import {
DIRECT_PATH,
EXPLORE_PATH,
@@ -48,9 +50,8 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
const Room = React.lazy(() => import('../features/room').then(m => ({ default: m.Room })));
const Room = React.lazy(() => import('../features/room').then((m) => ({ default: m.Room })));
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -62,22 +63,38 @@ import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
const RoomSettingsRenderer = React.lazy(() => import('../features/room-settings').then(m => ({ default: m.RoomSettingsRenderer })));
const RoomSettingsRenderer = React.lazy(() =>
import('../features/room-settings').then((m) => ({ default: m.RoomSettingsRenderer }))
);
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
const SpaceSettingsRenderer = React.lazy(() => import('../features/space-settings').then(m => ({ default: m.SpaceSettingsRenderer })));
const SpaceSettingsRenderer = React.lazy(() =>
import('../features/space-settings').then((m) => ({ default: m.SpaceSettingsRenderer }))
);
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
const CreateRoomModalRenderer = React.lazy(() => import('../features/create-room').then(m => ({ default: m.CreateRoomModalRenderer })));
const CreateRoomModalRenderer = React.lazy(() =>
import('../features/create-room').then((m) => ({ default: m.CreateRoomModalRenderer }))
);
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
const CreateSpaceModalRenderer = React.lazy(() => import('../features/create-space').then(m => ({ default: m.CreateSpaceModalRenderer })));
const SearchModalRenderer = React.lazy(() => import('../features/search').then(m => ({ default: m.SearchModalRenderer })));
const Explore = React.lazy(() => import('./client/explore').then(m => ({ default: m.Explore })));
const FeaturedRooms = React.lazy(() => import('./client/explore').then(m => ({ default: m.FeaturedRooms })));
const PublicRooms = React.lazy(() => import('./client/explore').then(m => ({ default: m.PublicRooms })));
const Notifications = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Notifications })));
const Inbox = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Inbox })));
const Invites = React.lazy(() => import('./client/inbox').then(m => ({ default: m.Invites })));
const Lobby = React.lazy(() => import('../features/lobby').then(m => ({ default: m.Lobby })));
const CreateSpaceModalRenderer = React.lazy(() =>
import('../features/create-space').then((m) => ({ default: m.CreateSpaceModalRenderer }))
);
const SearchModalRenderer = React.lazy(() =>
import('../features/search').then((m) => ({ default: m.SearchModalRenderer }))
);
const Explore = React.lazy(() => import('./client/explore').then((m) => ({ default: m.Explore })));
const FeaturedRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.FeaturedRooms }))
);
const PublicRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.PublicRooms }))
);
const Notifications = React.lazy(() =>
import('./client/inbox').then((m) => ({ default: m.Notifications }))
);
const Inbox = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Inbox })));
const Invites = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Invites })));
const Lobby = React.lazy(() => import('../features/lobby').then((m) => ({ default: m.Lobby })));
import { getFallbackSession } from '../state/sessions';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
@@ -112,9 +129,30 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</React.Suspense>
}
>
<Route path={LOGIN_PATH} element={<React.Suspense fallback={null}><Login /></React.Suspense>} />
<Route path={REGISTER_PATH} element={<React.Suspense fallback={null}><Register /></React.Suspense>} />
<Route path={RESET_PASSWORD_PATH} element={<React.Suspense fallback={null}><ResetPassword /></React.Suspense>} />
<Route
path={LOGIN_PATH}
element={
<React.Suspense fallback={null}>
<Login />
</React.Suspense>
}
/>
<Route
path={REGISTER_PATH}
element={
<React.Suspense fallback={null}>
<Register />
</React.Suspense>
}
/>
<Route
path={RESET_PASSWORD_PATH}
element={
<React.Suspense fallback={null}>
<ResetPassword />
</React.Suspense>
}
/>
</Route>
<Route
@@ -149,12 +187,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
</ClientLayout>
<CallStatusRenderer />
</CallEmbedProvider>
<React.Suspense fallback={null}><SearchModalRenderer /></React.Suspense>
<React.Suspense fallback={null}>
<SearchModalRenderer />
</React.Suspense>
<UserRoomProfileRenderer />
<React.Suspense fallback={null}><CreateRoomModalRenderer /></React.Suspense>
<React.Suspense fallback={null}><CreateSpaceModalRenderer /></React.Suspense>
<React.Suspense fallback={null}><RoomSettingsRenderer /></React.Suspense>
<React.Suspense fallback={null}><SpaceSettingsRenderer /></React.Suspense>
<React.Suspense fallback={null}>
<CreateRoomModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<CreateSpaceModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<RoomSettingsRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<SpaceSettingsRenderer />
</React.Suspense>
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
@@ -250,7 +298,14 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_LOBBY_PATH} element={<React.Suspense fallback={null}><Lobby /></React.Suspense>} />
<Route
path={_LOBBY_PATH}
element={
<React.Suspense fallback={null}>
<Lobby />
</React.Suspense>
}
/>
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
@@ -269,7 +324,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}>
<React.Suspense fallback={null}><Explore /></React.Suspense>
<React.Suspense fallback={null}>
<Explore />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
@@ -284,8 +341,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_FEATURED_PATH} element={<React.Suspense fallback={null}><FeaturedRooms /></React.Suspense>} />
<Route path={_SERVER_PATH} element={<React.Suspense fallback={null}><PublicRooms /></React.Suspense>} />
<Route
path={_FEATURED_PATH}
element={
<React.Suspense fallback={null}>
<FeaturedRooms />
</React.Suspense>
}
/>
<Route
path={_SERVER_PATH}
element={
<React.Suspense fallback={null}>
<PublicRooms />
</React.Suspense>
}
/>
</Route>
<Route path={CREATE_PATH} element={<Create />} />
<Route
@@ -294,7 +365,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<PageRoot
nav={
<MobileFriendlyPageNav path={INBOX_PATH}>
<React.Suspense fallback={null}><Inbox /></React.Suspense>
<React.Suspense fallback={null}>
<Inbox />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
@@ -309,8 +382,22 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
element={<WelcomePage />}
/>
)}
<Route path={_NOTIFICATIONS_PATH} element={<React.Suspense fallback={null}><Notifications /></React.Suspense>} />
<Route path={_INVITES_PATH} element={<React.Suspense fallback={null}><Invites /></React.Suspense>} />
<Route
path={_NOTIFICATIONS_PATH}
element={
<React.Suspense fallback={null}>
<Notifications />
</React.Suspense>
}
/>
<Route
path={_INVITES_PATH}
element={
<React.Suspense fallback={null}>
<Invites />
</React.Suspense>
}
/>
</Route>
</Route>
<Route path="/*" element={<p>Page not found</p>} />
+6 -2
View File
@@ -25,7 +25,9 @@ export function UnAuthRouteThemeManager() {
if (lotusTerminal) {
const isLight = systemThemeKind === ThemeKind.Light;
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
document.body.classList.add(...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames);
document.body.classList.add(
...(isLight ? LotusTerminalLightTheme : LotusTerminalTheme).classNames
);
document.body.classList.add(lotusTerminalBodyClass);
} else {
document.documentElement.removeAttribute('data-theme');
@@ -47,7 +49,9 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) {
const terminalIsLight = lotusTerminal && activeTheme.kind === ThemeKind.Light;
const effectiveTheme = lotusTerminal
? (terminalIsLight ? LotusTerminalLightTheme : LotusTerminalTheme)
? terminalIsLight
? LotusTerminalLightTheme
: LotusTerminalTheme
: activeTheme;
// Boot animation only fires when lotusTerminal is toggled on, not on every theme change
+7 -1
View File
@@ -18,7 +18,13 @@ export function AuthFooter() {
>
v{pkg.version}
</Text>
<Text as="a" size="T300" href="https://matrix.lotusguild.org" target="_blank" rel="noreferrer">
<Text
as="a"
size="T300"
href="https://matrix.lotusguild.org"
target="_blank"
rel="noreferrer"
>
Community
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
+3 -1
View File
@@ -135,7 +135,9 @@ export function AuthLayout() {
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={LotusLogo} alt="Lotus Chat Logo" />
<Text as="h1" size="H3">Lotus Chat</Text>
<Text as="h1" size="H3">
Lotus Chat
</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
@@ -230,7 +230,15 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
<Text as="label" htmlFor="passwordInput" size="L400" priority="300">
Password
</Text>
<PasswordInput id="passwordInput" name="passwordInput" aria-label="Password" variant="Background" size="500" outlined required />
<PasswordInput
id="passwordInput"
name="passwordInput"
aria-label="Password"
variant="Background"
size="500"
outlined
required
/>
<Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
{loginState.status === AsyncStatus.Error && (
<>
+2 -2
View File
@@ -118,8 +118,8 @@ export const useLoginComplete = (data?: CustomLoginResponse) => {
const afterLoginRedirectUrl = getAfterLoginRedirectPath();
deleteAfterLoginRedirectPath();
const _redir = afterLoginRedirectUrl;
const _safePath = (_redir && /^\/(?!\/)/.test(_redir)) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
const _safePath = _redir && /^\/(?!\/)/.test(_redir) ? _redir : getHomePath();
navigate(_safePath, { replace: true });
}
}, [data, navigate]);
};
+12 -4
View File
@@ -21,14 +21,22 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
borderRadius: '0 0 4px 0',
transition: 'top 0.1s',
}}
onFocus={(e) => { (e.currentTarget as HTMLElement).style.top = '0'; }}
onBlur={(e) => { (e.currentTarget as HTMLElement).style.top = '-40px'; }}
onFocus={(e) => {
(e.currentTarget as HTMLElement).style.top = '0';
}}
onBlur={(e) => {
(e.currentTarget as HTMLElement).style.top = '-40px';
}}
>
Skip to main content
</a>
<Box grow="Yes">
<Box shrink="No" as="nav" aria-label="Room navigation">{nav}</Box>
<Box grow="Yes" as="main" id="main-content">{children}</Box>
<Box shrink="No" as="nav" aria-label="Room navigation">
{nav}
</Box>
<Box grow="Yes" as="main" id="main-content">
{children}
</Box>
</Box>
</>
);
+2 -1
View File
@@ -22,7 +22,8 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children:
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Unable to connect to the homeserver. The homeserver or your internet connection may be down.
Unable to connect to the homeserver. The homeserver or your internet connection
may be down.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
+9 -1
View File
@@ -15,7 +15,15 @@ export function WelcomePage() {
>
<PageHeroSection>
<PageHero
icon={<img width="70" height="70" src={LotusLogo} alt="Lotus Chat" style={{ objectFit: "contain" }} />}
icon={
<img
width="70"
height="70"
src={LotusLogo}
alt="Lotus Chat"
style={{ objectFit: 'contain' }}
/>
}
title="Welcome to Lotus Chat"
subTitle={
<span>
+7 -1
View File
@@ -108,7 +108,13 @@ function DirectHeader() {
</Text>
</Box>
<Box>
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Direct messages options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Direct messages options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
+16 -3
View File
@@ -94,9 +94,16 @@ export function AddServer() {
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4">Add Server</Text>
<Text as="h2" size="H4">
Add Server
</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300" aria-label="Close">
<IconButton
size="300"
onClick={() => setDialog(false)}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -110,7 +117,13 @@ export function AddServer() {
<Text priority="400">Add server name to explore public communities.</Text>
<Box direction="Column" gap="100">
<Text size="L400">Server Name</Text>
<Input ref={serverInputRef} name="serverInput" aria-label="Server name" variant="Background" required />
<Input
ref={serverInputRef}
name="serverInput"
aria-label="Server name"
variant="Background"
required
/>
{exploreState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to load public rooms. Please try again.
+6 -2
View File
@@ -56,7 +56,9 @@ export function FeaturedRooms() {
<Box direction="Column" gap="700">
{spaces && spaces.length > 0 && (
<Box direction="Column" gap="400">
<Text as="h2" size="H4">Featured Spaces</Text>
<Text as="h2" size="H4">
Featured Spaces
</Text>
<RoomCardGrid>
{spaces.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
@@ -85,7 +87,9 @@ export function FeaturedRooms() {
)}
{rooms && rooms.length > 0 && (
<Box direction="Column" gap="400">
<Text as="h3" size="H4">Featured Rooms</Text>
<Text as="h3" size="H4">
Featured Rooms
</Text>
<RoomCardGrid>
{rooms.map((roomIdOrAlias) => (
<RoomSummaryLoader key={roomIdOrAlias} roomIdOrAlias={roomIdOrAlias}>
+3 -1
View File
@@ -537,7 +537,9 @@ export function PublicRooms() {
{isSearch ? (
<Text as="h3" size="H4">{`Results for "${serverSearchParams.term}"`}</Text>
) : (
<Text as="h3" size="H4">Popular Communities</Text>
<Text as="h3" size="H4">
Popular Communities
</Text>
)}
<Box gap="200">
{roomTypeFilters.map((filter) => (
+7 -1
View File
@@ -122,7 +122,13 @@ function HomeHeader() {
</Text>
</Box>
<Box>
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Home options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Home options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
+9 -3
View File
@@ -429,7 +429,9 @@ function KnownInvites({
}: KnownInvitesProps) {
return (
<Box direction="Column" gap="200">
<Text as="h3" size="H4">Primary</Text>
<Text as="h3" size="H4">
Primary
</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
{invites.map((invite) => (
@@ -488,7 +490,9 @@ function UnknownInvites({
return (
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
<Text as="h3" size="H4">Public</Text>
<Text as="h3" size="H4">
Public
</Text>
<Box>
{invites.length > 0 && (
<Chip
@@ -585,7 +589,9 @@ function SpamInvites({
return (
<Box direction="Column" gap="200">
<Text as="h3" size="H4">Spam</Text>
<Text as="h3" size="H4">
Spam
</Text>
{invites.length > 0 ? (
<Box direction="Column" gap="100">
<SequenceCard
+7 -1
View File
@@ -522,7 +522,13 @@ function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose} aria-label="Close folder">
<IconButton
data-id={folder.id}
size="300"
variant="Background"
onClick={onClose}
aria-label="Close folder"
>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
+10 -2
View File
@@ -274,7 +274,13 @@ function SpaceHeader() {
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
</Box>
<Box shrink="No">
<IconButton aria-expanded={!!menuAnchor} aria-haspopup="menu" variant="Background" onClick={handleOpenMenu} aria-label="Space options">
<IconButton
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
variant="Background"
onClick={handleOpenMenu}
aria-label="Space options"
>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
@@ -433,7 +439,9 @@ export function Space() {
return false;
}
const showRoomAnyway =
roomsWithUnreadSet.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
roomsWithUnreadSet.has(roomId) ||
roomId === selectedRoomId ||
callEmbed?.roomId === roomId;
return !showRoomAnyway;
},
[space.roomId, closedCategories, roomsWithUnreadSet, selectedRoomId, callEmbed]
+14 -3
View File
@@ -30,7 +30,10 @@ export class CallControl extends EventEmitter implements CallControlState {
private get settingsButton(): HTMLElement | undefined {
// EC 0.19.3: settings button has data-testid="settings-bottom-center"
return (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? undefined;
return (
(this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ??
undefined
);
}
private get reactionsButton(): HTMLElement | undefined {
@@ -98,7 +101,13 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public async forceState(desired: CallControlState) {
this.state = new CallControlState(desired.microphone, desired.video, desired.sound, this.screenshare, this.spotlight);
this.state = new CallControlState(
desired.microphone,
desired.video,
desired.sound,
this.screenshare,
this.spotlight
);
await this.applyState();
}
@@ -177,7 +186,9 @@ export class CallControl extends EventEmitter implements CallControlState {
// EC auto-switches to spotlight when screenshare starts — revert to grid
if (!prevScreenshare && screenshare) {
setTimeout(() => { if (this.spotlight) this.gridButton?.click(); }, 600);
setTimeout(() => {
if (this.spotlight) this.gridButton?.click();
}, 600);
}
}
+3 -1
View File
@@ -462,7 +462,9 @@ export const getReactCustomHtmlParser = (
{...props}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any); }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any);
}}
onClick={params.handleSpoilerClick}
className={css.Spoiler()}
aria-label="Spoiler — click to reveal"
+8 -5
View File
@@ -6,9 +6,9 @@ export type ListAction<T> =
item: T | T[];
}
| {
type: 'REPLACE';
item: T;
replacement: T;
type: 'REPLACE';
item: T;
replacement: T;
}
| {
type: 'DELETE';
@@ -34,9 +34,12 @@ export const createListAtom = <T>() => {
return;
}
if (action.type === 'REPLACE') {
set(baseListAtom, items.map((item) => item === action.item ? action.replacement : item));
set(
baseListAtom,
items.map((item) => (item === action.item ? action.replacement : item))
);
}
}
);
};
export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
export type TListAtom<T> = ReturnType<typeof createListAtom<T>>;
+1 -3
View File
@@ -21,9 +21,7 @@ export type TUploadItem = {
export type TUploadListAtom = ReturnType<typeof createListAtom<TUploadItem>>;
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(
createListAtom
);
export const roomIdToUploadItemsAtomFamily = atomFamily<string, TUploadListAtom>(createListAtom);
export const roomUploadAtomFamily = createUploadAtomFamily();
+23 -2
View File
@@ -9,7 +9,24 @@ export type DateFormat =
| 'YYYY-MM-DD'
| '';
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' | 'waves' | 'neon' | 'aurora';
export type ChatBackground =
| 'none'
| 'blueprint'
| 'carbon'
| 'stars'
| 'topographic'
| 'herringbone'
| 'crosshatch'
| 'chevron'
| 'polka'
| 'triangles'
| 'plaid'
| 'tactical'
| 'circuit'
| 'hexgrid'
| 'waves'
| 'neon'
| 'aurora';
export enum MessageLayout {
Modern = 0,
Compact = 1,
@@ -114,7 +131,11 @@ export const getSettings = (): Settings => {
};
export const setSettings = (settings: Settings) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* quota */ }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
/* quota */
}
};
const baseSettings = atom<Settings>(getSettings());
+18 -2
View File
@@ -84,7 +84,15 @@ const transformFontTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
@@ -92,7 +100,15 @@ const transformSpanTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
style: `${attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color']) ? `background-color: ${attribs['data-mx-bg-color']};` : ''} ${attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color']) ? `color: ${attribs['data-mx-color']}` : ''}`.trim(),
style: `${
attribs['data-mx-bg-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-bg-color'])
? `background-color: ${attribs['data-mx-bg-color']};`
: ''
} ${
attribs['data-mx-color'] && /^#[0-9a-fA-F]{3,8}$/.test(attribs['data-mx-color'])
? `color: ${attribs['data-mx-color']}`
: ''
}`.trim(),
},
});
-1
View File
@@ -421,4 +421,3 @@ export const lotusTerminalLightTheme = createTheme(color, {
Overlay: 'rgba(237, 240, 245, 0.97)',
},
});
+197 -159
View File
@@ -2,16 +2,16 @@ import { globalStyle, keyframes, style } from '@vanilla-extract/css';
const glitch1 = keyframes({
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
'92%': { clipPath: 'inset(15% 0 72% 0)', transform: 'skewX(-4deg)' },
'94%': { clipPath: 'inset(54% 0 22% 0)', transform: 'skewX(3deg)' },
'96%': { clipPath: 'inset(30% 0 48% 0)', transform: 'skewX(-2deg)' },
'92%': { clipPath: 'inset(15% 0 72% 0)', transform: 'skewX(-4deg)' },
'94%': { clipPath: 'inset(54% 0 22% 0)', transform: 'skewX(3deg)' },
'96%': { clipPath: 'inset(30% 0 48% 0)', transform: 'skewX(-2deg)' },
});
const glitch2 = keyframes({
'0%, 90%, 100%': { clipPath: 'inset(0)', transform: 'skewX(0)' },
'92%': { clipPath: 'inset(60% 0 8% 0)', transform: 'skewX(3deg)' },
'94%': { clipPath: 'inset(8% 0 68% 0)', transform: 'skewX(-3deg)' },
'96%': { clipPath: 'inset(42% 0 38% 0)', transform: 'skewX(1deg)' },
'92%': { clipPath: 'inset(60% 0 8% 0)', transform: 'skewX(3deg)' },
'94%': { clipPath: 'inset(8% 0 68% 0)', transform: 'skewX(-3deg)' },
'96%': { clipPath: 'inset(42% 0 38% 0)', transform: 'skewX(1deg)' },
});
export const lotusTerminalBodyClass = style({
@@ -23,67 +23,67 @@ export const lotusTerminalBodyClass = style({
backgroundSize: '28px 28px',
vars: {
// Backgrounds
'--lt-bg-primary': '#030508',
'--lt-bg-primary': '#030508',
'--lt-bg-secondary': '#060c14',
'--lt-bg-tertiary': '#0d1520',
'--lt-bg-card': '#07101a',
'--lt-bg-terminal': '#010304',
'--lt-bg-tertiary': '#0d1520',
'--lt-bg-card': '#07101a',
'--lt-bg-terminal': '#010304',
// Accent — Orange
'--lt-accent-orange': '#FF6B00',
'--lt-accent-orange': '#FF6B00',
'--lt-accent-orange-bright': '#FF8C2B',
'--lt-accent-orange-dim': 'rgba(255,107,0,0.12)',
'--lt-accent-orange-dim': 'rgba(255,107,0,0.12)',
'--lt-accent-orange-border': 'rgba(255,107,0,0.35)',
// Accent — Amber
'--lt-accent-amber': '#FFB300',
'--lt-accent-amber': '#FFB300',
'--lt-accent-amber-dim': 'rgba(255,179,0,0.10)',
// Accent — Cyan
'--lt-accent-cyan': '#00D4FF',
'--lt-accent-cyan': '#00D4FF',
'--lt-accent-cyan-bright': '#33DFFF',
'--lt-accent-cyan-dim': 'rgba(0,212,255,0.10)',
'--lt-accent-cyan-dim': 'rgba(0,212,255,0.10)',
'--lt-accent-cyan-border': 'rgba(0,212,255,0.22)',
// Accent — Green
'--lt-accent-green': '#00FF88',
'--lt-accent-green': '#00FF88',
'--lt-accent-green-bright': '#33FFAA',
'--lt-accent-green-dim': 'rgba(0,255,136,0.10)',
'--lt-accent-green-dim': 'rgba(0,255,136,0.10)',
'--lt-accent-green-border': 'rgba(0,255,136,0.22)',
// Accent — Red
'--lt-accent-red': '#FF2D55',
'--lt-accent-red': '#FF2D55',
'--lt-accent-red-dim': 'rgba(255,45,85,0.12)',
// Accent — Gold
'--lt-accent-gold': '#FFD700',
'--lt-accent-gold': '#FFD700',
'--lt-accent-gold-dim': 'rgba(255,215,0,0.10)',
// Accent — Purple
'--lt-accent-purple': '#BF5FFF',
'--lt-accent-purple': '#BF5FFF',
'--lt-accent-purple-dim': 'rgba(191,95,255,0.10)',
// Text
'--lt-text-primary': '#c4d9ee',
'--lt-text-primary': '#c4d9ee',
'--lt-text-secondary': '#7fa3bf',
'--lt-text-muted': '#3e607a',
'--lt-text-dim': '#1e3347',
'--lt-text-muted': '#3e607a',
'--lt-text-dim': '#1e3347',
// Borders
'--lt-border-color': 'rgba(0,212,255,0.16)',
'--lt-border-color-hi': '#00D4FF',
'--lt-border-color': 'rgba(0,212,255,0.16)',
'--lt-border-color-hi': '#00D4FF',
'--lt-border-color-dim': 'rgba(0,212,255,0.07)',
// Glows — text
'--lt-glow-orange': '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
'--lt-glow-orange-intense':'0 0 8px #FF6B00, 0 0 22px #FF6B00, 0 0 40px rgba(255,107,0,0.45)',
'--lt-glow-cyan': '0 0 6px #00D4FF, 0 0 16px rgba(0,212,255,0.45)',
'--lt-glow-cyan-intense': '0 0 8px #00D4FF, 0 0 22px #00D4FF, 0 0 38px rgba(0,212,255,0.35)',
'--lt-glow-green': '0 0 6px #00FF88, 0 0 16px rgba(0,255,136,0.45)',
'--lt-glow-orange': '0 0 6px #FF6B00, 0 0 16px rgba(255,107,0,0.55)',
'--lt-glow-orange-intense': '0 0 8px #FF6B00, 0 0 22px #FF6B00, 0 0 40px rgba(255,107,0,0.45)',
'--lt-glow-cyan': '0 0 6px #00D4FF, 0 0 16px rgba(0,212,255,0.45)',
'--lt-glow-cyan-intense': '0 0 8px #00D4FF, 0 0 22px #00D4FF, 0 0 38px rgba(0,212,255,0.35)',
'--lt-glow-green': '0 0 6px #00FF88, 0 0 16px rgba(0,255,136,0.45)',
'--lt-glow-green-intense': '0 0 8px #00FF88, 0 0 22px #00FF88, 0 0 36px rgba(0,255,136,0.35)',
'--lt-glow-amber': '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)',
'--lt-glow-amber': '0 0 6px #FFB300, 0 0 14px rgba(255,179,0,0.40)',
'--lt-glow-amber-intense': '0 0 8px #FFB300, 0 0 20px #FFB300, 0 0 34px rgba(255,179,0,0.45)',
'--lt-glow-red': '0 0 6px #FF2D55, 0 0 16px rgba(255,45,85,0.45)',
'--lt-glow-red': '0 0 6px #FF2D55, 0 0 16px rgba(255,45,85,0.45)',
// Glows — box
'--lt-box-glow-orange': '0 0 18px rgba(255,107,0,0.22), 0 0 36px rgba(255,107,0,0.08)',
'--lt-box-glow-cyan': '0 0 18px rgba(0,212,255,0.18), 0 0 36px rgba(0,212,255,0.06)',
'--lt-box-glow-green': '0 0 18px rgba(0,255,136,0.18), 0 0 36px rgba(0,255,136,0.06)',
'--lt-box-glow-red': '0 0 18px rgba(255,45,85,0.22), 0 0 36px rgba(255,45,85,0.08)',
'--lt-box-glow-amber': '0 0 18px rgba(255,179,0,0.18), 0 0 36px rgba(255,179,0,0.06)',
'--lt-box-glow-cyan': '0 0 18px rgba(0,212,255,0.18), 0 0 36px rgba(0,212,255,0.06)',
'--lt-box-glow-green': '0 0 18px rgba(0,255,136,0.18), 0 0 36px rgba(0,255,136,0.06)',
'--lt-box-glow-red': '0 0 18px rgba(255,45,85,0.22), 0 0 36px rgba(255,45,85,0.08)',
'--lt-box-glow-amber': '0 0 18px rgba(255,179,0,0.18), 0 0 36px rgba(255,179,0,0.06)',
// Fonts
'--lt-font-mono': "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
'--lt-font-mono': "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
'--lt-font-display': "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
'--lt-font-crt': "'VT323', 'Courier New', monospace",
'--lt-font-crt': "'VT323', 'Courier New', monospace",
} as any,
});
@@ -97,7 +97,8 @@ globalStyle(`body.${lotusTerminalBodyClass}::before`, {
content: "''",
position: 'fixed',
inset: 0,
background: 'repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 3px)',
background:
'repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 3px)',
pointerEvents: 'none',
zIndex: 9999,
});
@@ -250,8 +251,7 @@ globalStyle(`body.${lotusTerminalBodyClass} hr`, {
// ── Input / textarea / contenteditable focus — orange glow ─────────────────
globalStyle(
`body.${lotusTerminalBodyClass} input:focus,` +
`body.${lotusTerminalBodyClass} textarea:focus`,
`body.${lotusTerminalBodyClass} input:focus,` + `body.${lotusTerminalBodyClass} textarea:focus`,
{
outline: 'none',
borderColor: '#FF6B00',
@@ -353,8 +353,6 @@ globalStyle(`body.${lotusTerminalBodyClass}`, {
color: '#c4d9ee',
});
// ── Reaction chips (emoji reactions on messages) ────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} button[data-reaction-key]`, {
backgroundColor: 'rgba(0,212,255,0.06)',
@@ -391,66 +389,66 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass}`, {
backgroundImage: 'radial-gradient(circle, rgba(90,110,150,0.14) 1px, transparent 1px)',
color: '#111827',
vars: {
'--lt-bg-primary': '#edf0f5',
'--lt-bg-secondary': '#e2e7ef',
'--lt-bg-tertiary': '#d4dae6',
'--lt-bg-card': '#ffffff',
'--lt-bg-terminal': '#f4f6fa',
'--lt-accent-orange': '#c44e00',
'--lt-accent-orange-bright':'#d45800',
'--lt-accent-orange-dim': 'rgba(196,78,0,0.10)',
'--lt-accent-orange-border':'rgba(196,78,0,0.28)',
'--lt-accent-amber': '#8a5a00',
'--lt-accent-amber-dim': 'rgba(138,90,0,0.10)',
'--lt-accent-cyan': '#0062b8',
'--lt-accent-cyan-bright': '#0070cc',
'--lt-accent-cyan-dim': 'rgba(0,98,184,0.10)',
'--lt-accent-cyan-border': 'rgba(0,98,184,0.22)',
'--lt-accent-green': '#006d35',
'--lt-bg-primary': '#edf0f5',
'--lt-bg-secondary': '#e2e7ef',
'--lt-bg-tertiary': '#d4dae6',
'--lt-bg-card': '#ffffff',
'--lt-bg-terminal': '#f4f6fa',
'--lt-accent-orange': '#c44e00',
'--lt-accent-orange-bright': '#d45800',
'--lt-accent-orange-dim': 'rgba(196,78,0,0.10)',
'--lt-accent-orange-border': 'rgba(196,78,0,0.28)',
'--lt-accent-amber': '#8a5a00',
'--lt-accent-amber-dim': 'rgba(138,90,0,0.10)',
'--lt-accent-cyan': '#0062b8',
'--lt-accent-cyan-bright': '#0070cc',
'--lt-accent-cyan-dim': 'rgba(0,98,184,0.10)',
'--lt-accent-cyan-border': 'rgba(0,98,184,0.22)',
'--lt-accent-green': '#006d35',
'--lt-accent-green-bright': '#007d3e',
'--lt-accent-green-dim': 'rgba(0,109,53,0.10)',
'--lt-accent-green-dim': 'rgba(0,109,53,0.10)',
'--lt-accent-green-border': 'rgba(0,109,53,0.22)',
'--lt-accent-red': '#b5001f',
'--lt-accent-red-dim': 'rgba(181,0,31,0.12)',
'--lt-accent-gold': '#8a5a00',
'--lt-accent-gold-dim': 'rgba(138,90,0,0.10)',
'--lt-accent-purple': '#6b2fb8',
'--lt-accent-purple-dim': 'rgba(107,47,184,0.10)',
'--lt-text-primary': '#111827',
'--lt-text-secondary': '#2d3d56',
'--lt-text-muted': '#5a6e8c',
'--lt-text-dim': '#8a9ab8',
'--lt-border-color': 'rgba(50,80,130,0.18)',
'--lt-border-color-hi': '#0062b8',
'--lt-border-color-dim': 'rgba(50,80,130,0.09)',
'--lt-glow-orange': '0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18)',
'--lt-accent-red': '#b5001f',
'--lt-accent-red-dim': 'rgba(181,0,31,0.12)',
'--lt-accent-gold': '#8a5a00',
'--lt-accent-gold-dim': 'rgba(138,90,0,0.10)',
'--lt-accent-purple': '#6b2fb8',
'--lt-accent-purple-dim': 'rgba(107,47,184,0.10)',
'--lt-text-primary': '#111827',
'--lt-text-secondary': '#2d3d56',
'--lt-text-muted': '#5a6e8c',
'--lt-text-dim': '#8a9ab8',
'--lt-border-color': 'rgba(50,80,130,0.18)',
'--lt-border-color-hi': '#0062b8',
'--lt-border-color-dim': 'rgba(50,80,130,0.09)',
'--lt-glow-orange': '0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18)',
'--lt-glow-orange-intense': '0 0 0 2px rgba(196,78,0,0.35), 0 2px 10px rgba(196,78,0,0.25)',
'--lt-glow-cyan': '0 0 0 1px rgba(0,98,184,0.25), 0 1px 6px rgba(0,98,184,0.18)',
'--lt-glow-cyan-intense': '0 0 0 2px rgba(0,98,184,0.35), 0 2px 10px rgba(0,98,184,0.25)',
'--lt-glow-green': '0 0 0 1px rgba(0,109,53,0.25), 0 1px 6px rgba(0,109,53,0.18)',
'--lt-glow-green-intense': '0 0 0 2px rgba(0,109,53,0.35), 0 2px 10px rgba(0,109,53,0.25)',
'--lt-glow-amber': '0 0 0 1px rgba(138,90,0,0.25), 0 1px 6px rgba(138,90,0,0.18)',
'--lt-glow-amber-intense': '0 0 0 2px rgba(138,90,0,0.35), 0 2px 10px rgba(138,90,0,0.25)',
'--lt-glow-red': '0 0 0 1px rgba(181,0,31,0.25), 0 1px 6px rgba(181,0,31,0.18)',
'--lt-box-glow-orange': '0 0 0 2px rgba(196,78,0,0.22), 0 2px 8px rgba(196,78,0,0.12)',
'--lt-box-glow-cyan': '0 0 0 2px rgba(0,98,184,0.22), 0 2px 8px rgba(0,98,184,0.12)',
'--lt-box-glow-green': '0 0 0 2px rgba(0,109,53,0.22), 0 2px 8px rgba(0,109,53,0.12)',
'--lt-box-glow-red': '0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12)',
'--lt-box-glow-amber': '0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12)',
'--lt-glow-cyan': '0 0 0 1px rgba(0,98,184,0.25), 0 1px 6px rgba(0,98,184,0.18)',
'--lt-glow-cyan-intense': '0 0 0 2px rgba(0,98,184,0.35), 0 2px 10px rgba(0,98,184,0.25)',
'--lt-glow-green': '0 0 0 1px rgba(0,109,53,0.25), 0 1px 6px rgba(0,109,53,0.18)',
'--lt-glow-green-intense': '0 0 0 2px rgba(0,109,53,0.35), 0 2px 10px rgba(0,109,53,0.25)',
'--lt-glow-amber': '0 0 0 1px rgba(138,90,0,0.25), 0 1px 6px rgba(138,90,0,0.18)',
'--lt-glow-amber-intense': '0 0 0 2px rgba(138,90,0,0.35), 0 2px 10px rgba(138,90,0,0.25)',
'--lt-glow-red': '0 0 0 1px rgba(181,0,31,0.25), 0 1px 6px rgba(181,0,31,0.18)',
'--lt-box-glow-orange': '0 0 0 2px rgba(196,78,0,0.22), 0 2px 8px rgba(196,78,0,0.12)',
'--lt-box-glow-cyan': '0 0 0 2px rgba(0,98,184,0.22), 0 2px 8px rgba(0,98,184,0.12)',
'--lt-box-glow-green': '0 0 0 2px rgba(0,109,53,0.22), 0 2px 8px rgba(0,109,53,0.12)',
'--lt-box-glow-red': '0 0 0 2px rgba(181,0,31,0.22), 0 2px 8px rgba(181,0,31,0.12)',
'--lt-box-glow-amber': '0 0 0 2px rgba(138,90,0,0.22), 0 2px 8px rgba(138,90,0,0.12)',
} as any,
});
// Scanlines + vignette: OFF in light mode (base.css:3676-3678)
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass}::before,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass}::after`,
`html[data-theme="light"] body.${lotusTerminalBodyClass}::after`,
{ display: 'none' }
);
// Caret
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} input,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea`,
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea`,
{ caretColor: '#c44e00' }
);
@@ -462,9 +460,12 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-s
background: 'rgba(0,98,184,0.25)',
borderRadius: '3px',
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb:hover`, {
background: 'rgba(0,98,184,0.50)',
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} ::-webkit-scrollbar-thumb:hover`,
{
background: 'rgba(0,98,184,0.50)',
}
);
// Selection
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ::selection`, {
@@ -500,12 +501,12 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} pre`, {
// Inline semantics
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} strong,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} b`,
`html[data-theme="light"] body.${lotusTerminalBodyClass} b`,
{ color: '#c44e00' }
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} em,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} i`,
`html[data-theme="light"] body.${lotusTerminalBodyClass} i`,
{ color: '#0062b8' }
);
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} mark`, {
@@ -514,7 +515,7 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} mark`, {
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} del,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} s`,
`html[data-theme="light"] body.${lotusTerminalBodyClass} s`,
{ color: '#b5001f' }
);
@@ -534,15 +535,18 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} hr`, {
// Input focus
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} input:focus,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea:focus`,
`html[data-theme="light"] body.${lotusTerminalBodyClass} textarea:focus`,
{
borderColor: '#c44e00',
boxShadow: '0 0 0 2px rgba(196,78,0,0.22), 0 1px 6px rgba(196,78,0,0.12)',
}
);
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [contenteditable="true"]:focus`, {
boxShadow: '0 0 0 1px rgba(196,78,0,0.40), 0 1px 6px rgba(196,78,0,0.10)',
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [contenteditable="true"]:focus`,
{
boxShadow: '0 0 0 1px rgba(196,78,0,0.40), 0 1px 6px rgba(196,78,0,0.10)',
}
);
// Tables
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} table`, {
@@ -610,33 +614,45 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} button[data
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)',
});
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)',
}
);
// ── GIF picker (terminal mode) ───────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] input,` +
`body.${lotusTerminalBodyClass} [data-gif-terminal] form`, {
background: '#030c14 !important' as any,
color: '#e8edf5 !important' as any,
fontFamily: "'JetBrains Mono','Cascadia Code','Fira Code',monospace !important" as any,
border: '1px solid rgba(255,107,0,0.35) !important' as any,
borderRadius: '4px !important' as any,
fontSize: '12px !important' as any,
boxShadow: 'none !important' as any,
});
globalStyle(
`body.${lotusTerminalBodyClass} [data-gif-terminal] input,` +
`body.${lotusTerminalBodyClass} [data-gif-terminal] form`,
{
background: '#030c14 !important' as any,
color: '#e8edf5 !important' as any,
fontFamily: "'JetBrains Mono','Cascadia Code','Fira Code',monospace !important" as any,
border: '1px solid rgba(255,107,0,0.35) !important' as any,
borderRadius: '4px !important' as any,
fontSize: '12px !important' as any,
boxShadow: 'none !important' as any,
}
);
globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] input:focus`, {
borderColor: 'rgba(255,107,0,0.70) !important' as any,
boxShadow: '0 0 0 2px rgba(255,107,0,0.12) !important' as any,
@@ -645,10 +661,13 @@ globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] input:focus`, {
globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] input::placeholder`, {
color: 'rgba(255,107,0,0.40) !important' as any,
});
globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] svg,` +
`body.${lotusTerminalBodyClass} [data-gif-terminal] button[type="reset"]`, {
display: 'none !important' as any,
});
globalStyle(
`body.${lotusTerminalBodyClass} [data-gif-terminal] svg,` +
`body.${lotusTerminalBodyClass} [data-gif-terminal] button[type="reset"]`,
{
display: 'none !important' as any,
}
);
globalStyle(`body.${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar`, {
width: '4px',
});
@@ -689,33 +708,50 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-url-p
// ── GIF picker light TDS (dark-mode rules already exist via [data-gif-terminal]) ──
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] input,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] form`, {
background: '#f4f6fa !important' as any,
color: '#111827 !important' as any,
border: '1px solid rgba(196,78,0,0.28) !important' as any,
fontFamily: "'JetBrains Mono','Cascadia Code','Fira Code',monospace !important" as any,
fontSize: '12px !important' as any,
boxShadow: 'none !important' as any,
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] input:focus`, {
borderColor: 'rgba(196,78,0,0.60) !important' as any,
boxShadow: '0 0 0 2px rgba(196,78,0,0.12) !important' as any,
outline: 'none !important' as any,
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] input::placeholder`, {
color: 'rgba(196,78,0,0.45) !important' as any,
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] svg,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] button[type="reset"]`, {
display: 'none !important' as any,
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-track`, {
background: '#e2e7ef',
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-thumb`, {
background: 'rgba(196,78,0,0.35)',
borderRadius: '2px',
});
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] form`,
{
background: '#f4f6fa !important' as any,
color: '#111827 !important' as any,
border: '1px solid rgba(196,78,0,0.28) !important' as any,
fontFamily: "'JetBrains Mono','Cascadia Code','Fira Code',monospace !important" as any,
fontSize: '12px !important' as any,
boxShadow: 'none !important' as any,
}
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] input:focus`,
{
borderColor: 'rgba(196,78,0,0.60) !important' as any,
boxShadow: '0 0 0 2px rgba(196,78,0,0.12) !important' as any,
outline: 'none !important' as any,
}
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] input::placeholder`,
{
color: 'rgba(196,78,0,0.45) !important' as any,
}
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] svg,` +
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] button[type="reset"]`,
{
display: 'none !important' as any,
}
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-track`,
{
background: '#e2e7ef',
}
);
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [data-gif-terminal] ::-webkit-scrollbar-thumb`,
{
background: 'rgba(196,78,0,0.35)',
borderRadius: '2px',
}
);
// ── Tooltip TDS ──────────────────────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} ._6plmi2g > div`, {
@@ -749,10 +785,13 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [role="swit
background: 'rgba(0,98,184,0.10) !important' as any,
borderColor: 'rgba(0,98,184,0.35) !important' as any,
});
globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} [role="switch"][aria-checked="true"]`, {
background: 'rgba(196,78,0,0.18) !important' as any,
borderColor: 'rgba(196,78,0,0.55) !important' as any,
});
globalStyle(
`html[data-theme="light"] body.${lotusTerminalBodyClass} [role="switch"][aria-checked="true"]`,
{
background: 'rgba(196,78,0,0.18) !important' as any,
borderColor: 'rgba(196,78,0,0.55) !important' as any,
}
);
// ── Spinner TDS ───────────────────────────────────────────────────────────────
globalStyle(`body.${lotusTerminalBodyClass} ._31czpko`, {
@@ -826,4 +865,3 @@ globalStyle(`html[data-theme="light"] body.${lotusTerminalBodyClass} ._13tt0gb6:
background: 'rgba(0,98,184,0.08) !important' as any,
color: '#0062b8 !important' as any,
});
+2 -1
View File
@@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
'page.codeberg.everypizza.msc4193.spoiler.reason';
export type IImageInfo = {
w?: number;
+1 -1
View File
@@ -10,7 +10,7 @@ function hashCode(str) {
for (i = 0; i < str.length; i += 1) {
chr = str.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = ((hash << 5) - hash) + chr;
hash = (hash << 5) - hash + chr;
// eslint-disable-next-line no-bitwise
hash |= 0;
}
+37 -46
View File
@@ -37,7 +37,7 @@ async function deriveKeys(salt, iterations, password) {
new TextEncoder().encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits'],
['deriveBits']
);
} catch (e) {
throw friendlyError(`subtleCrypto.importKey failed: ${e}`, cryptoFailMsg());
@@ -53,40 +53,38 @@ async function deriveKeys(salt, iterations, password) {
hash: 'SHA-512',
},
key,
512,
512
);
} catch (e) {
throw friendlyError(`subtleCrypto.deriveBits failed: ${e}`, cryptoFailMsg());
}
const now = new Date();
console.log(`E2e import/export: deriveKeys took ${(now - start)}ms`);
console.log(`E2e import/export: deriveKeys took ${now - start}ms`);
const aesKey = keybits.slice(0, 32);
const hmacKey = keybits.slice(32);
const aesProm = subtleCrypto.importKey(
'raw',
aesKey,
{ name: 'AES-CTR' },
false,
['encrypt', 'decrypt'],
).catch((e) => {
throw friendlyError(`subtleCrypto.importKey failed for AES key: ${e}`, cryptoFailMsg());
});
const aesProm = subtleCrypto
.importKey('raw', aesKey, { name: 'AES-CTR' }, false, ['encrypt', 'decrypt'])
.catch((e) => {
throw friendlyError(`subtleCrypto.importKey failed for AES key: ${e}`, cryptoFailMsg());
});
const hmacProm = subtleCrypto.importKey(
'raw',
hmacKey,
{
name: 'HMAC',
hash: { name: 'SHA-256' },
},
false,
['sign', 'verify'],
).catch((e) => {
throw friendlyError(`subtleCrypto.importKey failed for HMAC key: ${e}`, cryptoFailMsg());
});
const hmacProm = subtleCrypto
.importKey(
'raw',
hmacKey,
{
name: 'HMAC',
hash: { name: 'SHA-256' },
},
false,
['sign', 'verify']
)
.catch((e) => {
throw friendlyError(`subtleCrypto.importKey failed for HMAC key: ${e}`, cryptoFailMsg());
});
// eslint-disable-next-line no-return-await
return await Promise.all([aesProm, hmacProm]);
@@ -177,7 +175,6 @@ function unpackMegolmKeyFile(data) {
return decodeBase64(fileStr.slice(dataStart, dataEnd));
}
/**
* ascii-armour a megolm key file
*
@@ -189,20 +186,20 @@ function unpackMegolmKeyFile(data) {
function packMegolmKeyFile(data) {
// we split into lines before base64ing, because encodeBase64 doesn't deal
// terribly well with large arrays.
const LINE_LENGTH = ((72 * 4) / 3);
const LINE_LENGTH = (72 * 4) / 3;
const nLines = Math.ceil(data.length / LINE_LENGTH);
const lines = new Array(nLines + 3);
lines[0] = HEADER_LINE;
let o = 0;
let i;
for (i = 1; i <= nLines; i += 1) {
lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH));
lines[i] = encodeBase64(data.subarray(o, o + LINE_LENGTH));
o += LINE_LENGTH;
}
lines[i] = TRAILER_LINE;
i += 1;
lines[i] = '';
return (new TextEncoder().encode(lines.join('\n'))).buffer;
return new TextEncoder().encode(lines.join('\n')).buffer;
}
export async function decryptMegolmKeyFile(data, password) {
@@ -225,7 +222,7 @@ export async function decryptMegolmKeyFile(data, password) {
const salt = body.subarray(1, 1 + 16);
const iv = body.subarray(17, 17 + 16);
const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36];
const iterations = (body[33] << 24) | (body[34] << 16) | (body[35] << 8) | body[36];
const ciphertext = body.subarray(37, 37 + ciphertextLength);
const hmac = body.subarray(-32);
@@ -234,12 +231,7 @@ export async function decryptMegolmKeyFile(data, password) {
let isValid;
try {
isValid = await subtleCrypto.verify(
{ name: 'HMAC' },
hmacKey,
hmac,
toVerify,
);
isValid = await subtleCrypto.verify({ name: 'HMAC' }, hmacKey, hmac, toVerify);
} catch (e) {
throw friendlyError(`subtleCrypto.verify failed: ${e}`, cryptoFailMsg());
}
@@ -256,7 +248,7 @@ export async function decryptMegolmKeyFile(data, password) {
length: 64,
},
aesKey,
ciphertext,
ciphertext
);
} catch (e) {
throw friendlyError(`subtleCrypto.decrypt failed: ${e}`, cryptoFailMsg());
@@ -302,34 +294,33 @@ export async function encryptMegolmKeyFile(data, password, options) {
length: 64,
},
aesKey,
encodedData,
encodedData
);
} catch (e) {
throw friendlyError('subtleCrypto.encrypt failed: ' + e, cryptoFailMsg());
}
const cipherArray = new Uint8Array(ciphertext);
const bodyLength = (1+salt.length+iv.length+4+cipherArray.length+32);
const bodyLength = 1 + salt.length + iv.length + 4 + cipherArray.length + 32;
const resultBuffer = new Uint8Array(bodyLength);
let idx = 0;
resultBuffer[idx++] = 1; // version
resultBuffer.set(salt, idx); idx += salt.length;
resultBuffer.set(iv, idx); idx += iv.length;
resultBuffer.set(salt, idx);
idx += salt.length;
resultBuffer.set(iv, idx);
idx += iv.length;
resultBuffer[idx++] = kdfRounds >> 24;
resultBuffer[idx++] = (kdfRounds >> 16) & 0xff;
resultBuffer[idx++] = (kdfRounds >> 8) & 0xff;
resultBuffer[idx++] = kdfRounds & 0xff;
resultBuffer.set(cipherArray, idx); idx += cipherArray.length;
resultBuffer.set(cipherArray, idx);
idx += cipherArray.length;
const toSign = resultBuffer.subarray(0, idx);
let hmac;
try {
hmac = await subtleCrypto.sign(
{ name: 'HMAC' },
hmacKey,
toSign,
);
hmac = await subtleCrypto.sign({ name: 'HMAC' }, hmacKey, toSign);
} catch (e) {
throw friendlyError('subtleCrypto.sign failed: ' + e, cryptoFailMsg());
}