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.
- checksums.yaml +7 -0
- data/.rubocop.yml +43 -0
- data/.simplecov +52 -0
- data/AGENTS.md +5 -0
- data/Appraisals +17 -0
- data/CHANGELOG.md +74 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/chats.css +818 -0
- data/app/controllers/chats/application_controller.rb +65 -0
- data/app/controllers/chats/conversations_controller.rb +198 -0
- data/app/controllers/chats/messages_controller.rb +118 -0
- data/app/controllers/chats/reactions_controller.rb +33 -0
- data/app/helpers/chats/engine_helper.rb +212 -0
- data/app/javascript/chats/composer_controller.js +258 -0
- data/app/javascript/chats/debounced_submit_controller.js +40 -0
- data/app/javascript/chats/thread_controller.js +855 -0
- data/app/views/chats/conversations/_conversation_row.html.erb +28 -0
- data/app/views/chats/conversations/_messages_page.html.erb +16 -0
- data/app/views/chats/conversations/_read_state.html.erb +11 -0
- data/app/views/chats/conversations/index.html.erb +54 -0
- data/app/views/chats/conversations/refresh.turbo_stream.erb +13 -0
- data/app/views/chats/conversations/show.html.erb +137 -0
- data/app/views/chats/messages/_composer.html.erb +67 -0
- data/app/views/chats/messages/_message.html.erb +158 -0
- data/app/views/chats/messages/create.turbo_stream.erb +6 -0
- data/app/views/chats/messages/errors.turbo_stream.erb +3 -0
- data/app/views/chats/shared/_unread_badge.html.erb +6 -0
- data/config/importmap.rb +16 -0
- data/config/locales/en.yml +87 -0
- data/config/locales/es.yml +87 -0
- data/config/routes.rb +24 -0
- data/docs/PRD.md +254 -0
- data/docs/campfire_review.md +46 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/chats/broadcasts.rb +147 -0
- data/lib/chats/configuration.rb +286 -0
- data/lib/chats/engine.rb +146 -0
- data/lib/chats/errors.rb +20 -0
- data/lib/chats/macros.rb +28 -0
- data/lib/chats/models/application_record.rb +11 -0
- data/lib/chats/models/concerns/chat_subject.rb +35 -0
- data/lib/chats/models/concerns/messager.rb +102 -0
- data/lib/chats/models/conversation.rb +347 -0
- data/lib/chats/models/message.rb +323 -0
- data/lib/chats/models/participant.rb +151 -0
- data/lib/chats/models/reaction.rb +70 -0
- data/lib/chats/version.rb +5 -0
- data/lib/chats.rb +188 -0
- data/lib/generators/chats/install_generator.rb +62 -0
- data/lib/generators/chats/templates/create_chats_tables.rb.erb +159 -0
- data/lib/generators/chats/templates/initializer.rb +138 -0
- data/lib/generators/chats/views_generator.rb +49 -0
- 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
|
+
}
|