Skip to content

Commit d94b970

Browse files
committed
emoji reactions
1 parent 8d9ebc3 commit d94b970

File tree

13 files changed

+374
-43
lines changed

13 files changed

+374
-43
lines changed

packages/core/src/observables/dataChannel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ReceivedChatMessage } from '../components/chat';
1414
export const DataTopic = {
1515
CHAT: 'lk.chat',
1616
TRANSCRIPTION: 'lk.transcription',
17+
REACTIONS: 'lk.reactions',
1718
} as const;
1819

1920
/** @deprecated */
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import * as React from 'react';
2+
import { useRoomContext } from '../../context';
3+
import { setupDataMessageHandler } from '@livekit/components-core';
4+
import { DataTopic } from '@livekit/components-core';
5+
import { mergeProps } from '../../utils';
6+
import { useEmojiReactionContext } from '../../context/EmojiReactionContext';
7+
8+
const EMOJI_OPTIONS = ['👍', '👎', '❤️', '😂', '😮', '😢', '👏', '🎉'];
9+
10+
export interface EmojiReactionButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
11+
showIcon?: boolean;
12+
showText?: boolean;
13+
}
14+
15+
export function EmojiReactionButton({
16+
showIcon = true,
17+
showText = false,
18+
...props
19+
}: EmojiReactionButtonProps) {
20+
const room = useRoomContext();
21+
const { addReaction } = useEmojiReactionContext();
22+
const [isOpen, setIsOpen] = React.useState(false);
23+
const buttonRef = React.useRef<HTMLButtonElement>(null);
24+
const popupRef = React.useRef<HTMLDivElement>(null);
25+
26+
const { send, isSendingObservable } = React.useMemo(
27+
() => setupDataMessageHandler(room, DataTopic.REACTIONS),
28+
[room],
29+
);
30+
31+
const [isSending, setIsSending] = React.useState(false);
32+
33+
React.useEffect(() => {
34+
const subscription = isSendingObservable.subscribe(setIsSending);
35+
return () => subscription.unsubscribe();
36+
}, [isSendingObservable]);
37+
38+
React.useEffect(() => {
39+
const handleClickOutside = (event: MouseEvent) => {
40+
if (
41+
buttonRef.current &&
42+
popupRef.current &&
43+
!buttonRef.current.contains(event.target as Node) &&
44+
!popupRef.current.contains(event.target as Node)
45+
) {
46+
setIsOpen(false);
47+
}
48+
};
49+
50+
document.addEventListener('mousedown', handleClickOutside);
51+
return () => document.removeEventListener('mousedown', handleClickOutside);
52+
}, []);
53+
54+
const handleEmojiClick = React.useCallback(
55+
async (emoji: string) => {
56+
if (isSending) return;
57+
58+
try {
59+
const payload = new TextEncoder().encode(JSON.stringify({ emoji }));
60+
await send(payload);
61+
62+
// Add local reaction immediately
63+
addReaction({
64+
emoji,
65+
from: room.localParticipant,
66+
});
67+
68+
// Don't close the popup - let user send multiple reactions
69+
} catch (error) {
70+
console.error('Failed to send emoji reaction:', error);
71+
}
72+
},
73+
[send, isSending, room.localParticipant, addReaction],
74+
);
75+
76+
const htmlProps = mergeProps(
77+
{
78+
className: 'lk-button lk-emoji-reaction-button',
79+
onClick: () => setIsOpen(!isOpen),
80+
disabled: isSending,
81+
},
82+
props,
83+
);
84+
85+
return (
86+
<div className="lk-button-group">
87+
<button ref={buttonRef} {...htmlProps}>
88+
{showIcon && <span>😊</span>}
89+
{showText && 'Reactions'}
90+
</button>
91+
{isOpen && (
92+
<div ref={popupRef} className="lk-emoji-popup">
93+
{EMOJI_OPTIONS.map((emoji) => (
94+
<button
95+
key={emoji}
96+
className="lk-emoji-option"
97+
onClick={() => handleEmojiClick(emoji)}
98+
disabled={isSending}
99+
>
100+
{emoji}
101+
</button>
102+
))}
103+
</div>
104+
)}
105+
</div>
106+
);
107+
}

packages/react/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './controls/MediaDeviceSelect';
77
export * from './controls/StartAudio';
88
export * from './controls/StartMediaButton';
99
export * from './controls/TrackToggle';
10+
export * from './controls/EmojiReactionButton';
1011
export * from './layout';
1112
export * from './layout/LayoutContextProvider';
1213
export * from './LiveKitRoom';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as React from 'react';
2+
import { useRoomContext } from '../../context';
3+
import { setupDataMessageHandler } from '@livekit/components-core';
4+
import { DataTopic } from '@livekit/components-core';
5+
import { useMaybeParticipantContext } from '../../context';
6+
import { useEmojiReactionContext } from '../../context/EmojiReactionContext';
7+
8+
export interface EmojiReactionProps {
9+
className?: string;
10+
}
11+
12+
export function EmojiReaction({ className }: EmojiReactionProps) {
13+
const room = useRoomContext();
14+
const participant = useMaybeParticipantContext();
15+
const { reactions: globalReactions } = useEmojiReactionContext();
16+
const [localReactions, setLocalReactions] = React.useState<Array<{ emoji: string; id: string; timestamp: number }>>([]);
17+
18+
// Filter reactions for this specific participant
19+
const participantReactions = React.useMemo(() => {
20+
const fromContext = globalReactions
21+
.filter(reaction => reaction.from.identity === participant?.identity)
22+
.map(reaction => ({
23+
emoji: reaction.emoji,
24+
id: reaction.id,
25+
timestamp: reaction.timestamp,
26+
}));
27+
28+
return [...fromContext, ...localReactions];
29+
}, [globalReactions, localReactions, participant?.identity]);
30+
31+
React.useEffect(() => {
32+
if (!room || !participant) return;
33+
34+
const { messageObservable } = setupDataMessageHandler(room, DataTopic.REACTIONS);
35+
36+
const subscription = messageObservable.subscribe((message) => {
37+
// Listen for messages from this participant (excluding local participant since they're handled by context)
38+
if (message.from?.identity === participant.identity && !message.from?.isLocal) {
39+
try {
40+
const data = JSON.parse(new TextDecoder().decode(message.payload));
41+
if (data.emoji) {
42+
const reactionId = `${Date.now()}-${Math.random()}`;
43+
setLocalReactions(prev => [...prev, {
44+
emoji: data.emoji,
45+
id: reactionId,
46+
timestamp: Date.now()
47+
}]);
48+
49+
// Remove reaction after 3 seconds
50+
setTimeout(() => {
51+
setLocalReactions(prev => prev.filter(r => r.id !== reactionId));
52+
}, 3000);
53+
}
54+
} catch (error) {
55+
console.error('Failed to parse emoji reaction:', error);
56+
}
57+
}
58+
});
59+
60+
return () => subscription.unsubscribe();
61+
}, [room, participant]);
62+
63+
if (participantReactions.length === 0) return null;
64+
65+
return (
66+
<div className={`lk-emoji-reactions ${className || ''}`}>
67+
{participantReactions.map((reaction) => (
68+
<div key={reaction.id} className="lk-emoji-reaction">
69+
{reaction.emoji}
70+
</div>
71+
))}
72+
</div>
73+
);
74+
}

packages/react/src/components/participant/ParticipantTile.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ParticipantPlaceholder } from '../../assets/images';
2020
import { LockLockedIcon, ScreenShareIcon } from '../../assets/icons';
2121
import { VideoTrack } from './VideoTrack';
2222
import { AudioTrack } from './AudioTrack';
23+
import { EmojiReaction } from './EmojiReaction';
2324
import { useParticipantTile } from '../../hooks';
2425
import { useIsEncrypted } from '../../hooks/useIsEncrypted';
2526

@@ -139,9 +140,9 @@ export const ParticipantTile: (
139140
{children ?? (
140141
<>
141142
{isTrackReference(trackReference) &&
142-
(trackReference.publication?.kind === 'video' ||
143-
trackReference.source === Track.Source.Camera ||
144-
trackReference.source === Track.Source.ScreenShare) ? (
143+
(trackReference.publication?.kind === 'video' ||
144+
trackReference.source === Track.Source.Camera ||
145+
trackReference.source === Track.Source.ScreenShare) ? (
145146
<VideoTrack
146147
trackRef={trackReference}
147148
onSubscriptionStatusChanged={handleSubscribe}
@@ -183,6 +184,7 @@ export const ParticipantTile: (
183184
</div>
184185
</>
185186
)}
187+
<EmojiReaction />
186188
<FocusToggle trackRef={trackReference} />
187189
</ParticipantContextIfNeeded>
188190
</TrackRefContextIfNeeded>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from 'react';
2+
import type { Participant } from 'livekit-client';
3+
4+
export interface EmojiReaction {
5+
emoji: string;
6+
id: string;
7+
timestamp: number;
8+
from: Participant;
9+
}
10+
11+
interface EmojiReactionContextValue {
12+
addReaction: (reaction: Omit<EmojiReaction, 'id' | 'timestamp'>) => void;
13+
reactions: EmojiReaction[];
14+
}
15+
16+
const EmojiReactionContext = React.createContext<EmojiReactionContextValue | null>(null);
17+
18+
export function EmojiReactionProvider({ children }: React.PropsWithChildren) {
19+
const [reactions, setReactions] = React.useState<EmojiReaction[]>([]);
20+
21+
const addReaction = React.useCallback((reaction: Omit<EmojiReaction, 'id' | 'timestamp'>) => {
22+
const newReaction: EmojiReaction = {
23+
...reaction,
24+
id: `${Date.now()}-${Math.random()}`,
25+
timestamp: Date.now(),
26+
};
27+
28+
setReactions(prev => [...prev, newReaction]);
29+
30+
// Remove reaction after 3 seconds
31+
setTimeout(() => {
32+
setReactions(prev => prev.filter(r => r.id !== newReaction.id));
33+
}, 3000);
34+
}, []);
35+
36+
const value = React.useMemo(() => ({
37+
addReaction,
38+
reactions,
39+
}), [addReaction, reactions]);
40+
41+
return (
42+
<EmojiReactionContext.Provider value={value}>
43+
{children}
44+
</EmojiReactionContext.Provider>
45+
);
46+
}
47+
48+
export function useEmojiReactionContext() {
49+
const context = React.useContext(EmojiReactionContext);
50+
if (!context) {
51+
throw new Error('useEmojiReactionContext must be used within EmojiReactionProvider');
52+
}
53+
return context;
54+
}

packages/react/src/context/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export {} from './chat-context';
1+
export { } from './chat-context';
22
export type { LayoutContextType } from './layout-context';
33
export {
44
LayoutContext,
@@ -14,7 +14,7 @@ export {
1414
useMaybeParticipantContext,
1515
useParticipantContext,
1616
} from './participant-context';
17-
export {} from './pin-context';
17+
export { } from './pin-context';
1818
export { RoomContext, useEnsureRoom, useMaybeRoomContext, useRoomContext } from './room-context';
1919
export {
2020
TrackRefContext,
@@ -24,3 +24,4 @@ export {
2424
} from './track-reference-context';
2525

2626
export { type FeatureFlags, useFeatureContext, LKFeatureContext } from './feature-context';
27+
export { EmojiReactionProvider, useEmojiReactionContext, type EmojiReaction } from './EmojiReactionContext';

packages/react/src/prefabs/ControlBar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DisconnectButton } from '../components/controls/DisconnectButton';
55
import { TrackToggle } from '../components/controls/TrackToggle';
66
import { ChatIcon, GearIcon, LeaveIcon } from '../assets/icons';
77
import { ChatToggle } from '../components/controls/ChatToggle';
8+
import { EmojiReactionButton } from '../components/controls/EmojiReactionButton';
89
import { useLocalParticipantPermissions, usePersistentUserChoices } from '../hooks';
910
import { useMediaQuery } from '../hooks/internal';
1011
import { useMaybeLayoutContext } from '../context';
@@ -21,6 +22,7 @@ export type ControlBarControls = {
2122
screenShare?: boolean;
2223
leave?: boolean;
2324
settings?: boolean;
25+
reactions?: boolean;
2426
};
2527

2628
const trackSourceToProtocol = (source: Track.Source) => {
@@ -107,6 +109,7 @@ export function ControlBar({
107109
visibleControls.microphone ??= canPublishSource(Track.Source.Microphone);
108110
visibleControls.screenShare ??= canPublishSource(Track.Source.ScreenShare);
109111
visibleControls.chat ??= localPermissions.canPublishData && controls?.chat;
112+
visibleControls.reactions ??= localPermissions.canPublishData && controls?.reactions;
110113
}
111114

112115
const showIcon = React.useMemo(
@@ -209,6 +212,11 @@ export function ControlBar({
209212
{showText && 'Chat'}
210213
</ChatToggle>
211214
)}
215+
{visibleControls.reactions && (
216+
<EmojiReactionButton showIcon={showIcon} showText={showText}>
217+
{showText && 'Reactions'}
218+
</EmojiReactionButton>
219+
)}
212220
{visibleControls.settings && (
213221
<SettingsMenuToggle>
214222
{showIcon && <GearIcon />}

0 commit comments

Comments
 (0)