Skip to content

Commit 5fbe548

Browse files
committed
feat: highlight queue tab when host has unseen joins
Persistent warning-colored tab with rippling glow and waving raise-hand icon while a host or co-host has new queue arrivals they haven't viewed. Clears when the queue tab is opened or the queue empties.
1 parent bc4e1a2 commit 5fbe548

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

packages/shared/src/components/liveRooms/LiveRoom.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => {
104104
const [focusedSpeakerIndex, setFocusedSpeakerIndex] = useState<number | null>(
105105
null,
106106
);
107+
const [hasUnseenQueueJoins, setHasUnseenQueueJoins] = useState(false);
108+
const previousQueueLengthRef = useRef<number | null>(null);
107109
const lastLoggedRoomErrorRef = useRef<string | null>(null);
108110
const { buildStandupExtra, logStandupAction } = useLiveRoomStandupAnalytics({
109111
roomId,
@@ -405,6 +407,45 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => {
405407
}
406408
}, [activeTab, isFreeForAll]);
407409

410+
useEffect(() => {
411+
if (!roomState) {
412+
return;
413+
}
414+
const queueLength = queuedParticipantIds.length;
415+
const previousLength = previousQueueLengthRef.current;
416+
previousQueueLengthRef.current = queueLength;
417+
418+
if (queueLength === 0) {
419+
setHasUnseenQueueJoins(false);
420+
return;
421+
}
422+
if (previousLength === null) {
423+
return;
424+
}
425+
if (!hasHostPrivileges || isFreeForAll) {
426+
return;
427+
}
428+
if (activeTab === 'queue') {
429+
return;
430+
}
431+
if (queueLength <= previousLength) {
432+
return;
433+
}
434+
setHasUnseenQueueJoins(true);
435+
}, [
436+
roomState,
437+
queuedParticipantIds.length,
438+
hasHostPrivileges,
439+
isFreeForAll,
440+
activeTab,
441+
]);
442+
443+
useEffect(() => {
444+
if (activeTab === 'queue') {
445+
setHasUnseenQueueJoins(false);
446+
}
447+
}, [activeTab]);
448+
408449
useEffect(() => {
409450
setStagePage((currentPage) => Math.min(currentPage, stagePageCount - 1));
410451
}, [stagePageCount]);
@@ -638,6 +679,8 @@ const LiveRoomInner = ({ roomId }: LiveRoomProps): ReactElement => {
638679
active={activeTab}
639680
tabs={sidePanelTabs}
640681
onChange={handleTabChange}
682+
attentionTabId="queue"
683+
hasAttention={hasUnseenQueueJoins}
641684
/>
642685
<div {...sidePanelSwipeHandlers} className="min-h-0 flex-1">
643686
{activeTab === 'chat' ? (

packages/shared/src/components/liveRooms/LiveRoomSidePanelTabs.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import type { ReactElement } from 'react';
22
import React from 'react';
33
import classNames from 'classnames';
4+
import { RaiseHandIcon } from '../icons/RaiseHand';
5+
import { IconSize } from '../Icon';
46

57
export type LiveRoomSidePanelTab = 'chat' | 'queue' | 'audience';
68

79
interface LiveRoomSidePanelTabsProps {
810
active: LiveRoomSidePanelTab;
911
tabs: { id: LiveRoomSidePanelTab; label: string; count?: number }[];
1012
onChange: (tab: LiveRoomSidePanelTab) => void;
13+
attentionTabId?: LiveRoomSidePanelTab;
14+
hasAttention?: boolean;
1115
}
1216

1317
export const LiveRoomSidePanelTabs = ({
1418
active,
1519
tabs,
1620
onChange,
21+
attentionTabId,
22+
hasAttention = false,
1723
}: LiveRoomSidePanelTabsProps): ReactElement => (
1824
<div
1925
role="tablist"
@@ -22,6 +28,8 @@ export const LiveRoomSidePanelTabs = ({
2228
>
2329
{tabs.map((tab) => {
2430
const isActive = tab.id === active;
31+
const showAttention =
32+
hasAttention && attentionTabId === tab.id && !isActive;
2533

2634
return (
2735
<button
@@ -31,25 +39,45 @@ export const LiveRoomSidePanelTabs = ({
3139
aria-selected={isActive}
3240
onClick={() => onChange(tab.id)}
3341
className={classNames(
34-
'flex flex-1 items-center justify-center gap-1.5 rounded-8 px-2 py-1 transition-colors typo-footnote tablet:gap-2 tablet:rounded-10 tablet:px-3 tablet:py-2 tablet:typo-callout',
35-
isActive
36-
? 'bg-surface-float font-bold text-text-primary'
37-
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
42+
'relative flex flex-1 items-center justify-center gap-1.5 rounded-8 px-2 py-1 transition-colors typo-footnote tablet:gap-2 tablet:rounded-10 tablet:px-3 tablet:py-2 tablet:typo-callout',
43+
isActive && 'bg-surface-float font-bold text-text-primary',
44+
!isActive &&
45+
!showAttention &&
46+
'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
47+
showAttention &&
48+
'bg-status-warning font-bold text-white hover:brightness-110',
3849
)}
3950
>
51+
{showAttention ? (
52+
<RaiseHandIcon
53+
secondary
54+
size={IconSize.XSmall}
55+
className="origin-bottom animate-queue-attention-wave"
56+
/>
57+
) : null}
4058
{tab.label}
4159
{tab.count !== undefined && tab.count > 0 ? (
4260
<span
4361
className={classNames(
4462
'rounded-full px-1.5 typo-caption2',
45-
isActive
63+
showAttention && 'bg-white text-status-warning',
64+
!showAttention && isActive
4665
? 'bg-action-upvote-float text-action-upvote-default'
47-
: 'bg-surface-float text-text-tertiary',
66+
: null,
67+
!showAttention && !isActive
68+
? 'bg-surface-float text-text-tertiary'
69+
: null,
4870
)}
4971
>
5072
{tab.count}
5173
</span>
5274
) : null}
75+
{showAttention ? (
76+
<span
77+
aria-hidden="true"
78+
className="pointer-events-none absolute inset-0 animate-queue-attention rounded-8 tablet:rounded-10"
79+
/>
80+
) : null}
5381
</button>
5482
);
5583
})}

packages/shared/tailwind.config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,17 @@ export default {
278278
'60%': { transform: 'rotate(-10deg)' },
279279
'80%': { transform: 'rotate(6deg)' },
280280
},
281+
'queue-attention': {
282+
'0%': { boxShadow: '0 0 0 0 var(--status-warning)' },
283+
'70%': { boxShadow: '0 0 0 8px transparent' },
284+
'100%': { boxShadow: '0 0 0 0 transparent' },
285+
},
286+
'queue-attention-wave': {
287+
'0%, 60%, 100%': { transform: 'rotate(0deg)' },
288+
'15%': { transform: 'rotate(-18deg)' },
289+
'30%': { transform: 'rotate(14deg)' },
290+
'45%': { transform: 'rotate(-8deg)' },
291+
},
281292
},
282293
animation: {
283294
'scale-down-pulse':
@@ -289,6 +300,10 @@ export default {
289300
'raise-hand-pop':
290301
'raise-hand-pop 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both',
291302
'raise-hand-wave': 'raise-hand-wave 700ms ease-in-out 240ms both',
303+
'queue-attention':
304+
'queue-attention 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
305+
'queue-attention-wave':
306+
'queue-attention-wave 1.6s ease-in-out infinite',
292307
},
293308
},
294309
lineClamp: {

0 commit comments

Comments
 (0)