Skip to content

Lesson 5 — Presence

What presence means

Presence shows which board members are currently viewing the board. Rather than two separate lists — one for all members, one for online members — we use a single row of avatars where online members get a green dot. This mirrors how Slack, Notion, and Linear handle it: one list, two visual states.

The implementation uses ActionCable with a dedicated presence channel. When a user opens the board, their browser subscribes and announces their arrival. When they leave, the browser unsubscribes and announces their departure. The channel broadcasts the updated online list to all subscribers.

Seed data

Before testing presence, update the seed names to give meaningful two-letter initials:

1
2
3
4
5
6
7
# db/seeds.rb
alice = User.create!(name: "Alice Smith",  email: "alice@example.com",
                     password: "password")
bob   = User.create!(name: "Bob Walker",   email: "bob@example.com",
                     password: "password")
carol = User.create!(name: "Carol Harris", email: "carol@example.com",
                     password: "password")

Now avatars show AS, BW, and CH rather than A, B, C. Reseed:

1
bin/rails db:seed:replant

ApplicationCable::Connection

The presence channel needs current_user. Wire it up in the ActionCable connection using the same signed cookie that Authentication uses:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if session = Session.find_by(id: cookies.signed[:session_id])
        session.user
      else
        reject_unauthorized_connection
      end
    end
  end
end
1
2
3
4
5
# app/channels/application_cable/channel.rb
module ApplicationCable
  class Channel < ActionCable::Channel::Base
  end
end

PresenceChannel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# app/channels/presence_channel.rb
class PresenceChannel < ApplicationCable::Channel
  def subscribed
    @board = Board.find(params[:board_id])
    stream_from "presence:#{@board.id}"
    add_presence
    # Send current state immediately to the new subscriber
    transmit({ members: current_presence.values })
  end

  def unsubscribed
    remove_presence
  end

  private

  def add_presence
    members = current_presence
    members[current_user.id.to_s] = {
      id:   current_user.id,
      name: current_user.name
    }
    store_presence(members)
    broadcast_presence(members)
  end

  def remove_presence
    members = current_presence
    members.delete(current_user.id.to_s)
    store_presence(members)
    broadcast_presence(members)
  end

  def redis_key = "presence:#{@board.id}"

  def current_presence
    raw = Rails.cache.read(redis_key)
    raw ? JSON.parse(raw) : {}
  end

  def store_presence(members)
    Rails.cache.write(redis_key, members.to_json, expires_in: 24.hours)
  end

  def broadcast_presence(members)
    ActionCable.server.broadcast(
      "presence:#{@board.id}",
      { members: members.values }
    )
  end
end

Rails.cache stores the current online list. In development the default memory cache works fine for a single server process. In production with multiple processes, configure Redis or Solid Cache.

ActionCable consumer

Create the consumer file if it doesn’t exist:

1
2
3
// app/javascript/channels/consumer.js
import { createConsumer } from "@hotwired/actioncable"
export default createConsumer()

Pin it in importmap.rb:

1
2
pin "@hotwired/actioncable", to: "actioncable.esm.js"
pin "channels/consumer", to: "channels/consumer.js"

Components::MemberAvatars

One component handles both jobs — rendering all members and highlighting the ones currently online with a green dot:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# app/components/member_avatars.rb
class Components::MemberAvatars < Components::Base
  prop :board, ::Board

  def view_template
    div(
      class: "flex items-center gap-1",
      data:  {
        controller:              "presence",
        presence_board_id_value: @board.id.to_s
      }
    ) do
      @board.memberships.includes(:user).each do |membership|
        div(
          class: "relative",
          title: membership.user.name,
          data:  {
            presence_target:  "member",
            presence_user_id: membership.user_id
          }
        ) do
          Avatar(name: membership.user.name, size: :sm)
        end
      end
    end
  end
end

Each member div has data-presence-target="member" and data-presence-user-id so the Stimulus controller can identify and update it. No green dot is rendered server-side — the controller adds and removes dots based on the broadcast.

presence_controller.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// app/javascript/controllers/presence_controller.js
import { Controller } from "@hotwired/stimulus"
import consumer from "channels/consumer"

export default class extends Controller {
  static values  = { boardId: String }
  static targets = ["member"]

  connect() {
    this.subscription = consumer.subscriptions.create(
      { channel: "PresenceChannel", board_id: this.boardIdValue },
      {
        received: (data) => this.updatePresence(data.members)
      }
    )
  }

  disconnect() {
    this.subscription?.unsubscribe()
  }

  updatePresence(members) {
    if (!members) return
    const onlineIds = members.map(m => m.id)

    this.memberTargets.forEach(el => {
      const userId = parseInt(el.dataset.presenceUserId)
      const dot    = el.querySelector(".presence-dot")
      const online = onlineIds.includes(userId)

      if (online && !dot) {
        const span       = document.createElement("span")
        span.className   = "presence-dot absolute bottom-0 right-0 " \
                         + "block h-2.5 w-2.5 rounded-full " \
                         + "bg-success ring-2 ring-surface"
        el.appendChild(span)
      } else if (!online && dot) {
        dot.remove()
      }
    })
  }
}

updatePresence receives the broadcast payload — an array of online member objects. It compares each member avatar’s presenceUserId against the online list and adds or removes the green dot accordingly.

The dot uses the class presence-dot as a stable selector so el.querySelector(".presence-dot") reliably finds it for removal. Tailwind utility classes alone aren’t reliable selectors since they can combine with other classes.

Adding MemberAvatars to the board view

Replace any previous PresenceBar or separate avatar references in Views::Boards::Show#render_header with the single unified component:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def render_header
  Breadcrumb() do |b|
    b.item "Boards", url: boards_path
    b.item @board.name
  end

  div(class: "flex items-center justify-between mt-4 mb-6") do
    h1(class: "text-2xl font-bold text-text") { @board.name }
    div(class: "flex items-center gap-3") do
      MemberAvatars(board: @board)
      Link(label: "Members", href: board_members_path(@board),
           variant: :secondary) if @policy.manage_members?
      render_board_actions
    end
  end
end

Testing presence

To test with multiple simultaneous users you need separate browser cookie stores — two windows or tabs of the same browser share cookies and will overwrite each other’s session.

Use any of these combinations:

  • Chrome + Firefox — completely separate cookie stores
  • Chrome + Chrome Incognito — incognito has its own isolated store
  • Two different browser profiles — each maintains its own cookies

Sign in as Alice in one browser and Bob in another. Open the same board in both. Alice’s avatar should get a green dot in Bob’s browser, and Bob’s avatar should get a green dot in Alice’s browser. Close one browser — that user’s dot should disappear within a few seconds.

This is a development concern only. In production each user has their own device and browser — the issue doesn’t arise.


Module 11 summary

Access policy

Action Member Admin
View board
Create card
Edit card
Delete card
Move card
Edit column
Delete column
Add column
Edit board
Delete board
Manage members

Card deletion is open to all members — a known simplification. To restrict it to the card creator or admins, add created_by_id to cards and add a delete_card? method to BoardPolicy that checks card.created_by == user || admin?.

What we built

  • BoardPolicy — plain Ruby object, no Pundit, one method per permission
  • BoardMembersController — add existing users by email, remove members, protect admin membership from removal
  • Policy-driven UI — KanbanColumn and KanbanCard show/hide controls based on role
  • PresenceChannel — ActionCable channel tracking connected members per board via Rails.cache
  • PresenceBar component — avatars of connected members
  • presence_controller.js — subscribes on connect, updates avatars from broadcast