diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx new file mode 100644 index 00000000..ac1c00d7 --- /dev/null +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { Participant, RoomEvent } from 'livekit-client' +import { ToastProvider, toastQueue } from './components/ToastProvider' +import { NotificationType } from './NotificationType' +import { Div } from '@/primitives' + +export const MainNotificationToast = () => { + const room = useRoomContext() + // fixme - remove toast if the user quit room in the 5s she joined + // fixme - don't show toast on mobile screen + useEffect(() => { + const showJoinNotification = (participant: Participant) => { + toastQueue.add( + { + participant, + type: NotificationType.Joined, + }, + { + timeout: 5000, + } + ) + } + room.on(RoomEvent.ParticipantConnected, showJoinNotification) + return () => { + room.off(RoomEvent.ParticipantConnected, showJoinNotification) + } + }, [room]) + + return ( +
+ +
+ ) +} diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts new file mode 100644 index 00000000..2f965016 --- /dev/null +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -0,0 +1,4 @@ +export enum NotificationType { + Joined = 'joined', + Default = 'default', +} diff --git a/src/frontend/src/features/notifications/components/Toast.tsx b/src/frontend/src/features/notifications/components/Toast.tsx new file mode 100644 index 00000000..59b5c1f3 --- /dev/null +++ b/src/frontend/src/features/notifications/components/Toast.tsx @@ -0,0 +1,58 @@ +import { useToast } from '@react-aria/toast' +import { Button } from '@/primitives' +import { RiCloseLine } from '@remixicon/react' +import { ToastState } from '@react-stately/toast' +import { styled } from '@/styled-system/jsx' +import { useRef } from 'react' +import { ToastData } from './ToastProvider' +import type { QueuedToast } from '@react-stately/toast' + +export const StyledToastContainer = styled('div', { + base: { + margin: 0.5, + boxShadow: + 'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px', + backgroundColor: '#494c4f', + color: 'white', + borderRadius: '8px', + '&[data-entering]': { animation: 'fade 200ms' }, + '&[data-exiting]': { animation: 'fade 150ms reverse ease-in' }, + width: 'fit-content', + marginLeft: 'auto', + }, +}) + +const StyledToast = styled('div', { + base: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '1rem', + padding: '10px', + }, +}) + +export interface ToastProps { + key: string + toast: QueuedToast + state: ToastState +} + +export function Toast({ state, ...props }: ToastProps) { + const ref = useRef(null) + const { toastProps, contentProps, closeButtonProps } = useToast( + props, + state, + ref + ) + return ( + + +
{props.toast.content?.message} machine a
+ +
+
+ ) +} diff --git a/src/frontend/src/features/notifications/components/ToastJoined.tsx b/src/frontend/src/features/notifications/components/ToastJoined.tsx new file mode 100644 index 00000000..eee9d277 --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastJoined.tsx @@ -0,0 +1,73 @@ +import { useToast } from '@react-aria/toast' +import { useRef } from 'react' +import { Button as RACButton } from 'react-aria-components' +import { Track } from 'livekit-client' +import Source = Track.Source + +import { useMaybeLayoutContext } from '@livekit/components-react' +import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' +import { StyledToastContainer, ToastProps } from './Toast' +import { HStack, styled } from '@/styled-system/jsx' +import { Div } from '@/primitives' +import { useTranslation } from 'react-i18next' + +const ClickableToast = styled(RACButton, { + base: { + cursor: 'pointer', + display: 'flex', + borderRadius: 'inherit', + }, +}) + +export function ToastJoined({ state, ...props }: ToastProps) { + const { t } = useTranslation('notifications') + const ref = useRef(null) + const { toastProps, contentProps, titleProps, closeButtonProps } = useToast( + props, + state, + ref + ) + const layoutContext = useMaybeLayoutContext() + const participant = props.toast.content.participant + const trackReference = { + participant, + publication: participant.getTrackPublication(Source.Camera), + source: Source.Camera, + } + const pinParticipant = () => { + layoutContext?.pin.dispatch?.({ + msg: 'set_pin', + trackReference, + }) + } + return ( + + { + pinParticipant() + closeButtonProps.onPress?.(e) + }} + > + +
+ +
+
+ {t('joined.description', { + name: participant.name || t('defaultName'), + })} +
+
+
+
+ ) +} diff --git a/src/frontend/src/features/notifications/components/ToastProvider.tsx b/src/frontend/src/features/notifications/components/ToastProvider.tsx new file mode 100644 index 00000000..2ddf22e9 --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastProvider.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react-refresh/only-export-components */ +import { ToastQueue, useToastQueue } from '@react-stately/toast' +import { ToastRegion } from './ToastRegion' +import { Participant } from 'livekit-client' +import { NotificationType } from '../NotificationType' + +export interface ToastData { + participant: Participant + type: NotificationType + message?: string +} + +// Using a global queue for toasts allows for centralized management and queuing of notifications +// from anywhere in the app, providing greater flexibility in complex scenarios. +export const toastQueue = new ToastQueue({ + maxVisibleToasts: 5, +}) + +export const ToastProvider = ({ ...props }) => { + const state = useToastQueue(toastQueue) + return ( + <> + {state.visibleToasts.length > 0 && ( + + )} + + ) +} diff --git a/src/frontend/src/features/notifications/components/ToastRegion.tsx b/src/frontend/src/features/notifications/components/ToastRegion.tsx new file mode 100644 index 00000000..b27ffc1c --- /dev/null +++ b/src/frontend/src/features/notifications/components/ToastRegion.tsx @@ -0,0 +1,26 @@ +import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast' +import type { ToastState } from '@react-stately/toast' +import { Toast } from './Toast' +import { useRef } from 'react' +import { NotificationType } from '../NotificationType' +import { ToastJoined } from './ToastJoined' +import { ToastData } from './ToastProvider' + +interface ToastRegionProps extends AriaToastRegionProps { + state: ToastState +} + +export function ToastRegion({ state, ...props }: ToastRegionProps) { + const ref = useRef(null) + const { regionProps } = useToastRegion(props, state, ref) + return ( +
+ {state.visibleToasts.map((toast) => { + if (toast.content?.type === NotificationType.Joined) { + return + } + return + })} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index f6b36f2d..80fd924c 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -35,6 +35,7 @@ import { useSnapshot } from 'valtio' import { participantsStore } from '@/stores/participants' import { FocusLayout } from '../components/FocusLayout' import { ParticipantTile } from '../components/ParticipantTile' +import { MainNotificationToast } from '@/features/notifications/MainNotificationToast' const LayoutWrapper = styled( 'div', @@ -184,25 +185,33 @@ export function VideoConference({ >
- {!focusTrack ? ( -
- - - -
- ) : ( -
- - +
+ {!focusTrack ? ( +
+ - - {focusTrack && } - -
- )} + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )} + +