diff --git a/CHANGELOG.md b/CHANGELOG.md index eaac76e904..1078d41061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,16 @@ and this project adheres to ### Changed - 🔥(frontend) drop unused vendored ConnectionObserver +- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines ### Fixed - 📈(frontend) downgrade unreachable external home URL from error to event - 🐛(frontend) handle 401 responses when syncing user preferences - 🐛(frontend) harden speaker test against missing sinks and play errors +- 🐛(frontend) implement hysteresis band for the control bar layout +- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift +- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog ## [1.26.0] - 2026-08-12 diff --git a/src/frontend/src/features/analytics/exceptionFilters.ts b/src/frontend/src/features/analytics/exceptionFilters.ts new file mode 100644 index 0000000000..9a11cfe905 --- /dev/null +++ b/src/frontend/src/features/analytics/exceptionFilters.ts @@ -0,0 +1,24 @@ +import type { CaptureResult } from 'posthog-js' + +const IGNORED_EXCEPTION_PATTERNS = [ + /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/, +] + +const shouldIgnoreException = (value: unknown): boolean => + typeof value === 'string' && + IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value)) + +export const filterExceptions = ( + event: CaptureResult | null +): CaptureResult | null => { + if (event?.event !== '$exception') return event + + const exceptionList = event.properties?.['$exception_list'] + const values: unknown[] = Array.isArray(exceptionList) + ? exceptionList.map((exception) => exception?.value) + : [] + + values.push(event.properties?.['$exception_message']) + + return values.some(shouldIgnoreException) ? null : event +} diff --git a/src/frontend/src/features/analytics/hooks/useAnalytics.ts b/src/frontend/src/features/analytics/hooks/useAnalytics.ts index a1c2d37241..1729bea28e 100644 --- a/src/frontend/src/features/analytics/hooks/useAnalytics.ts +++ b/src/frontend/src/features/analytics/hooks/useAnalytics.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { type ApiUser } from '@/features/auth/api/ApiUser' import { useUser } from '@/features/auth/api/useUser' import { getPosthog } from '../utils' +import { filterExceptions } from '../exceptionFilters' export const startAnalyticsSession = (data: ApiUser) => { getPosthog().then((ph) => { @@ -47,6 +48,7 @@ export const useAnalytics = ({ capture_unhandled_rejections: true, capture_console_errors: true, }, + before_send: filterExceptions, }) }) }, [id, host, flags_api_host, isDisabled]) diff --git a/src/frontend/src/features/chat/components/ChatMessageBody.tsx b/src/frontend/src/features/chat/components/ChatMessageBody.tsx index 67381ea4d6..f9decfdcaa 100644 --- a/src/frontend/src/features/chat/components/ChatMessageBody.tsx +++ b/src/frontend/src/features/chat/components/ChatMessageBody.tsx @@ -1,6 +1,6 @@ import { ChatRow } from '@/stores/chat' import React, { useMemo } from 'react' -import { formatChatMessageLinks } from '@livekit/components-react' +import { formatChatMessageLinks } from '../utils' import { css } from '@/styled-system/css' import { Text } from '@/primitives' diff --git a/src/frontend/src/features/chat/utils.tsx b/src/frontend/src/features/chat/utils.tsx new file mode 100644 index 0000000000..7542e3b1bf --- /dev/null +++ b/src/frontend/src/features/chat/utils.tsx @@ -0,0 +1,32 @@ +import { tokenize, createDefaultGrammar } from '@livekit/components-core' +import { ReactNode } from 'react' + +const defaultGrammar = Object.freeze(createDefaultGrammar()) + +export function formatChatMessageLinks(message: string): ReactNode { + const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '') + return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => { + if (typeof tok === `string`) { + return tok + } else { + const content = tok.content.toString() + const href = + tok.type === `url` + ? /^http(s?):\/\//.test(content) + ? content + : `https://${content}` + : `mailto:${content}` + return ( + + {content} + + ) + } + }) +} diff --git a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx index 07e91a5f9c..acfb484b04 100644 --- a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx +++ b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx @@ -26,8 +26,8 @@ const StyledContainer = styled('div', { backgroundColor: 'primaryDark.100', maxWidth: '100%', opacity: 0, - transform: 'translateY(3.25rem)', - transition: 'opacity, transform', + translate: '0 3.25rem', + transition: 'opacity, translate', transitionDuration: '0.5s', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', pointerEvents: 'none', @@ -36,7 +36,7 @@ const StyledContainer = styled('div', { isVisible: { true: { opacity: 1, - transform: 'translateY(0)', + translate: '0 0', pointerEvents: 'auto', }, }, @@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({ shouldBeCenteredWithToggleButton, setShouldBeCenteredWithToggleButton, ] = useState(false) - const [rightOffset, setRightOffset] = useState(0) + const [offsetX, setOffsetX] = useState(0) const updateArrows = useCallback(() => { const el = scrollRef.current @@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({ useLayoutEffect(() => { if (!shouldBeCenteredWithToggleButton || isMobile) { - setRightOffset(0) + setOffsetX(0) return } @@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({ const containerCenterX = containerRect.left + containerRect.width / 2 const shift = toggleCenterX - containerCenterX if (Math.abs(shift) < 0.5) return - setRightOffset((prev) => prev - shift * 2) + setOffsetX((prev) => prev + shift) } const schedule = () => { @@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({ isVisible={isVisible} style={ shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering - ? { marginRight: `${rightOffset}px` } + ? { transform: `translateX(${offsetX}px)` } : { margin: '0 15px' } } > diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx index 582d08c158..d7b5e064af 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx @@ -12,7 +12,8 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' -const CONTROL_BAR_BREAKPOINT = 1100 +const CONTROL_BAR_BREAKPOINT_WIDE = 1100 +const CONTROL_BAR_BREAKPOINT_NARROW = 1050 const NavigationControls = ({ onPress, @@ -65,10 +66,9 @@ export const LateralMenu = () => { ) } - interface BreakpointObserverProps { containerRef: RefObject - onWideChange: (isWide: boolean) => void + onWideChange: (isWide: boolean | null) => void } const BreakpointObserver = ({ @@ -76,7 +76,20 @@ const BreakpointObserver = ({ onWideChange, }: BreakpointObserverProps) => { const { width } = useSize(containerRef) - const isWide = width > CONTROL_BAR_BREAKPOINT + const [isWide, setIsWide] = useState(null) + + useEffect(() => { + if (!width) { + return + } + if (width > CONTROL_BAR_BREAKPOINT_WIDE) { + setIsWide(true) + } else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) { + setIsWide(false) + } else { + setIsWide((prev) => (prev === null ? false : prev)) + } + }, [width]) useEffect(() => { onWideChange(isWide) @@ -90,7 +103,7 @@ export const MoreOptions = ({ }: { parentElement: RefObject }) => { - const [isWide, setIsWide] = useState(false) + const [isWide, setIsWide] = useState(null) return ( ) }