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 && }
+
+
+ )}
+
+