chats 0.6.0 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0635f1e1c801684a95223db2e59dbb843a67fd9edfe858973b4e9ac6bb32c2a
4
- data.tar.gz: 9273751eca09bf387e640a45760f00dbd9875af8b8a75a1c2c4ae0de9ff2f527
3
+ metadata.gz: fa521d419a2dc6b1d672e2919c4db795674c744fd97df02027ce33b81f582675
4
+ data.tar.gz: c64cf6cfad1d3f41b9eaf9938936017ecb3af6840feb74f342699c7e0d5d6a01
5
5
  SHA512:
6
- metadata.gz: 94843453f041d4ff766b577b8ba9c28f4a04123f6532e7c27109836dae84f9c354d3c3051cf7f6ad6ec4b05abcbc46e78deacaaec131d63cd0c1af5631bdf4c1
7
- data.tar.gz: 0b61d2704ea93dd153e0b5ff899b9029b561f710f117955dd82f971a232a2891edc903b526af2cfe4438dfd3e655237a9a31dd9512f9406409a75675df138b94
6
+ metadata.gz: 99f45e5ce16f754d2e32a39bffa6664c4205fd401882b2794152a339eacfc6bb155514303c3e483e68f48f0e7dc52028ea0a7ce9180d776c3083df1b6ff3dd29
7
+ data.tar.gz: 660e92ea36bc25cea9ad374479495c32f5fcbd1f04f661cfff561eb9f6ce300e8afecd0b5a8294d30630e7577cd35a1f81395ae2848a806be60046b729f25f46
data/CHANGELOG.md CHANGED
@@ -4,6 +4,59 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.2] - 2026-10-18
8
+
9
+ ### Fixed
10
+ - **A moderation removal that removed nothing said it had.**
11
+ `Chats::Message#remove_reported_field!(:body)`, the seam moderate calls to
12
+ take reported content down, returned true for a message that was already
13
+ deleted, so moderate recorded a second "content removed" with a statement
14
+ of reasons that was false. It now returns whether anything was removed:
15
+ false for a message already deleted (by its sender or an earlier
16
+ decision), one whose body is already blank, and one already gone
17
+ (`deletion: :hard`); true only when it actually removed something (the
18
+ tombstone, or the row under `deletion: :hard`). It decides under the
19
+ message's row lock and reads the message again under it, so two
20
+ decisions taken at once cannot both see it standing. moderate
21
+ 1.0.0.beta3 and later refuse a removal that removed nothing; earlier
22
+ versions ignore the return value. A group's `remove_reported_field!(:title)`
23
+ keeps the same contract (false for a title already removed).
24
+
25
+ ## [0.6.1] - 2026-10-18
26
+
27
+ The package is the gem again, and three fixes to the two-pane page.
28
+ **No migration, nothing to configure.**
29
+
30
+ ### Fixed
31
+ - **The package shipped seven old packages.** The gemspec takes its files
32
+ from `git ls-files`, and the built `chats-0.3.2.gem` … `chats-0.5.1.gem`
33
+ had been committed (`chats-0.3.1.gem` since 0.3.1), so 0.6.0 weighed
34
+ 3.5 MB. They are untracked, `*.gem` is in `.gitignore`, and the gemspec
35
+ leaves out any `*.gem` it is handed anyway; `test/gemspec_test.rb` keeps
36
+ it so. The README's screenshots still ship, since the README links them.
37
+ - **The tab kept the previous page's title after a row click.** Turbo
38
+ leaves `<head>` alone when it promotes a frame load to a visit, so from
39
+ `/messages/A` clicking B left A's title. The messaging page's title now
40
+ has one source, the new `chats_page_title` helper ("Messages", "Bob ·
41
+ Messages": `chats.page_title.inbox` / `.conversation`, localized), which
42
+ `config.page_title = ->(conversation, viewer) { … }` rewords. The bundled
43
+ `index` and `show` give it to `content_for :title` (a layout doing
44
+ `yield(:title)` shows it on a full page), and the pane carries it
45
+ (`data-title`), which `chats--split` gives the tab when a row opens it.
46
+ The pane's answer stays in turbo-rails' minimal frame layout, now even
47
+ under a `config.layout`. A layout that titles pages from what the
48
+ controller loaded keeps its title on full pages and shows
49
+ `chats_page_title` after a row click: say it the same way in
50
+ `config.page_title`.
51
+ - **A tapped row kept its hover tint on touch screens.** Every hover rule
52
+ on inbox rows and on the dock (rows, the bar's chevron, "See all
53
+ messages", the window buttons) now sits inside `@media (hover: hover)`.
54
+ - **The open row kept its unread count until the list's live refresh.** The
55
+ pane says when the server read the conversation as it rendered it
56
+ (`data-read`), and the thread now dispatches `chats:read` once a read it
57
+ posted lands; on either, `chats--split` clears that row's count and bold
58
+ at once. The refresh that follows renders the same.
59
+
7
60
  ## [0.6.0] - 2026-10-18
8
61
 
9
62
  The messaging page is two panes, LinkedIn-style: on a wide screen
data/README.md CHANGED
@@ -253,7 +253,7 @@ Use **`:flag`, never `:block`** for chat: you don't gag someone mid-conversation
253
253
  |---|---|
254
254
  | `reported_owner` | the sender (who a decision notifies, who a ban targets) |
255
255
  | `moderation_snapshot(:body)` | the body — frozen as evidence at report time, surviving later edits/deletes |
256
- | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path |
256
+ | `remove_reported_field!(:body)` | the **soft-delete tombstone** — a moderator's removal looks exactly like a user deletion ("Message deleted"), no special admin rendering path. Returns whether anything was removed: `true` only when it actually removed something (the tombstone, or the row under `deletion: :hard`), `false` for a message already deleted (by its sender or an earlier decision), already gone, or whose body is already blank, decided under the message's row lock so two decisions at once can't both remove it. moderate 1.0.0.beta3 and later refuse a removal that removed nothing; earlier versions ignore the return value. A group's `remove_reported_field!(:title)` keeps the same contract |
257
257
  | `report_visible_to?(viewer)` | participants only (a DM isn't public content), and never the author |
258
258
  | `moderation_field_value(:files)` / change detection | attachment-aware seams so image filters classify what actually changed |
259
259
 
@@ -598,6 +598,8 @@ Each row is a real link to `/messages/:id` (keeping the list's search or stack i
598
598
 
599
599
  **Reads** happen when you open a conversation: a row clicked, a link, a reload. The page's own refreshes never read (one may arrive in a background tab), and a conversation the page renders unread reads itself as soon as it's on screen.
600
600
 
601
+ **The page's title** comes from one place, `chats_page_title`: "Messages" for the inbox, "Bob · Messages" with a conversation open (`chats.page_title.*`, localized). The bundled pages hand it to `content_for :title`, so a layout that shows `<title><%= content_for(:title) || "My app" %></title>` (the Rails default) gets it, and the pane carries it, so the tab follows when a row opens a conversation (Turbo leaves `<head>` alone on a frame visit). Word it your way, for both, with `config.page_title = ->(conversation, viewer) { … }` (`conversation` is nil on the inbox). A layout that builds its own title from what the controller loaded (a `@page_title`) keeps it on full page loads, and shows `chats_page_title` after a row click: say it the same way in `config.page_title`.
602
+
601
603
  The [messaging dock](#-messaging-dock) stays off this page, as on every engine page; its "See all messages" opens it, and a window's ⤢ opens its conversation here.
602
604
 
603
605
  ```ruby
@@ -770,6 +772,7 @@ Chats.configure do |config|
770
772
  config.messager_url = ->(messager) { nil } # nil ⇒ names render as plain text
771
773
  config.message_signature = nil # ->(message) { } for signed bubbles
772
774
  config.verified_badge = nil # ->(messager) { markup } for verified: true
775
+ config.page_title = nil # ->(conversation, viewer) { String }; nil ⇒ "Bob · Messages"
773
776
  end
774
777
  ```
775
778
 
@@ -86,7 +86,11 @@
86
86
  text-decoration: none;
87
87
  color: inherit;
88
88
  }
89
- .chats-row__link:hover { background: var(--chats-surface); }
89
+ /* Hover only where there is one: a touch screen "hovers" what it tapped and
90
+ keeps the tint after the finger is gone (the open row stayed grey). */
91
+ @media (hover: hover) {
92
+ .chats-row__link:hover { background: var(--chats-surface); }
93
+ }
90
94
  .chats-row__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
91
95
  .chats-row__top, .chats-row__bottom {
92
96
  display: flex;
@@ -1312,7 +1316,9 @@ turbo-frame[id^="chats_page_"] {
1312
1316
  height: 18px;
1313
1317
  transition: transform var(--chats-dock-duration) var(--chats-dock-ease);
1314
1318
  }
1315
- .chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1319
+ @media (hover: hover) {
1320
+ .chats-dock__toggle:hover .chats-dock__chevron { background: var(--chats-dock-hover); color: var(--chats-text); }
1321
+ }
1316
1322
  .chats-dock__panel[open] .chats-dock__chevron svg { transform: rotate(180deg); }
1317
1323
  .chats-dock__close-glyph { display: none; }
1318
1324
 
@@ -1350,7 +1356,9 @@ turbo-frame[id^="chats_page_"] {
1350
1356
  }
1351
1357
  .chats-dock__list { list-style: none; margin: 0; padding: 4px 0; }
1352
1358
  .chats-dock__list .chats-row__link { padding: 9px 12px; gap: 10px; }
1353
- .chats-dock__list .chats-row__link:hover { background: var(--chats-dock-hover); }
1359
+ @media (hover: hover) {
1360
+ .chats-dock__list .chats-row__link:hover { background: var(--chats-dock-hover); }
1361
+ }
1354
1362
  .chats-dock__list .chats-avatar { width: 40px; height: 40px; font-size: 0.85rem; }
1355
1363
  .chats-dock__list .chats-row__title { font-size: 0.875rem; }
1356
1364
  .chats-dock__list .chats-row__subject { font-size: 0.6875rem; }
@@ -1371,7 +1379,9 @@ turbo-frame[id^="chats_page_"] {
1371
1379
  font-weight: 650;
1372
1380
  text-decoration: none;
1373
1381
  }
1374
- .chats-dock__see-all:hover { background: var(--chats-dock-hover); text-decoration: underline; }
1382
+ @media (hover: hover) {
1383
+ .chats-dock__see-all:hover { background: var(--chats-dock-hover); text-decoration: underline; }
1384
+ }
1375
1385
 
1376
1386
  .chats-dock .chats-empty { padding: 40px 20px; }
1377
1387
  .chats-dock .chats-empty__icon { font-size: 2rem; margin-bottom: 0.5rem; }
@@ -1453,7 +1463,9 @@ turbo-frame[id^="chats_page_"] {
1453
1463
  }
1454
1464
  .chats-window__icon::-webkit-details-marker { display: none; }
1455
1465
  .chats-window__icon svg { width: 18px; height: 18px; }
1456
- .chats-window__icon:hover { background: var(--chats-dock-hover); color: var(--chats-text); }
1466
+ @media (hover: hover) {
1467
+ .chats-window__icon:hover { background: var(--chats-dock-hover); color: var(--chats-text); }
1468
+ }
1457
1469
  .chats-window__back { display: none; }
1458
1470
  /* The host's thread actions (conversation_header_actions). */
1459
1471
  .chats-window__menu { flex: 0 0 auto; }
@@ -1475,7 +1487,9 @@ turbo-frame[id^="chats_page_"] {
1475
1487
  box-shadow: none;
1476
1488
  }
1477
1489
  .chats-window--unread :is(.chats-window__subject, .chats-window__icon, .chats-verified) { color: inherit; }
1478
- .chats-window--unread .chats-window__icon:hover { background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; }
1490
+ @media (hover: hover) {
1491
+ .chats-window--unread .chats-window__icon:hover { background: color-mix(in srgb, currentColor 16%, transparent); color: inherit; }
1492
+ }
1479
1493
  .chats-window--unread .chats-window__header .chats-badge {
1480
1494
  background: var(--chats-dock-unread-text);
1481
1495
  color: var(--chats-dock-unread-bg);
@@ -248,8 +248,13 @@ module Chats
248
248
  # page's own conversation frame, else the whole page around it (any
249
249
  # other frame of the page, the search's results among them, finds its
250
250
  # own part in it).
251
+ #
252
+ # The pane alone comes in turbo-rails' minimal frame layout, always (even
253
+ # under a `config.layout`): nothing of the host's page is rendered for
254
+ # Turbo to throw away. Its title travels on the pane (`data-title`,
255
+ # chats_page_title), since Turbo leaves <head> alone on a frame visit.
251
256
  def render_split_page
252
- return render :pane, layout: false if pane_request?
257
+ return render :pane, layout: "turbo_rails/frame" if pane_request?
253
258
 
254
259
  load_inbox
255
260
  render :index
@@ -329,6 +329,21 @@ module Chats
329
329
  path.presence || chats_routes.conversations_path(with: Chats.inbox_with_sgid(messager))
330
330
  end
331
331
 
332
+ # The messaging page's title: the inbox's, or +conversation+'s.
333
+ # `config.page_title` when the host sets one, else the localized
334
+ # `chats.page_title.*`. ONE source for both ways a conversation opens:
335
+ # the bundled pages hand it to `content_for :title` on a full visit, and
336
+ # the two-pane page's pane carries it (`data-title`) for chats--split to
337
+ # give the tab when a row opens it, since Turbo leaves <head> alone then.
338
+ def chats_page_title(conversation = nil)
339
+ viewer = chats_current_messager
340
+ custom = Chats.config.page_title&.call(conversation, viewer)
341
+ return custom.to_s if custom.present?
342
+ return t("chats.page_title.inbox") unless conversation
343
+
344
+ t("chats.page_title.conversation", name: conversation.title_for(viewer))
345
+ end
346
+
332
347
  # The gem's bundled stylesheet (CSS-variable themed — see chats.css).
333
348
  # Called from the engine's own views; hosts that eject + restyle the
334
349
  # views with their own framework simply don't include it.
@@ -22,6 +22,16 @@ import { Controller } from "@hotwired/stimulus"
22
22
  // update of the list) would disable it again on a wide screen; the frame
23
23
  // keeps what the screen says.
24
24
  //
25
+ // The tab's title follows the pane: Turbo leaves <head> alone when it
26
+ // promotes a frame load to a visit, so the title would stay the previous
27
+ // page's. The pane carries the page's title with it open (`data-title`,
28
+ // chats_page_title: the same the full page gives `content_for :title`).
29
+ //
30
+ // The open conversation's row stops counting it unread as soon as it is
31
+ // read (the pane's answer says the server read it, `data-read`; or its
32
+ // thread reads it on screen, `chats:read`), without waiting for the list's
33
+ // live refresh, which may be seconds away or not come at all.
34
+ //
25
35
  // When the pane opens a conversation, its row becomes the current one
26
36
  // (aria-current="page", .chats-row--current) at once, exactly as the server
27
37
  // renders /messages/:id, instead of when the list next refreshes. And the
@@ -82,9 +92,33 @@ export default class extends Controller {
82
92
  }
83
93
  link.closest(".chats-row")?.classList.toggle("chats-row--current", current)
84
94
  })
95
+ const pane = this.frameTarget.querySelector(".chats-split__pane")
96
+ if (pane?.dataset.title) document.title = pane.dataset.title
97
+ if (pane?.dataset.read === "true") this.markRead(pane)
85
98
  this.arrive()
86
99
  }
87
100
 
101
+ // chats:read, from the pane's thread.
102
+ threadRead(event) {
103
+ const pane = event.target?.closest?.(".chats-split__pane")
104
+ if (pane) this.markRead(pane)
105
+ }
106
+
107
+ // The conversation's row, read: no count, no bold, as the next refresh
108
+ // will render it.
109
+ markRead(pane) {
110
+ if (!this.hasListTarget || !pane.dataset.path) return
111
+
112
+ const path = new URL(pane.dataset.path, document.baseURI).pathname
113
+ this.listTarget.querySelectorAll(`a[data-turbo-frame="${this.frameTarget.id}"]`).forEach((link) => {
114
+ if (new URL(link.getAttribute("href"), document.baseURI).pathname !== path) return
115
+
116
+ const row = link.closest(".chats-row")
117
+ row?.classList.remove("chats-row--unread")
118
+ row?.querySelector(".chats-row__bottom .chats-badge")?.remove()
119
+ })
120
+ }
121
+
88
122
  arrive() {
89
123
  const pane = this.frameTarget.querySelector(".chats-split__pane")
90
124
  if (!pane) return
@@ -456,9 +456,14 @@ export default class extends Controller {
456
456
  this.pendingRead = true
457
457
  return
458
458
  }
459
+ // chats:read, once the server has it: the two-pane page clears the
460
+ // conversation's row (chats--split) without waiting for the list's
461
+ // live refresh.
459
462
  fetch(this.readUrlValue, {
460
463
  method: "POST",
461
464
  headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" }
465
+ }).then((response) => {
466
+ if (response.ok) this.element.dispatchEvent(new CustomEvent("chats:read", { bubbles: true }))
462
467
  }).catch(() => {})
463
468
  }
464
469
 
@@ -17,11 +17,22 @@
17
17
  The header stays outside it, so a refresh still brings it up to date
18
18
  (a mute, a renamed group).
19
19
 
20
+ data-title: the page's title with this conversation open
21
+ (chats_page_title, the same the full page gives `content_for :title`):
22
+ chats--split gives it to the tab when a row opens the pane, since Turbo
23
+ leaves <head> alone on a frame visit.
24
+
25
+ data-read: the server read the conversation as it rendered this (a row
26
+ clicked); chats--split then clears its row's unread count at once,
27
+ instead of when the list next refreshes. A pane rendered unread clears
28
+ it when its thread reads (the thread's chats:read event).
29
+
20
30
  Locals: conversation, participant, messages, more, first_unread_id,
21
31
  counterpart, read_pending (as in chats/conversations/_thread). %>
22
32
  <% title_id = dom_id(conversation, :title) %>
23
33
  <%= tag.section class: "chats-split__pane", "aria-labelledby": title_id,
24
- data: { conversation_id: conversation.id } do %>
34
+ data: { conversation_id: conversation.id, path: conversation_path(conversation),
35
+ title: chats_page_title(conversation), read: !read_pending } do %>
25
36
  <%= render "chats/conversations/thread_header",
26
37
  conversation: conversation, participant: participant, counterpart: counterpart,
27
38
  heading: :h2, title_id: title_id, back_path: conversations_path(**chats_inbox_filter) %>
@@ -50,6 +50,9 @@
50
50
  `.restore` visit that honors this. Requires the host layout to
51
51
  `yield :head` (the Rails convention); harmless no-op otherwise.
52
52
  https://turbo.hotwired.dev/handbook/building#opting-out-of-caching %>
53
+ <%# The page's title, for a layout that shows `yield(:title)`: the inbox's,
54
+ or the open conversation's (chats_page_title, config.page_title). %>
55
+ <% content_for :title, chats_page_title(@conversation) %>
53
56
  <% content_for :head do %>
54
57
  <meta name="turbo-cache-control" content="no-cache">
55
58
  <% end %>
@@ -149,6 +152,7 @@
149
152
  controller: "chats--split",
150
153
  action: "turbo:before-prefetch->chats--split#prefetch " \
151
154
  "turbo:frame-load->chats--split#opened " \
155
+ "chats:read->chats--split#threadRead " \
152
156
  "turbo:before-morph-attribute->chats--split#keepEnabled"
153
157
  } do %>
154
158
  <nav class="chats-split__list" aria-label="<%= t("chats.inbox.title") %>" data-chats--split-target="list">
@@ -1,7 +1,9 @@
1
1
  <%# The answer to a row click on the two-pane messaging page: the request
2
2
  comes from the page's `chats_conversation` frame (Turbo-Frame header),
3
3
  so the answer is that frame with the conversation pane in it and
4
- nothing else — no list, no layout (ConversationsController#show).
4
+ nothing else — no list, turbo-rails' minimal frame layout
5
+ (ConversationsController#show). The pane carries its title
6
+ (data-title), which chats--split gives the tab.
5
7
  Turbo swaps it into the frame and, the frame being
6
8
  data-turbo-action="advance", moves the address bar to /messages/:id. %>
7
9
  <%= turbo_frame_tag Chats::ConversationsController::PANE_FRAME do %>
@@ -4,6 +4,7 @@
4
4
  two-pane page's conversation pane render too — see that partial for how
5
5
  the live thread works). Under the default `:split`, /messages/:id is
6
6
  the two-pane page instead (index.html.erb). %>
7
+ <% content_for :title, chats_page_title(@conversation) %>
7
8
  <%= chats_styles %>
8
9
 
9
10
  <%= render layout: "chats/conversations/thread",
@@ -1,5 +1,8 @@
1
1
  en:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Messages"
5
+ conversation: "%{name} · Messages"
3
6
  inbox:
4
7
  title: "Messages"
5
8
  search_placeholder: "Search conversations"
@@ -1,5 +1,8 @@
1
1
  es:
2
2
  chats:
3
+ page_title:
4
+ inbox: "Mensajes"
5
+ conversation: "%{name} · Mensajes"
3
6
  inbox:
4
7
  title: "Mensajes"
5
8
  search_placeholder: "Buscar conversaciones"
@@ -174,6 +174,14 @@ module Chats
174
174
  # suppress the badge on some messagers without touching the models.
175
175
  attr_reader :verified_badge
176
176
 
177
+ # ->(conversation, viewer) { String } — the title of the messaging page:
178
+ # the inbox (conversation nil) or a conversation. The bundled views
179
+ # give it to `content_for :title` (a layout doing `yield(:title)` shows
180
+ # it), and the two-pane page gives the tab the conversation's when a
181
+ # row opens it in the pane. nil (the default) is the localized
182
+ # `chats.page_title.inbox` / `chats.page_title.conversation`.
183
+ attr_reader :page_title
184
+
177
185
  def initialize
178
186
  @messager_class = "User"
179
187
  @parent_controller = "::ApplicationController"
@@ -210,6 +218,7 @@ module Chats
210
218
  @messager_url = ->(_messager) { nil }
211
219
  @message_signature = nil
212
220
  @verified_badge = nil
221
+ @page_title = nil
213
222
 
214
223
  @messager_display_name = lambda do |messager|
215
224
  messager.try(:display_name) || messager.try(:name) ||
@@ -335,6 +344,10 @@ module Chats
335
344
  @verified_badge = value.nil? ? nil : ensure_callable(value, "verified_badge")
336
345
  end
337
346
 
347
+ def page_title=(value)
348
+ @page_title = value.nil? ? nil : ensure_callable(value, "page_title")
349
+ end
350
+
338
351
  def messager_display_name=(value)
339
352
  @messager_display_name = ensure_callable(value, "messager_display_name")
340
353
  end
@@ -449,11 +449,19 @@ module Chats
449
449
  field.to_s == "title" && title.present?
450
450
  end
451
451
 
452
+ # Whether anything was removed (see Chats::Message#remove_reported_field!):
453
+ # false for a title already gone, decided under the row lock.
452
454
  def remove_reported_field!(field)
453
455
  return false unless field.to_s == "title"
454
456
 
455
- update!(title: nil)
456
- true
457
+ with_lock do
458
+ next false if title.blank?
459
+
460
+ update!(title: nil)
461
+ true
462
+ end
463
+ rescue ActiveRecord::RecordNotFound
464
+ false
457
465
  end
458
466
 
459
467
  def report_visible_to?(viewer, field: nil)
@@ -356,12 +356,28 @@ module Chats
356
356
  # A moderator removing a reported message body = the soft-delete
357
357
  # tombstone path, so the thread shows "Message deleted" instead of a
358
358
  # hole, and attachments are purged with it.
359
+ #
360
+ # Returns whether anything was removed: true only when it actually
361
+ # removed something (the tombstone, or the row under `deletion: :hard`);
362
+ # false for a message already deleted (by its sender, by an earlier
363
+ # decision), already gone, or whose body is already blank. moderate
364
+ # 1.0.0.beta3 and later take that as the truth and refuse a removal that
365
+ # removed nothing, rather than record "content removed" with a statement
366
+ # of reasons that would be false; earlier versions ignore the return
367
+ # value. Decided under the message's row lock, read again under it: two
368
+ # decisions taken at once cannot both see it standing.
359
369
  def remove_reported_field!(field)
360
370
  return false unless field.to_s == "body"
361
371
 
362
- # Trust & Safety outranks a product lock: a closed conversation must
363
- # never be a place reported content can hide.
364
- soft_delete!(enforce_lock: false)
372
+ with_lock do
373
+ next false if deleted? || body.blank?
374
+
375
+ # Trust & Safety outranks a product lock: a closed conversation must
376
+ # never be a place reported content can hide.
377
+ soft_delete!(enforce_lock: false)
378
+ end
379
+ rescue ActiveRecord::RecordNotFound
380
+ false # hard-deleted meanwhile: nothing left to remove
365
381
  end
366
382
 
367
383
  # Only people *in* the conversation may report a message (a message
data/lib/chats/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chats
4
- VERSION = "0.6.0"
4
+ VERSION = "0.6.2"
5
5
  end
@@ -93,6 +93,14 @@ Chats.configure do |config|
93
93
  #
94
94
  # config.inbox_layout = :split
95
95
  #
96
+ # The messaging page's title (the bundled views give it to
97
+ # `content_for :title`, and the two-pane page to the tab when a row opens
98
+ # a conversation). nil: "Messages" / "Bob · Messages" (chats.page_title.*).
99
+ #
100
+ # config.page_title = ->(conversation, viewer) {
101
+ # conversation ? "#{conversation.title_for(viewer)} | MyApp" : "Messages | MyApp"
102
+ # }
103
+ #
96
104
  # Encrypt message bodies at rest (ActiveRecord Encryption; requires
97
105
  # `bin/rails db:encryption:init`). Body search degrades when enabled.
98
106
  #
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-18 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -173,13 +173,6 @@ files:
173
173
  - app/views/chats/messages/locked.turbo_stream.erb
174
174
  - app/views/chats/shared/_unread_badge.html.erb
175
175
  - app/views/chats/shared/_verified_badge.html.erb
176
- - chats-0.3.1.gem
177
- - chats-0.3.2.gem
178
- - chats-0.4.0.gem
179
- - chats-0.4.1.gem
180
- - chats-0.4.2.gem
181
- - chats-0.5.0.gem
182
- - chats-0.5.1.gem
183
176
  - config/importmap.rb
184
177
  - config/locales/en.yml
185
178
  - config/locales/es.yml
data/chats-0.3.1.gem DELETED
Binary file
data/chats-0.3.2.gem DELETED
Binary file
data/chats-0.4.0.gem DELETED
Binary file
data/chats-0.4.1.gem DELETED
Binary file
data/chats-0.4.2.gem DELETED
Binary file
data/chats-0.5.0.gem DELETED
Binary file
data/chats-0.5.1.gem DELETED
Binary file