consulcon26-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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4594cc67cf39cab5eafad671dc2c00f9c116a80d8257dc51ef7fea4c357207af
4
+ data.tar.gz: f0a17a9961906cfd754dc94c14b22bbd1ae1307e06a54b763eb282cd00059db4
5
+ SHA512:
6
+ metadata.gz: 9ebe81205c74eb1607749b72f18e61d69173ee0a36df5fcf2a6def3b7f6375f54720b5a3bd7ff390262c5f6bd8fb633a63ffdda596f16cb39e8abae79767407a
7
+ data.tar.gz: 7bf8be8bf9c93b06eb128e2c1e5f4f54d34421e722cfd6b1551605c5f0299caffa64a429f0747a220b57c51d2f980f9887f926c68eb8ad24eb1e2ed2e275d703
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # chat
2
+
3
+ Gema Rails que expone `Chat::RoomComponent`, un [ViewComponent](https://viewcomponent.org)
4
+ que renderiza una sala de chat conectada, directamente desde el navegador,
5
+ a un servidor WebSocket remoto (no usa ActionCable).
6
+
7
+ ## Instalación
8
+
9
+ Añade al `Gemfile` de la app anfitriona:
10
+
11
+ ```ruby
12
+ gem "consulcon26-chat", require: "chat"
13
+ ```
14
+
15
+ ```
16
+ bundle install
17
+ ```
18
+
19
+ El JS se sirve vía Sprockets (asset pipeline clásico), sin importmap ni
20
+ Stimulus — pensado para encajar en apps que ya tienen su propio pipeline de
21
+ JS (como CONSUL) sin tener que añadir nada nuevo a su configuración.
22
+
23
+ ## Configuración
24
+
25
+ En un inicializador (`config/initializers/chat.rb`):
26
+
27
+ ```ruby
28
+ Chat.configure do |config|
29
+ config.base_url = "https://chat.midominio.com" # API REST del chat server
30
+ end
31
+ ```
32
+
33
+ ## Cargar el JS y el CSS
34
+
35
+ El engine registra automáticamente `app/assets/javascripts` y
36
+ `app/assets/stylesheets` de la gema en el load path de Sprockets de la app
37
+ anfitriona (y los añade a `config.assets.precompile`, así que tampoco hace
38
+ falta tocar `config/initializers/assets.rb` para producción). No necesitas
39
+ añadir nada al manifest global (`application.js`) ni a ningún `//= require`
40
+ compartido: basta con incluir el script solo en la vista donde renderices
41
+ el componente:
42
+
43
+ ```erb
44
+ <%= javascript_include_tag "chat/room_controller" %>
45
+ <%= stylesheet_link_tag "chat/room_component" %> <%# opcional %>
46
+
47
+ <%= render Chat::RoomComponent.new(...) %>
48
+ ```
49
+
50
+ `chat/room_controller.js` se auto-inicializa: al cargar, escanea el DOM en
51
+ busca de `[data-chat-room-url]` (lo que pinta el componente) y conecta el
52
+ WebSocket él solo — no hay que registrar ni llamar a nada manualmente. Si
53
+ la app usa Turbolinks, se engancha a `turbolinks:load` para reinicializar
54
+ en cada navegación y a `turbolinks:before-cache` para cerrar el socket
55
+ antes de cachear la página (evita conexiones colgadas al volver atrás); si
56
+ no hay Turbolinks, usa `DOMContentLoaded` como alternativa.
57
+
58
+ ## Uso
59
+
60
+ ```erb
61
+ <%= render Chat::RoomComponent.new(
62
+ websocket_url: "wss://chat.midominio.com/ws",
63
+ room: Chat::Client.proposal_room_id(proposal.id),
64
+ auth_token: session[:chat_auth_token],
65
+ username: current_user.email
66
+ ) %>
67
+ ```
68
+
69
+ ### Opciones
70
+
71
+ | Opción | Requerido | Descripción |
72
+ |-----------------|-----------|----------------------------------------------------------------------------------------------------|
73
+ | `websocket_url` | sí | URL del endpoint `/ws` del servidor remoto (`ws://` o `wss://`) |
74
+ | `room` | no | Id exacto de la sala (el mismo con el que se creó vía `Chat::Client.create_room`, p. ej. `"proposal:5"`). Se usa al unirse a la sala (`join_room`), al enviar mensajes y como path de `GET /history/:room` |
75
+ | `auth_token` | no | JWT devuelto por `Chat::Client.login_user`. Se añade como query param `token` al conectar el WebSocket y se usa para `GET /history/:room` |
76
+ | `username` | no | El mismo username con el que este usuario se registró/logueó en el chat server (p. ej. `current_user.email`). **Solo para mostrar**: nunca se envía al servidor — se compara contra el `from` de cada mensaje (historial y en vivo) para etiquetar como "Tú" los mensajes del propio usuario en vez de repetir su username |
77
+ | `height` | no | Alto del contenedor (por defecto `"480px"`) |
78
+ | `placeholder` | no | Placeholder del input de texto |
79
+
80
+ ## Protocolo de mensajes en el WebSocket
81
+
82
+ El controlador implementa el protocolo del chat server tal cual, sin
83
+ capa de traducción:
84
+
85
+ - Al conectar (y en cada reconexión), si hay `room` configurada, envía
86
+ `{"type":"join_room","room_id":"<room>"}` para suscribirse y empezar a
87
+ recibir los mensajes de esa sala en tiempo real.
88
+ - Al enviar un mensaje desde el formulario, manda
89
+ `{"type":"msg","to":"<room>","body":"<texto>"}`. No se envía `user` ni
90
+ `timestamp`: el servidor identifica al remitente por el JWT de la conexión
91
+ y asigna `sent_at` al persistir (el mismo criterio que ya usa `/history`).
92
+ - Al recibir un frame `{"type":"msg","from":"...","to":"<room>","body":"..."}`
93
+ cuyo `to` coincide con la sala, lo añade a la lista de mensajes — así es
94
+ como ven en vivo los mensajes de **otros** participantes.
95
+ - Al enviar, además, el mensaje se pinta localmente al momento (eco
96
+ optimista, con "Tú" como autor): el servidor no reenvía al propio
97
+ remitente su propio mensaje en el broadcast de la room, y no hay acks en
98
+ el protocolo, así que es la única forma de que el emisor vea su mensaje
99
+ aparecer sin esperar/depender de ese broadcast.
100
+ - Cualquier otro frame (`presence`, un `msg` de otra sala o 1:1, o algo no
101
+ reconocido) se ignora silenciosamente en este componente, igual que hace
102
+ el propio servidor con frames desconocidos.
103
+
104
+ Si necesitas otro comportamiento (mostrar mensajes 1:1, presence, etc.),
105
+ `window.ChatRoom.initialize` es la función de auto-init; puedes copiar
106
+ `room_controller.js` como base y ajustarlo, o acceder a la instancia ya
107
+ conectada de cualquier elemento vía `el.chatRoomConnection` desde tu propio
108
+ JS.
109
+
110
+ ## Registro y login contra el chat server
111
+
112
+ Además del componente, la gema expone un cliente HTTP (`Chat::Client`) para
113
+ los dos puntos donde la app anfitriona necesita hablar con el chat server
114
+ por REST, no por WebSocket: dar de alta al usuario y obtener su JWT.
115
+
116
+ ### Registro
117
+
118
+ Tras el flujo de registro de Devise, registra al usuario en el chat server:
119
+
120
+ ```ruby
121
+ # app/controllers/users/registrations_controller.rb
122
+ class Users::RegistrationsController < Devise::RegistrationsController
123
+ def create
124
+ super do |user|
125
+ next unless user.persisted?
126
+
127
+ Chat::Client.register_user(username: user.email, password: params[:user][:password])
128
+ end
129
+ end
130
+ end
131
+ ```
132
+
133
+ `params[:user][:password]` es la contraseña en texto plano tal como llegó en
134
+ el formulario: en este punto todavía está disponible en los params de la
135
+ request, pero no en `user` (Devise solo guarda el hash). Es la única
136
+ oportunidad de capturarla para este paso.
137
+
138
+ Si el username ya existe en el chat server, `Chat::Client.register_user`
139
+ lanza `Chat::UsernameTakenError`; captúrala si quieres degradar sin romper
140
+ el registro en la app:
141
+
142
+ ```ruby
143
+ begin
144
+ Chat::Client.register_user(username: user.email, password: params[:user][:password])
145
+ rescue Chat::UsernameTakenError, Chat::ServerError, Chat::ConnectionError => e
146
+ Rails.logger.error("No se pudo registrar #{user.email} en el chat server: #{e.message}")
147
+ end
148
+ ```
149
+
150
+ ### Login
151
+
152
+ Tras el login de Devise, autentica también contra el chat server y guarda
153
+ el JWT en la sesión (pasando `session:`, la propia gema lo guarda en
154
+ `session[:chat_auth_token]`):
155
+
156
+ ```ruby
157
+ # app/controllers/users/sessions_controller.rb
158
+ class Users::SessionsController < Devise::SessionsController
159
+ def create
160
+ super do |user|
161
+ Chat::Client.login_user(username: user.email, password: params[:user][:password], session: session)
162
+ end
163
+ end
164
+ end
165
+ ```
166
+
167
+ Después, en cualquier vista, el token guardado está disponible para pasarlo
168
+ al componente:
169
+
170
+ ```erb
171
+ <%= render Chat::RoomComponent.new(
172
+ websocket_url: "wss://chat.midominio.com/ws",
173
+ room: Chat::Client.proposal_room_id(proposal.id),
174
+ auth_token: session[:chat_auth_token],
175
+ username: current_user.email
176
+ ) %>
177
+ ```
178
+
179
+ La clave de sesión es configurable vía `config.session_token_key` (por
180
+ defecto `:chat_auth_token`).
181
+
182
+ Si las credenciales no son válidas en el chat server,
183
+ `Chat::Client.login_user` lanza `Chat::InvalidCredentialsError` — esto
184
+ puede pasar si el usuario nunca llegó a registrarse allí (p. ej. falló el
185
+ paso de registro); decide tú cómo manejarlo (reintentar el registro,
186
+ loguear el error, etc.), la gema no oculta el fallo.
187
+
188
+ ## Crear una sala de chat
189
+
190
+ Cuando se crea el elemento de la app anfitriona asociado a una sala (p. ej.
191
+ una `Proposal` de CONSUL), da de alta la sala en el chat server:
192
+
193
+ ```ruby
194
+ class Proposal < ApplicationRecord
195
+ after_create :create_chat_room
196
+
197
+ private
198
+
199
+ def create_chat_room
200
+ Chat::Client.create_room(
201
+ id: Chat::Client.proposal_room_id(id),
202
+ title: title,
203
+ summary: summary,
204
+ token: ChatServiceToken.fetch # ver nota sobre el token más abajo
205
+ )
206
+ rescue Chat::Error => e
207
+ Rails.logger.error("No se pudo crear la sala de chat para la propuesta #{id}: #{e.message}")
208
+ end
209
+ end
210
+ ```
211
+
212
+ `Chat::Client.proposal_room_id(id)` construye el id con el formato exacto
213
+ que exige el servidor (`"proposal:<id>"`); pásale el id de la propuesta de
214
+ CONSUL, no se lo pases ya formateado.
215
+
216
+ La llamada es **idempotente**: si la sala ya existía, el servidor devuelve
217
+ igualmente 201 con los datos de esta petición (no sobreescribe lo que ya
218
+ había en BD) — así que es seguro llamarla también, por ejemplo, en un
219
+ `after_save` si el título/resumen pueden cambiar, sin que eso actualice
220
+ realmente la sala existente en el servidor.
221
+
222
+ `create_room` valida `id`/`title`/`summary` en el propio cliente antes de
223
+ llamar al servidor (lanza `ArgumentError` si falta alguno o si `id` no seguía
224
+ el formato esperado), porque el servidor no valida esos campos y responde
225
+ 500 si los recibe vacíos.
226
+
227
+ ### Sobre el `token` de este endpoint
228
+
229
+ `POST /rooms` es un endpoint protegido: exige un JWT válido, igual que el
230
+ WebSocket. Si esta llamada la haces desde una request de usuario (p. ej. un
231
+ `before_action` del controller al crear la propuesta) puedes reutilizar
232
+ `session[:chat_auth_token]`. Si la creas en un contexto sin sesión de
233
+ usuario (un rake task, un job, una consola), necesitarás un token de
234
+ servicio propio — la gema no asume cómo lo obtienes en ese caso, solo
235
+ espera que le pases un JWT válido en `token:`.
236
+
237
+ ## Historial de la sala
238
+
239
+ Al renderizar `Chat::RoomComponent`, si le pasas `room` y `auth_token`, el
240
+ propio componente llama a `GET /history/:room` antes de pintar la vista y
241
+ precarga los mensajes existentes (hasta 200, los últimos por orden
242
+ cronológico) dentro del contenedor de mensajes — así la sala no aparece
243
+ vacía al cargar, y los mensajes que lleguen luego por WebSocket se añaden a
244
+ continuación del historial, sin duplicarlo.
245
+
246
+ No necesitas hacer nada extra para esto: es automático siempre que ambos
247
+ parámetros estén presentes. Si falta `room` o `auth_token`, o si la
248
+ petición al chat server falla (`Chat::InvalidTokenError`,
249
+ `Chat::ServerError`, `Chat::ConnectionError`), el componente se renderiza
250
+ igualmente sin historial — nunca rompe la página por esto, solo deja un
251
+ `Rails.logger.warn` si algo falló.
252
+
253
+ Si necesitas el historial fuera del componente (para precachearlo, mostrarlo
254
+ en otro sitio, etc.), también puedes llamarlo directamente:
255
+
256
+ ```ruby
257
+ Chat::Client.history(id: "proposal:5", token: session[:chat_auth_token])
258
+ # => [{ from: "ana", body: "hola!", sent_at: "2026-07-06T16:30:38" }, ...]
259
+ ```
260
+
261
+ (`Chat::Client.history` devuelve el payload tal cual lo da el servidor —
262
+ claves `from`/`body`/`sent_at` — a diferencia de `initial_messages` del
263
+ componente, que ya viene normalizado a `user`/`body` para pintarse igual
264
+ que los mensajes en vivo del WebSocket.)
265
+
266
+ ## Reconexión
267
+
268
+ Si la conexión se cierra de forma inesperada, el controlador reintenta
269
+ conectar automáticamente (por defecto cada 2000ms, configurable vía el
270
+ atributo `data-chat-room-reconnect-delay`, en milisegundos).
@@ -0,0 +1,193 @@
1
+ // Sprockets-compatible chat room WebSocket client. No build step, no
2
+ // Stimulus: plain JS that scans the DOM for [data-chat-room-url] elements
3
+ // and wires each one up. Auto-initializes on turbolinks:load (and
4
+ // DOMContentLoaded as a fallback for apps without Turbolinks), and closes
5
+ // sockets on turbolinks:before-cache so navigating away doesn't leak
6
+ // connections.
7
+ (function () {
8
+ "use strict";
9
+
10
+ // Deterministic per-username color (same hash as RoomComponent#author_color
11
+ // in Ruby), so a given user's name shows in the same color whether it
12
+ // came from server-rendered history or a live WebSocket message.
13
+ function authorColor(name) {
14
+ if (!name) return "#1f6feb";
15
+
16
+ var hash = 0;
17
+ for (var i = 0; i < name.length; i++) {
18
+ // >>> 0 keeps this an unsigned 32-bit int, matching Ruby's & 0xFFFFFFFF
19
+ // (plain & would reinterpret it as signed once the top bit is set,
20
+ // diverging from the Ruby hash from that point on).
21
+ hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
22
+ }
23
+ return "hsl(" + (hash % 360) + ", 65%, 40%)";
24
+ }
25
+
26
+ function ChatRoomConnection(el) {
27
+ this.el = el;
28
+ this.url = el.getAttribute("data-chat-room-url");
29
+ this.room = el.getAttribute("data-chat-room-room");
30
+ this.authToken = el.getAttribute("data-chat-room-auth-token");
31
+ this.username = el.getAttribute("data-chat-room-username");
32
+ this.reconnectDelay = parseInt(el.getAttribute("data-chat-room-reconnect-delay"), 10) || 2000;
33
+ this.manualClose = false;
34
+
35
+ this.messagesEl = el.querySelector("[data-chat-room-target='messages']");
36
+ this.formEl = el.querySelector("[data-chat-room-target='form']");
37
+ this.inputEl = el.querySelector("[data-chat-room-target='input']");
38
+ this.statusEl = el.querySelector("[data-chat-room-target='status']");
39
+
40
+ this.onSubmit = this.onSubmit.bind(this);
41
+ if (this.formEl) this.formEl.addEventListener("submit", this.onSubmit);
42
+
43
+ // The server-rendered history is already in the DOM at this point;
44
+ // scroll to the latest messages instead of leaving the oldest ones in view.
45
+ this.scrollToBottom();
46
+
47
+ this.connect();
48
+ }
49
+
50
+ ChatRoomConnection.prototype.connect = function () {
51
+ var self = this;
52
+
53
+ this.updateStatus("Conectando...");
54
+ this.socket = new WebSocket(this.buildUrl());
55
+
56
+ this.socket.addEventListener("open", function () {
57
+ self.handleOpen();
58
+ });
59
+ this.socket.addEventListener("message", function (event) {
60
+ self.handleMessage(event);
61
+ });
62
+ this.socket.addEventListener("close", function () {
63
+ self.handleClose();
64
+ });
65
+ this.socket.addEventListener("error", function () {
66
+ self.updateStatus("Error de conexión");
67
+ });
68
+ };
69
+
70
+ ChatRoomConnection.prototype.buildUrl = function () {
71
+ var url = new URL(this.url, window.location.href);
72
+ if (this.authToken) url.searchParams.set("token", this.authToken);
73
+ return url.toString();
74
+ };
75
+
76
+ ChatRoomConnection.prototype.handleOpen = function () {
77
+ this.updateStatus("Conectado");
78
+
79
+ if (this.room) {
80
+ this.socket.send(JSON.stringify({ type: "join_room", room_id: this.room }));
81
+ }
82
+ };
83
+
84
+ ChatRoomConnection.prototype.handleMessage = function (event) {
85
+ var frame;
86
+
87
+ try {
88
+ frame = JSON.parse(event.data);
89
+ } catch (_error) {
90
+ return; // malformed/unknown frames are ignored, same as the server
91
+ }
92
+
93
+ if (frame.type !== "msg") return; // ignore "presence" and anything else
94
+ if (!this.room || frame.to !== this.room) return; // not this room
95
+
96
+ var isOwnMessage = this.username && frame.from === this.username;
97
+ this.appendMessage({ user: isOwnMessage ? "Tú" : frame.from, body: frame.body });
98
+ };
99
+
100
+ ChatRoomConnection.prototype.handleClose = function () {
101
+ var self = this;
102
+
103
+ this.updateStatus("Desconectado");
104
+
105
+ if (!this.manualClose) {
106
+ setTimeout(function () {
107
+ self.connect();
108
+ }, this.reconnectDelay);
109
+ }
110
+ };
111
+
112
+ ChatRoomConnection.prototype.onSubmit = function (event) {
113
+ event.preventDefault();
114
+
115
+ var body = this.inputEl.value.trim();
116
+ if (!body || !this.room || !this.socket || this.socket.readyState !== WebSocket.OPEN) return;
117
+
118
+ this.socket.send(JSON.stringify({ type: "msg", to: this.room, body: body }));
119
+ // Optimistic append: the server does not echo the message back to its
120
+ // own sender, only to the room's other subscribers, and there are no
121
+ // acks in this protocol — so this is the only way the sender sees
122
+ // their own message appear.
123
+ this.appendMessage({ user: "Tú", body: body });
124
+ this.inputEl.value = "";
125
+ };
126
+
127
+ ChatRoomConnection.prototype.appendMessage = function (message) {
128
+ var wrapper = document.createElement("div");
129
+ wrapper.className = "chat-room__message" + (message.user === "Tú" ? " chat-room__message--own" : "");
130
+
131
+ var bubble = document.createElement("div");
132
+ bubble.className = "chat-room__bubble";
133
+
134
+ if (message.user && message.user !== "Tú") {
135
+ var author = document.createElement("div");
136
+ author.className = "chat-room__message-author";
137
+ author.style.color = authorColor(message.user);
138
+ author.textContent = message.user;
139
+ bubble.appendChild(author);
140
+ }
141
+
142
+ var body = document.createElement("div");
143
+ body.className = "chat-room__message-body";
144
+ body.textContent = message.body || "";
145
+ bubble.appendChild(body);
146
+
147
+ wrapper.appendChild(bubble);
148
+ this.messagesEl.appendChild(wrapper);
149
+ this.scrollToBottom();
150
+ };
151
+
152
+ ChatRoomConnection.prototype.scrollToBottom = function () {
153
+ if (this.messagesEl) this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
154
+ };
155
+
156
+ ChatRoomConnection.prototype.updateStatus = function (text) {
157
+ if (!this.statusEl) return;
158
+ this.statusEl.textContent = text;
159
+ this.statusEl.hidden = text === "Conectado";
160
+ };
161
+
162
+ ChatRoomConnection.prototype.disconnect = function () {
163
+ this.manualClose = true;
164
+ if (this.socket) this.socket.close();
165
+ };
166
+
167
+ function initialize() {
168
+ var elements = document.querySelectorAll("[data-chat-room-url]:not([data-chat-room-initialized])");
169
+
170
+ for (var i = 0; i < elements.length; i++) {
171
+ var el = elements[i];
172
+ el.setAttribute("data-chat-room-initialized", "true");
173
+ el.chatRoomConnection = new ChatRoomConnection(el);
174
+ }
175
+ }
176
+
177
+ function teardown() {
178
+ var elements = document.querySelectorAll("[data-chat-room-initialized]");
179
+
180
+ for (var i = 0; i < elements.length; i++) {
181
+ var el = elements[i];
182
+ if (el.chatRoomConnection) el.chatRoomConnection.disconnect();
183
+ el.removeAttribute("data-chat-room-initialized");
184
+ delete el.chatRoomConnection;
185
+ }
186
+ }
187
+
188
+ window.ChatRoom = { initialize: initialize };
189
+
190
+ document.addEventListener("DOMContentLoaded", initialize);
191
+ document.addEventListener("turbolinks:load", initialize);
192
+ document.addEventListener("turbolinks:before-cache", teardown);
193
+ })();
@@ -0,0 +1,99 @@
1
+ .chat-room {
2
+ display: flex;
3
+ flex-direction: column;
4
+ border: 1px solid #d0d7de;
5
+ border-radius: 8px;
6
+ overflow: hidden;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
8
+ font-size: 14px;
9
+ background: #fff;
10
+ }
11
+
12
+ .chat-room__messages {
13
+ flex: 1;
14
+ overflow-y: auto;
15
+ padding: 12px;
16
+ display: flex;
17
+ flex-direction: column;
18
+ align-items: stretch;
19
+ }
20
+
21
+ .chat-room__message {
22
+ width: 100%;
23
+ box-sizing: border-box;
24
+ margin-bottom: 10px;
25
+ line-height: 1.4;
26
+ }
27
+
28
+ .chat-room__message--own {
29
+ text-align: right;
30
+ }
31
+
32
+ .chat-room__bubble {
33
+ display: inline-block;
34
+ max-width: 90%;
35
+ padding: 8px 12px;
36
+ border-radius: 14px;
37
+ background: #f0f2f5;
38
+ text-align: left;
39
+ word-wrap: break-word;
40
+ }
41
+
42
+ .chat-room__message--own .chat-room__bubble {
43
+ background: #e0ecfe;
44
+ }
45
+
46
+ .chat-room__message-author {
47
+ font-weight: 600;
48
+ margin-bottom: 2px;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ }
53
+
54
+ .chat-room__status {
55
+ padding: 4px 12px;
56
+ font-size: 11px;
57
+ color: #6e7781;
58
+ border-bottom: 1px solid #eaeef2;
59
+ }
60
+
61
+ .chat-room__form {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 8px;
65
+ padding: 8px;
66
+ border-top: 1px solid #eaeef2;
67
+ }
68
+
69
+ .chat-room__input {
70
+ flex: 1;
71
+ height: 36px !important;
72
+ margin: 0 !important;
73
+ padding: 0 10px !important;
74
+ border: 1px solid #d0d7de !important;
75
+ border-radius: 6px !important;
76
+ font-size: inherit;
77
+ box-sizing: border-box !important;
78
+ }
79
+
80
+ .chat-room__submit {
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ flex-shrink: 0;
85
+ width: 36px !important;
86
+ height: 36px !important;
87
+ margin: 0 !important;
88
+ padding: 0 !important;
89
+ border: none !important;
90
+ border-radius: 6px !important;
91
+ background: #1f6feb;
92
+ color: #fff;
93
+ cursor: pointer;
94
+ box-sizing: border-box !important;
95
+ }
96
+
97
+ .chat-room__submit:hover {
98
+ background: #1a5fd1;
99
+ }
@@ -0,0 +1,37 @@
1
+ <div
2
+ id="<%= dom_id %>"
3
+ class="chat-room"
4
+ style="height: <%= height %>;"
5
+ data-chat-room-url="<%= websocket_url %>"
6
+ <% if room.present? %>data-chat-room-room="<%= room %>"<% end %>
7
+ <% if auth_token.present? %>data-chat-room-auth-token="<%= auth_token %>"<% end %>
8
+ <% if username.present? %>data-chat-room-username="<%= username %>"<% end %>
9
+ >
10
+ <div class="chat-room__status" data-chat-room-target="status" hidden></div>
11
+
12
+ <div class="chat-room__messages" data-chat-room-target="messages" role="log" aria-live="polite">
13
+ <% initial_messages.each do |message| %>
14
+ <div class="chat-room__message<%= " chat-room__message--own" if message[:user] == "Tú" %>">
15
+ <div class="chat-room__bubble">
16
+ <% if message[:user].present? && message[:user] != "Tú" %><div class="chat-room__message-author" style="color: <%= author_color(message[:user]) %>;"><%= message[:user] %></div><% end %>
17
+ <div class="chat-room__message-body"><%= message[:body] %></div>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+ </div>
22
+
23
+ <form class="chat-room__form" data-chat-room-target="form">
24
+ <input
25
+ type="text"
26
+ class="chat-room__input"
27
+ data-chat-room-target="input"
28
+ autocomplete="off"
29
+ placeholder="<%= placeholder %>"
30
+ />
31
+ <button type="submit" class="chat-room__submit" aria-label="Enviar mensaje" title="Enviar mensaje">
32
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
33
+ <path fill="currentColor" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
34
+ </svg>
35
+ </button>
36
+ </form>
37
+ </div>
@@ -0,0 +1,54 @@
1
+ module Chat
2
+ class RoomComponent < ViewComponent::Base
3
+ attr_reader :websocket_url, :room, :auth_token, :username, :height, :placeholder
4
+
5
+ # `username` is only used for display: it's compared against the
6
+ # `from` of each history message so the current user's own past
7
+ # messages show "Tú" instead of their username, matching how live
8
+ # messages are labeled (see room_controller.js). It's never sent to
9
+ # the chat server — the server already knows the sender from the JWT.
10
+ def initialize(websocket_url:, room: nil, auth_token: nil, username: nil, height: "480px",
11
+ placeholder: "Escribe un mensaje...")
12
+ @websocket_url = websocket_url
13
+ @room = room
14
+ @auth_token = auth_token
15
+ @username = username
16
+ @height = height
17
+ @placeholder = placeholder
18
+ end
19
+
20
+ def dom_id
21
+ @dom_id ||= "chat-room-#{object_id}"
22
+ end
23
+
24
+ # Messages already in the room, fetched from GET /history/:room before
25
+ # rendering, normalized to the same { user:, body: } shape
26
+ # room_controller.js renders for live WebSocket messages. Empty if
27
+ # `room` or `auth_token` is missing, or if the history fetch fails —
28
+ # the chat still renders, just without prior messages.
29
+ def initial_messages
30
+ @initial_messages ||= []
31
+ end
32
+
33
+ def before_render
34
+ return if room.blank? || auth_token.blank?
35
+
36
+ @initial_messages = Chat::Client.history(id: room, token: auth_token).map do |message|
37
+ { user: (message[:from] == username ? "Tú" : message[:from]), body: message[:body] }
38
+ end
39
+ rescue Chat::Error => e
40
+ Rails.logger&.warn("[chat] could not load history for room #{room}: #{e.message}")
41
+ @initial_messages = []
42
+ end
43
+
44
+ # Deterministic per-username color (same hash as room_controller.js's
45
+ # authorColor), so a given user's name shows in the same color whether
46
+ # it comes from server-rendered history or a live WebSocket message.
47
+ def author_color(name)
48
+ return "#1f6feb" if name.blank?
49
+
50
+ hash = name.each_char.reduce(0) { |acc, char| (acc * 31 + char.ord) & 0xFFFFFFFF }
51
+ "hsl(#{hash % 360}, 65%, 40%)"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,166 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Chat
6
+ # Thin HTTP client for the chat server's REST API (auth_handler.erl):
7
+ # registers users and exchanges credentials for the JWT later used to
8
+ # authenticate the WebSocket connection and the other protected endpoints.
9
+ module Client
10
+ module_function
11
+
12
+ # POST /register
13
+ #
14
+ # Returns true on success (201).
15
+ # Raises Chat::UsernameTakenError on 409, Chat::ServerError otherwise.
16
+ def register_user(username:, password:)
17
+ response = post("/register", { username: username, password: password })
18
+
19
+ case response.code.to_i
20
+ when 201
21
+ true
22
+ when 409
23
+ raise UsernameTakenError, error_message(response) || "username already taken"
24
+ else
25
+ raise ServerError.new(status: response.code.to_i, body: response.body)
26
+ end
27
+ end
28
+
29
+ # POST /login
30
+ #
31
+ # Returns the JWT (String). If `session` is given, also stores it at
32
+ # session[Chat.configuration.session_token_key] so the caller doesn't
33
+ # have to wire that up by hand.
34
+ #
35
+ # Raises Chat::InvalidCredentialsError on 401, Chat::ServerError otherwise.
36
+ def login_user(username:, password:, session: nil)
37
+ response = post("/login", { username: username, password: password })
38
+
39
+ case response.code.to_i
40
+ when 200
41
+ token = JSON.parse(response.body)["token"]
42
+ session[Chat.configuration.session_token_key] = token if session
43
+ token
44
+ when 401
45
+ raise InvalidCredentialsError, error_message(response) || "invalid credentials"
46
+ else
47
+ raise ServerError.new(status: response.code.to_i, body: response.body)
48
+ end
49
+ end
50
+
51
+ # POST /rooms?token=<jwt>
52
+ #
53
+ # Idempotent room creation: if `id` already exists server-side, the
54
+ # server does not fail nor overwrite it, it just echoes back the data
55
+ # from this request (not what was actually stored). `id` must follow
56
+ # "proposal:<consul_proposal_id>" — use proposal_room_id to build it.
57
+ #
58
+ # Returns a Hash with symbol keys: { id:, title:, summary: }.
59
+ # Raises ArgumentError if id/title/summary are missing, to avoid the
60
+ # server's 500 (Erlang match crash on missing keys).
61
+ # Raises Chat::InvalidTokenError on 401, Chat::ServerError otherwise.
62
+ def create_room(id:, title:, summary:, token:)
63
+ validate_room_params!(id: id, title: title, summary: summary)
64
+
65
+ response = post("/rooms", { id: id, title: title, summary: summary }, query: { token: token })
66
+
67
+ case response.code.to_i
68
+ when 201
69
+ JSON.parse(response.body).transform_keys(&:to_sym)
70
+ when 401
71
+ raise InvalidTokenError, error_message(response) || "invalid token"
72
+ else
73
+ raise ServerError.new(status: response.code.to_i, body: response.body)
74
+ end
75
+ end
76
+
77
+ # GET /history/<id>?token=<jwt>
78
+ #
79
+ # `id` is the same id a room was created with (e.g. "proposal:5"), or a
80
+ # username for 1:1 chat history. Returns up to 200 messages ordered by
81
+ # sent_at ASC, as an Array of Hashes with symbol keys: { from:, body:,
82
+ # sent_at: }. Returns [] if the room/conversation has no messages, or
83
+ # doesn't exist — the server does not 404 for that.
84
+ #
85
+ # Raises Chat::InvalidTokenError on 401, Chat::ServerError otherwise
86
+ # (including the 500 the server returns on a DB failure).
87
+ def history(id:, token:)
88
+ response = get("/history/#{escape_path_segment(id)}", query: { token: token })
89
+
90
+ case response.code.to_i
91
+ when 200
92
+ JSON.parse(response.body)["messages"].map { |message| message.transform_keys(&:to_sym) }
93
+ when 401
94
+ raise InvalidTokenError, error_message(response) || "invalid token"
95
+ else
96
+ raise ServerError.new(status: response.code.to_i, body: response.body)
97
+ end
98
+ end
99
+
100
+ # Builds the room id the chat server expects for a CONSUL proposal.
101
+ def proposal_room_id(proposal_id)
102
+ "proposal:#{proposal_id}"
103
+ end
104
+
105
+ def validate_room_params!(id:, title:, summary:)
106
+ { id: id, title: title, summary: summary }.each do |key, value|
107
+ raise ArgumentError, "#{key} is required" if value.nil? || value.to_s.strip.empty?
108
+ end
109
+
110
+ return if id.to_s.match?(/\Aproposal:.+\z/)
111
+
112
+ raise ArgumentError, "id must follow the \"proposal:<id>\" format, got #{id.inspect}"
113
+ end
114
+ private_class_method :validate_room_params!
115
+
116
+ def post(path, payload, query: nil)
117
+ request(Net::HTTP::Post, path, query: query) do |req|
118
+ req.body = JSON.generate(payload)
119
+ end
120
+ end
121
+ private_class_method :post
122
+
123
+ def get(path, query: nil)
124
+ request(Net::HTTP::Get, path, query: query)
125
+ end
126
+ private_class_method :get
127
+
128
+ def request(http_method_class, path, query: nil)
129
+ uri = URI.join(base_url, path)
130
+ uri.query = URI.encode_www_form(query) if query
131
+
132
+ http = Net::HTTP.new(uri.host, uri.port)
133
+ http.use_ssl = uri.scheme == "https"
134
+ http.open_timeout = Chat.configuration.open_timeout
135
+ http.read_timeout = Chat.configuration.read_timeout
136
+
137
+ req = http_method_class.new(uri)
138
+ req["Content-Type"] = "application/json"
139
+ req["Accept"] = "application/json"
140
+ yield req if block_given?
141
+
142
+ http.request(req)
143
+ rescue SystemCallError, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
144
+ raise ConnectionError, e.message
145
+ end
146
+ private_class_method :request
147
+
148
+ def escape_path_segment(segment)
149
+ URI::DEFAULT_PARSER.escape(segment.to_s, /[^A-Za-z0-9\-._~:]/)
150
+ end
151
+ private_class_method :escape_path_segment
152
+
153
+ def base_url
154
+ Chat.configuration.base_url or
155
+ raise Error, "Chat.configuration.base_url is not set. Configure it with Chat.configure { |c| c.base_url = \"...\" }."
156
+ end
157
+ private_class_method :base_url
158
+
159
+ def error_message(response)
160
+ JSON.parse(response.body)["error"]
161
+ rescue JSON::ParserError
162
+ nil
163
+ end
164
+ private_class_method :error_message
165
+ end
166
+ end
@@ -0,0 +1,29 @@
1
+ module Chat
2
+ class Configuration
3
+ # Base URL of the external chat server's REST API, e.g. "https://chat.example.com".
4
+ attr_accessor :base_url
5
+
6
+ # Session key the gem writes the JWT to when Chat::Client.login_user is
7
+ # called with a `session:` argument.
8
+ attr_accessor :session_token_key
9
+
10
+ # Open/read timeouts (seconds) for requests made by Chat::Client.
11
+ attr_accessor :open_timeout, :read_timeout
12
+
13
+ def initialize
14
+ @session_token_key = :chat_auth_token
15
+ @open_timeout = 5
16
+ @read_timeout = 5
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ def configure
26
+ yield configuration
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ require "rails/engine"
2
+ require "view_component"
3
+
4
+ module Chat
5
+ class Engine < ::Rails::Engine
6
+ # Registers this gem's JS/CSS with the host app's Sprockets asset
7
+ # pipeline. No importmap/Stimulus involved: room_controller.js is a
8
+ # plain script meant to be loaded with `javascript_include_tag
9
+ # "chat/room_controller"`, and it self-initializes.
10
+ initializer "chat.assets" do |app|
11
+ next unless app.config.respond_to?(:assets)
12
+
13
+ app.config.assets.paths << root.join("app/assets/javascripts")
14
+ app.config.assets.paths << root.join("app/assets/stylesheets")
15
+
16
+ # Included automatically in `rails assets:precompile`, so the host
17
+ # app doesn't need to touch config/initializers/assets.rb either.
18
+ app.config.assets.precompile += %w[chat/room_controller.js chat/room_component.css]
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module Chat
2
+ class Error < StandardError; end
3
+
4
+ # Raised on POST /register when the server responds 409 Conflict.
5
+ class UsernameTakenError < Error; end
6
+
7
+ # Raised on POST /login when the server responds 401 Unauthorized.
8
+ class InvalidCredentialsError < Error; end
9
+
10
+ # Raised by any token-protected endpoint (POST /rooms, GET /conversations...)
11
+ # when the server responds 401 Unauthorized because the token is missing,
12
+ # malformed or expired.
13
+ class InvalidTokenError < Error; end
14
+
15
+ # Raised for any other non-2xx response (including the 500 the server
16
+ # returns when a request crashes on the Erlang side).
17
+ class ServerError < Error
18
+ attr_reader :status, :body
19
+
20
+ def initialize(status:, body: nil)
21
+ @status = status
22
+ @body = body
23
+ super("Chat server responded with #{status}#{": #{body}" if body}")
24
+ end
25
+ end
26
+
27
+ # Raised when the HTTP request itself fails (timeout, DNS, connection refused...).
28
+ class ConnectionError < Error; end
29
+ end
@@ -0,0 +1,3 @@
1
+ module Chat
2
+ VERSION = "0.1.0"
3
+ end
data/lib/chat.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "chat/version"
2
+ require "chat/errors"
3
+ require "chat/configuration"
4
+ require "chat/client"
5
+ require "chat/engine"
6
+
7
+ module Chat
8
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: consulcon26-chat
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Diego Calvo
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-13 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: view_component
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ description: A Rails engine that ships a ViewComponent chat room UI which connects,
42
+ directly from the browser, to a remote WebSocket server.
43
+ email: diegocc6@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - app/assets/javascripts/chat/room_controller.js
50
+ - app/assets/stylesheets/chat/room_component.css
51
+ - app/components/chat/room_component.html.erb
52
+ - app/components/chat/room_component.rb
53
+ - lib/chat.rb
54
+ - lib/chat/client.rb
55
+ - lib/chat/configuration.rb
56
+ - lib/chat/engine.rb
57
+ - lib/chat/errors.rb
58
+ - lib/chat/version.rb
59
+ homepage: https://rubygems.org/gems/consulcon26-chat
60
+ licenses:
61
+ - MIT
62
+ metadata: {}
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.1.6
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: ViewComponent chat room backed by a remote WebSocket
82
+ test_files: []