Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/observables/dataChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ReceivedChatMessage } from '../components/chat';
export const DataTopic = {
CHAT: 'lk.chat',
TRANSCRIPTION: 'lk.transcription',
REACTIONS: 'lk.reactions',
} as const;

/** @deprecated */
Expand Down
107 changes: 107 additions & 0 deletions packages/react/src/components/controls/EmojiReactionButton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement> {
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<HTMLButtonElement>(null);
const popupRef = React.useRef<HTMLDivElement>(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 (
<div className="lk-button-group">
<button ref={buttonRef} {...htmlProps}>
{showIcon && <span>😊</span>}
{showText && 'Reactions'}
</button>
{isOpen && (
<div ref={popupRef} className="lk-emoji-popup">
{EMOJI_OPTIONS.map((emoji) => (
<button
key={emoji}
className="lk-emoji-option"
onClick={() => handleEmojiClick(emoji)}
disabled={isSending}
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
74 changes: 74 additions & 0 deletions packages/react/src/components/participant/EmojiReaction.tsx
Original file line number Diff line number Diff line change
@@ -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<Array<{ emoji: string; id: string; timestamp: number }>>([]);

// 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 (
<div className={`lk-emoji-reactions ${className || ''}`}>
{participantReactions.map((reaction) => (
<div key={reaction.id} className="lk-emoji-reaction">
{reaction.emoji}
</div>
))}
</div>
);
}
8 changes: 5 additions & 3 deletions packages/react/src/components/participant/ParticipantTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
Expand Down Expand Up @@ -183,6 +184,7 @@ export const ParticipantTile: (
</div>
</>
)}
<EmojiReaction />
<FocusToggle trackRef={trackReference} />
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
Expand Down
54 changes: 54 additions & 0 deletions packages/react/src/context/EmojiReactionContext.tsx
Original file line number Diff line number Diff line change
@@ -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<EmojiReaction, 'id' | 'timestamp'>) => void;
reactions: EmojiReaction[];
}

const EmojiReactionContext = React.createContext<EmojiReactionContextValue | null>(null);

export function EmojiReactionProvider({ children }: React.PropsWithChildren) {
const [reactions, setReactions] = React.useState<EmojiReaction[]>([]);

const addReaction = React.useCallback((reaction: Omit<EmojiReaction, 'id' | 'timestamp'>) => {
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 (
<EmojiReactionContext.Provider value={value}>
{children}
</EmojiReactionContext.Provider>
);
}

export function useEmojiReactionContext() {
const context = React.useContext(EmojiReactionContext);
if (!context) {
throw new Error('useEmojiReactionContext must be used within EmojiReactionProvider');
}
return context;
}
5 changes: 3 additions & 2 deletions packages/react/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {} from './chat-context';
export { } from './chat-context';
export type { LayoutContextType } from './layout-context';
export {
LayoutContext,
Expand All @@ -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,
Expand All @@ -24,3 +24,4 @@ export {
} from './track-reference-context';

export { type FeatureFlags, useFeatureContext, LKFeatureContext } from './feature-context';
export { EmojiReactionProvider, useEmojiReactionContext, type EmojiReaction } from './EmojiReactionContext';
8 changes: 8 additions & 0 deletions packages/react/src/prefabs/ControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ export type ControlBarControls = {
screenShare?: boolean;
leave?: boolean;
settings?: boolean;
reactions?: boolean;
};

const trackSourceToProtocol = (source: Track.Source) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -209,6 +212,11 @@ export function ControlBar({
{showText && 'Chat'}
</ChatToggle>
)}
{visibleControls.reactions && (
<EmojiReactionButton showIcon={showIcon} showText={showText}>
{showText && 'Reactions'}
</EmojiReactionButton>
)}
{visibleControls.settings && (
<SettingsMenuToggle>
{showIcon && <GearIcon />}
Expand Down
Loading
Loading