1import { Controller } from '@hotwired/stimulus'; 2 3export default class extends Controller { 4 static targets = ['messagesContainer', 'soundToggle']; 5 static values = { 6 topic: String, 7 currentUserId: String 8 }; 9 10 connect() { 11 if (!this.topicValue) { 12 console.error('dm-thread controller missing topic'); 13 return; 14 } 15 16 // Initialize sound toggle state from localStorage (default to false) 17 const soundEnabled = localStorage.getItem('dm_thread_sound_enabled') === 'true'; 18 if (this.hasSoundToggleTarget) { 19 this.soundToggleTarget.checked = soundEnabled; 20 } 21 22 // We listen to the global mercure:new_message event dispatched by notification_controller.js 23 // This avoids maintaining multiple EventSource connections 24 this.messageListener = (e) => { 25 const data = e.detail; 26 if (data && String(data.threadId) === String(this.topicValue.split('/').pop())) { 27 // Map the data fields to what appendMessage expects 28 const messageData = { 29 id: data.messageId, 30 body: data.body, 31 senderUserId: data.senderId, 32 formattedTime: data.formattedTime 33 }; 34 this.appendMessage(messageData); 35 } 36 }; 37 window.addEventListener('mercure:new_message', this.messageListener); 38 39 // Scroll to the bottom on load to show latest messages 40 this.scrollToBottom(); 41 } 42 43 disconnect() { 44 if (this.messageListener) { 45 window.removeEventListener('mercure:new_message', this.messageListener); 46 } 47 } 48 49 toggleSound(event) { 50 localStorage.setItem('dm_thread_sound_enabled', event.target.checked ? 'true' : 'false'); 51 } 52 53 playSound() { 54 const soundEnabled = localStorage.getItem('dm_thread_sound_enabled') === 'true'; 55 if (!soundEnabled) { 56 return; 57 } 58 59 try { 60 const AudioContext = window.AudioContext || window.webkitAudioContext; 61 if (!AudioContext) return; 62 63 const ctx = new AudioContext(); 64 const osc = ctx.createOscillator(); 65 const gainNode = ctx.createGain(); 66 67 osc.type = 'sine'; 68 osc.frequency.setValueAtTime(880, ctx.currentTime); 69 osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3); 70 71 gainNode.gain.setValueAtTime(0.1, ctx.currentTime); 72 gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); 73 74 osc.connect(gainNode); 75 gainNode.connect(ctx.destination); 76 77 osc.start(); 78 osc.stop(ctx.currentTime + 0.3); 79 } catch (e) { 80 // Ignore if blocked 81 } 82 } 83 84 appendMessage(data) { 85 // If message is already present, do nothing (duplicate prevention) 86 if (document.getElementById(`msg-row-${data.id}`)) { 87 return; 88 } 89 90 const container = this.hasMessagesContainerTarget ? this.messagesContainerTarget : this.element.querySelector('.msg-thread'); 91 if (!container) { 92 return; 93 } 94 95 // Determine if mine or theirs 96 const isMine = String(data.senderUserId) === String(this.currentUserIdValue); 97 98 // Escape body safely 99 const tempDiv = document.createElement('div'); 100 tempDiv.textContent = data.body || ''; 101 const safeBody = tempDiv.innerHTML.replace(/\n/g, '<br>'); 102 103 let template = ''; 104 if (isMine) { 105 template = ` 106 <div id="msg-row-${data.id}" class="msg-row is-mine"> 107 <div class="msg-bubble"> 108 <div>${safeBody}</div> 109 </div> 110 <div class="msg-time">${data.formattedTime || ''}</div> 111 </div> 112 `; 113 } else { 114 template = ` 115 <div id="msg-row-${data.id}" class="msg-row is-theirs" 116 data-controller="read-receipt" 117 data-read-receipt-url-value="/message/read/${data.id}"> 118 <div class="msg-bubble position-relative unread" data-read-receipt-target="bubble"> 119 <div>${safeBody}</div>
120 <span class="badge bg-danger rounded-pill position-absolute top-0 start-100 translate-middle unread-badge" data-read-receipt-target="badge" style="font-size: 0.65em;">New</span> 121 </div> 122 <div class="msg-time">${data.formattedTime || ''}</div> 123 </div> 124 `; 125 } 126 127 container.insertAdjacentHTML('beforeend', template); 128 129 // Auto-scroll to the bottom so the new message is visible 130 this.scrollToBottom(); 131 132 // Play sound if it's not our own message 133 if (!isMine) { 134 this.playSound(); 135 } 136 } 137 138 scrollToBottom() { 139 setTimeout(() => { 140 const form = document.querySelector('.card:has(#messageform)') || document.querySelector('#messageform'); 141 if (form) { 142 form.scrollIntoView({ behavior: 'smooth', block: 'center' }); 143 } 144 }, 50); 145 } 146}
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.