Skip to content
April 08, 2026 · 5 min read Ruby on Rails

Building a Self-Hosted Video Calling System with Rails 8 & Jitsi Meet — Part 2: Real-Time Notifications with ActionCable

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.

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:

  1. Popup notification — appears immediately when someone calls you
  2. Persistent banner — stays at the bottom of the screen until the meeting ends
  3. 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

ruby
# 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:

ruby
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.

javascript
// 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:

erb
<%# 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

yaml
# 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

text
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.

Filed under Ruby on Rails

Discussion

Comments

Leave a comment

No comments yet. Be the first to share your thoughts!

Enjoyed this post?

Subscribe to get notified about new articles.