Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import Link from '../utilities/Link';
import { LogEvent } from '../../lib/log';
import styles from './CreateLiveRoomForm.module.css';

const DEFAULT_FREE_FOR_ALL_SPEAKER_LIMIT = 4;
const DEFAULT_FREE_FOR_ALL_SPEAKER_LIMIT = 100;
const DEFAULT_SCHEDULE_DELAY_MS = 30 * 60 * 1000;
const DESCRIPTION_MAX_LENGTH = 4000;

Expand Down
23 changes: 7 additions & 16 deletions packages/shared/src/components/liveRooms/LiveRoom.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ describe('LiveRoom', () => {
expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
});

it('limits chat reaction shortcuts to the remaining slots when active reactions exist', async () => {
it('always shows all quick reactions and toggles a reacted emoji off when clicked', async () => {
const sendChatMessageReaction = jest.fn().mockResolvedValue(undefined);
const removeChatMessageReaction = jest.fn().mockResolvedValue(undefined);
mockUseLiveRoomConnection.mockReturnValue(
Expand Down Expand Up @@ -749,16 +749,11 @@ describe('LiveRoom', () => {

renderLiveRoom();

expect(
screen.getByRole('button', {
name: 'Remove 🔥 reaction from message from @speaker1',
}),
).toHaveTextContent('2');
expect(
screen.getAllByRole('button', {
name: /(?:React|Remove) .* (?:to|reaction from) message from @speaker1/,
}),
).toHaveLength(5);
const fireRemoveButtons = screen.getAllByRole('button', {
name: 'Remove 🔥 reaction from message from @speaker1',
});
expect(fireRemoveButtons).toHaveLength(2);
expect(fireRemoveButtons[0]).toHaveTextContent('2');
expect(
screen.getByRole('button', {
name: 'React 👀 to message from @speaker1',
Expand All @@ -775,11 +770,7 @@ describe('LiveRoom', () => {
}),
).toBeInTheDocument();

fireEvent.click(
screen.getByRole('button', {
name: 'Remove 🔥 reaction from message from @speaker1',
}),
);
fireEvent.click(fireRemoveButtons[0]);

await waitFor(() =>
expect(removeChatMessageReaction).toHaveBeenCalledWith('message-1', '🔥'),
Expand Down
14 changes: 10 additions & 4 deletions packages/shared/src/components/liveRooms/LiveRoomChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ export const LiveRoomChatPanel = ({
const [pickerMessageId, setPickerMessageId] = useState<string | null>(null);
const longPressHandlers = useTouchLongPress<string>({
enabled: isMobile && canChat,
onLongPress: setPickerMessageId,
onLongPress: (messageId) => {
window.getSelection()?.removeAllRanges();
setPickerMessageId(messageId);
},
});

const pickerMessage = pickerMessageId
Expand Down Expand Up @@ -350,7 +353,7 @@ export const LiveRoomChatPanel = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex flex-1 flex-col gap-3 overflow-y-auto p-3"
className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-2"
>
{chatMessages.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
Expand All @@ -370,7 +373,7 @@ export const LiveRoomChatPanel = ({
</Typography>
</div>
) : (
chatMessages.map((message) => {
chatMessages.map((message, messageIndex) => {
const sender =
participantProfilesById.get(message.participantId) ??
buildParticipantProfile(message.participantId);
Expand All @@ -391,7 +394,7 @@ export const LiveRoomChatPanel = ({
<article
key={message.messageId}
className={classNames(
'group flex items-start gap-2 px-1 py-1.5',
'group relative flex items-start gap-2 px-1 py-1',
isMobile && 'select-none [-webkit-touch-callout:none]',
)}
onTouchStart={(event) =>
Expand Down Expand Up @@ -434,6 +437,9 @@ export const LiveRoomChatPanel = ({
senderName={senderName}
reactionBusy={reactionBusy}
hideQuickReactions={isMobile}
floatingTrayPlacement={
messageIndex === 0 ? 'below' : 'above'
}
onReactionAction={runReactionAction}
/>
</div>
Expand Down
243 changes: 132 additions & 111 deletions packages/shared/src/components/liveRooms/LiveRoomChatReactions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ReactElement } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { EmojiPicker } from '../fields/EmojiPicker';
import { IconSize } from '../Icon';
Expand Down Expand Up @@ -53,7 +52,6 @@ const FIRST_REACTION_BURST_DURATION_MS = 720;
const FIRST_REACTION_PARTICLE_MAX_DELAY_MS = 90;
const FIRST_REACTION_BURST_CLEAR_DELAY_MS =
FIRST_REACTION_BURST_DURATION_MS + FIRST_REACTION_PARTICLE_MAX_DELAY_MS;
const MAX_REACTION_SLOTS = 5;

export const getChatReactionGroups = (
message: LiveRoomChatEntry,
Expand Down Expand Up @@ -293,6 +291,7 @@ interface LiveRoomChatReactionsProps {
senderName: string;
reactionBusy: string | null;
hideQuickReactions?: boolean;
floatingTrayPlacement?: 'above' | 'below';
onReactionAction: (
messageId: string,
reactionKey: string,
Expand All @@ -308,15 +307,18 @@ export const LiveRoomChatReactions = ({
senderName,
reactionBusy,
hideQuickReactions = false,
floatingTrayPlacement = 'above',
onReactionAction,
}: LiveRoomChatReactionsProps): ReactElement | null => {
const { firstReactionBurst, getPulseSignal } =
useChatReactionAnimations(message);
const reactionGroups = getChatReactionGroups(message, currentParticipantId);
const reactionKeys = new Set(reactionGroups.map((reaction) => reaction.key));
const quickReactionKeys = LIVE_ROOM_QUICK_REACTION_EMOJIS.filter(
(reactionKey) => !reactionKeys.has(reactionKey),
).slice(0, Math.max(0, MAX_REACTION_SLOTS - reactionGroups.length));
const myReactionKeys = new Set(
reactionGroups
.filter((group) => group.isReactedByCurrentParticipant)
.map((group) => group.key),
);
const quickReactionKeys = LIVE_ROOM_QUICK_REACTION_EMOJIS;
const showQuickReactions = canChat && quickReactionKeys.length > 0;
const hasActiveReactions = reactionGroups.length > 0;
const baseReactionAnalytics = {
Expand All @@ -333,117 +335,136 @@ export const LiveRoomChatReactions = ({
}

const renderQuickReactionsTray = !hideQuickReactions;
const showFloatingTray =
renderQuickReactionsTray && (showQuickReactions || canChat);

return (
<div
className={classNames(
'relative mt-1 flex flex-wrap items-center gap-1 transition-opacity',
!hasActiveReactions
? 'opacity-100 tablet:opacity-0 tablet:group-focus-within:opacity-100 tablet:group-hover:opacity-100'
: 'opacity-100',
)}
>
{firstReactionBurst ? (
<FirstReactionBurst
emoji={firstReactionBurst.emoji}
signal={firstReactionBurst.signal}
/>
<>
{hasActiveReactions ? (
<div className="relative mt-1 flex flex-wrap items-center gap-1">
{firstReactionBurst ? (
<FirstReactionBurst
emoji={firstReactionBurst.emoji}
signal={firstReactionBurst.signal}
/>
) : null}
{reactionGroups.map((reaction) => {
const reactionKey = `${message.messageId}-${reaction.key}`;

return (
<ChatReactionChip
key={reaction.key}
emoji={reaction.key}
count={reaction.count}
ariaLabel={`${
reaction.isReactedByCurrentParticipant ? 'Remove' : 'React'
} ${reaction.key} ${
reaction.isReactedByCurrentParticipant
? 'reaction from'
: 'to'
} message from ${senderName}`}
disabled={!canChat || !!reactionBusy}
isSending={reactionBusy === reactionKey}
pulseSignal={getPulseSignal(reaction.key)}
onClick={() =>
onReactionAction(
message.messageId,
reaction.key,
{
...baseReactionAnalytics,
source: 'active_chip',
},
reaction.isReactedByCurrentParticipant,
)
}
/>
);
})}
</div>
) : null}
{reactionGroups.map((reaction) => {
const reactionKey = `${message.messageId}-${reaction.key}`;

return (
<ChatReactionChip
key={reaction.key}
emoji={reaction.key}
count={reaction.count}
ariaLabel={`${
reaction.isReactedByCurrentParticipant ? 'Remove' : 'React'
} ${reaction.key} ${
reaction.isReactedByCurrentParticipant ? 'reaction from' : 'to'
} message from ${senderName}`}
disabled={!canChat || !!reactionBusy}
isSending={reactionBusy === reactionKey}
pulseSignal={getPulseSignal(reaction.key)}
onClick={() =>
onReactionAction(
message.messageId,
reaction.key,
{
...baseReactionAnalytics,
source: 'active_chip',
},
reaction.isReactedByCurrentParticipant,
)
}
/>
);
})}
{renderQuickReactionsTray && (showQuickReactions || canChat) ? (
{showFloatingTray ? (
<div
key={hasActiveReactions ? 'active-reactions' : 'empty-reactions'}
className={classNames(
'flex flex-wrap items-center gap-1 transition-opacity',
hasActiveReactions &&
'opacity-100 tablet:opacity-0 tablet:group-focus-within:opacity-100 tablet:group-hover:opacity-100',
)}
className={
floatingTrayPlacement === 'below'
? 'absolute right-2 top-full z-1 hidden group-focus-within:flex group-hover:flex'
: 'absolute bottom-full right-2 z-1 hidden group-focus-within:flex group-hover:flex'
}
>
{showQuickReactions
? quickReactionKeys.map((reactionKey) => {
const busyKey = `${message.messageId}-${reactionKey}`;

return (
<button
key={reactionKey}
type="button"
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"
aria-label={`React ${reactionKey} to message from ${senderName}`}
disabled={!!reactionBusy}
onClick={() =>
onReactionAction(message.messageId, reactionKey, {
...baseReactionAnalytics,
source: 'quick_shortcut',
})
}
>
<span>{reactionKey}</span>
{reactionBusy === busyKey ? (
<span className="sr-only">Sending</span>
) : null}
</button>
);
})
: null}
<EmojiPicker
value=""
label={null}
className="shrink-0"
onChange={(reactionKey) => {
if (!reactionKey) {
return;
}

onReactionAction(message.messageId, reactionKey, {
...baseReactionAnalytics,
source: 'custom_picker',
});
}}
renderTrigger={({ isOpen, toggleOpen }) => (
<Button
type="button"
size={ButtonSize.XSmall}
variant={isOpen ? ButtonVariant.Primary : ButtonVariant.Float}
className="!size-6 shrink-0"
icon={<PlusIcon size={IconSize.Size16} />}
aria-label={`Custom reaction to message from ${senderName}`}
aria-expanded={isOpen}
disabled={!!reactionBusy}
onClick={toggleOpen}
/>
)}
/>
<div className="flex items-center gap-0.5 rounded-12 border border-border-subtlest-tertiary bg-background-default p-0.5 shadow-2">
{showQuickReactions
? quickReactionKeys.map((reactionKey) => {
const busyKey = `${message.messageId}-${reactionKey}`;
const isReacted = myReactionKeys.has(reactionKey);

return (
<button
key={reactionKey}
type="button"
className={
isReacted
? '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'
: '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'
}
aria-label={`${
isReacted ? 'Remove' : 'React'
} ${reactionKey} ${
isReacted ? 'reaction from' : 'to'
} message from ${senderName}`}
aria-pressed={isReacted}
disabled={!!reactionBusy}
onClick={() =>
onReactionAction(
message.messageId,
reactionKey,
{
...baseReactionAnalytics,
source: 'quick_shortcut',
},
isReacted,
)
}
>
<span>{reactionKey}</span>
{reactionBusy === busyKey ? (
<span className="sr-only">Sending</span>
) : null}
</button>
);
})
: null}
<EmojiPicker
value=""
label={null}
className="shrink-0"
onChange={(reactionKey) => {
if (!reactionKey) {
return;
}

onReactionAction(message.messageId, reactionKey, {
...baseReactionAnalytics,
source: 'custom_picker',
});
}}
renderTrigger={({ isOpen, toggleOpen }) => (
<Button
type="button"
size={ButtonSize.XSmall}
variant={
isOpen ? ButtonVariant.Primary : ButtonVariant.Tertiary
}
className="!size-6 shrink-0"
icon={<PlusIcon size={IconSize.Size16} />}
aria-label={`Custom reaction to message from ${senderName}`}
aria-expanded={isOpen}
disabled={!!reactionBusy}
onClick={toggleOpen}
/>
)}
/>
</div>
</div>
) : null}
</div>
</>
);
};
Loading
Loading