1import { Controller } from "@hotwired/stimulus"; 2 3const BOTTOM_THRESHOLD_PX = 48; 4 5export default class extends Controller { 6 static targets = [ 7 "thread", 8 "messages", 9 "composer", 10 "newMessagesButton", 11 "confirmDialog", 12 "confirmForm", 13 "confirmMessage", 14 "confirmAttributes", 15 ]; 16 17 connect() { 18 this.wasNearBottom = true; 19 this.initialized = false; 20 this.messageObserver = new MutationObserver((mutations) => 21 this.messagesMutated(mutations), 22 ); 23 this.messageObserver.observe(this.messagesTarget, { childList: true }); 24 25 this.initializeThread(); 26 } 27 28 disconnect() { 29 this.messageObserver?.disconnect(); 30 } 31 32 positionMessageMenu(event) { 33 const details = event.currentTarget; 34 if (!details.open) return; 35 36 this.element 37 .querySelectorAll("details[data-message-menu-details][open]") 38 .forEach((openDetails) => { 39 if (openDetails !== details) openDetails.open = false; 40 }); 41 42 const menu = details.querySelector("[data-role='message-menu']"); 43 if (!menu) return; 44 45 this.positionFloatingElement(details, menu); 46 } 47 48 openTaskHoverCard(event) { 49 const container = event.currentTarget; 50 const card = container.querySelector("[data-role='task-hover-card']"); 51 if (!card) return; 52 53 card.classList.remove("hidden"); 54 this.positionFloatingElement(container, card, { fixed: true }); 55 } 56 57 closeTaskHoverCard(event) { 58 event.currentTarget 59 .closest("[data-task-hover-container]") 60 ?.querySelector("[data-role='task-hover-card']") 61 ?.classList.add("hidden"); 62 } 63 64 positionFloatingElement(trigger, element, options = {}) { 65 const gap = 8; 66 const triggerRect = trigger.getBoundingClientRect(); 67 const threadRect = this.hasThreadTarget 68 ? this.threadTarget.getBoundingClientRect() 69 : { top: 0, left: 0, right: window.innerWidth }; 70 const composerTop = this.hasComposerTarget 71 ? this.composerTarget.getBoundingClientRect().top 72 : window.innerHeight; 73 const elementHeight = element.offsetHeight; 74 const elementWidth = element.offsetWidth; 75 76 if (options.fixed) { 77 const spaceAbove = triggerRect.top - threadRect.top; 78 const spaceBelow = 79 Math.min(composerTop, window.innerHeight) - triggerRect.bottom; 80 const openBelow = 81 spaceBelow >= elementHeight || 82 (spaceAbove < elementHeight && spaceBelow >= spaceAbove); 83 const top = openBelow 84 ? triggerRect.bottom 85 : triggerRect.top - elementHeight; 86 const left = Math.min( 87 Math.max(triggerRect.left, threadRect.left), 88 Math.min(threadRect.right, window.innerWidth) - elementWidth, 89 ); 90 91 element.style.top = `${top}px`; 92 element.style.left = `${left}px`; 93 return; 94 } 95 96 const fitsAbove = triggerRect.top - elementHeight - gap >= threadRect.top; 97 const fitsBelow = triggerRect.bottom + elementHeight + gap <= composerTop; 98 const openBelow = !fitsAbove && fitsBelow; 99 const alignRight = triggerRect.left + elementWidth + gap > threadRect.right; 100 101 element.classList.toggle("top-full", openBelow); 102 element.classList.toggle("pt-2", openBelow); 103 element.classList.toggle("bottom-full", !openBelow); 104 element.classList.toggle("pb-2", !openBelow); 105 element.classList.toggle("right-0", alignRight); 106 element.classList.toggle("left-0", !alignRight); 107 } 108 109 initializeThread() { 110 this.wasNearBottom = this.isNearBottom(); 111 this.initialized = true; 112 this.hideNewMessagesButton(); 113 } 114 115 threadScrolled() { 116 this.wasNearBottom = this.isNearBottom(); 117 118 if (this.wasNearBottom) { 119 this.hideNewMessagesButton(); 120 } 121 } 122 123 scrollToBottom() { 124 this.scrollThreadToBottom("smooth"); 125 this.hideNewMessagesButton(); 126 } 127 128 openConfirmation(event) { 129 if (!this.hasConfirmDialogTarget || !this.hasConfirmFormTarget) return; 130 131 const url = event.currentTarget.dataset.url; 132 if (!url) return; 133 134 event.currentTarget.closest("details")?.removeAttribute("open"); 135 this.confirmFormTarget.action = url; 136 this.confirmFormTarget.dataset.method =
137 event.currentTarget.dataset.method || "DELETE"; 138 this.confirmAttributesTarget.innerHTML = ""; 139 140 const attributeName = event.currentTarget.dataset.attributeName; 141 const attributeValue = event.currentTarget.dataset.attributeValue; 142 if (attributeName && attributeValue != null) { 143 const input = document.createElement("input"); 144 input.type = "hidden"; 145 input.name = attributeName; 146 input.value = attributeValue; 147 this.confirmAttributesTarget.appendChild(input); 148 } 149 150 const confirmTitle = event.currentTarget.dataset.confirmationTitle; 151 const confirmButtonLabel = 152 event.currentTarget.dataset.confirmationConfirmLabel; 153 154 if (this.hasConfirmMessageTarget) { 155 this.confirmMessageTarget.textContent = 156 event.currentTarget.dataset.confirmationMessage || 157 "Are you sure you want to continue?"; 158 } 159 160 this.confirmDialogTarget.querySelector( 161 "[data-role='confirm-title']", 162 ).textContent = confirmTitle || "Confirm Delete"; 163 this.confirmDialogTarget.querySelector( 164 "[data-role='confirm-submit']", 165 ).textContent = confirmButtonLabel || "Delete"; 166 167 this.confirmDialogTarget.showModal(); 168 } 169 170 closeConfirmation() { 171 if (!this.hasConfirmDialogTarget) return; 172 173 this.confirmDialogTarget.close(); 174 } 175 176 openComposeContact(event) { 177 const value = event.currentTarget.dataset.foldComposeContactValue; 178 if (!value) return; 179 180 window.dispatchEvent( 181 new CustomEvent("fold:compose-contact", { 182 detail: { 183 contact: { 184 value, 185 label: event.currentTarget.dataset.foldComposeContactLabel || value, 186 }, 187 channelId: event.currentTarget.dataset.foldComposeChannelId, 188 channelType: event.currentTarget.dataset.foldComposeChannelType, 189 }, 190 }), 191 ); 192 193 event.currentTarget.closest("details")?.removeAttribute("open"); 194 } 195 196 orderMediaAttachmentSubmitted(event) { 197 if (!event.detail.success) return; 198 199 event.target.closest("details")?.removeAttribute("open"); 200 } 201 202 loadOrderMediaAttachment(event) { 203 const details = event.currentTarget; 204 if (!details.open) return; 205 206 const frame = details.querySelector("turbo-frame[data-src]"); 207 if (!frame) return; 208 209 const url = new URL(frame.dataset.src, window.location.origin); 210 url.searchParams.set("_", Date.now().toString()); 211 frame.innerHTML = 212 '<div tabindex="0" class="d-dropdown-content z-[100] mb-2 w-[350px] rounded-lg border border-gray-200 bg-white p-3 text-sm text-base-content/60 shadow-sm">Loading orders...</div>'; 213 frame.src = url.toString(); 214 } 215 216 async submitDelete(event) { 217 event.preventDefault(); 218 219 const formData = new FormData(this.confirmFormTarget); 220 formData.delete("_method"); 221 const csrfParam = document 222 .querySelector('meta[name="csrf-param"]') 223 ?.getAttribute("content"); 224 if (csrfParam) { 225 formData.delete(csrfParam); 226 } 227 228 const response = await fetch(this.confirmFormTarget.action, { 229 method: this.confirmFormTarget.dataset.method || "DELETE", 230 headers: this.requestHeaders("application/json"), 231 body: formData, 232 }); 233 234 if (response.ok) { 235 this.closeConfirmation(); 236 return; 237 } 238 239 this.closeConfirmation(); 240 } 241 242 async retryMessage(event) { 243 const url = event.currentTarget.dataset.url; 244 if (!url) return; 245 246 event.currentTarget.disabled = true; 247 248 try { 249 const response = await fetch(url, { 250 method: "POST", 251 headers: this.requestHeaders("application/json"), 252 }); 253 254 if (!response.ok) { 255 this.handleRequestError(response); 256 } 257 } finally { 258 event.currentTarget.disabled = false; 259 } 260 } 261 262 messagesMutated(mutations) { 263 const additions = mutations.reduce((count, mutation) => { 264 return ( 265 count + 266 Array.from(mutation.addedNodes).filter((node) => { 267 return node.nodeType === Node.ELEMENT_NODE; 268 }).length 269 ); 270 }, 0); 271 272 if (!this.initialized || additions === 0) return; 273 274 if (this.wasNearBottom) { 275 this.scrollThreadToBottom("smooth"); 276 this.hideNewMessagesButton(); 277 return; 278 } 279 280 this.showNewMessagesButton(); 281 } 282 283 isNearBottom() { 284 return Math.abs(this.threadTarget.scrollTop) <= BOTTOM_THRESHOLD_PX; 285 } 286 287 scrollThreadToBottom(behavior) { 288 this.threadTarget.scrollTo({ 289 top: 0, 290 behavior, 291 }); 292 } 293 294 showNewMessagesButton() { 295 this.newMessagesButtonTarget.classList.remove("hidden"); 296 this.newMessagesButtonTarget.classList.add("flex"); 297 } 298 299 hideNewMessagesButton() { 300 this.newMessagesButtonTarget.classList.add("hidden"); 301 this.newMessagesButtonTarget.classList.remove("flex"); 302 } 303 304 requestHeaders(accept = "application/json") { 305 const headers = { Accept: accept }; 306 const csrfToken = document 307 .querySelector('meta[name="csrf-token"]') 308 ?.getAttribute("content"); 309 310 if (csrfToken) { 311 headers["X-CSRF-Token"] = csrfToken; 312 } 313 314 return headers; 315 } 316 317 handleRequestError(response) { 318 console.error(response); 319 } 320};
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.