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

View File

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

View File

@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client'
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.
export const ParticipantsList = () => {
@@ -36,8 +35,7 @@ export const ParticipantsList = () => {
]
const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata)
return data.raised
return !!participant.attributes.handRaisedAt
})
const { waitingParticipants, handleParticipantEntry } =

View File

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