♻️(frontend) harden PiP notifications stacking and dedupe

Group banner and toasts in one stack, cap visibility, fix chat dedup.
This commit is contained in:
Cyril
2026-04-27 11:48:09 +02:00
parent d7fb31ef2c
commit 1080407bb3
4 changed files with 48 additions and 26 deletions
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
// cover bursts and re-emits while staying negligible in memory.
const MAX_TRACKED_CHAT_IDS = 16
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
@@ -24,9 +28,9 @@ export const MainNotificationToast = () => {
const { appendReaction } = useReactions()
// Chat uses keepAlive in multiple SidePanels (main + PiP), so the same
// message event can fire more than once. Track the last id to deduplicate.
const lastChatMsgIdRef = useRef<string>('')
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
// Dedupe against a small ring of recent ids.
const seenChatMsgIdsRef = useRef<string[]>([])
useEffect(() => {
const handleChatMessage = (
@@ -34,8 +38,13 @@ export const MainNotificationToast = () => {
participant?: Participant | undefined
) => {
if (!participant || participant.isLocal) return
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
lastChatMsgIdRef.current = chatMessage.id ?? ''
const id = chatMessage.id
if (id) {
const seen = seenChatMsgIdsRef.current
if (seen.includes(id)) return
seen.push(id)
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
}
triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add(
{
@@ -49,14 +49,33 @@ export const PipView = () => {
<PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
<SidePanel store={pipLayoutStore} />
<PipConnectionStateToast />
<PipNotificationOverlay />
<OverlayStack>
<PipConnectionStateToast />
<PipNotificationOverlay />
</OverlayStack>
</PipContainer>
)
}
const OverlayStack = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
right: '0.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.375rem',
pointerEvents: 'none',
zIndex: 1000,
'& > *': { pointerEvents: 'auto' },
},
})
const PipContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
display: 'grid',
@@ -25,19 +25,11 @@ export const PipConnectionStateToast = () => {
if (!label) return null
return (
<Banner role="status" aria-live="polite">
{label}
</Banner>
)
return <Banner role="status">{label}</Banner>
}
const Banner = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'greyscale.800',
color: 'white',
fontSize: '0.8125rem',
@@ -46,7 +38,9 @@ const Banner = styled('div', {
borderRadius: '6px',
boxShadow:
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
zIndex: 1001,
animation: 'fade 200ms',
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
})
@@ -13,6 +13,8 @@ import { PipToastBody } from './PipToastBody'
* Shows shared toasts in the PiP window.
* We use a local aria-live region so screen readers can read them in PiP.
*/
const MAX_VISIBLE = 3
export const PipNotificationOverlay = () => {
const state = useToastQueue<ToastData>(toastQueue)
const { t } = useTranslation('rooms', {
@@ -21,14 +23,15 @@ export const PipNotificationOverlay = () => {
if (state.visibleToasts.length === 0) return null
const toasts = state.visibleToasts.slice(0, MAX_VISIBLE)
return (
<Region
role="region"
aria-label={t('notificationsLabel')}
aria-live="polite"
aria-relevant="additions"
>
{state.visibleToasts.map((toast) => (
{toasts.map((toast) => (
<ToastCard key={toast.key} aria-atomic="true">
<PipToastBody toast={toast} />
<Button
@@ -48,17 +51,11 @@ export const PipNotificationOverlay = () => {
const Region = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
right: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
alignItems: 'center',
pointerEvents: 'none',
zIndex: 1000,
'& > *': { pointerEvents: 'auto' },
width: '100%',
},
})
@@ -75,5 +72,8 @@ const ToastCard = styled('div', {
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
paddingRight: '0.25rem',
animation: 'fade 200ms',
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
})