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 +7 -0
- data/README.md +270 -0
- data/app/assets/javascripts/chat/room_controller.js +193 -0
- data/app/assets/stylesheets/chat/room_component.css +99 -0
- data/app/components/chat/room_component.html.erb +37 -0
- data/app/components/chat/room_component.rb +54 -0
- data/lib/chat/client.rb +166 -0
- data/lib/chat/configuration.rb +29 -0
- data/lib/chat/engine.rb +21 -0
- data/lib/chat/errors.rb +29 -0
- data/lib/chat/version.rb +3 -0
- data/lib/chat.rb +8 -0
- metadata +82 -0
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
|
data/lib/chat/client.rb
ADDED
|
@@ -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
|
data/lib/chat/engine.rb
ADDED
|
@@ -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
|
data/lib/chat/errors.rb
ADDED
|
@@ -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
|
data/lib/chat/version.rb
ADDED
data/lib/chat.rb
ADDED
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: []
|