diff --git a/packages/core/src/observables/dataChannel.ts b/packages/core/src/observables/dataChannel.ts index 04f6bf9f4..ea1cf1ea6 100644 --- a/packages/core/src/observables/dataChannel.ts +++ b/packages/core/src/observables/dataChannel.ts @@ -14,6 +14,7 @@ import { ReceivedChatMessage } from '../components/chat'; export const DataTopic = { CHAT: 'lk.chat', TRANSCRIPTION: 'lk.transcription', + REACTIONS: 'lk.reactions', } as const; /** @deprecated */ diff --git a/packages/react/src/components/controls/EmojiReactionButton.tsx b/packages/react/src/components/controls/EmojiReactionButton.tsx new file mode 100644 index 000000000..37f1741af --- /dev/null +++ b/packages/react/src/components/controls/EmojiReactionButton.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { useRoomContext } from '../../context'; +import { setupDataMessageHandler } from '@livekit/components-core'; +import { DataTopic } from '@livekit/components-core'; +import { mergeProps } from '../../utils'; +import { useEmojiReactionContext } from '../../context/EmojiReactionContext'; + +const EMOJI_OPTIONS = ['👍', '👎', '❤️', '😂', '😮', '😢', '👏', '🎉']; + +export interface EmojiReactionButtonProps extends React.HTMLAttributes { + showIcon?: boolean; + showText?: boolean; +} + +export function EmojiReactionButton({ + showIcon = true, + showText = false, + ...props +}: EmojiReactionButtonProps) { + const room = useRoomContext(); + const { addReaction } = useEmojiReactionContext(); + const [isOpen, setIsOpen] = React.useState(false); + const buttonRef = React.useRef(null); + const popupRef = React.useRef(null); + + const { send, isSendingObservable } = React.useMemo( + () => setupDataMessageHandler(room, DataTopic.REACTIONS), + [room], + ); + + const [isSending, setIsSending] = React.useState(false); + + React.useEffect(() => { + const subscription = isSendingObservable.subscribe(setIsSending); + return () => subscription.unsubscribe(); + }, [isSendingObservable]); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + buttonRef.current && + popupRef.current && + !buttonRef.current.contains(event.target as Node) && + !popupRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleEmojiClick = React.useCallback( + async (emoji: string) => { + if (isSending) return; + + try { + const payload = new TextEncoder().encode(JSON.stringify({ emoji })); + await send(payload); + + // Add local reaction immediately + addReaction({ + emoji, + from: room.localParticipant, + }); + + // Don't close the popup - let user send multiple reactions + } catch (error) { + console.error('Failed to send emoji reaction:', error); + } + }, + [send, isSending, room.localParticipant, addReaction], + ); + + const htmlProps = mergeProps( + { + className: 'lk-button lk-emoji-reaction-button', + onClick: () => setIsOpen(!isOpen), + disabled: isSending, + }, + props, + ); + + return ( +
+ + {isOpen && ( +
+ {EMOJI_OPTIONS.map((emoji) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index ca61b230a..c0254f2ad 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,6 +7,7 @@ export * from './controls/MediaDeviceSelect'; export * from './controls/StartAudio'; export * from './controls/StartMediaButton'; export * from './controls/TrackToggle'; +export * from './controls/EmojiReactionButton'; export * from './layout'; export * from './layout/LayoutContextProvider'; export * from './LiveKitRoom'; diff --git a/packages/react/src/components/participant/EmojiReaction.tsx b/packages/react/src/components/participant/EmojiReaction.tsx new file mode 100644 index 000000000..6c2f94fa4 --- /dev/null +++ b/packages/react/src/components/participant/EmojiReaction.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { useRoomContext } from '../../context'; +import { setupDataMessageHandler } from '@livekit/components-core'; +import { DataTopic } from '@livekit/components-core'; +import { useMaybeParticipantContext } from '../../context'; +import { useEmojiReactionContext } from '../../context/EmojiReactionContext'; + +export interface EmojiReactionProps { + className?: string; +} + +export function EmojiReaction({ className }: EmojiReactionProps) { + const room = useRoomContext(); + const participant = useMaybeParticipantContext(); + const { reactions: globalReactions } = useEmojiReactionContext(); + const [localReactions, setLocalReactions] = React.useState>([]); + + // Filter reactions for this specific participant + const participantReactions = React.useMemo(() => { + const fromContext = globalReactions + .filter(reaction => reaction.from.identity === participant?.identity) + .map(reaction => ({ + emoji: reaction.emoji, + id: reaction.id, + timestamp: reaction.timestamp, + })); + + return [...fromContext, ...localReactions]; + }, [globalReactions, localReactions, participant?.identity]); + + React.useEffect(() => { + if (!room || !participant) return; + + const { messageObservable } = setupDataMessageHandler(room, DataTopic.REACTIONS); + + const subscription = messageObservable.subscribe((message) => { + // Listen for messages from this participant (excluding local participant since they're handled by context) + if (message.from?.identity === participant.identity && !message.from?.isLocal) { + try { + const data = JSON.parse(new TextDecoder().decode(message.payload)); + if (data.emoji) { + const reactionId = `${Date.now()}-${Math.random()}`; + setLocalReactions(prev => [...prev, { + emoji: data.emoji, + id: reactionId, + timestamp: Date.now() + }]); + + // Remove reaction after 3 seconds + setTimeout(() => { + setLocalReactions(prev => prev.filter(r => r.id !== reactionId)); + }, 3000); + } + } catch (error) { + console.error('Failed to parse emoji reaction:', error); + } + } + }); + + return () => subscription.unsubscribe(); + }, [room, participant]); + + if (participantReactions.length === 0) return null; + + return ( +
+ {participantReactions.map((reaction) => ( +
+ {reaction.emoji} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/packages/react/src/components/participant/ParticipantTile.tsx b/packages/react/src/components/participant/ParticipantTile.tsx index 5d0706e0b..73b2c2a0a 100644 --- a/packages/react/src/components/participant/ParticipantTile.tsx +++ b/packages/react/src/components/participant/ParticipantTile.tsx @@ -20,6 +20,7 @@ import { ParticipantPlaceholder } from '../../assets/images'; import { LockLockedIcon, ScreenShareIcon } from '../../assets/icons'; import { VideoTrack } from './VideoTrack'; import { AudioTrack } from './AudioTrack'; +import { EmojiReaction } from './EmojiReaction'; import { useParticipantTile } from '../../hooks'; import { useIsEncrypted } from '../../hooks/useIsEncrypted'; @@ -139,9 +140,9 @@ export const ParticipantTile: ( {children ?? ( <> {isTrackReference(trackReference) && - (trackReference.publication?.kind === 'video' || - trackReference.source === Track.Source.Camera || - trackReference.source === Track.Source.ScreenShare) ? ( + (trackReference.publication?.kind === 'video' || + trackReference.source === Track.Source.Camera || + trackReference.source === Track.Source.ScreenShare) ? ( )} + diff --git a/packages/react/src/context/EmojiReactionContext.tsx b/packages/react/src/context/EmojiReactionContext.tsx new file mode 100644 index 000000000..432e66eea --- /dev/null +++ b/packages/react/src/context/EmojiReactionContext.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import type { Participant } from 'livekit-client'; + +export interface EmojiReaction { + emoji: string; + id: string; + timestamp: number; + from: Participant; +} + +interface EmojiReactionContextValue { + addReaction: (reaction: Omit) => void; + reactions: EmojiReaction[]; +} + +const EmojiReactionContext = React.createContext(null); + +export function EmojiReactionProvider({ children }: React.PropsWithChildren) { + const [reactions, setReactions] = React.useState([]); + + const addReaction = React.useCallback((reaction: Omit) => { + const newReaction: EmojiReaction = { + ...reaction, + id: `${Date.now()}-${Math.random()}`, + timestamp: Date.now(), + }; + + setReactions(prev => [...prev, newReaction]); + + // Remove reaction after 3 seconds + setTimeout(() => { + setReactions(prev => prev.filter(r => r.id !== newReaction.id)); + }, 3000); + }, []); + + const value = React.useMemo(() => ({ + addReaction, + reactions, + }), [addReaction, reactions]); + + return ( + + {children} + + ); +} + +export function useEmojiReactionContext() { + const context = React.useContext(EmojiReactionContext); + if (!context) { + throw new Error('useEmojiReactionContext must be used within EmojiReactionProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/react/src/context/index.ts b/packages/react/src/context/index.ts index 1b49fcdbb..eab8ad50d 100644 --- a/packages/react/src/context/index.ts +++ b/packages/react/src/context/index.ts @@ -1,4 +1,4 @@ -export {} from './chat-context'; +export { } from './chat-context'; export type { LayoutContextType } from './layout-context'; export { LayoutContext, @@ -14,7 +14,7 @@ export { useMaybeParticipantContext, useParticipantContext, } from './participant-context'; -export {} from './pin-context'; +export { } from './pin-context'; export { RoomContext, useEnsureRoom, useMaybeRoomContext, useRoomContext } from './room-context'; export { TrackRefContext, @@ -24,3 +24,4 @@ export { } from './track-reference-context'; export { type FeatureFlags, useFeatureContext, LKFeatureContext } from './feature-context'; +export { EmojiReactionProvider, useEmojiReactionContext, type EmojiReaction } from './EmojiReactionContext'; diff --git a/packages/react/src/prefabs/ControlBar.tsx b/packages/react/src/prefabs/ControlBar.tsx index 2b004834e..e132f0107 100644 --- a/packages/react/src/prefabs/ControlBar.tsx +++ b/packages/react/src/prefabs/ControlBar.tsx @@ -5,6 +5,7 @@ import { DisconnectButton } from '../components/controls/DisconnectButton'; import { TrackToggle } from '../components/controls/TrackToggle'; import { ChatIcon, GearIcon, LeaveIcon } from '../assets/icons'; import { ChatToggle } from '../components/controls/ChatToggle'; +import { EmojiReactionButton } from '../components/controls/EmojiReactionButton'; import { useLocalParticipantPermissions, usePersistentUserChoices } from '../hooks'; import { useMediaQuery } from '../hooks/internal'; import { useMaybeLayoutContext } from '../context'; @@ -21,6 +22,7 @@ export type ControlBarControls = { screenShare?: boolean; leave?: boolean; settings?: boolean; + reactions?: boolean; }; const trackSourceToProtocol = (source: Track.Source) => { @@ -107,6 +109,7 @@ export function ControlBar({ visibleControls.microphone ??= canPublishSource(Track.Source.Microphone); visibleControls.screenShare ??= canPublishSource(Track.Source.ScreenShare); visibleControls.chat ??= localPermissions.canPublishData && controls?.chat; + visibleControls.reactions ??= localPermissions.canPublishData && controls?.reactions; } const showIcon = React.useMemo( @@ -209,6 +212,11 @@ export function ControlBar({ {showText && 'Chat'} )} + {visibleControls.reactions && ( + + {showText && 'Reactions'} + + )} {visibleControls.settings && ( {showIcon && } diff --git a/packages/react/src/prefabs/VideoConference.tsx b/packages/react/src/prefabs/VideoConference.tsx index 62af5d61f..b5b5558bd 100644 --- a/packages/react/src/prefabs/VideoConference.tsx +++ b/packages/react/src/prefabs/VideoConference.tsx @@ -23,6 +23,7 @@ import { usePinnedTracks, useTracks } from '../hooks'; import { Chat } from './Chat'; import { ControlBar } from './ControlBar'; import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles'; +import { EmojiReactionProvider } from '../context/EmojiReactionContext'; /** * @public @@ -133,45 +134,49 @@ export function VideoConference({ return (
{isWeb() && ( - -
- {!focusTrack ? ( -
- - - + + +
+
+ {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )}
- ) : ( -
- - - - - {focusTrack && } - -
- )} - -
- - {SettingsComponent && ( -
- + + {SettingsComponent && ( +
+ +
+ )}
- )} -
+ + +
)} diff --git a/packages/styles/scss/components/controls/_emoji-reaction-button.scss b/packages/styles/scss/components/controls/_emoji-reaction-button.scss new file mode 100644 index 000000000..721d4870c --- /dev/null +++ b/packages/styles/scss/components/controls/_emoji-reaction-button.scss @@ -0,0 +1,39 @@ +.lk-emoji-reaction-button { + position: relative; + + .lk-emoji-popup { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 0.5rem; + background-color: var(--bg2); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 0.5rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.25rem; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + + .lk-emoji-option { + font-size: 1.5rem; + padding: 0.5rem; + border: none; + background: none; + cursor: pointer; + border-radius: var(--border-radius); + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--accent-bg); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + } +} \ No newline at end of file diff --git a/packages/styles/scss/components/controls/index.scss b/packages/styles/scss/components/controls/index.scss index c92102698..1490ef553 100644 --- a/packages/styles/scss/components/controls/index.scss +++ b/packages/styles/scss/components/controls/index.scss @@ -2,7 +2,8 @@ @use 'disconnect-button'; @use 'focus-toggle'; @use 'chat-toggle'; +@use 'emoji-reaction-button'; @use 'media-device-select'; @use 'start-audio'; @use 'pagination-control'; -@use 'pagination-indicator'; +@use 'pagination-indicator'; \ No newline at end of file diff --git a/packages/styles/scss/components/participant/_emoji-reaction.scss b/packages/styles/scss/components/participant/_emoji-reaction.scss new file mode 100644 index 000000000..6c53cfe56 --- /dev/null +++ b/packages/styles/scss/components/participant/_emoji-reaction.scss @@ -0,0 +1,39 @@ +.lk-emoji-reactions { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10; + pointer-events: none; + + .lk-emoji-reaction { + font-size: 3rem; + animation: emojiReaction 3s ease-out forwards; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } +} + +@keyframes emojiReaction { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.5); + } + + 20% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.2); + } + + 80% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.1) translateY(-20px); + } +} \ No newline at end of file diff --git a/packages/styles/scss/components/participant/index.scss b/packages/styles/scss/components/participant/index.scss index 4509bcb3c..edafab907 100644 --- a/packages/styles/scss/components/participant/index.scss +++ b/packages/styles/scss/components/participant/index.scss @@ -4,3 +4,4 @@ @use 'participant-media'; @use 'audio-visualizer'; @use 'participant-tile'; +@use 'emoji-reaction'; \ No newline at end of file diff --git a/packages/styles/scss/prefabs/video-conference.scss b/packages/styles/scss/prefabs/video-conference.scss index 89497316d..5d1a85528 100644 --- a/packages/styles/scss/prefabs/video-conference.scss +++ b/packages/styles/scss/prefabs/video-conference.scss @@ -19,15 +19,25 @@ .video-conference { position: relative; display: flex; + flex-direction: column; align-items: stretch; height: 100%; } +.video-conference-content { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + flex: 1; +} + .video-conference-inner { display: flex; flex-direction: column; align-items: stretch; width: 100%; + flex: 1; } .settings-menu-modal { @@ -53,4 +63,4 @@ max-width: 100%; max-height: 100%; overflow-y: auto; -} +} \ No newline at end of file