From 443d8407fcd401fae19a3c20ed1c5abd5eaceda1 Mon Sep 17 00:00:00 2001 From: Lotus Bot Date: Tue, 19 May 2026 16:45:02 -0400 Subject: [PATCH] PTT fixes, TDS expansions, performance hooks, state event renderers PTT fixes (BUG-7/8/9): - BUG-7: Fix isEditable to use ownerDocument.body (works in EC iframe context) - BUG-8: Release mic if callEmbed changes during active PTT (cleanup fn) - BUG-9: Wire iframe blur/focus listeners for stuck-mic prevention PiP bug fix (BUG-11): - Track prevPipMode ref so position only resets when first entering pip mode, not on every callVisible change (user drag position preserved) GIF improvements (BUG-15): - Show user-visible error text in toolbar on GIF send failure - Also surface size-limit rejection with a 4-second auto-clearing message Performance: - useInterval: replace useMemo with useEffect for setInterval (React StrictMode safe) - usePan: add unmount cleanup effect to remove document mousemove/mouseup listeners Feature: timeline state event renderers (low effort, high value): - Added renderers for RoomEncryption, RoomJoinRules, RoomGuestAccess, RoomCanonicalAlias - These were silently falling through to the hidden-events fallback TDS (Lotus Terminal Design System): - Fix: correct 8 escaped template literals in GIF picker light-mode block - Add data-emoji-board attribute to EmojiBoardLayout for stable CSS targeting - Add Tooltip panel styles (dark + light mode) - Add Switch toggle styles (dark + light mode) - Add Spinner stroke colors (dark + light mode) - Add EmojiBoard panel styles (dark + light mode) - Add PopOut/Menu/floating panel styles (dark + light mode) --- src/app/components/CallEmbedProvider.tsx | 18 ++- src/app/components/emoji-board/EmojiBoard.tsx | 3 + src/app/features/call/CallControls.tsx | 24 +++- src/app/features/room/RoomInput.tsx | 14 +- src/app/features/room/RoomTimeline.tsx | 64 +++++++++ src/app/hooks/useInterval.ts | 23 +--- src/app/hooks/usePan.ts | 7 + src/lotus-terminal.css.ts | 126 ++++++++++++++++-- 8 files changed, 241 insertions(+), 38 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 4fc42deb6..8051299d4 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -415,20 +415,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const activeDragCleanupRef = React.useRef<(() => void) | null>(null); React.useEffect(() => () => { activeDragCleanupRef.current?.(); }, []); + // Track previous pipMode to only reset position when first entering pip (not on callVisible changes) + const prevPipModeRef = React.useRef(false); React.useEffect(() => { const el = callEmbedRef.current; if (!el) return; if (pipMode) { - 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'; + 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.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] = ''; }); el.style.visibility = callVisible ? '' : 'hidden'; } + prevPipModeRef.current = pipMode; }, [pipMode, callVisible]); React.useEffect(() => { diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d5a76c715..466e2c108 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -52,6 +52,8 @@ import { } from './components'; import { EmojiBoardTab, EmojiType } from './types'; import { VirtualTile } from '../virtualizer'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; @@ -503,6 +505,7 @@ export function EmojiBoard({ }} > {onTabChange && } diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index d0971cc8b..31f45d9a5 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -97,20 +97,21 @@ export function CallControls({ callEmbed }: CallControlsProps) { setCords(undefined); }; + const pttActiveRef = useRef(false); + useEffect(() => { if (!pttMode) return; const iframeWindow = callEmbed.iframe.contentWindow; const onKeyDown = (e: KeyboardEvent) => { if (e.code !== pttKey || e.repeat) return; - // Don't intercept keys typed into a text input or editable element const target = e.target as HTMLElement; - // Skip PTT if key is pressed inside any text-input or editable surface + // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe const isEditable = (el: HTMLElement): boolean => { const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; let node: HTMLElement | null = el; - while (node && node !== document.body) { + while (node && node !== el.ownerDocument.body) { if (node.contentEditable === 'true') return true; if (node.contentEditable === 'false') return false; node = node.parentElement; @@ -120,29 +121,34 @@ export function CallControls({ callEmbed }: CallControlsProps) { if (isEditable(target)) return; e.preventDefault(); if (!microphoneRef.current) callEmbed.control.setMicrophone(true); + pttActiveRef.current = true; setPttActive(true); }; const onKeyUp = (e: KeyboardEvent) => { if (e.code !== pttKey) return; callEmbed.control.setMicrophone(false); + pttActiveRef.current = false; setPttActive(false); }; - // Release PTT when the tab loses focus to prevent stuck-on mic const onBlur = () => { callEmbed.control.setMicrophone(false); + pttActiveRef.current = false; setPttActive(false); }; - // Re-mute on focus restore: EC can re-assert audio_enabled:true on audio-context resume const onFocus = () => { callEmbed.control.setMicrophone(false); + pttActiveRef.current = false; setPttActive(false); }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); window.addEventListener('blur', onBlur); window.addEventListener('focus', onFocus); + // BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe iframeWindow?.addEventListener('keydown', onKeyDown); iframeWindow?.addEventListener('keyup', onKeyUp); + iframeWindow?.addEventListener('blur', onBlur); + iframeWindow?.addEventListener('focus', onFocus); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); @@ -150,6 +156,14 @@ export function CallControls({ callEmbed }: CallControlsProps) { window.removeEventListener('focus', onFocus); iframeWindow?.removeEventListener('keydown', onKeyDown); iframeWindow?.removeEventListener('keyup', onKeyUp); + iframeWindow?.removeEventListener('blur', onBlur); + iframeWindow?.removeEventListener('focus', onFocus); + // BUG-8: if callEmbed changes while PTT is active, release mic on cleanup + if (pttActiveRef.current) { + callEmbed.control.setMicrophone(false); + pttActiveRef.current = false; + setPttActive(false); + } }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 0e77bf1c4..91c5747b6 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -241,6 +241,7 @@ export const RoomInput = forwardRef( const { gifApiKey } = useClientConfig(); const gifBtnRef = useRef(null); const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500); + const [gifError, setGifError] = React.useState(null); const isComposing = useComposingCheck(); @@ -469,7 +470,11 @@ export const RoomInput = forwardRef( if (!contentType.startsWith('image/')) return; const blob = await res.blob(); - if (blob.size > 20 * 1024 * 1024) return; // 20 MB cap + if (blob.size > 20 * 1024 * 1024) { + setGifError('GIF is too large (max 20 MB).'); + setTimeout(() => setGifError(null), 4000); + return; + } const uploadRes = await mx.uploadContent( new File([blob], 'image.gif', { type: 'image/gif' }), @@ -485,6 +490,8 @@ export const RoomInput = forwardRef( }); } catch (e) { console.error('GIF send failed', e); + setGifError('Failed to send GIF. Please try again.'); + setTimeout(() => setGifError(null), 4000); } }, [mx, roomId] @@ -774,6 +781,11 @@ export const RoomInput = forwardRef( )} )} + {gifError && ( + + {gifError} + + )} ); }, + [StateEvent.RoomEncryption]: (mEventId, mEvent, item) => { + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); + const timeJSX = ( +