chats 0.6.2 → 0.7.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -0
  3. data/README.md +90 -28
  4. data/app/assets/stylesheets/chats.css +225 -15
  5. data/app/helpers/chats/engine_helper.rb +101 -7
  6. data/app/javascript/chats/dock_controller.js +54 -33
  7. data/app/javascript/chats/sheet.js +69 -0
  8. data/app/javascript/chats/thread_controller.js +199 -5
  9. data/app/javascript/chats/tray_controller.js +506 -0
  10. data/app/views/chats/conversations/_thread.html.erb +4 -0
  11. data/app/views/chats/dock/_dock.html.erb +9 -1
  12. data/app/views/chats/messages/_message.html.erb +30 -3
  13. data/app/views/chats/tray/_badge.html.erb +12 -0
  14. data/app/views/chats/tray/_corner.html.erb +16 -0
  15. data/app/views/chats/tray/_item.html.erb +44 -0
  16. data/app/views/chats/tray/_tray.html.erb +27 -0
  17. data/config/importmap.rb +4 -0
  18. data/config/locales/en.yml +1 -0
  19. data/config/locales/es.yml +1 -0
  20. data/lib/chats/after_commit.rb +87 -0
  21. data/lib/chats/broadcasts.rb +8 -0
  22. data/lib/chats/models/participant.rb +14 -1
  23. data/lib/chats/subscribers.rb +14 -1
  24. data/lib/chats/version.rb +1 -1
  25. data/lib/generators/chats/templates/initializer.rb +1 -0
  26. metadata +8 -16
  27. data/docs/PRD.md +0 -254
  28. data/docs/campfire_review.md +0 -46
  29. data/docs/screenshots/composer-actions.png +0 -0
  30. data/docs/screenshots/dock-bar.png +0 -0
  31. data/docs/screenshots/dock-desktop.png +0 -0
  32. data/docs/screenshots/dock-phone-button.png +0 -0
  33. data/docs/screenshots/dock-phone-panel.png +0 -0
  34. data/docs/screenshots/dock-phone-window.png +0 -0
  35. data/docs/screenshots/inbox.png +0 -0
  36. data/docs/screenshots/split-inbox-empty.png +0 -0
  37. data/docs/screenshots/split-inbox-phone-list.png +0 -0
  38. data/docs/screenshots/split-inbox-phone-thread.png +0 -0
  39. data/docs/screenshots/split-inbox.png +0 -0
  40. data/docs/screenshots/system-note-action.png +0 -0
  41. data/docs/screenshots/thread-actions-phone.png +0 -0
@@ -0,0 +1,69 @@
1
+ // chats/sheet: what every full-screen sheet in the corner shares, the
2
+ // dock's (its panel, a window in front) and the tray's (an open item).
3
+ //
4
+ // A sheet is modal: everything around it is `inert` and the page behind it
5
+ // doesn't scroll. Two sheets can overlap for a moment (one opens as the
6
+ // other closes, in either order), so the page's inert and its scroll lock
7
+ // are HELD BY OWNER: a node stays inert, and the page locked, while anyone
8
+ // still holds it, and one sheet closing never releases what another holds.
9
+ // A node the host made inert itself is never touched.
10
+ //
11
+ // Not a controller: outside controllers/ so the eager loader never tries to
12
+ // register it (config/importmap.rb).
13
+
14
+ const held = new Map() // node → the owners holding it inert
15
+ const locks = new Set() // the owners holding the page's scroll
16
+
17
+ // Everything on the page but +element+: the siblings of it and of each of
18
+ // its ancestors, up to <body>. A host may render the corner inside a layout
19
+ // wrapper, and inerting the wrapper would inert the sheet too.
20
+ export function around(element) {
21
+ const nodes = []
22
+ for (let node = element; node?.parentElement && node !== document.body; node = node.parentElement) {
23
+ nodes.push(...Array.from(node.parentElement.children).filter((sibling) => sibling !== node))
24
+ }
25
+ return nodes
26
+ }
27
+
28
+ export function holdInert(owner, nodes) {
29
+ nodes.forEach((node) => {
30
+ let owners = held.get(node)
31
+ if (!owners) {
32
+ if (node.inert) return // the host's own: not ours to hold, nor to release
33
+ owners = new Set()
34
+ held.set(node, owners)
35
+ }
36
+ owners.add(owner)
37
+ node.inert = true
38
+ })
39
+ }
40
+
41
+ export function releaseInert(owner) {
42
+ held.forEach((owners, node) => {
43
+ if (!owners.delete(owner) || owners.size) return
44
+ held.delete(node)
45
+ node.inert = false
46
+ })
47
+ }
48
+
49
+ export function lockScroll(owner, locked) {
50
+ if (locked) {
51
+ locks.add(owner)
52
+ } else {
53
+ locks.delete(owner)
54
+ }
55
+ document.documentElement.classList.toggle("chats-dock-sheet-open", locks.size > 0)
56
+ }
57
+
58
+ // Full-screen means the VISIBLE viewport: with the keyboard up it is
59
+ // shorter than 100dvh, and a composer must sit on the keys. Sets the
60
+ // variables the sheet's CSS reads on +element+, and says when the keyboard
61
+ // is up (on a phone, where +compact+).
62
+ export function fitViewport(element, compact) {
63
+ const viewport = window.visualViewport
64
+ if (!viewport) return
65
+
66
+ element.style.setProperty("--chats-vvh", `${viewport.height}px`)
67
+ element.style.setProperty("--chats-vv-top", `${viewport.offsetTop}px`)
68
+ element.classList.toggle("chats-dock--keyboard", Boolean(compact) && window.innerHeight - viewport.height > 150)
69
+ }
@@ -90,6 +90,7 @@ export default class extends Controller {
90
90
  daySeparatorClass: { type: String, default: "chats-day-separator" },
91
91
  typingSuffix: String,
92
92
  copiedLabel: String,
93
+ menuLabel: String,
93
94
  group: Boolean,
94
95
  readPending: Boolean
95
96
  }
@@ -115,6 +116,7 @@ export default class extends Controller {
115
116
  this.renderReceipts()
116
117
  this.renderDaySeparators()
117
118
  this.renderMessageGroups()
119
+ this.syncMenuTabStop()
118
120
  if (this.readPendingValue) this.queueRead()
119
121
  })
120
122
 
@@ -140,6 +142,18 @@ export default class extends Controller {
140
142
  this.element.addEventListener("chats:caught-up", this.caughtUp)
141
143
  this.stopRecovery = recoverMissedBroadcasts(this.element, () => this.refreshThread(), { stale: () => this.stale })
142
144
  this.watchScreenPresence()
145
+ // Which message's "⋯" has the focus, so a stream replacing (or
146
+ // removing) that bubble can hand it on instead of dropping it on the
147
+ // body. focusin, because a removed element's focus leaves silently; a
148
+ // pointer press means the reader moved on.
149
+ this.trackMenuFocus = (event) => {
150
+ const trigger = event.target.closest?.("[data-chats-message-menu-trigger]")
151
+ this.focusedMenuMessage = trigger && this.element.contains(trigger) ? trigger.closest(".chats-message") : null
152
+ if (this.focusedMenuMessage) this.setMenuTabStop(trigger)
153
+ }
154
+ this.forgetMenuFocus = () => { this.focusedMenuMessage = null }
155
+ document.addEventListener("focusin", this.trackMenuFocus)
156
+ document.addEventListener("pointerdown", this.forgetMenuFocus, true)
143
157
  }
144
158
 
145
159
  disconnect() {
@@ -162,6 +176,8 @@ export default class extends Controller {
162
176
  this.presenceObserver?.disconnect()
163
177
  this.gate?.removeEventListener("chats:activated", this.flushPendingRead)
164
178
  this.element.removeEventListener("click", this.suppressClickCapture, true)
179
+ document.removeEventListener("focusin", this.trackMenuFocus)
180
+ document.removeEventListener("pointerdown", this.forgetMenuFocus, true)
165
181
  clearTimeout(this.pressTimer)
166
182
  this.teardownPopup()
167
183
  clearTimeout(this.typingTimer)
@@ -172,10 +188,26 @@ export default class extends Controller {
172
188
 
173
189
  // --- Bubbles ---------------------------------------------------------------
174
190
 
191
+ // The tab stop left with its bubble (removed, trimmed): the newest takes over.
192
+ messageTargetDisconnected(element) {
193
+ if (this.menuTabStop && element.contains(this.menuTabStop)) {
194
+ this.menuTabStop = null
195
+ this.syncMenuTabStop()
196
+ }
197
+ // The focused "⋯" went with its bubble (an edit, a reaction, a
198
+ // deletion broadcast): once the stream is done, the focus goes to the
199
+ // same message's new "⋯", or the nearest message's if it is gone.
200
+ if (element === this.focusedMenuMessage && this.focusDropped()) {
201
+ this.focusedMenuMessage = null
202
+ requestAnimationFrame(() => this.refocusMenu(element))
203
+ }
204
+ }
205
+
175
206
  messageTargetConnected(element) {
176
207
  this.trackLegacyCursor(element)
177
208
  this.classify(element)
178
209
  if (this.booting) return
210
+ this.syncMenuTabStop()
179
211
  // Live appends can land out of order (multi-worker hosts broadcast from
180
212
  // concurrent jobs). Re-slot the bubble if its predecessor is newer; the
181
213
  // move re-fires this callback, which then proceeds in order.
@@ -209,6 +241,11 @@ export default class extends Controller {
209
241
  const own = element.dataset.senderKey && element.dataset.senderKey === this.meValue
210
242
  element.classList.toggle("chats-message--own", own)
211
243
 
244
+ // The "⋯" trigger's name in the READER's language: the bubble may be
245
+ // a broadcast rendered in the sender's.
246
+ const trigger = element.querySelector("[data-chats-message-menu-trigger]")
247
+ if (trigger && this.menuLabelValue) trigger.setAttribute("aria-label", this.menuLabelValue)
248
+
212
249
  const receipt = element.querySelector("[data-chats-message-receipt]")
213
250
  const label = element.querySelector("[data-chats-message-receipt-label]")
214
251
  if (receipt && !own) {
@@ -855,6 +892,77 @@ export default class extends Controller {
855
892
  if (!this.popupOpenFor) this.openPopup(bubble)
856
893
  }
857
894
 
895
+ // The "⋯" buttons are ONE tab stop for the thread (roving tabindex):
896
+ // all render tabindex="-1" and this keeps one at 0, the newest message's
897
+ // or the one the keyboard is on, so a long thread never puts hundreds of
898
+ // stops before its composer. ArrowUp / ArrowDown move along them.
899
+ // https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
900
+ syncMenuTabStop() {
901
+ const stop = this.menuTabStop
902
+ if (stop?.isConnected && document.activeElement === stop) return
903
+
904
+ const triggers = this.element.querySelectorAll("[data-chats-message-menu-trigger]")
905
+ this.setMenuTabStop(triggers[triggers.length - 1])
906
+ }
907
+
908
+ setMenuTabStop(trigger) {
909
+ if (this.menuTabStop === trigger) return
910
+ if (this.menuTabStop) this.menuTabStop.tabIndex = -1
911
+ this.menuTabStop = trigger
912
+ if (trigger) trigger.tabIndex = 0
913
+ }
914
+
915
+ focusDropped() {
916
+ return !document.activeElement || document.activeElement === document.body
917
+ }
918
+
919
+ // The bubble that replaced `gone` (same id), else the message created
920
+ // nearest before it, else the one after.
921
+ refocusMenu(gone) {
922
+ if (!this.focusDropped()) return
923
+
924
+ const created = Number(gone.dataset.createdAtUs)
925
+ const messages = this.messageTargets.filter((message) => message.querySelector("[data-chats-message-menu-trigger]"))
926
+ const same = messages.find((message) => message.id === gone.id)
927
+ const before = messages.filter((message) => Number(message.dataset.createdAtUs) <= created).pop()
928
+ const after = messages.find((message) => Number(message.dataset.createdAtUs) > created)
929
+ const trigger = (same || before || after)?.querySelector("[data-chats-message-menu-trigger]")
930
+ if (!trigger) return
931
+ this.setMenuTabStop(trigger)
932
+ trigger.focus({ preventScroll: true })
933
+ }
934
+
935
+ moveMenuFocus(event) {
936
+ const step = { ArrowUp: -1, ArrowDown: 1 }[event.key]
937
+ if (!step) return
938
+
939
+ event.preventDefault()
940
+ const triggers = [...this.element.querySelectorAll("[data-chats-message-menu-trigger]")]
941
+ const next = triggers[triggers.indexOf(event.currentTarget) + step]
942
+ if (!next) return
943
+ this.setMenuTabStop(next)
944
+ next.focus()
945
+ }
946
+
947
+ // The bubble's "⋯" (desktop only, see chats.css): a click, Enter or
948
+ // Space opens the popup with the focus in the menu, and closing gives it
949
+ // back. Nothing to suppress: this click IS the gesture.
950
+ openMenu(event) {
951
+ const bubble = event.currentTarget.closest(".chats-message")
952
+ if (!bubble || this.popupOpenFor) return
953
+
954
+ event.preventDefault()
955
+ this.openPopup(bubble)
956
+ this.suppressNextClick = false
957
+ if (!this.popupOpenFor) return
958
+
959
+ this.setMenuTabStop(event.currentTarget)
960
+ this.popupReturnFocus = { trigger: event.currentTarget, message: bubble.id }
961
+ const first = this.popupMenuTarget.querySelector(".chats-popup__item") ||
962
+ this.popupReactionsTarget.querySelector(".chats-popup__reaction")
963
+ first?.focus({ preventScroll: true })
964
+ }
965
+
858
966
  openPopup(bubble) {
859
967
  this.cancelPress()
860
968
  if (!this.hasPopupTarget) return
@@ -864,7 +972,13 @@ export default class extends Controller {
864
972
  if (!template) return
865
973
 
866
974
  const own = bubble.dataset.senderKey === this.meValue
975
+ // A close's teardown still pending (reopened within its 260ms morph)
976
+ // must not take this popup down with it.
977
+ clearTimeout(this.popupCloseTimer)
978
+ this.teardownPopup()
979
+
867
980
  const content = template.content.cloneNode(true)
981
+ this.stampPageToken(content)
868
982
  // Cosmetic visibility gates (the server still authorizes for real):
869
983
  // own-only items (edit/delete) vanish on foreign bubbles; other-only
870
984
  // items (e.g. a host report link — you don't report yourself) on own.
@@ -872,7 +986,10 @@ export default class extends Controller {
872
986
  if (own) content.querySelectorAll("[data-chats-other-only]").forEach((node) => node.remove())
873
987
 
874
988
  const reactions = content.querySelector(".chats-popup__reactions")
875
- const menu = content.querySelector(".chats-popup__menu")
989
+ // A menu the gates emptied (a photo's, foreign, with nothing of the
990
+ // host's left for it) is no menu at all, not an empty card.
991
+ let menu = content.querySelector(".chats-popup__menu")
992
+ if (menu && !menu.firstElementChild) menu = null
876
993
  this.popupReactionsTarget.replaceChildren(...(reactions ? [reactions] : []))
877
994
  this.popupMenuTarget.replaceChildren(...(menu ? [menu] : []))
878
995
 
@@ -935,10 +1052,46 @@ export default class extends Controller {
935
1052
 
936
1053
  this.popupKeydown = (event) => {
937
1054
  if (event.key === "Escape") this.closePopup()
1055
+ else if (event.key === "Tab") this.cyclePopupFocus(event)
1056
+ else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) this.moveInPopupMenu(event)
938
1057
  }
939
1058
  document.addEventListener("keydown", this.popupKeydown)
940
1059
  }
941
1060
 
1061
+ // The popup is modal (role="dialog" aria-modal, on touch as on a desk:
1062
+ // its backdrop covers the thread and a tap on it closes the menu), so the
1063
+ // focus stays in it: Tab / Shift+Tab cycle the reactions and the menu's
1064
+ // items, never reaching the page behind, nor the lifted bubble's clone
1065
+ // (a picture of the message, not a control).
1066
+ popupControls() {
1067
+ return [...this.popupReactionsTarget.querySelectorAll(".chats-popup__reaction"),
1068
+ ...this.popupMenuTarget.querySelectorAll(".chats-popup__item")]
1069
+ }
1070
+
1071
+ cyclePopupFocus(event) {
1072
+ event.preventDefault()
1073
+ const controls = this.popupControls()
1074
+ if (!controls.length) return
1075
+
1076
+ const step = event.shiftKey ? -1 : 1
1077
+ const index = controls.indexOf(document.activeElement)
1078
+ const next = index === -1 ? (step > 0 ? 0 : controls.length - 1) : (index + step + controls.length) % controls.length
1079
+ controls[next].focus()
1080
+ }
1081
+
1082
+ // A menu's own keys (role="menu"): ArrowUp / ArrowDown along its items,
1083
+ // round the ends, Home / End to the first and the last.
1084
+ moveInPopupMenu(event) {
1085
+ const items = [...this.popupMenuTarget.querySelectorAll(".chats-popup__item")]
1086
+ const index = items.indexOf(document.activeElement)
1087
+ if (index === -1) return
1088
+
1089
+ event.preventDefault()
1090
+ const last = items.length - 1
1091
+ const next = { ArrowUp: index === 0 ? last : index - 1, ArrowDown: index === last ? 0 : index + 1, Home: 0, End: last }
1092
+ items[next[event.key]].focus()
1093
+ }
1094
+
942
1095
  closePopup() {
943
1096
  const bubble = this.popupOpenFor
944
1097
  if (!bubble) return
@@ -960,9 +1113,26 @@ export default class extends Controller {
960
1113
  clearTimeout(this.popupCloseTimer)
961
1114
  this.popupCloseTimer = setTimeout(() => this.teardownPopup(visual), POPUP_TRANSITION_MS)
962
1115
  document.removeEventListener("keydown", this.popupKeydown)
1116
+ // Back to the "⋯" the menu was opened from, if the focus is still in
1117
+ // the popup (or was dropped on the page by a click on the backdrop).
1118
+ // A trigger replaced while the menu was open (a reaction chosen from
1119
+ // it re-renders the bubble) is found again by its message's id.
1120
+ const opener = this.popupReturnFocus
1121
+ const focused = document.activeElement
1122
+ this.popupReturnFocus = null
1123
+ const returnFocus = opener?.trigger.isConnected ? opener.trigger : opener &&
1124
+ this.element.querySelector(`#${CSS.escape(opener.message)} [data-chats-message-menu-trigger]`)
1125
+ if (returnFocus && (!focused || focused === document.body || this.popupTarget.contains(focused))) {
1126
+ returnFocus.focus({ preventScroll: true })
1127
+ }
963
1128
  }
964
1129
 
1130
+ // Every way the popup goes ends here (closed, Escape, the backdrop, an
1131
+ // item chosen, the thread itself leaving: a dock window closed under it),
1132
+ // so this is where its keys are let go: a document listener outliving the
1133
+ // popup would go on swallowing the page's Tab.
965
1134
  teardownPopup(visual = this.popupVisual) {
1135
+ document.removeEventListener("keydown", this.popupKeydown)
966
1136
  if (!this.hasPopupTarget) return
967
1137
  this.popupTarget.hidden = true
968
1138
  this.popupTarget.classList.remove("chats-popup--open")
@@ -981,8 +1151,30 @@ export default class extends Controller {
981
1151
  this.popupVisual = null
982
1152
  }
983
1153
 
1154
+ // The menu's forms (the gem's reactions and Delete, a host's button_to)
1155
+ // were rendered once, for everybody, often by a broadcast outside any
1156
+ // viewer's session: whatever token they carry is not this page's. Turbo
1157
+ // would send the page's own in a header, but a host can turn Turbo off
1158
+ // around a thread and submit natively, so each cloned form gets the page's
1159
+ // token (the csrf-param / csrf-token metas) in its own hidden input.
1160
+ stampPageToken(content) {
1161
+ const param = document.querySelector('meta[name="csrf-param"]')?.content
1162
+ const token = csrfToken()
1163
+ if (!param || !token) return
1164
+
1165
+ content.querySelectorAll("form").forEach((form) => {
1166
+ if (form.method === "get") return
1167
+ let input = [...form.querySelectorAll("input")].find((field) => field.name === param)
1168
+ if (!input) {
1169
+ input = Object.assign(document.createElement("input"), { type: "hidden", name: param })
1170
+ form.prepend(input)
1171
+ }
1172
+ input.value = token
1173
+ })
1174
+ }
1175
+
984
1176
  popupMenuClicked(event) {
985
- const item = event.target.closest("[data-chats-action], form button, form input[type=submit]")
1177
+ const item = event.target.closest("[data-chats-action], form button, form input[type=submit], a[href]")
986
1178
  if (!item) return
987
1179
 
988
1180
  const action = item.dataset.chatsAction
@@ -993,9 +1185,11 @@ export default class extends Controller {
993
1185
  event.preventDefault()
994
1186
  this.beginEditFromPopup()
995
1187
  } else {
996
- // A real form submission (reaction toggle, delete): let it fire, then
997
- // get out of the way — Telegram closes on selection too. The cloned
998
- // form's request has already started by the time we tear down.
1188
+ // A real form submission (reaction toggle, delete, a host's
1189
+ // button_to) or a host's link: let it fire, then get out of the way —
1190
+ // Telegram closes on selection too. The request (or visit) has started
1191
+ // by the time we tear down; a dock's popup would else ride along to
1192
+ // the next page.
999
1193
  setTimeout(() => this.closePopup(), 60)
1000
1194
  }
1001
1195
  }