PageSourceSearch

https://app.voicebox.ai/assets/controllers/recorder/share_consent_…2e9b362e03e8b31bfb6a8bba12103a73925a6a48.js

js voicebox.ai collected 2026-10-05 16:10:38 UTC 8,351 bytes, 201 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Publication consent on the post-message card. Saves on every change rather than behind a
4// button: the message is already sent by the time this renders, so there is no submit step
5// left to hang it on, and a visitor who closes the sheet has still had their answer recorded.
6//
7// The boxes carry intent only. The server stamps the time and the wording version — a
8// timestamp taken from a form is a timestamp a crafted form can choose.
9export default class extends Controller {
10  static targets = ["publicBox", "audioBox", "locationBox", "identityBox", "nested", "recall", "status"]
11  static values = { url: String, last: Object }
12
13  connect() {
14    // Last state the SERVER confirmed. A failed save must not leave the boxes showing an
15    // answer the server never took: a failed withdrawal that still looks unticked is the
16    // worst case, because "untick is the withdrawal" is exactly what we tell people.
17    //
18    // Nothing is stored for this message yet, so start from "nothing granted" rather than
19    // from what the boxes show: when the card arrives pre-set from a previous answer, the
20    // ticks are a request we have not made yet, and a failed save has to fall back to the
21    // truth (unpublished) instead of to the markup.
22    this.confirmed = { public: false, audio: false, location: false, identity: false }
23    this.syncNested()
24
25    // Importmap loads controllers as async ES modules, so these boxes are visible — and
26    // clickable — before `connect()` has bound `data-action`. A click in that gap unticks
27    // the box natively and runs none of this, which looks like a working control that
28    // silently did not save. Tests wait on this flag; see docs/E2E_TESTING.md.
29    this.element.setAttribute("data-share-consent-ready", "")
30
31    // Carried forward from this speaker's last ANSWER on this voicebox (a decline carries
32    // nothing, so reaching here means that answer was a grant). Persisted here
33    // rather than left as decoration: boxes that claim a message is public while the row
34    // says otherwise is the same divergence the rollback logic exists to prevent.
35    // Flagged `recalled` so the stored answer does not claim an act that happened on a
36    // different message. See Message::SHARE_CONSENT_SOURCE_RECALLED.
37    if (this.hasLastValue && this.lastValue?.public) this.save({ recalled: true })
38  }
39
40  disconnect() {
41    this.element.removeAttribute("data-share-consent-ready")
42  }
43
44  currentState() {
45    return {
46      public: Boolean(this.hasPublicBoxTarget && this.publicBoxTarget.checked),
47      audio: Boolean(this.audioBoxTarget_?.checked),
48      location: Boolean(this.locationBoxTarget_?.checked),
49      identity: Boolean(this.identityBoxTarget_?.checked)
50    }
51  }
52
53  applyState(state) {
54    if (this.hasPublicBoxTarget) this.publicBoxTarget.checked = state.public
55    if (this.audioBoxTarget_) this.audioBoxTarget_.checked = state.audio
56    if (this.locationBoxTarget_) this.locationBoxTarget_.checked = state.location
57    if (this.identityBoxTarget_) this.identityBoxTarget_.checked = state.identity
58    this.syncNested()
59  }
60
61  setBusy(busy) {
62    for (const box of this.boxes_) {
63      if (box) box.disabled = busy
64    }
65  }
66
67  // The sub-choices mean nothing on a message nobody publishes, so they are hidden and
68  // cleared when the parent is off. Clearing matters: a stale tick would read as consent
69  // if the parent were switched back on.
70  syncNested() {
71    const on = this.hasPublicBoxTarget && this.publicBoxTarget.checked
72    if (this.hasNestedTarget) this.nestedTarget.classList.toggle("d-none", !on)
73    this.refreshRecall()
74    if (on) return
75
76    for (const box of [this.audioBoxTarget_, this.locationBoxTarget_, this.identityBoxTarget_]) {
77      if (box) box.checked = false
78    }
79  }
80
81  get audioBoxTarget_() {
82    return this.hasAudioBoxTarget ? this.audioBoxTarget : null
83  }
84
85  get locationBoxTarget_() {
86    return this.hasLocationBoxTarget ? this.locationBoxTarget : null
87  }
88
89  get identityBoxTarget_() {
90    return this.hasIdentityBoxTarget ? this.identityBoxTarget : null
91  }
92
93  get boxes_() {
94    return [
95      this.hasPublicBoxTarget ? this.publicBoxTarget : null,
96      this.audioBoxTarget_,
97      this.locationBoxTarget_,
98      this.identityBoxTarget_
99    ]
100  }
101
102  // The (i) says the boxes were set the way they were chosen last time. It is true exactly
103  // while that is still what they show, so it is derived from the state rather than retired
104  // by whichever call site happens to notice a change — the version that hid it imperatively
105  // did nothing at all when a click landed before connect() had bound the action.
106  refreshRecall() {
107    if (!this.hasRecallTarget) return
108
109    const last = this.hasLastValue ? this.lastValue : null
110    const state = this.currentState()
111    const stillCarried = Boolean(last) &&
112      Boolean(last.public) === state.public &&
113      Boolean(last.audio) === state.audio &&
114      Boolean(last.location) === state.location &&
115      Boolean(last.identity) === state.identity
116
117    if (stillCarried) return
118    this.hideRecall()
119  }
120
121  // Disposes the Bootstrap tooltip first: hiding the trigger does not close a tooltip that
122  // is already open, which would leave the explanation floating over boxes it no longer
123  // describes.
124  hideRecall() {
125    if (!this.hasRecallTarget || this.recallTarget.classList.contains("d-none")) return
126
127    try {
128      window.bootstrap?.Tooltip?.getInstance(this.recallTarget)?.dispose()
129    } catch (_error) {
130      // Bootstrap absent or already disposed; hiding is still correct.
131    }
132    this.recallTarget.classList.add("d-none")
133  }
134
135  async save({ recalled = false } = {}) {
136    // syncNested also refreshes the (i): once the boxes stop matching what was carried,
137    // the explanation stops being true.
138    this.syncNested()
139    if (!this.hasUrlValue) return
140
141    // Snapshot the intent being sent, so a later success confirms exactly this and not
142    // whatever the boxes happen to say by the time the response lands.
143    const attempted = this.currentState()
144    this.setBusy(true)
145
146    const body = new URLSearchParams({
147      "share[public]": this.hasPublicBoxTarget && this.publicBoxTarget.checked ? "1" : "0",
148      "share[audio]": this.audioBoxTarget_?.checked ? "1" : "0",
149      "share[location]": this.locationBoxTarget_?.checked ? "1" : "0",
150      "share[identity]": this.identityBoxTarget_?.checked ? "1" : "0",
151      "share[recalled]": recalled ? "1" : "0"
152    })
153
154    try {
155      const response = await fetch(this.urlValue, {
156        method: "PATCH",
157        credentials: "same-origin",
158        headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
159        body
160      })
161      if (!response.ok) throw new Error("failed")
162
163      // Trust the server's echo over what we sent. It nests audio and location under the
164      // public choice, so a request that disagreed comes back clamped — and the boxes
165      // should show what was actually stored, not what was asked for.
166      const echoed = await response.json().then((data) => data?.share).catch(() => null)
167      this.confirmed = echoed
168        ? {
169            public: Boolean(echoed.public),
170            audio: Boolean(echoed.audio),
171            location: Boolean(echoed.location),
172            identity: Boolean(echoed.identity)
173          }
174        : attempted
175      this.applyState(this.confirmed)
176      this.clearError()
177    } catch (_error) {
178      // Put the boxes back to what the server actually holds. Telling someone the save
179      // failed while leaving the box showing their attempt is how they end up believing
180      // a message is withdrawn when it is still on the page.
181      this.applyState(this.confirmed)
182      this.showError()
183    } finally {
184      this.setBusy(false)
185    }
186  }
187
188  // A silent failure here would leave someone believing they had published, or believing they
189  // had withdrawn. Say so instead of retrying behind their back.
190  showError() {
191    if (!this.hasStatusTarget) return
192    this.statusTarget.textContent = "We could not save that. Check your connection and try again."
193    this.statusTarget.classList.remove("d-none")
194  }
195
196  clearError() {
197    if (!this.hasStatusTarget) return
198    this.statusTarget.textContent = ""
199    this.statusTarget.classList.add("d-none")
200  }
201};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.