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 +7 -0
- data/.rubocop.yml +67 -0
- data/README.md +316 -0
- data/Rakefile +8 -0
- data/app/channels/turbo_presence_channel.rb +59 -0
- data/app/javascript/turbo_presence/index.js +154 -0
- data/lib/turbo_presence/color.rb +13 -0
- data/lib/turbo_presence/configuration.rb +19 -0
- data/lib/turbo_presence/presence_store.rb +73 -0
- data/lib/turbo_presence/railtie.rb +19 -0
- data/lib/turbo_presence/room_token.rb +51 -0
- data/lib/turbo_presence/version.rb +5 -0
- data/lib/turbo_presence/view_helper.rb +24 -0
- data/lib/turbo_presence.rb +51 -0
- data/sig/turbo_presence.rbs +4 -0
- metadata +93 -0
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
|
+
[](https://github.com/jibranusman95/turbo_presence/actions)
|
|
6
|
+
[](https://badge.fury.io/rb/turbo_presence)
|
|
7
|
+
[](https://rubygems.org/gems/turbo_presence)
|
|
8
|
+
[](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,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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -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,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
|
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: []
|