♻️(frontend) refactor raised hand to rely on participant's attributes

Previously managed participant hand raised using raw metadata. LiveKit
introduced attributes concept, saves as metadata under hood but makes
update/handling easier.

Still pain that attributes must be strings, cannot pass boolean…

Refactor whole app to use attributes instead of metadata for the raised
hand feature. This commit might introduce regressions. I've double
checked participant list side pannel and the notification features.

Previously I persisted a boolean, I now persist the timestamp at which
the hand was raised. This will be useful in upcoming commits, especially
for sorting raised hands by order of arrival.
This commit is contained in:
lebaudantoine
2025-08-01 10:48:58 +02:00
committed by aleb_the_flash
parent 8518f83211
commit 199e0908e9
5 changed files with 32 additions and 49 deletions

View File

@@ -18,7 +18,6 @@ import {
ANIMATION_DURATION, ANIMATION_DURATION,
ReactionPortals, ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal' } from '@/features/rooms/livekit/components/ReactionPortal'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const MainNotificationToast = () => { export const MainNotificationToast = () => {
const room = useRoomContext() const room = useRoomContext()
@@ -155,17 +154,14 @@ export const MainNotificationToast = () => {
useEffect(() => { useEffect(() => {
const handleNotificationReceived = ( const handleNotificationReceived = (
prevMetadataStr: string | undefined, changedAttributes: Record<string, string>,
participant: Participant participant: Participant
) => { ) => {
if (!participant) return if (!participant) return
if (isMobileBrowser()) return if (isMobileBrowser()) return
if (participant.isLocal) return if (participant.isLocal) return
const prevMetadata = safeParseMetadata(prevMetadataStr) if (!('handRaisedAt' in changedAttributes)) return
const metadata = safeParseMetadata(participant.metadata)
if (prevMetadata?.raised == metadata?.raised) return
const existingToast = toastQueue.visibleToasts.find( const existingToast = toastQueue.visibleToasts.find(
(toast) => (toast) =>
@@ -173,12 +169,12 @@ export const MainNotificationToast = () => {
toast.content.type === NotificationType.HandRaised toast.content.type === NotificationType.HandRaised
) )
if (existingToast && prevMetadata.raised && !metadata.raised) { if (existingToast && !changedAttributes?.handRaisedAt) {
toastQueue.close(existingToast.key) toastQueue.close(existingToast.key)
return return
} }
if (!existingToast && !prevMetadata.raised && metadata.raised) { if (!existingToast && !!changedAttributes?.handRaisedAt) {
triggerNotificationSound(NotificationType.HandRaised) triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add( toastQueue.add(
{ {
@@ -190,10 +186,13 @@ export const MainNotificationToast = () => {
} }
} }
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived) room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
return () => { return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived) room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
} }
}, [room, triggerNotificationSound]) }, [room, triggerNotificationSound])

View File

@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi' import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl' import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData' import { useRoomData } from '../hooks/useRoomData'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const useLowerHandParticipant = () => { export const useLowerHandParticipant = () => {
const data = useRoomData() const data = useRoomData()
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
if (!data || !data?.livekit) { if (!data || !data?.livekit) {
throw new Error('Room data is not available') throw new Error('Room data is not available')
} }
const newMetadata = safeParseMetadata(participant.metadata) || {}
newMetadata.raised = !newMetadata.raised const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi( return fetchServerApi(
buildServerApiUrl( buildServerApiUrl(
data.livekit.url, data.livekit.url,
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
body: JSON.stringify({ body: JSON.stringify({
room: data.livekit.room, room: data.livekit.room,
identity: participant.identity, identity: participant.identity,
metadata: JSON.stringify(newMetadata), attributes: newAttributes,
permission: participant.permissions, permission: participant.permissions,
}), }),
} }

View File

@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants' import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => { export const ParticipantsList = () => {
@@ -36,8 +35,7 @@ export const ParticipantsList = () => {
] ]
const raisedHandParticipants = participants.filter((participant) => { const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata) return !!participant.attributes.handRaisedAt
return data.raised
}) })
const { waitingParticipants, handleParticipantEntry } = const { waitingParticipants, handleParticipantEntry } =

View File

@@ -1,5 +1,5 @@
import { LocalParticipant, Participant } from 'livekit-client' import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react' import { useParticipantAttribute } from '@livekit/components-react'
import { isLocal } from '@/utils/livekit' import { isLocal } from '@/utils/livekit'
type useRaisedHandProps = { type useRaisedHandProps = {
@@ -7,17 +7,22 @@ type useRaisedHandProps = {
} }
export function useRaisedHand({ participant }: useRaisedHandProps) { export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
const { metadata } = useParticipantInfo({ participant }) participant,
const parsedMetadata = JSON.parse(metadata || '{}') })
const toggleRaisedHand = () => { const isHandRaised = !!handRaisedAtAttribute
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised const toggleRaisedHand = async () => {
const localParticipant = participant as LocalParticipant if (!isLocal(participant)) return
localParticipant.setMetadata(JSON.stringify(parsedMetadata)) const localParticipant = participant as LocalParticipant
const attributes: Record<string, string> = {
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
} }
await localParticipant.setAttributes(attributes)
} }
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand } return { isHandRaised, toggleRaisedHand }
} }

View File

@@ -1,22 +0,0 @@
export const safeParseMetadata = (
metadataStr: string | null | undefined
): Record<string, unknown> => {
if (!metadataStr) {
return {}
}
try {
const parsed = JSON.parse(metadataStr)
// Ensure the result is an object
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Metadata parsed to non-object value:', parsed)
return {}
}
return parsed as Record<string, unknown>
} catch (error) {
console.error('Failed to parse metadata:', error)
return {}
}
}