From 3d615fa58226652124420e6fd1670b9af88abdce Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 9 Sep 2024 23:48:42 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20introduce=20in-app=20toas?= =?UTF-8?q?t=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast-related components are reusable. They follow React Aria recommendations. The UI is heavily inspired by GMeet, we should iterate on it. Why toast? It gives additional information and quick actions when an event occurs in the room. The ToastRegion design should be improved to be more extensible, to move away from poor practices with conditional rendering. Added initial toast display when users join, with room for further improvement. Please read the fixme items. --- .../notifications/MainNotificationToast.tsx | 35 +++++++++ .../notifications/NotificationType.ts | 4 + .../notifications/components/Toast.tsx | 58 +++++++++++++++ .../notifications/components/ToastJoined.tsx | 73 +++++++++++++++++++ .../components/ToastProvider.tsx | 28 +++++++ .../notifications/components/ToastRegion.tsx | 26 +++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 45 +++++++----- .../src/locales/de/notifications.json | 6 ++ .../src/locales/en/notifications.json | 6 ++ .../src/locales/fr/notifications.json | 6 ++ 10 files changed, 269 insertions(+), 18 deletions(-) create mode 100644 src/frontend/src/features/notifications/MainNotificationToast.tsx create mode 100644 src/frontend/src/features/notifications/NotificationType.ts create mode 100644 src/frontend/src/features/notifications/components/Toast.tsx create mode 100644 src/frontend/src/features/notifications/components/ToastJoined.tsx create mode 100644 src/frontend/src/features/notifications/components/ToastProvider.tsx create mode 100644 src/frontend/src/features/notifications/components/ToastRegion.tsx create mode 100644 src/frontend/src/locales/de/notifications.json create mode 100644 src/frontend/src/locales/en/notifications.json create mode 100644 src/frontend/src/locales/fr/notifications.json 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 && } + +
+ )} + +