mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-27 10:46:47 +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 { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -24,12 +24,18 @@ 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>('')
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
|
||||
lastChatMsgIdRef.current = chatMessage.id ?? ''
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
|
||||
@@ -170,7 +170,7 @@ export const DocumentPiPPortal = ({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [closePiP, isOpen, isSupported, openPiP])
|
||||
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||
|
||||
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||
import { PipStage } from './layouts/PipStage'
|
||||
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||
|
||||
export const PipView = () => {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
@@ -47,6 +49,8 @@ export const PipView = () => {
|
||||
<PipReactionsToolbar />
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
<SidePanel store={pipLayoutStore} />
|
||||
<PipConnectionStateToast />
|
||||
<PipNotificationOverlay />
|
||||
</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",
|
||||
"controlBar": "Besprechungssteuerung",
|
||||
"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": {
|
||||
"enter": "Vollbild",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Participants",
|
||||
"controlBar": "Meeting controls",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions"
|
||||
"nextReactions": "Next reactions",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnecting…",
|
||||
"disconnected": "Disconnected"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Participants",
|
||||
"controlBar": "Commandes de la réunion",
|
||||
"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": {
|
||||
"enter": "Plein écran",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Deelnemers",
|
||||
"controlBar": "Vergaderbesturing",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties"
|
||||
"nextReactions": "Volgende reacties",
|
||||
"notificationsLabel": "Meldingen",
|
||||
"dismissNotification": "Melding sluiten",
|
||||
"connection": {
|
||||
"reconnecting": "Opnieuw verbinden…",
|
||||
"disconnected": "Verbinding verbroken"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Volledig scherm",
|
||||
|
||||
Reference in New Issue
Block a user