Skip to content

Commit 7ff87cd

Browse files
authored
Add audience reactions: floating emoji reactions from phones to TV (#19)
* Add audience reactions e2e tests (multi-user Gherkin) Tests cover: - Single user sends reaction, TV shows floating emoji - Multiple users send reactions simultaneously - Reactions panel hidden when no song playing - Available emoji options displayed during playback * Implement audience reactions: emoji reactions from phones float across TV display Mobile users tap emojis (🔥❤️🎤👏😂🙌) during playback, which broadcast via WebSocket and animate upward on the TV display in real-time. * Fix audience reactions e2e step definitions for actual test IDs - join-session-button (not join-button) - tv-interface (not tv-display) - reactions-panel to verify playback state (visible on search tab) - match multi-user confirmation dialog pattern (10s timeout) * Redesign ReactionsPanel as a FAB instead of pinned block Emoji reactions now live behind a floating action button (bottom-right). Tap to expand the emoji column, tap an emoji to react. Much less intrusive. * Show sender name beneath floating emoji reactions on TV display * Adjust reaction name pill styling for TV readability * Add unit tests for ReactionsPanel and FloatingReactions components
1 parent 64f0ae9 commit 7ff87cd

19 files changed

Lines changed: 658 additions & 0 deletions

__tests__/app/tv/page.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ vi.mock("@/components/tv/ApplausePlayer", () => ({
103103
ApplausePlayer: () => <div data-testid="applause-player">ApplausePlayer</div>,
104104
}));
105105

106+
vi.mock("@/components/tv/FloatingReactions", () => ({
107+
FloatingReactions: () => (
108+
<div data-testid="floating-reactions">FloatingReactions</div>
109+
),
110+
}));
111+
112+
vi.mock("@/hooks/useReactions", () => ({
113+
useReactions: () => ({ reactions: [] }),
114+
}));
115+
106116
describe("TV Display Page", () => {
107117
beforeEach(() => {
108118
vi.clearAllMocks();
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from "react";
2+
import { render, screen, fireEvent, act } from "@testing-library/react";
3+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4+
import { ReactionsPanel } from "@/components/mobile/ReactionsPanel";
5+
6+
describe("ReactionsPanel", () => {
7+
const mockOnReaction = vi.fn();
8+
9+
beforeEach(() => {
10+
vi.clearAllMocks();
11+
vi.useFakeTimers();
12+
});
13+
14+
afterEach(() => {
15+
vi.useRealTimers();
16+
});
17+
18+
it("renders the FAB button in closed state", () => {
19+
render(<ReactionsPanel onReaction={mockOnReaction} />);
20+
21+
const fab = screen.getByLabelText("Open reactions");
22+
expect(fab).toBeInTheDocument();
23+
});
24+
25+
it("does not show emoji buttons when closed", () => {
26+
render(<ReactionsPanel onReaction={mockOnReaction} />);
27+
28+
expect(screen.queryByTestId("reaction-🔥")).not.toBeInTheDocument();
29+
});
30+
31+
it("shows emoji buttons when FAB is clicked", () => {
32+
render(<ReactionsPanel onReaction={mockOnReaction} />);
33+
34+
fireEvent.click(screen.getByLabelText("Open reactions"));
35+
36+
expect(screen.getByTestId("reaction-🔥")).toBeInTheDocument();
37+
expect(screen.getByTestId("reaction-❤️")).toBeInTheDocument();
38+
expect(screen.getByTestId("reaction-🎤")).toBeInTheDocument();
39+
expect(screen.getByTestId("reaction-👏")).toBeInTheDocument();
40+
expect(screen.getByTestId("reaction-😂")).toBeInTheDocument();
41+
expect(screen.getByTestId("reaction-🙌")).toBeInTheDocument();
42+
});
43+
44+
it("calls onReaction with the emoji when an emoji button is tapped", () => {
45+
render(<ReactionsPanel onReaction={mockOnReaction} />);
46+
47+
fireEvent.click(screen.getByLabelText("Open reactions"));
48+
fireEvent.click(screen.getByTestId("reaction-🔥"));
49+
50+
expect(mockOnReaction).toHaveBeenCalledWith("🔥");
51+
});
52+
53+
it("shows close button when open", () => {
54+
render(<ReactionsPanel onReaction={mockOnReaction} />);
55+
56+
fireEvent.click(screen.getByLabelText("Open reactions"));
57+
58+
expect(screen.getByLabelText("Close reactions")).toBeInTheDocument();
59+
});
60+
61+
it("hides emoji buttons when close is clicked", () => {
62+
render(<ReactionsPanel onReaction={mockOnReaction} />);
63+
64+
fireEvent.click(screen.getByLabelText("Open reactions"));
65+
expect(screen.getByTestId("reaction-🔥")).toBeInTheDocument();
66+
67+
fireEvent.click(screen.getByLabelText("Close reactions"));
68+
expect(screen.queryByTestId("reaction-🔥")).not.toBeInTheDocument();
69+
});
70+
71+
it("applies scale animation on tap then resets", () => {
72+
render(<ReactionsPanel onReaction={mockOnReaction} />);
73+
74+
fireEvent.click(screen.getByLabelText("Open reactions"));
75+
const btn = screen.getByTestId("reaction-❤️");
76+
77+
fireEvent.click(btn);
78+
expect(btn.className).toContain("scale-125");
79+
80+
act(() => {
81+
vi.advanceTimersByTime(200);
82+
});
83+
expect(btn.className).toContain("scale-100");
84+
});
85+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from "react";
2+
import { render, screen } from "@testing-library/react";
3+
import { describe, it, expect } from "vitest";
4+
import { FloatingReactions } from "@/components/tv/FloatingReactions";
5+
import { Reaction } from "@/hooks/useReactions";
6+
7+
const makeReaction = (
8+
id: string,
9+
emoji: string,
10+
userName: string
11+
): Reaction => ({
12+
id,
13+
emoji,
14+
userName,
15+
timestamp: Date.now(),
16+
});
17+
18+
describe("FloatingReactions", () => {
19+
it("renders nothing when reactions array is empty", () => {
20+
const { container } = render(<FloatingReactions reactions={[]} />);
21+
expect(container.innerHTML).toBe("");
22+
});
23+
24+
it("renders floating reactions with emojis and usernames", () => {
25+
const reactions = [
26+
makeReaction("r1", "🔥", "Alice"),
27+
makeReaction("r2", "❤️", "Bob"),
28+
];
29+
30+
render(<FloatingReactions reactions={reactions} />);
31+
32+
const elements = screen.getAllByTestId("floating-reaction");
33+
expect(elements).toHaveLength(2);
34+
expect(elements[0]).toHaveTextContent("🔥");
35+
expect(elements[0]).toHaveTextContent("Alice");
36+
expect(elements[1]).toHaveTextContent("❤️");
37+
expect(elements[1]).toHaveTextContent("Bob");
38+
});
39+
40+
it("positions reactions based on their ID hash", () => {
41+
const reactions = [makeReaction("abc", "🎤", "Charlie")];
42+
43+
render(<FloatingReactions reactions={reactions} />);
44+
45+
const el = screen.getByTestId("floating-reaction");
46+
const style = el.getAttribute("style");
47+
expect(style).toContain("left:");
48+
expect(style).toMatch(/left: \d+%/);
49+
});
50+
51+
it("renders the container with pointer-events-none", () => {
52+
const reactions = [makeReaction("r1", "👏", "Dave")];
53+
54+
render(<FloatingReactions reactions={reactions} />);
55+
56+
const container = screen.getByTestId("floating-reactions-container");
57+
expect(container.className).toContain("pointer-events-none");
58+
});
59+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
@multi-user
2+
Feature: Audience Reactions
3+
As audience members watching a karaoke performance
4+
We want to send emoji reactions from our phones
5+
So that the TV display shows real-time encouragement
6+
7+
Scenario: Audience member sends a reaction that appears on the TV
8+
Given "Alice" has joined the session on device 1
9+
And the TV display is open
10+
And a song is currently playing
11+
When Alice sends a "🔥" reaction
12+
Then the TV display shows the "🔥" reaction floating across the screen
13+
14+
Scenario: Multiple users send reactions simultaneously
15+
Given "Alice" has joined the session on device 1
16+
And "Bob" has joined the session on device 2
17+
And the TV display is open
18+
And a song is currently playing
19+
When Alice sends a "🎤" reaction
20+
And Bob sends a "❤️" reaction
21+
Then the TV display shows both reactions
22+
23+
Scenario: Reactions are only available during playback
24+
Given "Alice" has joined the session on device 1
25+
And no songs are in the queue
26+
Then Alice does not see the reactions panel
27+
28+
Scenario: Reaction panel shows available emoji options
29+
Given "Alice" has joined the session on device 1
30+
And a song is currently playing
31+
Then Alice sees the reactions panel with emoji options
32+
And the available reactions include "🔥", "❤️", "🎤", "👏", "😂", "🙌"
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { expect, Page, BrowserContext } from "@playwright/test";
2+
import { test as base, createBdd } from "playwright-bdd";
3+
4+
const test = base.extend<{
5+
alicePage: Page;
6+
aliceContext: BrowserContext;
7+
bobPage: Page;
8+
bobContext: BrowserContext;
9+
tvPage: Page;
10+
tvContext: BrowserContext;
11+
}>({
12+
aliceContext: async ({ browser }, use) => {
13+
const context = await browser.newContext();
14+
await use(context);
15+
await context.close();
16+
},
17+
alicePage: async ({ aliceContext }, use) => {
18+
const page = await aliceContext.newPage();
19+
await use(page);
20+
},
21+
bobContext: async ({ browser }, use) => {
22+
const context = await browser.newContext();
23+
await use(context);
24+
await context.close();
25+
},
26+
bobPage: async ({ bobContext }, use) => {
27+
const page = await bobContext.newPage();
28+
await use(page);
29+
},
30+
tvContext: async ({ browser }, use) => {
31+
const context = await browser.newContext();
32+
await use(context);
33+
await context.close();
34+
},
35+
tvPage: async ({ tvContext }, use) => {
36+
const page = await tvContext.newPage();
37+
await use(page);
38+
},
39+
});
40+
41+
export { test };
42+
const { Given, When, Then } = createBdd(test);
43+
44+
const BASE_URL = "http://localhost:3000";
45+
46+
async function clearQueue(page: Page): Promise<void> {
47+
const response = await page.request.get(`${BASE_URL}/api/queue`);
48+
const data = await response.json();
49+
if (data.queue) {
50+
for (const item of data.queue) {
51+
await page.request.delete(
52+
`${BASE_URL}/api/queue?queueItemId=${item.id}&userId=test`
53+
);
54+
}
55+
}
56+
await page.request.put(`${BASE_URL}/api/queue`, {
57+
data: { action: "skip", userId: "test" },
58+
});
59+
}
60+
61+
async function joinSession(page: Page, userName: string): Promise<void> {
62+
await page.goto(BASE_URL);
63+
const nameInput = page.getByTestId("username-input");
64+
await nameInput.fill(userName);
65+
const joinButton = page.getByTestId("join-session-button");
66+
await joinButton.click();
67+
await page.waitForSelector('[data-testid="search-input"]', {
68+
timeout: 15000,
69+
});
70+
}
71+
72+
async function addSongToQueue(page: Page): Promise<void> {
73+
const artistItem = page.getByTestId("artist-item").first();
74+
await artistItem.click();
75+
const addButton = page.getByTestId("add-song-button").first();
76+
await addButton.waitFor({ state: "visible", timeout: 30000 });
77+
await addButton.click();
78+
const dialog = page.locator("[data-testid='confirmation-dialog']");
79+
await dialog.waitFor({ timeout: 10000 });
80+
await dialog.locator("button[aria-label='Close']").click();
81+
await dialog.waitFor({ state: "hidden", timeout: 5000 });
82+
}
83+
84+
Given(
85+
"{string} has joined the session on device {int}",
86+
async ({ alicePage, bobPage }, name: string, device: number) => {
87+
const page = device === 1 ? alicePage : bobPage;
88+
if (device === 1) await clearQueue(page);
89+
await joinSession(page, name);
90+
}
91+
);
92+
93+
Given("the TV display is open", async ({ tvPage }) => {
94+
await tvPage.goto(`${BASE_URL}/tv`);
95+
await tvPage.waitForSelector('[data-testid="tv-interface"]', {
96+
timeout: 15000,
97+
});
98+
});
99+
100+
Given("a song is currently playing", async ({ alicePage }) => {
101+
await addSongToQueue(alicePage);
102+
await alicePage.waitForSelector('[data-testid="reactions-panel"]', {
103+
timeout: 30000,
104+
});
105+
});
106+
107+
Given("no songs are in the queue", async ({ alicePage }) => {
108+
await clearQueue(alicePage);
109+
});
110+
111+
async function openReactionsFab(page: Page): Promise<void> {
112+
const fab = page
113+
.getByTestId("reactions-panel")
114+
.locator("button[aria-label='Open reactions']");
115+
await fab.click();
116+
}
117+
118+
When(
119+
"Alice sends a {string} reaction",
120+
async ({ alicePage }, emoji: string) => {
121+
await openReactionsFab(alicePage);
122+
const reactionButton = alicePage.getByTestId(`reaction-${emoji}`);
123+
await reactionButton.waitFor({ state: "visible", timeout: 10000 });
124+
await reactionButton.click();
125+
}
126+
);
127+
128+
When("Bob sends a {string} reaction", async ({ bobPage }, emoji: string) => {
129+
await openReactionsFab(bobPage);
130+
const reactionButton = bobPage.getByTestId(`reaction-${emoji}`);
131+
await reactionButton.waitFor({ state: "visible", timeout: 10000 });
132+
await reactionButton.click();
133+
});
134+
135+
Then(
136+
"the TV display shows the {string} reaction floating across the screen",
137+
async ({ tvPage }, emoji: string) => {
138+
const reaction = tvPage.getByTestId("floating-reaction").filter({
139+
hasText: emoji,
140+
});
141+
await reaction.waitFor({ state: "visible", timeout: 10000 });
142+
}
143+
);
144+
145+
Then("the TV display shows both reactions", async ({ tvPage }) => {
146+
const reactions = tvPage.getByTestId("floating-reaction");
147+
await expect(reactions).toHaveCount(2, { timeout: 10000 });
148+
});
149+
150+
Then("Alice does not see the reactions panel", async ({ alicePage }) => {
151+
const panel = alicePage.getByTestId("reactions-panel");
152+
await expect(panel).toHaveCount(0);
153+
});
154+
155+
Then(
156+
"Alice sees the reactions panel with emoji options",
157+
async ({ alicePage }) => {
158+
const panel = alicePage.getByTestId("reactions-panel");
159+
await panel.waitFor({ state: "visible", timeout: 10000 });
160+
}
161+
);
162+
163+
Then(
164+
"the available reactions include {string}, {string}, {string}, {string}, {string}, {string}",
165+
async (
166+
{ alicePage },
167+
e1: string,
168+
e2: string,
169+
e3: string,
170+
e4: string,
171+
e5: string,
172+
e6: string
173+
) => {
174+
await openReactionsFab(alicePage);
175+
for (const emoji of [e1, e2, e3, e4, e5, e6]) {
176+
const button = alicePage.getByTestId(`reaction-${emoji}`);
177+
await expect(button).toBeVisible();
178+
}
179+
}
180+
);

0 commit comments

Comments
 (0)