mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-25 01:37:30 +00:00
✨(frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
This commit is contained in:
@@ -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 })
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user