PageSourceSearch

https://roommate.jp/assets/controllers/dm_thread_controller-VC-_6Cj.js

js roommate.jp collected 2026-10-05 04:56:51 UTC 5,277 bytes, 146 lines download raw bytes

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.