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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +147 -0
- data/README.md +90 -28
- data/app/assets/stylesheets/chats.css +225 -15
- data/app/helpers/chats/engine_helper.rb +101 -7
- data/app/javascript/chats/dock_controller.js +54 -33
- data/app/javascript/chats/sheet.js +69 -0
- data/app/javascript/chats/thread_controller.js +199 -5
- data/app/javascript/chats/tray_controller.js +506 -0
- data/app/views/chats/conversations/_thread.html.erb +4 -0
- data/app/views/chats/dock/_dock.html.erb +9 -1
- data/app/views/chats/messages/_message.html.erb +30 -3
- data/app/views/chats/tray/_badge.html.erb +12 -0
- data/app/views/chats/tray/_corner.html.erb +16 -0
- data/app/views/chats/tray/_item.html.erb +44 -0
- data/app/views/chats/tray/_tray.html.erb +27 -0
- data/config/importmap.rb +4 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/es.yml +1 -0
- data/lib/chats/after_commit.rb +87 -0
- data/lib/chats/broadcasts.rb +8 -0
- data/lib/chats/models/participant.rb +14 -1
- data/lib/chats/subscribers.rb +14 -1
- data/lib/chats/version.rb +1 -1
- data/lib/generators/chats/templates/initializer.rb +1 -0
- metadata +8 -16
- data/docs/PRD.md +0 -254
- data/docs/campfire_review.md +0 -46
- data/docs/screenshots/composer-actions.png +0 -0
- data/docs/screenshots/dock-bar.png +0 -0
- data/docs/screenshots/dock-desktop.png +0 -0
- data/docs/screenshots/dock-phone-button.png +0 -0
- data/docs/screenshots/dock-phone-panel.png +0 -0
- data/docs/screenshots/dock-phone-window.png +0 -0
- data/docs/screenshots/inbox.png +0 -0
- data/docs/screenshots/split-inbox-empty.png +0 -0
- data/docs/screenshots/split-inbox-phone-list.png +0 -0
- data/docs/screenshots/split-inbox-phone-thread.png +0 -0
- data/docs/screenshots/split-inbox.png +0 -0
- data/docs/screenshots/system-note-action.png +0 -0
- 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
|
-
|
|
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
|
|
997
|
-
//
|
|
998
|
-
//
|
|
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
|
}
|