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.