0.0
The project is in a healthy, maintained state
Track who's online, their activity state, and broadcast messages. Fully configurable - works with any user model, session system, and room concept.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

>= 7.0
 Project Readme

WhereIsWaldo

Real-time presence tracking for Rails + React using ActionCable.

Features

  • Presence tracking - know who's online
  • Live presence roster - per-device "who's around" (active/idle/background across web + mobile), pluggable per-account delivery (:poll / :nudge / :fanout / :broadcast)
  • Scope-based queries - online(org.users.admin)
  • Targeted broadcasting - send to any AR scope
  • Event subscriptions - components subscribe by event type (useWaldoEvent)
  • Activity monitoring - tab visibility, user activity
  • Multi-session - same user, multiple tabs/devices
  • Flexible storage - database or Redis

Quickstart

1. Install

# Gemfile — published on RubyGems.org
gem 'where_is_waldo', '~> 0.1'
# Database adapter (default)
rails generate where_is_waldo:install --subject_column=user_id
rails db:migrate

# Redis adapter (no migration needed)
rails generate where_is_waldo:install --adapter=redis --subject_column=user_id
# published on npm (public registry)
npm install @byscott-io/where-is-waldo @rails/actioncable

2. Configure

# config/initializers/where_is_waldo.rb
WhereIsWaldo.configure do |config|
  config.subject_class = "User"
  config.authenticate_proc = ->(request) {
    # Return user_id from your auth token
    decode_token(request.params[:token])[:user_id]
  }
end
// app.jsx — configure the connection once, wrap the app
import { configureCable, PresenceProvider } from '@byscott-io/where-is-waldo';

configureCable({
  url: '/cable',
  getToken: () => localStorage.getItem('token'),
  presence: {
    debug: true,  // Enable console logging for troubleshooting
  },
});

<PresenceProvider>
  <App />
</PresenceProvider>
// any component — subscribe to an event type and filter the payload yourself.
// Auto-unsubscribes on unmount; no central handler config.
import { useWaldoEvent } from '@byscott-io/where-is-waldo';

function Notifications() {
  useWaldoEvent('notification', (data) => showToast(data.message));
  useWaldoEvent('force_logout', () => logout());
  return null;
}

3. Use

# Query who's online
WhereIsWaldo.online(org.users)           # => AR relation
WhereIsWaldo.online(org.users.admin)     # => filter by scope
WhereIsWaldo.subject_online?(user.id)    # => true/false

# Broadcast messages
WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hello!" })
WhereIsWaldo.broadcast_to(user, :force_logout, { reason: "Password changed" })

Detailed Documentation

Server Configuration

WhereIsWaldo.configure do |config|
  config.adapter = :database            # or :redis
  config.table_name = "presences"
  config.session_column = :session_id
  config.subject_column = :user_id      # or :member_id, :student_id
  config.subject_class = "User"         # or "Member", "Student"

  config.timeout = 90                   # seconds until offline
  config.heartbeat_interval = 30

  # Optional: custom subject data in presence hash. NOTE: with the roster
  # enabled, these fields are broadcast to every member of the org — see
  # Security.
  config.subject_data_proc = ->(user) {
    { id: user.id, name: user.name, avatar: user.avatar_url }
  }

  # Live presence roster (see "Live Presence Roster"). Set roster_org to
  # enable; roster_members is optional (defaults to org.<subjects>).
  config.roster_org = ->(user) { user.account }
  config.roster_members = ->(org) { org.users.active }

  # Redis adapter
  # config.redis_client = Redis.new(url: ENV["REDIS_URL"])
end

Querying Presence

# Get online subjects from any AR scope
WhereIsWaldo.online(org.users)
WhereIsWaldo.online(User.where(role: "admin"))
WhereIsWaldo.online(classroom.students)

# Get just IDs
WhereIsWaldo.online_ids(org.users)

# Check specific subject
WhereIsWaldo.subject_online?(user.id)

# Get all sessions for a subject
WhereIsWaldo.sessions_for_subject(user.id)
# => [{ session_id: "...", tab_visible: true, subject_active: false, ... }]

Broadcasting

# To any AR scope
WhereIsWaldo.broadcast_to(org.users, :notification, { message: "Hi" })
WhereIsWaldo.broadcast_to(org.users.admin, :alert, { level: "warning" })

# Only to online subjects
WhereIsWaldo.broadcast_to_online(org.users, :update, { data: "..." })

# To a single subject (all their sessions)
WhereIsWaldo.broadcast_to(user, :force_logout, {})

# To a specific session
WhereIsWaldo.broadcast_to_session(session_id, :warning, { message: "..." })

Live Presence Roster ("who's around in my org")

A ready-made way to show live presence awareness across an org/account — who's here right now, on what device, and how active. It is built as data, not UI: the server keeps the client's roster in sync and you render whatever component you like.

Delivery is a per-account strategy so you can trade latency × cost × visibility-enforcement to fit your app. The server picks the mode and the client adapts automatically (no client mode config). Modes (default :poll):

Mode Latency Visibility Cost/transition
:poll (default) ~poll interval server-side query — any rule flat (1 cached query/poll)
:nudge near-instant server-side query — any rule O(1) content-free trigger + filtered poll
:fanout instant server-side per-viewer — any rule (incl. asymmetric) O(audience)
:broadcast instant none (everyone in account sees everyone) O(1)

:poll sends a full snapshot on connect, then the client polls and the server replies with a server-filtered diff (baseline cached per session, TTL'd for auto-resync) — so arbitrary/asymmetric visibility "just works" via roster_visible_to. :nudge is :poll plus a content-free "re-poll" broadcast on each transition, so clients refresh near-instantly instead of waiting for the next interval — same airtight server-side filtering, just lower latency; the nudge carries no identity/state (only "activity happened"). :fanout pushes instantly to a per-viewer stream: on a transition the subject's delta goes to every viewer in its directional roster_viewers_of (the inverse of roster_visible_to), so even asymmetric visibility (manager-sees-report-but-not-vice-versa) is exact — at O(audience) broadcasts per transition. :broadcast instead streams one shared account stream and pushes deltas instantly, with no visibility filtering (open-visibility accounts only); pair it with the client filter option below for cosmetic per-viewer hiding.

// :broadcast + cosmetic client filter (NOT a security boundary — the full
// roster still reaches the client; use a server-side mode to truly enforce).
const { online } = usePresenceRoster({ filter: (m) => canISee(m.id) });

Which mode should I use?

Two questions decide it:

1. Is the client always interested, or only sometimes?

  • Sometimes — presence is ambient (an avatar stack / sidebar you glance at; the component mounts only when relevant). Use the pull family (:poll / :nudge): the client fetches only while something's mounted and cares, and the server is idle at rest.
  • Always — presence is the live experience (chat, collaborative cursors). Use the push family (:fanout / :broadcast): the server streams every change as it happens.

2. Then the within-family choice:

  • :poll vs :nudgelatency. Both pull, both filtered. :poll is interval-fresh; :nudge is near-instant (a content-free "changed" ping triggers an immediate poll).
  • :fanout vs :broadcastvisibility. Both push. :fanout keeps per-viewer server-side filtering; :broadcast uses one shared stream with no filtering (open-visibility accounts only).

Two things people get subtly wrong:

  • Filtering is not fanout-only. :poll, :nudge, and :fanout all enforce visibility server-side. :broadcast is the only unfiltered mode.
  • :fanout ships the data; :nudge ships a "time to poll" signal. Both give instant, filtered updates — :fanout spends server fan-out (O(audience) data pushes), :nudge spends client polls (O(1) ping + a poll per interested viewer). Same result, different cost.

See docs/PRESENCE_ROSTER_PLAN.md for the full mode matrix and tradeoffs.

Per-device, multi-session. A subject's state is aggregated across all their live sessions (multiple browser tabs, mobile, etc.):

{ id: 7, status: "active", devices: { web: "idle", mobile: "active" } }
  • status — highest activity across devices (the "active anywhere?" answer):
    • active — a live session is visible/foreground and working
    • idle — a live session is visible/foreground but not actively using
    • background — only backgrounded/hidden sessions are live
    • offline — no live sessions
  • devices[platform] — that platform's own status (answers "active on mobile?" vs. "active at all?").

Configure

WhereIsWaldo.configure do |config|
  # The org/account a subject belongs to. Required to enable the roster.
  config.roster_org = ->(subject) { subject.account }

  # What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
  # server-enforced. Defaults to the viewer's whole org when unset.
  config.roster_visible_to = ->(viewer) { viewer.visible_users }

  # Who may see a SUBJECT — the inverse of roster_visible_to. Only needed
  # for :fanout (enables instant push under asymmetric visibility).
  config.roster_viewers_of = ->(subject) { subject.visible_to_users }

  # Delivery mode, per account. Symbol or a callable resolving account -> mode.
  # MUST be a function of the account (uniform for all its members). Default :poll.
  config.roster_mode = ->(account) { account.everyone_admin? ? :broadcast : :poll }

  # :broadcast only — the member list for the shared snapshot. Defaults to
  # org.public_send(<subjects>) inferred from subject_class (e.g. :users).
  config.roster_members = ->(org) { org.users.active }

  # Tuning (:poll/:nudge)
  config.roster_poll_interval = 15  # seconds
  config.roster_cache_ttl     = 90  # seconds; > poll gap → auto-resync
end

The RosterChannel is provided by the gem; no app code is needed beyond config.

Consume it (React — data hook, bring your own UI)

import { usePresenceRoster, presenceColor, memberLabel } from '@byscott-io/where-is-waldo';

function TeamPresence() {
  const { online, members, onlineCount, byId } = usePresenceRoster();

  // `members` is always the full, live set (snapshot seeded, deltas patched).
  return (
    <ul>
      {online.map((m) => (
        <li key={m.id}>
          <span style={{ color: presenceColor(m.status) }}></span>
          {memberLabel(m)}{m.status}
          {m.devices.mobile && ' 📱'}
        </li>
      ))}
    </ul>
  );
}

The hook and its reducer (../core/rosterStore) are pure — no DOM — so a React Native app reuses the exact same data logic and only swaps the view.

Query presence server-side

WhereIsWaldo.roster_snapshot(org)            # full roster + per-device state
WhereIsWaldo.roster_state_for(user.id)       # => { status:, devices: }
WhereIsWaldo.presence_on(user.id, :mobile)   # => "idle" (per-device)

Reporting presence from mobile

Mobile is "logged in" purely by connecting with metadata: { platform: "mobile" } and sending the same heartbeat shape as the web client. The heartbeat/activity state machine lives in a DOM-free core, createPresenceReporter, that the web usePresence hook wraps — a React Native app reuses the same core and only swaps the sensors: map app foreground/background to setVisible, and touches to reportActivity. No server-side changes; platform is read from the metadata (defaults to "web").

// React Native reporter — same core, native sensors.
import { useEffect, useRef } from 'react';
import { AppState, PanResponder } from 'react-native';
import { createPresenceReporter, configureCable } from '@byscott-io/where-is-waldo';

configureCable({ url: WS_URL, getToken: () => token });

export function usePresenceNative() {
  const reporterRef = useRef(null);

  useEffect(() => {
    const reporter = createPresenceReporter({ metadata: { platform: 'mobile' } });
    reporterRef.current = reporter;
    reporter.start();
    reporter.setVisible(AppState.currentState === 'active');

    const sub = AppState.addEventListener('change', (s) =>
      reporter.setVisible(s === 'active'),
    );
    return () => {
      sub.remove();
      reporter.stop();
    };
  }, []);

  // Feed touches as activity (attach these handlers to your root view).
  const pan = useRef(
    PanResponder.create({
      onStartShouldSetPanResponderCapture: () => {
        reporterRef.current?.reportActivity();
        return false; // observe only; don't capture the gesture
      },
    }),
  ).current;

  return pan.panHandlers;
}

createPresenceReporter is pure JS (no DOM), and @rails/actioncable works on React Native with the built-in WebSocket — so the reporter, the roster hook (usePresenceRoster), and the roster reducer all run unchanged on mobile; only the sensors and the view are platform-specific.

Client Event Subscriptions

Components subscribe to a raw event type with the useWaldoEvent hook, receive the payload, and decide for themselves whether it's relevant. The subscription auto-unsubscribes on unmount — there is no central handler registry. Adding a new event = a server broadcast_to + a useWaldoEvent call in whatever component cares.

import { useWaldoEvent } from '@byscott-io/where-is-waldo';

function ChatRoom({ roomId }) {
  // Single type — filter the payload yourself
  useWaldoEvent('chat_message', (data) => {
    if (data.room_id === roomId) addMessage(data);
  });

  // Many types at once. useWaldoEvent is a pure subscription — to coalesce a
  // burst into one call, compose a debounce at the call site (e.g. corebyscott's
  // useDebouncedCallback): const refetch = useDebouncedCallback(loadAll, 250);
  useWaldoEvent(['notification', 'data_refresh'], refetch);

  return null;
}

For a non-React / imperative context, subscribeToEvent(type, cb) returns an unsubscribe function:

import { subscribeToEvent } from '@byscott-io/where-is-waldo';

const unsubscribe = subscribeToEvent('chat_message', (data) => addMessage(data));
// later: unsubscribe();

React Hooks

import { usePresenceContext } from '@byscott-io/where-is-waldo';

function StatusIndicator() {
  const { connected, tabVisible, subjectActive } = usePresenceContext();

  return <span>{connected ? 'Online' : 'Offline'}</span>;
}

Cleanup Job

# For database adapter - schedule cleanup of stale records
# config/initializers/sidekiq.rb
Sidekiq::Cron::Job.create(
  name: 'Presence cleanup',
  cron: '*/5 * * * *',
  class: 'WhereIsWaldo::PresenceCleanupJob'
)

Version Management

rake version:show         # Show current version
rake version:bump[0.1.0]  # Bump gem and npm together

Security

ActionCable presence is only as safe as the connection auth around it. What the gem guarantees, and what your app must do:

Guaranteed by the gem

  • No client-chosen rooms. RosterChannel derives the org from the authenticated connection (current_subjectroster_org), never from a client-supplied param — a user cannot subscribe to another org's roster.
  • Self-scoped subject streams. PresenceChannel streams only the connection's own subject id, so targeted messages can't be eavesdropped.
  • Unauthenticated connections are rejected (JwtConnection).

Your app's responsibility

  • Identify the connection from a verified credential (a signed JWT, as JwtConnection does). Never trust a client-supplied subject_id. ⚠️ The dummy app authenticates from a query param for tests only — do not copy that into production.
  • subject_data_proc fans out org-wide. Every field it returns is broadcast to all roster members. Include only what all members may see; keep PII out unless intended.
  • Visibility enforcement depends on the delivery mode. :poll, :nudge, and :fanout enforce server-side — :poll/:nudge by presence_visible_ scope (a WHERE ... IN clause), :fanout by roster_viewers_of — so nothing an unauthorized member could read off the wire. :broadcast does no filtering: it shares one account stream and pushes every member's presence to everyone (roster_members only scopes the initial snapshot list, not the live stream). The client filter option is cosmetic — the full data still reaches the client — so it is not an access-control boundary. Select :broadcast (± client filter) only for genuinely open-visibility accounts; for any restricted visibility use a server-side mode (:poll is the default). See docs/PRESENCE_ROSTER_PLAN.md for the full mode matrix.
  • Token in the URL. The JWT is passed as ?token=…; use WSS only, keep tokens short-lived, and avoid logging query strings. Set config.action_cable.allowed_request_origins as defense-in-depth.
  • Heartbeat/DoS. Heartbeats are client-paced DB writes; roster broadcasts are gated to transitions. For large or hostile deployments use the Redis adapter and consider rate-limiting.

License

MIT