In Part 1, we built the models, JWT service, and Jitsi embed. Now we'll make it feel alive — when someone starts a meeting, participants see it instantly. No polling. No page refreshes.
The Problem
Without real-time notifications, participants have to manually check if a meeting started. That defeats the purpose of "quick calls." We need three layers of awareness:
- Popup notification — appears immediately when someone calls you
- Persistent banner — stays at the bottom of the screen until the meeting ends
- Dashboard widget — shows upcoming and live meetings on page load
The Approach: ActionCable + Stimulus
Rails 8 ships with SolidCable — database-backed pub/sub that replaces Redis for ActionCable. Combined with Stimulus controllers, we can build a real-time notification system that works across every page of the application.
The Channel Architecture
The key design decision: stream per-user, not per-meeting. This means:
- A user only receives notifications for meetings they're specifically invited to
- You can send different join URLs based on role (admin portal vs teacher portal)
- No cross-user data leakage
# app/channels/staff_meeting_channel.rb
class StaffMeetingChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
def self.broadcast_call_incoming(meeting, recipient)
# Build role-aware join URL
join_path = determine_join_path(meeting, recipient)
broadcast_to(recipient, {
type: "call_incoming",
meeting_id: meeting.id,
title: meeting.title,
organiser: meeting.organiser.display_name,
meeting_type: meeting.meeting_type,
join_url: join_path
})
end
def self.broadcast_meeting_ended(meeting, recipient)
broadcast_to(recipient, {
type: "meeting_ended",
meeting_id: meeting.id
})
end
private
def self.determine_join_path(meeting, recipient)
# Route to correct portal based on user's role
# Implementation depends on your role system
end
end
Broadcasting from the Controller
When the organiser starts a meeting, we iterate through participants and broadcast individually. This is intentional — each recipient gets a personalised payload with the correct join URL for their role:
def start
@meeting.start!
@meeting.staff_meeting_participants.includes(:user).find_each do |p|
StaffMeetingChannel.broadcast_call_incoming(@meeting, p.user)
end
redirect_to join_meeting_path(@meeting)
end
Performance note: For meetings with 50+ participants, consider moving the broadcast loop into a background job with SolidQueue. For most use cases (school staff meetings, clinic consultations), the synchronous approach is fast enough.
The Stimulus Controller: Three Notification Layers
This is where the UX magic happens. A single Stimulus controller attached to the <body> tag listens on every page and manages all three notification layers.
Layer 1: The Popup
When a call comes in, a floating card appears in the top-right corner with the caller's name, meeting title, and "Join Now" / "Dismiss" buttons. It auto-dismisses after 2 minutes, but the persistent banner (Layer 2) remains.
Layer 2: The Persistent Banner
A slim bar fixed to the bottom of the screen — stays visible as the user navigates between pages. Shows the meeting title, organiser, and a "Join" button. Only disappears when the meeting ends (via ActionCable broadcast) or the user joins.
Layer 3: The Dashboard Widget
Server-rendered on page load — shows the next 5 upcoming meetings with calendar-style date blocks, time, duration, and organiser. Live meetings get a pulsing red "Join" button. Today's meetings get highlighted.
// The controller subscribes to StaffMeetingChannel
// and manages DOM elements for all three layers.
//
// Key methods:
// handleMessage(data) — routes to showCallPopup or removeBanner
// showCallPopup(data) — creates floating notification
// showPersistentBanner(data) — creates bottom bar
// removePopup(id) / removeBanner(id) — cleanup on meeting end
The implementation uses vanilla DOM manipulation — no React, no additional libraries. The popup and banner are created as document.createElement("div") with Tailwind classes injected directly, then appended to document.body. This keeps the Stimulus controller self-contained.
Dashboard Widgets (Server-Side)
The dashboard widgets are shared partials rendered on both admin and teacher dashboards:
<%# Render order on dashboard: %>
<%= render "shared/live_meetings_banner" %> <%# Red banner if any meeting is active %>
<%= render "shared/upcoming_meetings" %> <%# Calendar widget with next 5 meetings %>
<%= render "shared/whats_new" %> <%# Release notes %>
The live meetings banner queries StaffMeeting.for_user(current_user).live — a scope that returns meetings where the user is either the organiser or an invited participant. This ensures staff only see meetings relevant to them.
Cable Configuration
# config/cable.yml
development:
adapter: async
production:
adapter: solid_cable
silence_polling: true
connects_to:
database:
writing: cable
SolidCable stores messages in your existing database — no Redis to manage. Rails 8 includes it by default and it handles the throughput requirements of notification-style broadcasts comfortably.
How It All Works Together
1. Admin creates meeting → invites participants
2. Admin clicks "Start" → status: live
3. ActionCable broadcasts "call_incoming" to each participant
4. Participant's Stimulus controller receives broadcast
→ Shows popup (top-right, 2 min timeout)
→ Shows persistent banner (bottom, stays until meeting ends)
5. Participant clicks "Join" → attendance recorded → iframe loads
6. Admin clicks "End Meeting" → broadcasts "meeting_ended"
→ Popup + banner auto-removed on all screens
7. Next dashboard visit → widget shows upcoming meetings
The result: Staff members are always aware of live meetings — whether they're on the dashboard, browsing reports, or entering grades. Three layers of notification ensure nobody misses a call.
Key Takeaways
- SolidCable eliminates Redis — one less service to manage in production
- Per-user streaming is safer than per-meeting — prevents cross-user leakage
- Three notification layers (popup → banner → widget) ensure awareness at every level
- Stimulus + vanilla DOM keeps the JavaScript footprint minimal
- Role-aware join URLs route users to the correct portal automatically
Next: Part 3 — Self-Hosted Jitsi on Hetzner
We'll set up our own Jitsi server for unlimited calls, JWT moderator control, and custom branding — replacing all traces of "Jitsi" with our own identity.
Need help implementing this in your project? Reach out — I've deployed this pattern in production for education and enterprise applications.
Leave a comment