Emoji journey
This is the core loop of the whole product: an attendee taps an emoji on their phone, and a second later it’s animating on the speaker’s slide. Here’s how that trip actually works, from the moment the page loads to the moment it lands on Google Slides.
How the attendee’s page gets wired up
When an attendee opens /t/my-talk, TalkLive.mount/3 runs twice: once for the initial HTTP render, and again once the WebSocket connects.
def mount(%{"slug" => slug}, _session, socket) do
case Talks.get_talk_by_slug(slug) do
nil -> {:ok, redirect(socket, to: "/")}
talk ->
if connected?(socket) do
Phoenix.PubSub.subscribe(Speechwave.PubSub, "reactions:#{slug}")
Phoenix.PubSub.subscribe(Speechwave.PubSub, "slides:#{slug}")
end
{:ok, assign(socket, talk: talk, emojis: @emojis, session_id: socket.id, current_slide: 0)}
end
end
connected?(socket) is false on the first render and true once the socket upgrades, so subscribing only when connected avoids subscribing twice. Two PubSub topics get subscribed here: reactions:#{slug}, which this chapter is about, and slides:#{slug}, covered in Slide tracking. An unknown slug redirects home instead of erroring. And session_id: socket.id gets set once here, then used throughout as the rate limiter’s bucket key.
The tap to emoji flow
phx-click="react" with phx-value-emoji="🔥" fires over the existing WebSocket. No HTTP request involved.Endpoint.broadcast!("reactions:#{slug}", "new_reaction", %{emoji: emoji}) goes out to every subscriber on the topic, regardless of whether it's a LiveView or a Channel process.handle_info/2 receives the broadcast and calls push_event(socket, "new_reaction", %{emoji: emoji}). The EmojiStream JS hook animates it client-side.spawnEmoji() on the slide.Endpoint.broadcast!/3 doesn't know or care whether a subscriber is a LiveView process or a Channel process. That single fact is what keeps TalkLive.handle_event completely decoupled from the extension's existence. The LiveView code would work identically if the extension didn't exist at all.