turbo_chat 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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +741 -0
  4. data/app/assets/config/chat_gem_manifest.js +6 -0
  5. data/app/assets/javascripts/chat_gem/application.js +4 -0
  6. data/app/assets/javascripts/chat_gem/lifecycle_events.js +93 -0
  7. data/app/assets/javascripts/chat_gem/messages.js +442 -0
  8. data/app/assets/javascripts/chat_gem/realtime.js +398 -0
  9. data/app/assets/javascripts/chat_gem/shared.js +488 -0
  10. data/app/assets/stylesheets/chat_gem/application.css +741 -0
  11. data/app/controllers/chat_gem/application_controller.rb +41 -0
  12. data/app/controllers/chat_gem/chat_memberships_controller.rb +81 -0
  13. data/app/controllers/chat_gem/chat_messages_controller.rb +144 -0
  14. data/app/controllers/chat_gem/chats_controller/event_payload_support.rb +58 -0
  15. data/app/controllers/chat_gem/chats_controller/invitation_support.rb +31 -0
  16. data/app/controllers/chat_gem/chats_controller.rb +125 -0
  17. data/app/helpers/chat_gem/application_helper/config_support.rb +41 -0
  18. data/app/helpers/chat_gem/application_helper/mention_support/entry_builder.rb +55 -0
  19. data/app/helpers/chat_gem/application_helper/mention_support/permission_support.rb +28 -0
  20. data/app/helpers/chat_gem/application_helper/mention_support/token_builder.rb +49 -0
  21. data/app/helpers/chat_gem/application_helper/mention_support.rb +80 -0
  22. data/app/helpers/chat_gem/application_helper/message_rendering.rb +165 -0
  23. data/app/helpers/chat_gem/application_helper/participant_support.rb +81 -0
  24. data/app/helpers/chat_gem/application_helper.rb +12 -0
  25. data/app/models/chat_gem/application_record.rb +5 -0
  26. data/app/models/chat_gem/chat.rb +127 -0
  27. data/app/models/chat_gem/chat_membership.rb +136 -0
  28. data/app/models/chat_gem/chat_message/blocked_words_moderation.rb +120 -0
  29. data/app/models/chat_gem/chat_message/body_length_validation.rb +20 -0
  30. data/app/models/chat_gem/chat_message/broadcasting.rb +61 -0
  31. data/app/models/chat_gem/chat_message/formatting.rb +81 -0
  32. data/app/models/chat_gem/chat_message/mention_validation.rb +85 -0
  33. data/app/models/chat_gem/chat_message/signals.rb +61 -0
  34. data/app/models/chat_gem/chat_message.rb +40 -0
  35. data/app/views/chat_gem/chat_messages/_chat_message.html.erb +1 -0
  36. data/app/views/chat_gem/chat_messages/_form.html.erb +22 -0
  37. data/app/views/chat_gem/chat_messages/_message.html.erb +83 -0
  38. data/app/views/chat_gem/chat_messages/_signal.html.erb +3 -0
  39. data/app/views/chat_gem/chat_messages/_signals.html.erb +24 -0
  40. data/app/views/chat_gem/chat_messages/index.html.erb +1 -0
  41. data/app/views/chat_gem/chats/index.html.erb +51 -0
  42. data/app/views/chat_gem/chats/new.html.erb +13 -0
  43. data/app/views/chat_gem/chats/show.html.erb +95 -0
  44. data/app/views/layouts/chat_gem/application.html.erb +20 -0
  45. data/config/routes.rb +16 -0
  46. data/db/migrate/20260215000000_create_chat_gem_chats.rb +8 -0
  47. data/db/migrate/20260215000001_create_chat_gem_chat_memberships.rb +19 -0
  48. data/db/migrate/20260215000002_create_chat_gem_chat_messages.rb +14 -0
  49. data/db/migrate/20260218000011_add_closed_at_to_chat_gem_chats.rb +6 -0
  50. data/db/migrate/20260218000012_add_custom_role_key_to_chat_memberships.rb +6 -0
  51. data/db/migrate/20260218000013_add_invitation_accepted_to_chat_gem_chat_memberships.rb +5 -0
  52. data/lib/chat_gem/configuration.rb +242 -0
  53. data/lib/chat_gem/engine.rb +29 -0
  54. data/lib/chat_gem/model_extensions/chat_participant.rb +45 -0
  55. data/lib/chat_gem/moderation.rb +194 -0
  56. data/lib/chat_gem/permission.rb +193 -0
  57. data/lib/chat_gem/signals.rb +26 -0
  58. data/lib/chat_gem/version.rb +3 -0
  59. data/lib/chat_gem.rb +24 -0
  60. data/lib/generators/chat_gem/install/install_generator.rb +18 -0
  61. data/lib/generators/chat_gem/install/templates/chat_gem.rb +36 -0
  62. data/lib/generators/turbo_chat/install/install_generator.rb +18 -0
  63. data/lib/generators/turbo_chat/install/templates/turbo_chat.rb +36 -0
  64. data/lib/tasks/chat_gem_tasks.rake +1 -0
  65. data/lib/tasks/turbo_chat_tasks.rake +10 -0
  66. data/lib/turbo_chat/version.rb +5 -0
  67. data/lib/turbo_chat.rb +24 -0
  68. data/turbo_chat.gemspec +31 -0
  69. metadata +155 -0
@@ -0,0 +1,398 @@
1
+ (function (namespace) {
2
+ var constants = namespace.constants || {};
3
+ var SIGNAL_TTL_SECONDS = constants.SIGNAL_TTL_SECONDS || 12;
4
+ var SIGNAL_START_DELAY_MS = constants.SIGNAL_START_DELAY_MS || 750;
5
+ var SIGNAL_IDLE_GRACE_MS = constants.SIGNAL_IDLE_GRACE_MS || 2500;
6
+ var SIGNAL_HEARTBEAT_MS = constants.SIGNAL_HEARTBEAT_MS || 4000;
7
+ var SIGNAL_RETREAT_MS = constants.SIGNAL_RETREAT_MS || 180;
8
+ var MENTION_BLUR_HIDE_DELAY_MS = constants.MENTION_BLUR_HIDE_DELAY_MS || 120;
9
+
10
+ var csrfToken = namespace.csrfToken;
11
+ var renderTurboStreamResponse = namespace.renderTurboStreamResponse;
12
+ var datasetFlagEnabled = namespace.datasetFlagEnabled;
13
+ var parseMentionOptions = namespace.parseMentionOptions;
14
+ var setupMentionAutocomplete = namespace.setupMentionAutocomplete;
15
+ var scrollLastMessageIntoView = namespace.scrollLastMessageIntoView;
16
+
17
+ function hideOwnSignals(container) {
18
+ if (!container) {
19
+ return;
20
+ }
21
+
22
+ if (datasetFlagEnabled(container, "chatShowSelfSignals")) {
23
+ return;
24
+ }
25
+
26
+ var selfType = container.dataset.chatSelfParticipantType;
27
+ var selfId = container.dataset.chatSelfParticipantId;
28
+ if (!selfType || !selfId) {
29
+ return;
30
+ }
31
+
32
+ container.querySelectorAll(".chat-typing-indicator").forEach(function (node) {
33
+ if (
34
+ node.dataset.chatSignalParticipantType === selfType &&
35
+ node.dataset.chatSignalParticipantId === selfId
36
+ ) {
37
+ node.remove();
38
+ }
39
+ });
40
+ }
41
+
42
+ function syncSignalContainerState(container) {
43
+ if (!container) {
44
+ return;
45
+ }
46
+
47
+ hideOwnSignals(container);
48
+
49
+ var hasVisibleSignals = container.querySelector(
50
+ ".chat-typing-indicator:not(.chat-typing-indicator--leaving)"
51
+ );
52
+ container.classList.toggle("chat-signals--active", Boolean(hasVisibleSignals));
53
+
54
+ var chatWindow = container.closest(".chat-window");
55
+ if (chatWindow) {
56
+ var messagesContainer = chatWindow.querySelector(".chat-messages");
57
+ var shouldStickToBottom = false;
58
+ if (messagesContainer) {
59
+ var distanceFromBottom = messagesContainer.scrollHeight - (messagesContainer.scrollTop + messagesContainer.clientHeight);
60
+ shouldStickToBottom = distanceFromBottom <= 24;
61
+ }
62
+
63
+ var signalOffset = hasVisibleSignals ? Math.ceil(container.scrollHeight) + 8 : 0;
64
+ chatWindow.style.setProperty("--chat-signal-offset", signalOffset + "px");
65
+ chatWindow.classList.toggle("chat-window--signals-active", signalOffset > 0);
66
+
67
+ if (messagesContainer && shouldStickToBottom) {
68
+ requestAnimationFrame(function () {
69
+ scrollLastMessageIntoView(messagesContainer);
70
+ });
71
+ }
72
+ }
73
+ }
74
+
75
+ function setupSignalContainer(container) {
76
+ if (!container) {
77
+ return;
78
+ }
79
+
80
+ if (container.dataset.chatSignalsBound === "true") {
81
+ syncSignalContainerState(container);
82
+ return;
83
+ }
84
+
85
+ container.dataset.chatSignalsBound = "true";
86
+ syncSignalContainerState(container);
87
+
88
+ var observer = new MutationObserver(function () {
89
+ syncSignalContainerState(container);
90
+ });
91
+
92
+ observer.observe(container, { childList: true });
93
+ }
94
+
95
+ function setupAllSignalContainers() {
96
+ document.querySelectorAll(".chat-signals").forEach(setupSignalContainer);
97
+ }
98
+
99
+ function collapseSignalNode(node) {
100
+ if (!node || node.dataset.chatSignalLeaving === "true") {
101
+ return;
102
+ }
103
+
104
+ node.dataset.chatSignalLeaving = "true";
105
+ node.classList.add("chat-typing-indicator--leaving");
106
+
107
+ setTimeout(function () {
108
+ if (!node.parentNode) {
109
+ return;
110
+ }
111
+
112
+ var parent = node.parentNode;
113
+ node.remove();
114
+ if (parent.classList && parent.classList.contains("chat-signals")) {
115
+ syncSignalContainerState(parent);
116
+ }
117
+ }, SIGNAL_RETREAT_MS);
118
+ }
119
+
120
+ function pruneSignals() {
121
+ var now = Math.floor(Date.now() / 1000);
122
+ document.querySelectorAll("[data-chat-signal-at]").forEach(function (node) {
123
+ var at = parseInt(node.dataset.chatSignalAt || "0", 10);
124
+ if (!at) {
125
+ return;
126
+ }
127
+
128
+ if (now - at > SIGNAL_TTL_SECONDS) {
129
+ collapseSignalNode(node);
130
+ }
131
+ });
132
+ }
133
+
134
+ function setupComposer(element) {
135
+ if (element.dataset.chatComposerBound === "true") {
136
+ return;
137
+ }
138
+
139
+ var messageInput = element.querySelector("[data-chat-message-input]");
140
+ var messageForm = element.querySelector("[data-chat-message-form]");
141
+ var signalForm = element.querySelector("[data-chat-signal-form]");
142
+ if (!messageInput || !signalForm || !messageForm) {
143
+ return;
144
+ }
145
+
146
+ element.dataset.chatComposerBound = "true";
147
+
148
+ var signalStartTimeoutId = null;
149
+ var signalIdleTimeoutId = null;
150
+ var signalHeartbeatIntervalId = null;
151
+ var signalActive = false;
152
+ var signalRequestInFlight = false;
153
+ var pendingSignalClear = false;
154
+ var emitTypingEvents = datasetFlagEnabled(element, "chatEmitTypingEvents");
155
+ var emitMessageEvents = datasetFlagEnabled(element, "chatEmitMessageEvents");
156
+ var mentionsEnabled = datasetFlagEnabled(element, "chatEnableMentions");
157
+ var mentionOptions = mentionsEnabled ? parseMentionOptions(element.dataset.chatMentionOptions) : [];
158
+ var mentionAutocomplete = setupMentionAutocomplete(messageInput, {
159
+ mentionOptions: mentionOptions,
160
+ menuHost: element
161
+ });
162
+ var typingEventEmitted = false;
163
+
164
+ function emitTypingEvent(eventName) {
165
+ if (!emitTypingEvents) {
166
+ return;
167
+ }
168
+
169
+ var event = new CustomEvent(eventName, {
170
+ bubbles: true,
171
+ detail: {
172
+ chatId: element.dataset.chatId || null
173
+ }
174
+ });
175
+ element.dispatchEvent(event);
176
+ }
177
+
178
+ function emitMessageSentEvent() {
179
+ if (!emitMessageEvents) {
180
+ return;
181
+ }
182
+
183
+ var event = new CustomEvent("chat-gem:message-sent", {
184
+ bubbles: true,
185
+ detail: {
186
+ chatId: element.dataset.chatId || null
187
+ }
188
+ });
189
+ element.dispatchEvent(event);
190
+ }
191
+
192
+ function postSignal(options) {
193
+ if (signalRequestInFlight) {
194
+ if (options && options.clear) {
195
+ pendingSignalClear = true;
196
+ }
197
+ return;
198
+ }
199
+
200
+ signalRequestInFlight = true;
201
+ var formData = new FormData(signalForm);
202
+ formData.set("chat_message[kind]", "signal");
203
+ formData.set("chat_message[signal_type]", "typing");
204
+ formData.set("chat_message[body]", "");
205
+ if (options && options.clear) {
206
+ formData.set("chat_message[clear]", "1");
207
+ } else {
208
+ formData.delete("chat_message[clear]");
209
+ }
210
+
211
+ fetch(signalForm.action, {
212
+ method: "POST",
213
+ body: formData,
214
+ credentials: "same-origin",
215
+ headers: {
216
+ "X-CSRF-Token": csrfToken(),
217
+ "Accept": "text/vnd.turbo-stream.html"
218
+ }
219
+ })
220
+ .then(renderTurboStreamResponse)
221
+ .catch(function () {})
222
+ .finally(function () {
223
+ signalRequestInFlight = false;
224
+ if (pendingSignalClear) {
225
+ pendingSignalClear = false;
226
+ postSignal({ clear: true });
227
+ }
228
+ });
229
+ }
230
+
231
+ function clearSignalStartTimer() {
232
+ if (!signalStartTimeoutId) {
233
+ return;
234
+ }
235
+
236
+ clearTimeout(signalStartTimeoutId);
237
+ signalStartTimeoutId = null;
238
+ }
239
+
240
+ function clearSignalIdleTimer() {
241
+ if (!signalIdleTimeoutId) {
242
+ return;
243
+ }
244
+
245
+ clearTimeout(signalIdleTimeoutId);
246
+ signalIdleTimeoutId = null;
247
+ }
248
+
249
+ function clearSignalHeartbeat() {
250
+ if (!signalHeartbeatIntervalId) {
251
+ return;
252
+ }
253
+
254
+ clearInterval(signalHeartbeatIntervalId);
255
+ signalHeartbeatIntervalId = null;
256
+ }
257
+
258
+ function resetSignalIdleTimer() {
259
+ clearSignalIdleTimer();
260
+ signalIdleTimeoutId = setTimeout(function () {
261
+ stopSignalLoop();
262
+ }, SIGNAL_IDLE_GRACE_MS);
263
+ }
264
+
265
+ function sendTypingSignal() {
266
+ pendingSignalClear = false;
267
+ postSignal();
268
+ }
269
+
270
+ function startSignalLoop() {
271
+ if (signalActive) {
272
+ resetSignalIdleTimer();
273
+ return;
274
+ }
275
+
276
+ signalActive = true;
277
+ if (!typingEventEmitted) {
278
+ emitTypingEvent("chat-gem:typing-started");
279
+ typingEventEmitted = true;
280
+ }
281
+ sendTypingSignal();
282
+ signalHeartbeatIntervalId = setInterval(function () {
283
+ if (!messageInput.value.trim()) {
284
+ stopSignalLoop();
285
+ return;
286
+ }
287
+
288
+ sendTypingSignal();
289
+ }, SIGNAL_HEARTBEAT_MS);
290
+ resetSignalIdleTimer();
291
+ }
292
+
293
+ function queueSignalStart() {
294
+ if (signalActive) {
295
+ resetSignalIdleTimer();
296
+ return;
297
+ }
298
+
299
+ if (signalStartTimeoutId) {
300
+ return;
301
+ }
302
+
303
+ signalStartTimeoutId = setTimeout(function () {
304
+ signalStartTimeoutId = null;
305
+ if (!messageInput.value.trim()) {
306
+ return;
307
+ }
308
+
309
+ startSignalLoop();
310
+ }, SIGNAL_START_DELAY_MS);
311
+ }
312
+
313
+ function stopSignalLoop() {
314
+ clearSignalStartTimer();
315
+ clearSignalIdleTimer();
316
+ clearSignalHeartbeat();
317
+ if (!signalActive) {
318
+ return;
319
+ }
320
+
321
+ signalActive = false;
322
+ if (typingEventEmitted) {
323
+ emitTypingEvent("chat-gem:typing-ended");
324
+ typingEventEmitted = false;
325
+ }
326
+ postSignal({ clear: true });
327
+ }
328
+
329
+ messageInput.addEventListener("keydown", function (event) {
330
+ if (mentionAutocomplete.handleKeydown(event)) {
331
+ return;
332
+ }
333
+
334
+ if (event.key !== "Enter") {
335
+ return;
336
+ }
337
+
338
+ // Submit on Enter. Keep newline for modified Enter (Shift/Ctrl/Alt/Meta).
339
+ if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) {
340
+ return;
341
+ }
342
+
343
+ event.preventDefault();
344
+ if (!messageInput.value.trim()) {
345
+ return;
346
+ }
347
+
348
+ if (typeof messageForm.requestSubmit === "function") {
349
+ messageForm.requestSubmit();
350
+ } else {
351
+ messageForm.submit();
352
+ }
353
+ });
354
+
355
+ messageForm.addEventListener("turbo:submit-end", function (event) {
356
+ if (event.detail && event.detail.success) {
357
+ messageInput.value = "";
358
+ mentionAutocomplete.hideMenu();
359
+ emitMessageSentEvent();
360
+ }
361
+
362
+ stopSignalLoop();
363
+ });
364
+
365
+ messageForm.addEventListener("submit", function () {
366
+ stopSignalLoop();
367
+ });
368
+
369
+ messageInput.addEventListener("input", function () {
370
+ if (!messageInput.value.trim()) {
371
+ stopSignalLoop();
372
+ mentionAutocomplete.hideMenu();
373
+ return;
374
+ }
375
+
376
+ mentionAutocomplete.updateMenu();
377
+ queueSignalStart();
378
+ if (signalActive) {
379
+ resetSignalIdleTimer();
380
+ }
381
+ });
382
+
383
+ messageInput.addEventListener("blur", function () {
384
+ setTimeout(function () {
385
+ mentionAutocomplete.hideMenu();
386
+ }, MENTION_BLUR_HIDE_DELAY_MS);
387
+ stopSignalLoop();
388
+ });
389
+ }
390
+
391
+ function setupAllComposers() {
392
+ document.querySelectorAll("[data-chat-composer]").forEach(setupComposer);
393
+ }
394
+
395
+ namespace.setupAllSignalContainers = setupAllSignalContainers;
396
+ namespace.setupAllComposers = setupAllComposers;
397
+ namespace.pruneSignals = pruneSignals;
398
+ })(window.ChatGemUI = window.ChatGemUI || {});