11import type { ReactElement } from 'react' ;
22import React , { useEffect , useMemo , useRef , useState } from 'react' ;
3- import classNames from 'classnames' ;
43import { Button , ButtonSize , ButtonVariant } from '../buttons/Button' ;
54import { EmojiPicker } from '../fields/EmojiPicker' ;
65import { IconSize } from '../Icon' ;
@@ -53,7 +52,6 @@ const FIRST_REACTION_BURST_DURATION_MS = 720;
5352const FIRST_REACTION_PARTICLE_MAX_DELAY_MS = 90 ;
5453const FIRST_REACTION_BURST_CLEAR_DELAY_MS =
5554 FIRST_REACTION_BURST_DURATION_MS + FIRST_REACTION_PARTICLE_MAX_DELAY_MS ;
56- const MAX_REACTION_SLOTS = 5 ;
5755
5856export const getChatReactionGroups = (
5957 message : LiveRoomChatEntry ,
@@ -293,6 +291,7 @@ interface LiveRoomChatReactionsProps {
293291 senderName : string ;
294292 reactionBusy : string | null ;
295293 hideQuickReactions ?: boolean ;
294+ floatingTrayPlacement ?: 'above' | 'below' ;
296295 onReactionAction : (
297296 messageId : string ,
298297 reactionKey : string ,
@@ -308,15 +307,18 @@ export const LiveRoomChatReactions = ({
308307 senderName,
309308 reactionBusy,
310309 hideQuickReactions = false ,
310+ floatingTrayPlacement = 'above' ,
311311 onReactionAction,
312312} : LiveRoomChatReactionsProps ) : ReactElement | null => {
313313 const { firstReactionBurst, getPulseSignal } =
314314 useChatReactionAnimations ( message ) ;
315315 const reactionGroups = getChatReactionGroups ( message , currentParticipantId ) ;
316- const reactionKeys = new Set ( reactionGroups . map ( ( reaction ) => reaction . key ) ) ;
317- const quickReactionKeys = LIVE_ROOM_QUICK_REACTION_EMOJIS . filter (
318- ( reactionKey ) => ! reactionKeys . has ( reactionKey ) ,
319- ) . slice ( 0 , Math . max ( 0 , MAX_REACTION_SLOTS - reactionGroups . length ) ) ;
316+ const myReactionKeys = new Set (
317+ reactionGroups
318+ . filter ( ( group ) => group . isReactedByCurrentParticipant )
319+ . map ( ( group ) => group . key ) ,
320+ ) ;
321+ const quickReactionKeys = LIVE_ROOM_QUICK_REACTION_EMOJIS ;
320322 const showQuickReactions = canChat && quickReactionKeys . length > 0 ;
321323 const hasActiveReactions = reactionGroups . length > 0 ;
322324 const baseReactionAnalytics = {
@@ -333,117 +335,136 @@ export const LiveRoomChatReactions = ({
333335 }
334336
335337 const renderQuickReactionsTray = ! hideQuickReactions ;
338+ const showFloatingTray =
339+ renderQuickReactionsTray && ( showQuickReactions || canChat ) ;
336340
337341 return (
338- < div
339- className = { classNames (
340- 'relative mt-1 flex flex-wrap items-center gap-1 transition-opacity' ,
341- ! hasActiveReactions
342- ? 'opacity-100 tablet:opacity-0 tablet:group-focus-within:opacity-100 tablet:group-hover:opacity-100'
343- : 'opacity-100' ,
344- ) }
345- >
346- { firstReactionBurst ? (
347- < FirstReactionBurst
348- emoji = { firstReactionBurst . emoji }
349- signal = { firstReactionBurst . signal }
350- />
342+ < >
343+ { hasActiveReactions ? (
344+ < div className = "relative mt-1 flex flex-wrap items-center gap-1" >
345+ { firstReactionBurst ? (
346+ < FirstReactionBurst
347+ emoji = { firstReactionBurst . emoji }
348+ signal = { firstReactionBurst . signal }
349+ />
350+ ) : null }
351+ { reactionGroups . map ( ( reaction ) => {
352+ const reactionKey = `${ message . messageId } -${ reaction . key } ` ;
353+
354+ return (
355+ < ChatReactionChip
356+ key = { reaction . key }
357+ emoji = { reaction . key }
358+ count = { reaction . count }
359+ ariaLabel = { `${
360+ reaction . isReactedByCurrentParticipant ? 'Remove' : 'React'
361+ } ${ reaction . key } ${
362+ reaction . isReactedByCurrentParticipant
363+ ? 'reaction from'
364+ : 'to'
365+ } message from ${ senderName } `}
366+ disabled = { ! canChat || ! ! reactionBusy }
367+ isSending = { reactionBusy === reactionKey }
368+ pulseSignal = { getPulseSignal ( reaction . key ) }
369+ onClick = { ( ) =>
370+ onReactionAction (
371+ message . messageId ,
372+ reaction . key ,
373+ {
374+ ...baseReactionAnalytics ,
375+ source : 'active_chip' ,
376+ } ,
377+ reaction . isReactedByCurrentParticipant ,
378+ )
379+ }
380+ />
381+ ) ;
382+ } ) }
383+ </ div >
351384 ) : null }
352- { reactionGroups . map ( ( reaction ) => {
353- const reactionKey = `${ message . messageId } -${ reaction . key } ` ;
354-
355- return (
356- < ChatReactionChip
357- key = { reaction . key }
358- emoji = { reaction . key }
359- count = { reaction . count }
360- ariaLabel = { `${
361- reaction . isReactedByCurrentParticipant ? 'Remove' : 'React'
362- } ${ reaction . key } ${
363- reaction . isReactedByCurrentParticipant ? 'reaction from' : 'to'
364- } message from ${ senderName } `}
365- disabled = { ! canChat || ! ! reactionBusy }
366- isSending = { reactionBusy === reactionKey }
367- pulseSignal = { getPulseSignal ( reaction . key ) }
368- onClick = { ( ) =>
369- onReactionAction (
370- message . messageId ,
371- reaction . key ,
372- {
373- ...baseReactionAnalytics ,
374- source : 'active_chip' ,
375- } ,
376- reaction . isReactedByCurrentParticipant ,
377- )
378- }
379- />
380- ) ;
381- } ) }
382- { renderQuickReactionsTray && ( showQuickReactions || canChat ) ? (
385+ { showFloatingTray ? (
383386 < div
384- key = { hasActiveReactions ? 'active-reactions' : 'empty-reactions' }
385- className = { classNames (
386- 'flex flex-wrap items-center gap-1 transition-opacity' ,
387- hasActiveReactions &&
388- 'opacity-100 tablet:opacity-0 tablet:group-focus-within:opacity-100 tablet:group-hover:opacity-100' ,
389- ) }
387+ className = {
388+ floatingTrayPlacement === 'below'
389+ ? 'absolute right-2 top-full z-1 hidden group-focus-within:flex group-hover:flex'
390+ : 'absolute bottom-full right-2 z-1 hidden group-focus-within:flex group-hover:flex'
391+ }
390392 >
391- { showQuickReactions
392- ? quickReactionKeys . map ( ( reactionKey ) => {
393- const busyKey = `${ message . messageId } -${ reactionKey } ` ;
394-
395- return (
396- < button
397- key = { reactionKey }
398- type = "button"
399- className = "flex size-6 items-center justify-center rounded-8 border border-border-subtlest-tertiary bg-surface-float text-sm leading-none text-text-secondary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50"
400- aria-label = { `React ${ reactionKey } to message from ${ senderName } ` }
401- disabled = { ! ! reactionBusy }
402- onClick = { ( ) =>
403- onReactionAction ( message . messageId , reactionKey , {
404- ...baseReactionAnalytics ,
405- source : 'quick_shortcut' ,
406- } )
407- }
408- >
409- < span > { reactionKey } </ span >
410- { reactionBusy === busyKey ? (
411- < span className = "sr-only" > Sending</ span >
412- ) : null }
413- </ button >
414- ) ;
415- } )
416- : null }
417- < EmojiPicker
418- value = ""
419- label = { null }
420- className = "shrink-0"
421- onChange = { ( reactionKey ) => {
422- if ( ! reactionKey ) {
423- return ;
424- }
425-
426- onReactionAction ( message . messageId , reactionKey , {
427- ...baseReactionAnalytics ,
428- source : 'custom_picker' ,
429- } ) ;
430- } }
431- renderTrigger = { ( { isOpen, toggleOpen } ) => (
432- < Button
433- type = "button"
434- size = { ButtonSize . XSmall }
435- variant = { isOpen ? ButtonVariant . Primary : ButtonVariant . Float }
436- className = "!size-6 shrink-0"
437- icon = { < PlusIcon size = { IconSize . Size16 } /> }
438- aria-label = { `Custom reaction to message from ${ senderName } ` }
439- aria-expanded = { isOpen }
440- disabled = { ! ! reactionBusy }
441- onClick = { toggleOpen }
442- />
443- ) }
444- />
393+ < div className = "flex items-center gap-0.5 rounded-12 border border-border-subtlest-tertiary bg-background-default p-0.5 shadow-2" >
394+ { showQuickReactions
395+ ? quickReactionKeys . map ( ( reactionKey ) => {
396+ const busyKey = `${ message . messageId } -${ reactionKey } ` ;
397+ const isReacted = myReactionKeys . has ( reactionKey ) ;
398+
399+ return (
400+ < button
401+ key = { reactionKey }
402+ type = "button"
403+ className = {
404+ isReacted
405+ ? 'flex size-6 items-center justify-center rounded-8 bg-action-upvote-float text-sm leading-none text-action-upvote-default hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50'
406+ : 'flex size-6 items-center justify-center rounded-8 text-sm leading-none text-text-secondary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50'
407+ }
408+ aria-label = { `${
409+ isReacted ? 'Remove' : 'React'
410+ } ${ reactionKey } ${
411+ isReacted ? 'reaction from' : 'to'
412+ } message from ${ senderName } `}
413+ aria-pressed = { isReacted }
414+ disabled = { ! ! reactionBusy }
415+ onClick = { ( ) =>
416+ onReactionAction (
417+ message . messageId ,
418+ reactionKey ,
419+ {
420+ ...baseReactionAnalytics ,
421+ source : 'quick_shortcut' ,
422+ } ,
423+ isReacted ,
424+ )
425+ }
426+ >
427+ < span > { reactionKey } </ span >
428+ { reactionBusy === busyKey ? (
429+ < span className = "sr-only" > Sending</ span >
430+ ) : null }
431+ </ button >
432+ ) ;
433+ } )
434+ : null }
435+ < EmojiPicker
436+ value = ""
437+ label = { null }
438+ className = "shrink-0"
439+ onChange = { ( reactionKey ) => {
440+ if ( ! reactionKey ) {
441+ return ;
442+ }
443+
444+ onReactionAction ( message . messageId , reactionKey , {
445+ ...baseReactionAnalytics ,
446+ source : 'custom_picker' ,
447+ } ) ;
448+ } }
449+ renderTrigger = { ( { isOpen, toggleOpen } ) => (
450+ < Button
451+ type = "button"
452+ size = { ButtonSize . XSmall }
453+ variant = {
454+ isOpen ? ButtonVariant . Primary : ButtonVariant . Tertiary
455+ }
456+ className = "!size-6 shrink-0"
457+ icon = { < PlusIcon size = { IconSize . Size16 } /> }
458+ aria-label = { `Custom reaction to message from ${ senderName } ` }
459+ aria-expanded = { isOpen }
460+ disabled = { ! ! reactionBusy }
461+ onClick = { toggleOpen }
462+ />
463+ ) }
464+ />
465+ </ div >
445466 </ div >
446467 ) : null }
447- </ div >
468+ </ >
448469 ) ;
449470} ;
0 commit comments