chats 0.1.1

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +43 -0
  3. data/.simplecov +52 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +74 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +384 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/chats.css +818 -0
  12. data/app/controllers/chats/application_controller.rb +65 -0
  13. data/app/controllers/chats/conversations_controller.rb +198 -0
  14. data/app/controllers/chats/messages_controller.rb +118 -0
  15. data/app/controllers/chats/reactions_controller.rb +33 -0
  16. data/app/helpers/chats/engine_helper.rb +212 -0
  17. data/app/javascript/chats/composer_controller.js +258 -0
  18. data/app/javascript/chats/debounced_submit_controller.js +40 -0
  19. data/app/javascript/chats/thread_controller.js +855 -0
  20. data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
  21. data/app/views/chats/conversations/_messages_page.html.erb +16 -0
  22. data/app/views/chats/conversations/_read_state.html.erb +11 -0
  23. data/app/views/chats/conversations/index.html.erb +54 -0
  24. data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
  25. data/app/views/chats/conversations/show.html.erb +137 -0
  26. data/app/views/chats/messages/_composer.html.erb +67 -0
  27. data/app/views/chats/messages/_message.html.erb +158 -0
  28. data/app/views/chats/messages/create.turbo_stream.erb +6 -0
  29. data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
  30. data/app/views/chats/shared/_unread_badge.html.erb +6 -0
  31. data/config/importmap.rb +16 -0
  32. data/config/locales/en.yml +87 -0
  33. data/config/locales/es.yml +87 -0
  34. data/config/routes.rb +24 -0
  35. data/docs/PRD.md +254 -0
  36. data/docs/campfire_review.md +46 -0
  37. data/gemfiles/rails_7.1.gemfile +36 -0
  38. data/gemfiles/rails_7.2.gemfile +36 -0
  39. data/gemfiles/rails_8.1.gemfile +36 -0
  40. data/lib/chats/broadcasts.rb +147 -0
  41. data/lib/chats/configuration.rb +286 -0
  42. data/lib/chats/engine.rb +146 -0
  43. data/lib/chats/errors.rb +20 -0
  44. data/lib/chats/macros.rb +28 -0
  45. data/lib/chats/models/application_record.rb +11 -0
  46. data/lib/chats/models/concerns/chat_subject.rb +35 -0
  47. data/lib/chats/models/concerns/messager.rb +102 -0
  48. data/lib/chats/models/conversation.rb +347 -0
  49. data/lib/chats/models/message.rb +323 -0
  50. data/lib/chats/models/participant.rb +151 -0
  51. data/lib/chats/models/reaction.rb +70 -0
  52. data/lib/chats/version.rb +5 -0
  53. data/lib/chats.rb +188 -0
  54. data/lib/generators/chats/install_generator.rb +62 -0
  55. data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
  56. data/lib/generators/chats/templates/initializer.rb +138 -0
  57. data/lib/generators/chats/views_generator.rb +49 -0
  58. metadata +204 -0
@@ -0,0 +1,258 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // chats--composer: the message box.
4
+ //
5
+ // * autosize grow the textarea with content (capped)
6
+ // * Enter-to-send desktop only ("pointer: fine") — on touch keyboards
7
+ // Enter means newline, like every messaging app;
8
+ // Shift+Enter always inserts a newline; IME
9
+ // composition (event.isComposing) never sends
10
+ // * typing pings throttled POST (~1 per 2.5s while typing) that the
11
+ // server fans out as the chats_typing stream action;
12
+ // disabled when the server didn't render a typing URL
13
+ // * attachment previews picking files renders a thumbnail strip above the
14
+ // input (with per-file remove), so "what am I about
15
+ // to send?" is always visible — a bare counter badge
16
+ // next to the clip is not an answer
17
+ // * reset-on-success listens for turbo:submit-end (wired via data-action
18
+ // on the form) and clears/refocuses only on success —
19
+ // a 422 keeps the draft intact
20
+ export default class extends Controller {
21
+ static targets = ["input", "files", "fileCount", "previews", "editBar", "editPreview"]
22
+ static values = { typingUrl: String }
23
+
24
+ connect() {
25
+ this.lastTypedAt = 0
26
+ this.objectUrls = []
27
+ this.autosize()
28
+ }
29
+
30
+ disconnect() {
31
+ this.revokeObjectUrls()
32
+ }
33
+
34
+ typed() {
35
+ this.autosize()
36
+ this.pingTyping()
37
+ }
38
+
39
+ // Wired to the send button's pointerdown/mousedown: preventing the default
40
+ // there stops the tap from moving focus OFF the textarea — without it, iOS
41
+ // blurs the input (keyboard slides away), the submit completes, and the
42
+ // reset-on-success refocus brings the keyboard right back: a full
43
+ // hide/show bounce on every send. Click is NOT cancelled by a prevented
44
+ // pointerdown, so the form still submits normally.
45
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/pointerdown_event
46
+ keepFocus(event) {
47
+ event.preventDefault()
48
+ }
49
+
50
+ enterSend(event) {
51
+ if (event.shiftKey || event.isComposing) return
52
+ if (!window.matchMedia("(pointer: fine)").matches) return
53
+
54
+ event.preventDefault()
55
+ if (this.empty()) return
56
+ this.element.requestSubmit()
57
+ }
58
+
59
+ submitted(event) {
60
+ if (!event.detail.success) return
61
+
62
+ if (this.editing) this.exitEdit()
63
+ this.element.reset()
64
+ this.clearPreviews()
65
+ this.autosize()
66
+ if (this.hasInputTarget) this.inputTarget.focus()
67
+ }
68
+
69
+ // --- Edit mode (Telegram's flow) ---------------------------------------------
70
+ //
71
+ // The thread controller's long-press menu dispatches chats:edit-message
72
+ // (wired via data-action on the form): load the body into the input,
73
+ // show the "edit message" quote cue above it, and re-target this SAME
74
+ // form at the message's update URL (create URL + /:id) with a hidden
75
+ // _method=patch — Rails method override, zero extra forms. Attachments
76
+ // are disabled while editing (edits are body-only by design).
77
+
78
+ beginEdit(event) {
79
+ const { id, body } = event.detail || {}
80
+ if (!id || !this.hasInputTarget) return
81
+
82
+ this.createAction ||= this.element.action
83
+ this.element.action = `${this.createAction}/${id}`
84
+ this.editing = true
85
+
86
+ if (!this.methodInput) {
87
+ this.methodInput = document.createElement("input")
88
+ this.methodInput.type = "hidden"
89
+ this.methodInput.name = "_method"
90
+ this.methodInput.value = "patch"
91
+ }
92
+ this.element.appendChild(this.methodInput)
93
+
94
+ this.element.classList.add("chats-composer--editing")
95
+ if (this.hasEditBarTarget) {
96
+ this.editBarTarget.hidden = false
97
+ // One trimmed line of the original, quote-style, so the user sees
98
+ // WHAT they're editing even after they've mangled the input text.
99
+ if (this.hasEditPreviewTarget) {
100
+ this.editPreviewTarget.textContent = body.replace(/\s+/g, " ").trim()
101
+ }
102
+ }
103
+ if (this.hasFilesTarget) {
104
+ this.filesTarget.value = ""
105
+ this.filesTarget.disabled = true
106
+ this.clearPreviews()
107
+ }
108
+
109
+ this.inputTarget.value = body
110
+ this.autosize()
111
+ this.inputTarget.focus()
112
+ this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
113
+ }
114
+
115
+ cancelEdit(event) {
116
+ event?.preventDefault()
117
+ this.exitEdit()
118
+ if (this.hasInputTarget) {
119
+ this.inputTarget.value = ""
120
+ this.autosize()
121
+ this.inputTarget.focus()
122
+ }
123
+ }
124
+
125
+ exitEdit() {
126
+ this.editing = false
127
+ if (this.createAction) this.element.action = this.createAction
128
+ // Remove (not just blank) the override input: form.reset() restores
129
+ // DEFAULT values, and a hidden input's default is its value attribute
130
+ // — a leftover _method=patch would turn the next send into a PATCH.
131
+ this.methodInput?.remove()
132
+ this.element.classList.remove("chats-composer--editing")
133
+ if (this.hasEditBarTarget) this.editBarTarget.hidden = true
134
+ if (this.hasFilesTarget) this.filesTarget.disabled = false
135
+ }
136
+
137
+ filesChanged() {
138
+ this.renderPreviews()
139
+ }
140
+
141
+ // Drop ONE file from the selection. `<input type="file">.files` is a
142
+ // read-only FileList — the only sanctioned way to edit a selection is to
143
+ // rebuild it through a DataTransfer and assign `input.files` wholesale:
144
+ // https://developer.mozilla.org/docs/Web/API/DataTransfer/items
145
+ removeAttachment(event) {
146
+ event.preventDefault()
147
+ if (!this.hasFilesTarget) return
148
+
149
+ const index = Number(event.currentTarget.dataset.index)
150
+ const transfer = new DataTransfer()
151
+ Array.from(this.filesTarget.files).forEach((file, i) => {
152
+ if (i !== index) transfer.items.add(file)
153
+ })
154
+ this.filesTarget.files = transfer.files
155
+ this.renderPreviews()
156
+ }
157
+
158
+ // --- Internals ---------------------------------------------------------------
159
+
160
+ renderPreviews() {
161
+ const files = this.hasFilesTarget ? Array.from(this.filesTarget.files) : []
162
+
163
+ // Legacy count chip — kept for ejected views that still render it; the
164
+ // previews strip is the real affordance.
165
+ if (this.hasFileCountTarget) {
166
+ this.fileCountTarget.textContent = files.length > 0 ? String(files.length) : ""
167
+ this.fileCountTarget.hidden = files.length === 0
168
+ }
169
+
170
+ if (!this.hasPreviewsTarget) return
171
+
172
+ this.revokeObjectUrls()
173
+ this.previewsTarget.innerHTML = ""
174
+ this.previewsTarget.hidden = files.length === 0
175
+
176
+ files.forEach((file, index) => {
177
+ this.previewsTarget.appendChild(this.previewTile(file, index))
178
+ })
179
+ }
180
+
181
+ previewTile(file, index) {
182
+ const tile = document.createElement("div")
183
+ tile.className = "chats-composer__preview"
184
+
185
+ if (file.type.startsWith("image/")) {
186
+ // Object URLs render the local file without uploading anything yet.
187
+ // They hold memory until revoked, so we track and revoke on re-render:
188
+ // https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static
189
+ const url = URL.createObjectURL(file)
190
+ this.objectUrls.push(url)
191
+ const img = document.createElement("img")
192
+ img.src = url
193
+ img.alt = file.name
194
+ img.className = "chats-composer__preview-image"
195
+ tile.appendChild(img)
196
+ } else {
197
+ const name = document.createElement("span")
198
+ name.textContent = file.name
199
+ name.className = "chats-composer__preview-name"
200
+ tile.appendChild(name)
201
+ }
202
+
203
+ const remove = document.createElement("button")
204
+ remove.type = "button"
205
+ remove.className = "chats-composer__preview-remove"
206
+ remove.setAttribute("aria-label", `✕ ${file.name}`)
207
+ remove.textContent = "✕"
208
+ remove.dataset.index = String(index)
209
+ remove.dataset.action = "chats--composer#removeAttachment"
210
+ tile.appendChild(remove)
211
+
212
+ return tile
213
+ }
214
+
215
+ clearPreviews() {
216
+ this.revokeObjectUrls()
217
+ if (this.hasPreviewsTarget) {
218
+ this.previewsTarget.innerHTML = ""
219
+ this.previewsTarget.hidden = true
220
+ }
221
+ if (this.hasFileCountTarget) this.fileCountTarget.hidden = true
222
+ }
223
+
224
+ revokeObjectUrls() {
225
+ this.objectUrls.forEach((url) => URL.revokeObjectURL(url))
226
+ this.objectUrls = []
227
+ }
228
+
229
+ autosize() {
230
+ if (!this.hasInputTarget) return
231
+ const input = this.inputTarget
232
+ input.style.height = "auto"
233
+ input.style.height = `${Math.min(input.scrollHeight, 160)}px`
234
+ }
235
+
236
+ pingTyping() {
237
+ if (!this.typingUrlValue || this.empty()) return
238
+
239
+ const now = Date.now()
240
+ if (now - this.lastTypedAt < 2500) return
241
+ this.lastTypedAt = now
242
+
243
+ fetch(this.typingUrlValue, {
244
+ method: "POST",
245
+ headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
246
+ }).catch(() => {})
247
+ }
248
+
249
+ empty() {
250
+ const hasText = this.hasInputTarget && this.inputTarget.value.trim().length > 0
251
+ const hasFiles = this.hasFilesTarget && this.filesTarget.files.length > 0
252
+ return !hasText && !hasFiles
253
+ }
254
+ }
255
+
256
+ function csrfToken() {
257
+ return document.querySelector('meta[name="csrf-token"]')?.content || ""
258
+ }
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // chats--debounced-submit: submit a GET filter form shortly after typing.
4
+ //
5
+ // Keep the form outside the Turbo Frame it targets so focus stays in the
6
+ // search field while the results frame refreshes.
7
+ export default class extends Controller {
8
+ static values = {
9
+ delay: { type: Number, default: 250 }
10
+ }
11
+
12
+ connect() {
13
+ this.timeout = null
14
+ }
15
+
16
+ disconnect() {
17
+ this.clear()
18
+ }
19
+
20
+ queue(event) {
21
+ if (event?.isComposing) return
22
+
23
+ this.clear()
24
+ this.timeout = window.setTimeout(() => {
25
+ this.submit()
26
+ }, this.delayValue)
27
+ }
28
+
29
+ submit() {
30
+ this.clear()
31
+ this.element.requestSubmit()
32
+ }
33
+
34
+ clear() {
35
+ if (!this.timeout) return
36
+
37
+ window.clearTimeout(this.timeout)
38
+ this.timeout = null
39
+ }
40
+ }