Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c69d93e4 | |||
| 6ae1c27f3f | |||
| e0c685b361 | |||
| 375a5c7354 | |||
| 9d69cf2bd3 | |||
| 05fae17d7d | |||
| 96173c81f0 | |||
| 8ae020d64a | |||
| 3e4f202404 | |||
| 068986804c |
@@ -34,6 +34,11 @@ RUN npm run build
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { Div } from '@/primitives'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
|
||||
enum ToastDuration {
|
||||
SHORT = 3000,
|
||||
MEDIUM = 4000,
|
||||
LONG = 5000,
|
||||
}
|
||||
|
||||
const NotificationDuration = {
|
||||
ALERT: ToastDuration.SHORT,
|
||||
MESSAGE: ToastDuration.LONG,
|
||||
PARTICIPANT_JOINED: ToastDuration.LONG,
|
||||
HAND_RAISED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant: participant,
|
||||
message: chatMessage.message,
|
||||
type: NotificationType.MessageReceived,
|
||||
},
|
||||
{ timeout: NotificationDuration.MESSAGE }
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ChatMessage, handleChatMessage)
|
||||
return () => {
|
||||
room.off(RoomEvent.ChatMessage, handleChatMessage)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const decoder = new TextDecoder()
|
||||
const notificationType = decoder.decode(payload)
|
||||
|
||||
if (!participant) return
|
||||
|
||||
switch (notificationType) {
|
||||
case NotificationType.ParticipantMuted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantMuted,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const showJoinNotification = (participant: Participant) => {
|
||||
if (isMobileBrowser()) {
|
||||
@@ -23,7 +88,7 @@ export const MainNotificationToast = () => {
|
||||
type: NotificationType.ParticipantJoined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
timeout: NotificationDuration.PARTICIPANT_JOINED,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -82,7 +147,7 @@ export const MainNotificationToast = () => {
|
||||
participant,
|
||||
type: NotificationType.HandRaised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
{ timeout: NotificationDuration.HAND_RAISED }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum NotificationType {
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
// todo - implement message received notification
|
||||
ParticipantMuted = 'participantMuted',
|
||||
MessageReceived = 'messageReceived',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { Text } from '@/primitives'
|
||||
import { RiMessage2Line } from '@remixicon/react'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps } = useToast(props, state, ref)
|
||||
|
||||
const toast = props.toast
|
||||
|
||||
const participant = toast.content.participant
|
||||
const message = toast.content.message
|
||||
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatOpen) {
|
||||
state.close(toast.key)
|
||||
}
|
||||
}, [isChatOpen, toast, state])
|
||||
|
||||
if (isChatOpen) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<RACButton onPress={() => toggleChat()} aria-label={t('openChat')}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
padding: '14px',
|
||||
gap: '0.75rem',
|
||||
textAlign: 'start',
|
||||
width: '150px',
|
||||
md: {
|
||||
width: '260px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'start',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiMessage2Line
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'primary.300',
|
||||
marginTop: '3px',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{participant.name}</span>
|
||||
</div>
|
||||
<Text margin={false} wrap={'pretty'} centered={false}>
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</RACButton>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMuted({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t('muted', { name: participant.name })}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { useRef } from 'react'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { ToastJoined } from './ToastJoined'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||
import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -23,6 +25,14 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.ParticipantMuted) {
|
||||
return <ToastMuted key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.MessageReceived) {
|
||||
return (
|
||||
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const Conference = ({
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
@@ -49,6 +49,7 @@ export const Conference = ({
|
||||
isError: isFetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: fetchKey,
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
|
||||
@@ -3,11 +3,23 @@ import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
const room = useRoomContext()
|
||||
|
||||
const muteParticipant = (participant: Participant) => {
|
||||
const notifyParticipant = async (participant: Participant) => {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(NotificationType.ParticipantMuted)
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
}
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
@@ -18,22 +30,33 @@ export const useMuteParticipant = () => {
|
||||
if (!trackSid) {
|
||||
throw new Error('Missing audio track')
|
||||
}
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
muted: true,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
muted: true,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
await notifyParticipant(participant)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { muteParticipant }
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
}
|
||||
}
|
||||
|
||||
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
|
||||
return { isHandRaised: parsedMetadata.raised || false, toggleRaisedHand }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useParams } from 'wouter'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
|
||||
export const useRoomData = (): ApiRoom | undefined => {
|
||||
const room = useRoomContext()
|
||||
const { roomId } = useParams()
|
||||
const queryKey = [keys.room, roomId, room.localParticipant.name]
|
||||
const queryKey = [keys.room, roomId]
|
||||
return queryClient.getQueryData<ApiRoom>(queryKey)
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
"raised": {
|
||||
"description": "",
|
||||
"cta": ""
|
||||
}
|
||||
},
|
||||
"muted": "",
|
||||
"openChat": ""
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
"raised": {
|
||||
"description": "{{name}} has raised their hand.",
|
||||
"cta": "Open waiting list"
|
||||
}
|
||||
},
|
||||
"muted": "{{name}} has muted your microphone. No participant can hear you.",
|
||||
"openChat": "Open chat"
|
||||
}
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
"raised": {
|
||||
"description": "{{name}} a levé la main.",
|
||||
"cta": "Ouvrir la file d'attente"
|
||||
}
|
||||
},
|
||||
"muted": "{{name}} a coupé votre micro. Aucun participant ne peut l'entendre.",
|
||||
"openChat": "Ouvrir le chat"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const DEFAULT_STATE: State = {
|
||||
new Map([
|
||||
[NotificationType.ParticipantJoined, true],
|
||||
[NotificationType.HandRaised, true],
|
||||
[NotificationType.MessageReceived, true],
|
||||
])
|
||||
),
|
||||
soundNotificationVolume: 0.1,
|
||||
@@ -24,7 +25,21 @@ function getNotificationsState(): State {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored, deserializeToProxyMap)
|
||||
return parsed || DEFAULT_STATE
|
||||
// Ensure all default notification types exist in the recovered state
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
soundNotifications: proxyMap(
|
||||
new Map(
|
||||
Array.from(DEFAULT_STATE.soundNotifications.keys()).map((key) => [
|
||||
key,
|
||||
parsed.soundNotifications.has(key)
|
||||
? parsed.soundNotifications.get(key)
|
||||
: DEFAULT_STATE.soundNotifications.get(key),
|
||||
])
|
||||
)
|
||||
),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[NotificationsStore] Failed to parse stored settings:',
|
||||
|
||||
Reference in New Issue
Block a user