(frontend) show room notifications inside the PiP window

Render the shared toast queue in PiP.
This commit is contained in:
Cyril
2026-04-27 11:04:11 +02:00
parent c8ae87b082
commit d7fb31ef2c
13 changed files with 540 additions and 256 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core' import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -24,12 +24,18 @@ export const MainNotificationToast = () => {
const { appendReaction } = useReactions() 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>('')
useEffect(() => { useEffect(() => {
const handleChatMessage = ( const handleChatMessage = (
chatMessage: ChatMessage, chatMessage: ChatMessage,
participant?: Participant | undefined participant?: Participant | undefined
) => { ) => {
if (!participant || participant.isLocal) return if (!participant || participant.isLocal) return
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
lastChatMsgIdRef.current = chatMessage.id ?? ''
triggerNotificationSound(NotificationType.MessageReceived) triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add( toastQueue.add(
{ {
@@ -170,7 +170,7 @@ export const DocumentPiPPortal = ({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [closePiP, isOpen, isSupported, openPiP]) }, [closePiP, isOpen, isSupported, openPiP, t])
// Focus stays on the trigger; PiP is announced as an auxiliary surface. // Focus stays on the trigger; PiP is announced as an auxiliary surface.
useEffect(() => { useEffect(() => {
@@ -11,6 +11,8 @@ import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
import { PipControlBar } from './PipControlBar' import { PipControlBar } from './PipControlBar'
import { PipReactionsToolbar } from './PipReactionsToolbar' import { PipReactionsToolbar } from './PipReactionsToolbar'
import { PipStage } from './layouts/PipStage' import { PipStage } from './layouts/PipStage'
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
export const PipView = () => { export const PipView = () => {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
@@ -47,6 +49,8 @@ export const PipView = () => {
<PipReactionsToolbar /> <PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} /> <PipControlBar showScreenShare={browserSupportsScreenSharing} />
<SidePanel store={pipLayoutStore} /> <SidePanel store={pipLayoutStore} />
<PipConnectionStateToast />
<PipNotificationOverlay />
</PipContainer> </PipContainer>
) )
} }
@@ -0,0 +1,52 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
/**
* Banner surfaced inside the PiP when the room connection degrades.
*
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
* to see while their attention is on the PiP rather than the main window.
*/
export const PipConnectionStateToast = () => {
const room = useRoomContext()
const state = useConnectionState(room)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture.connection',
})
const label =
state === ConnectionState.Reconnecting
? t('reconnecting')
: state === ConnectionState.Disconnected
? t('disconnected')
: null
if (!label) return null
return (
<Banner role="status" aria-live="polite">
{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',
lineHeight: 1.3,
padding: '0.375rem 0.75rem',
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',
},
})
@@ -0,0 +1,79 @@
import { useToastQueue } from '@react-stately/toast'
import { RiCloseLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import {
toastQueue,
type ToastData,
} from '@/features/notifications/components/ToastProvider'
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.
*/
export const PipNotificationOverlay = () => {
const state = useToastQueue<ToastData>(toastQueue)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
if (state.visibleToasts.length === 0) return null
return (
<Region
role="region"
aria-label={t('notificationsLabel')}
aria-live="polite"
aria-relevant="additions"
>
{state.visibleToasts.map((toast) => (
<ToastCard key={toast.key} aria-atomic="true">
<PipToastBody toast={toast} />
<Button
square
size="sm"
invisible
aria-label={t('dismissNotification')}
onPress={() => state.close(toast.key)}
>
<RiCloseLine size={16} color="white" aria-hidden="true" />
</Button>
</ToastCard>
))}
</Region>
)
}
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' },
},
})
const ToastCard = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
maxWidth: '100%',
backgroundColor: 'greyscale.700',
color: 'white',
borderRadius: '6px',
boxShadow:
'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',
},
})
@@ -0,0 +1,119 @@
import type { QueuedToast } from '@react-stately/toast'
import { useTranslation } from 'react-i18next'
import { RiHand, RiMessage2Line } from '@remixicon/react'
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { NotificationType } from '@/features/notifications/NotificationType'
import type { ToastData } from '@/features/notifications/components/ToastProvider'
import { RecordingMode } from '@/features/recording'
type Props = {
toast: QueuedToast<ToastData>
}
/**
* Renders the toast content used in PiP.
* PiP stays display-only, so main-window actions are not shown here.
*/
export const PipToastBody = ({ toast }: Props) => {
const { t } = useTranslation('notifications')
const { type, participant, message, removedSources } = toast.content
const name = participant?.name || t('defaultName')
switch (type) {
case NotificationType.ParticipantJoined:
return <Line>{t('joined.description', { name })}</Line>
case NotificationType.ParticipantMuted:
return <Line>{t('muted', { name })}</Line>
case NotificationType.HandRaised:
return (
<Line>
<RiHand
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
{t('raised.description', { name })}
</Line>
)
case NotificationType.MessageReceived:
return (
<Line>
<RiMessage2Line
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
<span>
<strong>{name}</strong>
{message ? ` - ${message}` : null}
</span>
</Line>
)
case NotificationType.TranscriptionStarted:
return <Line>{t('transcript.started', { name })}</Line>
case NotificationType.TranscriptionStopped:
return <Line>{t('transcript.stopped', { name })}</Line>
case NotificationType.TranscriptionLimitReached:
return <Line>{t('transcript.limitReached')}</Line>
case NotificationType.TranscriptionRequested:
return <Line>{t('transcript.requested', { name })}</Line>
case NotificationType.ScreenRecordingStarted:
return <Line>{t('screenRecording.started', { name })}</Line>
case NotificationType.ScreenRecordingStopped:
return <Line>{t('screenRecording.stopped', { name })}</Line>
case NotificationType.ScreenRecordingLimitReached:
return <Line>{t('screenRecording.limitReached')}</Line>
case NotificationType.ScreenRecordingRequested:
return <Line>{t('screenRecording.requested', { name })}</Line>
case NotificationType.RecordingSaving: {
const mode = toast.content.mode as RecordingMode | undefined
const key =
mode === RecordingMode.ScreenRecording
? 'recordingSave.screenRecording.default'
: 'recordingSave.transcript.default'
return <Line>{t(key)}</Line>
}
case NotificationType.PermissionsRemoved: {
const key = resolvePermissionsKey(removedSources)
if (!key) return null
return <Line>{t(`permissionsRemoved.${key}`)}</Line>
}
default:
return message ? <Line>{message}</Line> : null
}
}
const resolvePermissionsKey = (sources: unknown): string | null => {
if (!Array.isArray(sources) || sources.length === 0) return null
if (sources.length === 1) return sources[0] as string
if (sources.includes('screen_share')) return 'screen_share'
return null
}
const Line = ({ children }: { children: ReactNode }) => (
<HStack
alignItems="center"
gap="0.5rem"
padding="0.625rem 0.75rem"
className={css({
fontSize: '0.8125rem',
lineHeight: 1.3,
})}
>
{children}
</HStack>
)
const iconStyle = css({ flexShrink: 0 })
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Teilnehmer", "stage": "Teilnehmer",
"controlBar": "Besprechungssteuerung", "controlBar": "Besprechungssteuerung",
"previousReactions": "Vorherige Reaktionen", "previousReactions": "Vorherige Reaktionen",
"nextReactions": "Nächste Reaktionen" "nextReactions": "Nächste Reaktionen",
"notificationsLabel": "Benachrichtigungen",
"dismissNotification": "Benachrichtigung schließen",
"connection": {
"reconnecting": "Verbindung wird wiederhergestellt…",
"disconnected": "Verbindung getrennt"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Vollbild", "enter": "Vollbild",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Participants", "stage": "Participants",
"controlBar": "Meeting controls", "controlBar": "Meeting controls",
"previousReactions": "Previous reactions", "previousReactions": "Previous reactions",
"nextReactions": "Next reactions" "nextReactions": "Next reactions",
"notificationsLabel": "Notifications",
"dismissNotification": "Dismiss notification",
"connection": {
"reconnecting": "Reconnecting…",
"disconnected": "Disconnected"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Fullscreen", "enter": "Fullscreen",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Participants", "stage": "Participants",
"controlBar": "Commandes de la réunion", "controlBar": "Commandes de la réunion",
"previousReactions": "Réactions précédentes", "previousReactions": "Réactions précédentes",
"nextReactions": "Réactions suivantes" "nextReactions": "Réactions suivantes",
"notificationsLabel": "Notifications",
"dismissNotification": "Fermer la notification",
"connection": {
"reconnecting": "Reconnexion…",
"disconnected": "Déconnecté"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Plein écran", "enter": "Plein écran",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Deelnemers", "stage": "Deelnemers",
"controlBar": "Vergaderbesturing", "controlBar": "Vergaderbesturing",
"previousReactions": "Vorige reacties", "previousReactions": "Vorige reacties",
"nextReactions": "Volgende reacties" "nextReactions": "Volgende reacties",
"notificationsLabel": "Meldingen",
"dismissNotification": "Melding sluiten",
"connection": {
"reconnecting": "Opnieuw verbinden…",
"disconnected": "Verbinding verbroken"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Volledig scherm", "enter": "Volledig scherm",