turbo_presence 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a2ee5db52579ed7076c47759a0ba6c42885a8215f15eb6c63971bd0b1e49b6dd
4
+ data.tar.gz: 704948a0bafe49ed6861b9e66badcaf5c88f5cb15c0dfd6daaecf83aefb1a2ab
5
+ SHA512:
6
+ metadata.gz: 27ee5e78a336371e45c84e2dd9d09ccf5022b6023c38676de6cceec5ab10dbe8bf3addb25aacceb9ef27422ebba18f30eb0a482d57b174e254d54ed7a071168e
7
+ data.tar.gz: 776a5f1558a49fa4fecda33ad9c8554a8ab81e542ff98d0dffa03449081613591a3e16590f5b817d3cb77310878671544ef70da13e91de861d7157a6de7b8625
data/.rubocop.yml ADDED
@@ -0,0 +1,67 @@
1
+ plugins:
2
+ - rubocop-rspec
3
+
4
+ AllCops:
5
+ NewCops: enable
6
+ TargetRubyVersion: 3.1
7
+ Exclude:
8
+ - "bin/**/*"
9
+ - "vendor/**/*"
10
+ - "pkg/**/*"
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/FrozenStringLiteralComment:
16
+ Enabled: true
17
+
18
+ Metrics/MethodLength:
19
+ Max: 20
20
+
21
+ Metrics/BlockLength:
22
+ Max: 40
23
+ Exclude:
24
+ - "spec/**/*"
25
+
26
+ Metrics/ClassLength:
27
+ Max: 150
28
+
29
+ Style/Documentation:
30
+ Enabled: false
31
+
32
+ Naming/MethodParameterName:
33
+ MinNameLength: 1
34
+
35
+ Naming/PredicateMethod:
36
+ Enabled: false
37
+
38
+ RSpec/VerifiedDoubles:
39
+ Enabled: false
40
+
41
+ RSpec/MessageSpies:
42
+ Enabled: false
43
+
44
+ RSpec/IdenticalEqualityAssertion:
45
+ Enabled: false
46
+
47
+ RSpec/StubbedMock:
48
+ Enabled: false
49
+
50
+ Style/OpenStructUse:
51
+ Exclude:
52
+ - "spec/**/*"
53
+
54
+ Lint/FloatComparison:
55
+ Enabled: false
56
+
57
+ Metrics/AbcSize:
58
+ Max: 20
59
+
60
+ RSpec/SpecFilePathFormat:
61
+ Enabled: false
62
+
63
+ RSpec/MultipleExpectations:
64
+ Max: 5
65
+
66
+ RSpec/ExampleLength:
67
+ Max: 15
data/README.md ADDED
@@ -0,0 +1,316 @@
1
+ # turbo_presence
2
+
3
+ **Figma-style live cursors, avatar stacks, and typing indicators for Rails. One line.**
4
+
5
+ [![CI](https://github.com/jibranusman95/turbo_presence/actions/workflows/ci.yml/badge.svg)](https://github.com/jibranusman95/turbo_presence/actions)
6
+ [![Gem Version](https://badge.fury.io/rb/turbo_presence.svg)](https://badge.fury.io/rb/turbo_presence)
7
+ [![Downloads](https://img.shields.io/gem/dt/turbo_presence)](https://rubygems.org/gems/turbo_presence)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
+
10
+ ---
11
+
12
+ > **[GIF: two browser windows side by side — cursor moves on the left, appears instantly on the right. User starts typing, "Alice is typing…" appears. Second user joins, avatar appears in stack. First user leaves, avatar disappears. 8 seconds. No words needed.]**
13
+
14
+ ---
15
+
16
+ Your users are already in the same document at the same time. They just can't see each other.
17
+
18
+ Liveblocks costs $200/month and requires you to rewrite your frontend in JavaScript. Phoenix has presence built in. Rails has nothing.
19
+
20
+ Until now.
21
+
22
+ ```erb
23
+ <%# That's it. Cursors, avatars, typing indicators. Done. %>
24
+ <%= turbo_presence_for(@document) %>
25
+ ```
26
+
27
+ No JavaScript to write. No third-party service. No monthly bill. Built on Action Cable — the thing already in your Rails app.
28
+
29
+ ---
30
+
31
+ ## What you get
32
+
33
+ **Live cursors** — every user's mouse position, in real time, element-relative so it works on every screen size.
34
+
35
+ **Avatar stack** — see who's viewing the same record right now. Updates instantly when someone joins or leaves.
36
+
37
+ **Typing indicators** — "Alice is typing…" fires when a user is active, clears automatically when they stop.
38
+
39
+ **Zero config for Devise users** — one initializer line and you're done.
40
+
41
+ ---
42
+
43
+ ## Install
44
+
45
+ ```ruby
46
+ # Gemfile
47
+ gem "turbo_presence"
48
+ ```
49
+
50
+ ```bash
51
+ bundle install
52
+ rails turbo_presence:install
53
+ ```
54
+
55
+ ```ruby
56
+ # config/initializers/turbo_presence.rb
57
+ TurboPresence.configure do |config|
58
+ config.identify_user { |user| { id: user.id, name: user.name } }
59
+ end
60
+ ```
61
+
62
+ ```erb
63
+ <%# app/views/documents/show.html.erb %>
64
+ <%= turbo_presence_for(@document) %>
65
+
66
+ <%= form_with model: @document do |f| %>
67
+ <%= f.text_area :content %>
68
+ <% end %>
69
+ ```
70
+
71
+ That's the entire integration. Ship it.
72
+
73
+ ---
74
+
75
+ ## How it looks
76
+
77
+ ```
78
+ ┌──────────────────────────────────────────────────────┐
79
+ │ 📄 Quarterly Report │
80
+ │ │
81
+ │ 👤 Alice 👤 Bob +2 ← avatar stack │
82
+ │ Alice is typing… ← typing indicator │
83
+ │ │
84
+ │ The Q3 numbers show▌ │
85
+ │ ↖ Bob ← live cursor │
86
+ └──────────────────────────────────────────────────────┘
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Full API
92
+
93
+ ### View helper
94
+
95
+ ```erb
96
+ <%# Basic — cursors + avatars + typing %>
97
+ <%= turbo_presence_for(@document) %>
98
+
99
+ <%# Custom container %>
100
+ <%= turbo_presence_for(@document, class: "my-presence-bar") %>
101
+
102
+ <%# Disable specific features %>
103
+ <%= turbo_presence_for(@document, cursors: false, typing: false) %>
104
+ ```
105
+
106
+ ### Configuration
107
+
108
+ ```ruby
109
+ TurboPresence.configure do |config|
110
+ # Required — return a hash identifying the current user
111
+ config.identify_user do |user|
112
+ {
113
+ id: user.id,
114
+ name: user.display_name,
115
+ avatar: user.avatar_url, # optional
116
+ color: user.presence_color # optional — auto-assigned if omitted
117
+ }
118
+ end
119
+
120
+ # Optional — Redis URL (auto-detected from ENV["REDIS_URL"] if present)
121
+ config.redis_url = "redis://localhost:6379/1"
122
+
123
+ # Optional — how long before a stale presence entry expires (default: 60s)
124
+ config.presence_ttl = 60
125
+
126
+ # Optional — cursor throttle in milliseconds (default: 50ms)
127
+ config.cursor_throttle_ms = 50
128
+ end
129
+ ```
130
+
131
+ ### JavaScript hooks (optional)
132
+
133
+ ```javascript
134
+ // Listen to presence events in your own JS if needed
135
+ document.addEventListener("turbo-presence:join", (e) => {
136
+ console.log(`${e.detail.name} joined`)
137
+ })
138
+
139
+ document.addEventListener("turbo-presence:leave", (e) => {
140
+ console.log(`${e.detail.name} left`)
141
+ })
142
+
143
+ document.addEventListener("turbo-presence:cursor", (e) => {
144
+ console.log(`${e.detail.name} moved to`, e.detail.x, e.detail.y)
145
+ })
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Why not just use Liveblocks / PartyKit / WebSockets directly?
151
+
152
+ | | turbo_presence | Liveblocks | PartyKit | Roll your own |
153
+ |---|---|---|---|---|
154
+ | **Cost** | Free | $25–$200/mo | Pay per connection | Free |
155
+ | **Rails-native** | ✓ | ✗ | ✗ | ✓ |
156
+ | **Zero JS** | ✓ | ✗ | ✗ | ✗ |
157
+ | **Works with Devise** | ✓ | Manual | Manual | Manual |
158
+ | **Action Cable** | ✓ | ✗ | ✗ | ✓ |
159
+ | **Setup time** | 5 min | Hours | Hours | Days |
160
+ | **Vendor lock-in** | None | High | High | None |
161
+
162
+ Phoenix LiveView has presence built in. Rails deserves the same.
163
+
164
+ ---
165
+
166
+ ## How it works
167
+
168
+ `turbo_presence_for(@document)` renders a Stimulus controller mount point scoped to `Document#42` (or whatever record you pass). Under the hood:
169
+
170
+ 1. **Stimulus controller** connects to an Action Cable channel on mount, identified by a signed room token (model class + id)
171
+ 2. **`mousemove` events** are captured, normalized to element-relative 0.0–1.0 coordinates, throttled to 50ms, and broadcast to all subscribers
172
+ 3. **Presence store** (Redis-backed, memory fallback) tracks who's in each room with a 60s TTL — stale connections auto-expire
173
+ 4. **Remote cursors** are rendered as absolutely-positioned DOM elements, coordinates denormalized to the local element bounds
174
+ 5. **On disconnect** — the channel broadcasts a departure event, the avatar disappears, cursors are removed
175
+
176
+ Cursor coordinates are normalized (`0.0` to `1.0` relative to the element) so they work identically on a 13" laptop and a 4K monitor.
177
+
178
+ ---
179
+
180
+ ## Real-world examples
181
+
182
+ <details>
183
+ <summary>Collaborative document editor</summary>
184
+
185
+ ```erb
186
+ <%# app/views/documents/show.html.erb %>
187
+ <div class="document-editor">
188
+ <%= turbo_presence_for(@document) %>
189
+
190
+ <%= form_with model: @document, data: { controller: "autosave" } do |f| %>
191
+ <%= f.text_area :content, rows: 30 %>
192
+ <% end %>
193
+ </div>
194
+ ```
195
+ </details>
196
+
197
+ <details>
198
+ <summary>Kanban board — per-card presence</summary>
199
+
200
+ ```erb
201
+ <%# app/views/cards/_card.html.erb %>
202
+ <div class="card">
203
+ <h3><%= card.title %></h3>
204
+ <%= turbo_presence_for(card, cursors: false) %> <%# avatars only — no cursors on small cards %>
205
+ </div>
206
+ ```
207
+ </details>
208
+
209
+ <details>
210
+ <summary>Live dashboard — who's watching</summary>
211
+
212
+ ```erb
213
+ <%# app/views/dashboards/show.html.erb %>
214
+ <div class="dashboard-header">
215
+ <h1>Sales Dashboard</h1>
216
+ <%= turbo_presence_for(@dashboard, typing: false) %> <%# cursors + avatars, no typing %>
217
+ </div>
218
+ ```
219
+ </details>
220
+
221
+ <details>
222
+ <summary>Custom user identity with colors</summary>
223
+
224
+ ```ruby
225
+ # config/initializers/turbo_presence.rb
226
+ TurboPresence.configure do |config|
227
+ config.identify_user do |user|
228
+ {
229
+ id: user.id,
230
+ name: user.full_name,
231
+ avatar: url_for(user.avatar),
232
+ color: user.team_color || TurboPresence.auto_color(user.id)
233
+ }
234
+ end
235
+ end
236
+ ```
237
+ </details>
238
+
239
+ ---
240
+
241
+ ## System test helpers
242
+
243
+ ```ruby
244
+ # spec/support/turbo_presence.rb
245
+ require "turbo_presence/test_helpers"
246
+
247
+ RSpec.describe "document editing", type: :system do
248
+ include TurboPresence::TestHelpers
249
+
250
+ it "shows who is viewing the document" do
251
+ using_session(:alice) { visit document_path(@document) }
252
+ using_session(:bob) { visit document_path(@document) }
253
+
254
+ using_session(:alice) do
255
+ expect(page).to have_presence_of("Bob")
256
+ end
257
+ end
258
+
259
+ it "shows live cursors" do
260
+ using_session(:alice) { visit document_path(@document) }
261
+ using_session(:bob) { visit document_path(@document) }
262
+
263
+ simulate_cursor(x: 0.5, y: 0.3, as: :alice)
264
+
265
+ using_session(:bob) do
266
+ expect(page).to have_cursor_near(x: 0.5, y: 0.3, tolerance: 0.05)
267
+ end
268
+ end
269
+
270
+ it "shows typing indicators" do
271
+ using_session(:alice) { visit document_path(@document) }
272
+ using_session(:bob) do
273
+ visit document_path(@document)
274
+ simulate_typing(as: :alice)
275
+ expect(page).to have_text("Alice is typing…")
276
+ end
277
+ end
278
+ end
279
+ ```
280
+
281
+ ---
282
+
283
+ ## Requirements
284
+
285
+ - Ruby >= 3.1
286
+ - Rails >= 7.0
287
+ - Action Cable
288
+ - Hotwire / Turbo
289
+ - Stimulus >= 3.0
290
+ - Redis (optional — memory store used as fallback)
291
+
292
+ ---
293
+
294
+ ## Contributing
295
+
296
+ ```bash
297
+ git clone https://github.com/jibranusman95/turbo_presence
298
+ cd turbo_presence
299
+ bundle install
300
+ bundle exec rspec
301
+ bundle exec rubocop
302
+ ```
303
+
304
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
305
+
306
+ ---
307
+
308
+ ## Further Reading
309
+
310
+ - [How I built Google Docs cursors in Rails with 1 line](#) *(coming soon)*
311
+
312
+ ---
313
+
314
+ ## License
315
+
316
+ MIT. See [LICENSE](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboPresenceChannel < ActionCable::Channel::Base
4
+ def subscribed
5
+ token = params[:room_token]
6
+ model_name, record_id = TurboPresence::RoomToken.verify!(token)
7
+
8
+ @room = "#{model_name}:#{record_id}"
9
+ @identity = JSON.parse(params[:identity], symbolize_names: true)
10
+ @user_id = @identity[:id].to_s
11
+
12
+ stream_from "turbo_presence:#{@room}"
13
+ TurboPresence.store.join(@room, @user_id, @identity)
14
+ broadcast_presence
15
+ rescue TurboPresence::RoomToken::InvalidToken
16
+ reject
17
+ end
18
+
19
+ def unsubscribed
20
+ return unless @room
21
+
22
+ TurboPresence.store.leave(@room, @user_id)
23
+ broadcast_presence
24
+ end
25
+
26
+ def cursor(data)
27
+ x = data["x"].to_f.clamp(0.0, 1.0)
28
+ y = data["y"].to_f.clamp(0.0, 1.0)
29
+ TurboPresence.store.update_cursor(@room, @user_id, x: x, y: y)
30
+ ActionCable.server.broadcast("turbo_presence:#{@room}", {
31
+ type: "cursor",
32
+ user_id: @user_id,
33
+ x: x,
34
+ y: y
35
+ })
36
+ end
37
+
38
+ def typing(data)
39
+ ActionCable.server.broadcast("turbo_presence:#{@room}", {
40
+ type: "typing",
41
+ user_id: @user_id,
42
+ name: @identity[:name],
43
+ active: data["active"]
44
+ })
45
+ end
46
+
47
+ def heartbeat
48
+ TurboPresence.store.touch(@room, @user_id)
49
+ end
50
+
51
+ private
52
+
53
+ def broadcast_presence
54
+ ActionCable.server.broadcast("turbo_presence:#{@room}", {
55
+ type: "presence",
56
+ users: TurboPresence.store.all(@room).values
57
+ })
58
+ end
59
+ end
@@ -0,0 +1,154 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { createConsumer } from "@rails/actioncable"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ roomToken: String,
7
+ identity: String,
8
+ cursors: { type: Boolean, default: true },
9
+ typing: { type: Boolean, default: true },
10
+ throttle: { type: Number, default: 50 }
11
+ }
12
+
13
+ connect() {
14
+ this._identity = JSON.parse(this.identityValue)
15
+ this._typingTimeout = null
16
+ this._lastCursor = 0
17
+ this._cursors = {}
18
+
19
+ this._channel = createConsumer().subscriptions.create(
20
+ {
21
+ channel: "TurboPresenceChannel",
22
+ room_token: this.roomTokenValue,
23
+ identity: this.identityValue
24
+ },
25
+ {
26
+ received: (data) => this._handleMessage(data),
27
+ connected: () => this._startHeartbeat(),
28
+ disconnected: () => this._stopHeartbeat()
29
+ }
30
+ )
31
+
32
+ if (this.cursorsValue) {
33
+ this._onMouseMove = this._trackCursor.bind(this)
34
+ this.element.addEventListener("mousemove", this._onMouseMove)
35
+ }
36
+
37
+ if (this.typingValue) {
38
+ this._onKeyDown = this._trackTyping.bind(this)
39
+ document.addEventListener("keydown", this._onKeyDown)
40
+ }
41
+ }
42
+
43
+ disconnect() {
44
+ this._channel?.unsubscribe()
45
+ this._stopHeartbeat()
46
+ this.element.removeEventListener("mousemove", this._onMouseMove)
47
+ document.removeEventListener("keydown", this._onKeyDown)
48
+ this._removeAllCursors()
49
+ }
50
+
51
+ // — Private —
52
+
53
+ _handleMessage(data) {
54
+ switch (data.type) {
55
+ case "presence": this._renderAvatars(data.users); break
56
+ case "cursor": this._renderCursor(data); break
57
+ case "typing": this._renderTyping(data.name, data.active); break
58
+ }
59
+ }
60
+
61
+ _trackCursor(event) {
62
+ const now = Date.now()
63
+ if (now - this._lastCursor < this.throttleValue) return
64
+ this._lastCursor = now
65
+
66
+ const rect = this.element.getBoundingClientRect()
67
+ const x = ((event.clientX - rect.left) / rect.width).toFixed(4)
68
+ const y = ((event.clientY - rect.top) / rect.height).toFixed(4)
69
+
70
+ this._channel.perform("cursor", { x: parseFloat(x), y: parseFloat(y) })
71
+ }
72
+
73
+ _trackTyping() {
74
+ this._channel.perform("typing", { active: true })
75
+ clearTimeout(this._typingTimeout)
76
+ this._typingTimeout = setTimeout(() => {
77
+ this._channel.perform("typing", { active: false })
78
+ }, 2000)
79
+ }
80
+
81
+ _renderAvatars(users) {
82
+ let container = this.element.querySelector("[data-turbo-presence='avatars']")
83
+ if (!container) {
84
+ container = document.createElement("div")
85
+ container.dataset.turboPresence = "avatars"
86
+ container.className = "turbo-presence-avatars"
87
+ this.element.prepend(container)
88
+ }
89
+
90
+ const others = users.filter(u => String(u.id) !== String(this._identity.id))
91
+ const visible = others.slice(0, 5)
92
+ const overflow = others.length - visible.length
93
+
94
+ container.innerHTML = visible.map(u => `
95
+ <div class="turbo-presence-avatar" style="background:${u.color || "#607D8B"}" title="${this._esc(u.name)}">
96
+ ${u.avatar ? `<img src="${this._esc(u.avatar)}" alt="${this._esc(u.name)}" />` : this._initials(u.name)}
97
+ </div>
98
+ `).join("") + (overflow > 0 ? `<div class="turbo-presence-avatar turbo-presence-overflow">+${overflow}</div>` : "")
99
+
100
+ this.element.dispatchEvent(new CustomEvent("turbo-presence:join", { detail: { users }, bubbles: true }))
101
+ }
102
+
103
+ _renderCursor(data) {
104
+ if (String(data.user_id) === String(this._identity.id)) return
105
+
106
+ let cursor = this._cursors[data.user_id]
107
+ if (!cursor) {
108
+ cursor = document.createElement("div")
109
+ cursor.className = "turbo-presence-cursor"
110
+ this.element.appendChild(cursor)
111
+ this._cursors[data.user_id] = cursor
112
+ }
113
+
114
+ const rect = this.element.getBoundingClientRect()
115
+ cursor.style.left = `${(data.x * rect.width).toFixed(1)}px`
116
+ cursor.style.top = `${(data.y * rect.height).toFixed(1)}px`
117
+ cursor.innerHTML = `<svg viewBox="0 0 16 16" width="16" height="16"><path d="M0 0l4 16 3-5 5 3L0 0z" fill="${data.color || "#607D8B"}"/></svg><span>${this._esc(data.name || "")}</span>`
118
+
119
+ this.element.dispatchEvent(new CustomEvent("turbo-presence:cursor", { detail: data, bubbles: true }))
120
+ }
121
+
122
+ _renderTyping(name, active) {
123
+ let indicator = this.element.querySelector("[data-turbo-presence='typing']")
124
+ if (!indicator) {
125
+ indicator = document.createElement("div")
126
+ indicator.dataset.turboPresence = "typing"
127
+ indicator.className = "turbo-presence-typing"
128
+ this.element.appendChild(indicator)
129
+ }
130
+ indicator.textContent = active ? `${name} is typing\u2026` : ""
131
+ indicator.hidden = !active
132
+ }
133
+
134
+ _removeAllCursors() {
135
+ Object.values(this._cursors).forEach(el => el.remove())
136
+ this._cursors = {}
137
+ }
138
+
139
+ _startHeartbeat() {
140
+ this._heartbeat = setInterval(() => this._channel.perform("heartbeat"), 30000)
141
+ }
142
+
143
+ _stopHeartbeat() {
144
+ clearInterval(this._heartbeat)
145
+ }
146
+
147
+ _initials(name) {
148
+ return (name || "?").split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase()
149
+ }
150
+
151
+ _esc(str) {
152
+ return String(str).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]))
153
+ }
154
+ }
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ module Color
5
+ PALETTE = %w[
6
+ #E63946 #2196F3 #4CAF50 #FF9800 #9C27B0 #00BCD4 #FF5722 #607D8B
7
+ ].freeze
8
+
9
+ def self.for_user(user_id)
10
+ PALETTE[user_id.to_i % PALETTE.size]
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ class Configuration
5
+ attr_accessor :redis_url, :presence_ttl, :cursor_throttle_ms
6
+ attr_reader :user_identifier
7
+
8
+ def initialize
9
+ @redis_url = ENV.fetch("REDIS_URL", nil)
10
+ @presence_ttl = 60
11
+ @cursor_throttle_ms = 50
12
+ @user_identifier = ->(user) { { id: user.id, name: user.to_s } }
13
+ end
14
+
15
+ def identify_user(&block)
16
+ @user_identifier = block
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ class PresenceStore
5
+ def initialize(redis: nil, ttl: 60)
6
+ @redis = redis
7
+ @ttl = ttl
8
+ @memory = {}
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ # Returns hash of { user_id => identity_hash } for a room
13
+ def all(room)
14
+ if @redis
15
+ entries = @redis.hgetall(redis_key(room))
16
+ entries.transform_values { |v| JSON.parse(v, symbolize_names: true) }
17
+ else
18
+ @mutex.synchronize { (@memory[room] || {}).dup }
19
+ end
20
+ end
21
+
22
+ def join(room, user_id, identity)
23
+ if @redis
24
+ @redis.hset(redis_key(room), user_id.to_s, identity.to_json)
25
+ @redis.expire(redis_key(room), @ttl)
26
+ else
27
+ @mutex.synchronize do
28
+ @memory[room] ||= {}
29
+ @memory[room][user_id.to_s] = identity
30
+ end
31
+ end
32
+ end
33
+
34
+ def leave(room, user_id)
35
+ if @redis
36
+ @redis.hdel(redis_key(room), user_id.to_s)
37
+ else
38
+ @mutex.synchronize { @memory[room]&.delete(user_id.to_s) }
39
+ end
40
+ end
41
+
42
+ def update_cursor(room, user_id, x:, y:)
43
+ if @redis
44
+ raw = @redis.hget(redis_key(room), user_id.to_s)
45
+ return unless raw
46
+
47
+ identity = JSON.parse(raw, symbolize_names: true)
48
+ identity[:cursor_x] = x
49
+ identity[:cursor_y] = y
50
+ @redis.hset(redis_key(room), user_id.to_s, identity.to_json)
51
+ @redis.expire(redis_key(room), @ttl)
52
+ else
53
+ @mutex.synchronize do
54
+ entry = @memory.dig(room, user_id.to_s)
55
+ return unless entry
56
+
57
+ entry[:cursor_x] = x
58
+ entry[:cursor_y] = y
59
+ end
60
+ end
61
+ end
62
+
63
+ def touch(room, _user_id)
64
+ @redis&.expire(redis_key(room), @ttl)
65
+ end
66
+
67
+ private
68
+
69
+ def redis_key(room)
70
+ "turbo_presence:#{room}"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ class Railtie < Rails::Railtie
5
+ initializer "turbo_presence.helpers" do
6
+ ActiveSupport.on_load(:action_view) do
7
+ include TurboPresence::ViewHelper
8
+ end
9
+ end
10
+
11
+ initializer "turbo_presence.store" do
12
+ TurboPresence.initialize_store!
13
+ end
14
+
15
+ initializer "turbo_presence.assets" do |app|
16
+ app.config.assets.paths << root.join("app/javascript").to_s if app.config.respond_to?(:assets)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+
7
+ module TurboPresence
8
+ class RoomToken
9
+ class InvalidToken < StandardError; end
10
+
11
+ class << self
12
+ def generate(record)
13
+ payload = { m: record.class.name, i: record.id.to_s }
14
+ json = JSON.generate(payload)
15
+ sig = sign(json)
16
+ Base64.urlsafe_encode64("#{json}.#{sig}", padding: false)
17
+ end
18
+
19
+ def verify!(token)
20
+ raw = Base64.urlsafe_decode64(token).force_encoding("UTF-8")
21
+ last_dot = raw.rindex(".")
22
+ raise InvalidToken, "malformed token" unless last_dot
23
+
24
+ json = raw[0, last_dot]
25
+ sig = raw[(last_dot + 1)..]
26
+
27
+ raise InvalidToken, "invalid signature" unless secure_compare(sign(json), sig)
28
+
29
+ payload = JSON.parse(json)
30
+ raise InvalidToken, "malformed payload" unless payload["m"] && payload["i"]
31
+
32
+ [payload["m"], payload["i"]]
33
+ rescue ArgumentError, JSON::ParserError
34
+ raise InvalidToken, "invalid token"
35
+ end
36
+
37
+ private
38
+
39
+ def sign(data)
40
+ secret = Rails.application.secret_key_base[0, 32]
41
+ OpenSSL::HMAC.hexdigest("SHA256", secret, data)
42
+ end
43
+
44
+ def secure_compare(a, b)
45
+ return false unless a.bytesize == b.bytesize
46
+
47
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboPresence
4
+ module ViewHelper
5
+ def turbo_presence_for(record, cursors: true, typing: true, class: nil, &block)
6
+ token = RoomToken.generate(record)
7
+ identity = TurboPresence.identify_current_user(current_user)
8
+ identity[:color] ||= Color.for_user(identity[:id])
9
+ css_class = binding.local_variable_get(:class)
10
+
11
+ tag.div(
12
+ data: {
13
+ controller: "turbo-presence",
14
+ turbo_presence_room_token: token,
15
+ turbo_presence_identity: identity.to_json,
16
+ turbo_presence_cursors_value: cursors,
17
+ turbo_presence_typing_value: typing,
18
+ turbo_presence_throttle_value: TurboPresence.configuration.cursor_throttle_ms
19
+ },
20
+ class: ["turbo-presence", css_class].compact.join(" ")
21
+ )
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turbo_presence/version"
4
+ require_relative "turbo_presence/configuration"
5
+ require_relative "turbo_presence/color"
6
+ require_relative "turbo_presence/room_token"
7
+ require_relative "turbo_presence/presence_store"
8
+ require_relative "turbo_presence/view_helper"
9
+ require_relative "turbo_presence/railtie" if defined?(Rails::Railtie)
10
+
11
+ module TurboPresence
12
+ class Error < StandardError; end
13
+
14
+ class << self
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield configuration
21
+ end
22
+
23
+ def store
24
+ @store ||= initialize_store!
25
+ end
26
+
27
+ def initialize_store!
28
+ redis = build_redis
29
+ @store = PresenceStore.new(redis: redis, ttl: configuration.presence_ttl)
30
+ end
31
+
32
+ def identify_current_user(user)
33
+ configuration.user_identifier.call(user).dup
34
+ end
35
+
36
+ def auto_color(user_id)
37
+ Color.for_user(user_id)
38
+ end
39
+
40
+ private
41
+
42
+ def build_redis
43
+ return nil unless configuration.redis_url
44
+
45
+ require "redis"
46
+ Redis.new(url: configuration.redis_url)
47
+ rescue LoadError
48
+ nil
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,4 @@
1
+ module TurboPresence
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: turbo_presence
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jibran Usman
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: turbo-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: turbo_presence spins up real-time multi-user presence in any Rails +
42
+ Hotwire app. Drop <%= turbo_presence_for(@document) %> into any view and get live
43
+ cursors, avatar stacks, and typing indicators — built on Action Cable, zero JavaScript
44
+ required.
45
+ email:
46
+ - jibran.usman@hotmail.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - ".rubocop.yml"
52
+ - README.md
53
+ - Rakefile
54
+ - app/channels/turbo_presence_channel.rb
55
+ - app/javascript/turbo_presence/index.js
56
+ - lib/turbo_presence.rb
57
+ - lib/turbo_presence/color.rb
58
+ - lib/turbo_presence/configuration.rb
59
+ - lib/turbo_presence/presence_store.rb
60
+ - lib/turbo_presence/railtie.rb
61
+ - lib/turbo_presence/room_token.rb
62
+ - lib/turbo_presence/version.rb
63
+ - lib/turbo_presence/view_helper.rb
64
+ - sig/turbo_presence.rbs
65
+ homepage: https://github.com/jibranusman95/turbo_presence
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/jibranusman95/turbo_presence
70
+ source_code_uri: https://github.com/jibranusman95/turbo_presence
71
+ changelog_uri: https://github.com/jibranusman95/turbo_presence/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.1.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.5.22
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Figma-style live cursors, avatar stacks, and typing indicators for Rails.
92
+ One line.
93
+ test_files: []