1import { Controller } from '@hotwired/stimulus' 2import { createConsumer } from '@rails/actioncable' 3 4export default class extends Controller { 5 static targets = ['input', 'messages', 'typingIndicator', 'loader', 'formContainer'] 6 static values = { 7 campaignId: Number, 8 sessionId: String 9 } 10 11 connect () { 12 this.lastSentAt = 0 13 this.minDelay = 1000 // 1 second between messages 14 15 const consumer = createConsumer() 16 this.channel = consumer.subscriptions.create( 17 { 18 channel: 'CampaignWebChatChannel', 19 campaign_id: this.campaignIdValue 20 }, 21 { 22 received: (data) => { 23 if (data.type === 'typing') { 24 this.showTypingIndicator() 25 } else if (data.type === 'stopped_typing') { 26 this.hideTypingIndicator() 27 } else if (data.type === 'message_received') { 28 this.hideTypingIndicator() 29 this.appendMessage(data) 30 } else if (data.type === 'onboarding_complete') { 31 this.hideTypingIndicator() 32 this.showGeneratingLoader() 33 } else if (data.type === 'page_updated') { 34 window.location.reload() 35 } 36 } 37 } 38 ) 39 } 40 41 disconnect () { 42 if (this.channel) { 43 this.channel.unsubscribe() 44 } 45 } 46 47 send (event) { 48 event.preventDefault() 49 50 const text = this.inputTarget.value.trim() 51 52 if (!text) return 53 54 // Rate limiting - prevent spam 55 const now = Date.now() 56 const timeSinceLastSend = now - this.lastSentAt 57 if (timeSinceLastSend < this.minDelay) { 58 return 59 } 60 61 // Disable submit button temporarily 62 const submitButton = this.element.querySelector('[data-campaign-web-chat-target="submit"]') 63 if (submitButton) { 64 submitButton.disabled = true 65 } 66 67 this.lastSentAt = now 68 69 // Send message through channel 70 this.channel.send({ text }) 71 72 // Add message to UI immediately 73 this.appendMessage({ 74 text, 75 outgoing: false, 76 created_at_ago: 'less than a minute' 77 }) 78 79 // Clear input 80 this.inputTarget.value = '' 81 82 // Re-enable submit button after delay 83 setTimeout(() => { 84 if (submitButton) { 85 submitButton.disabled = false 86 } 87 }, this.minDelay) 88 } 89 90 appendMessage (data) { 91 const messageDiv = document.createElement('div') 92 messageDiv.className = `flex ${data.outgoing ? 'justify-start' : 'justify-end'} animate-fade-in` 93 94 messageDiv.innerHTML = ` 95 <div class="max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${data.outgoing ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-100'}"> 96 <p>${data.text}</p> 97 <div class="text-xs mt-1 opacity-75">${data.created_at_ago} ago</div> 98 </div> 99 ` 100 101 this.messagesTarget.prepend(messageDiv) 102 103 // Move typing indicator to the top (bottom visually) if it exists 104 if (this.hasTypingIndicatorTarget) { 105 this.messagesTarget.prepend(this.typingIndicatorTarget) 106 } 107 108 // Smooth scroll to bottom 109 this.smoothScrollToBottom() 110 } 111 112 smoothScrollToBottom () { 113 // For flex-col-reverse, scrollTop = 0 is the bottom 114 this.messagesTarget.scrollTo({ 115 top: 0, 116 behavior: 'smooth' 117 }) 118 } 119 120 showTypingIndicator () { 121 if (this.hasTypingIndicatorTarget) { 122 this.typingIndicatorTarget.classList.remove('hidden', 'opacity-0') 123 this.typingIndicatorTarget.classList.add('opacity-100') 124 125 // Move to top of messages container (bottom visually with flex-col-reverse) 126 this.messagesTarget.prepend(this.typingIndicatorTarget) 127 128 this.smoothScrollToBottom() 129 } 130 } 131 132 hideTypingIndicator () { 133 if (this.hasTypingIndicatorTarget) { 134 this.typingIndicatorTarget.classList.remove('opacity-100') 135 this.typingIndicatorTarget.classList.add('hidden', 'opacity-0') 136 } 137 } 138 139 showGeneratingLoader () { 140 // Hide the messages container and input form 141 this.messagesTarget.classList.add('hidden') 142 this.formContainerTarget.classList.add('hidden') 143 144 // Show the loader 145 this.loaderTarget.classList.remove('hidden') 146 } 147};
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.