- -
-

{{channel.name}}

-

{{channel.topic}}

+

{{channel.name}}

+

{{channel.topic}}

- - - + diff --git a/pages/public/client/app.js b/pages/public/client/app.js index 3a48de7..5196221 100644 --- a/pages/public/client/app.js +++ b/pages/public/client/app.js @@ -1,38 +1,161 @@ /** - * app.js — boots rdbljs islands and page-level setup. + * app.js — MVC bootstrap. * - * Loaded as ', '') - } - - // Enable task-list checkboxes so they're clickable (renderer marks them disabled) - function enableTaskCheckboxes(article) { - for (const cb of article.querySelectorAll('.task-list-item-checkbox[disabled]')) { - cb.removeAttribute('disabled') - } - } - - // Delegated click: message sender name → open DM - messages.addEventListener('click', e => { - const handle = e.target.closest('.dm-trigger') - if (!handle) return - const targetUserId = handle.dataset.userId - if (!targetUserId || targetUserId === userId) return - ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } }) - }) - - // ── Emoji picker ────────────────────────────────────────────────────────── - - const RECENT_KEY = 'devchitchat_recent_emoji' - const RECENT_MAX = 24 - - function loadRecentEmoji() { - try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] } - } - - function saveRecentEmoji(emoji) { - let recents = loadRecentEmoji().filter(e => e !== emoji) - recents.unshift(emoji) - if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX) - localStorage.setItem(RECENT_KEY, JSON.stringify(recents)) - refreshAllQuickPicks() - } - - function renderQuickPicks(toolbar) { - const slot = toolbar.querySelector('.quick-picks') - if (!slot) return - const recents = loadRecentEmoji().slice(0, 4) - slot.innerHTML = recents.map(emoji => - `` - ).join('') - } - - function refreshAllQuickPicks() { - for (const toolbar of messages.querySelectorAll('.message-hover-actions')) { - renderQuickPicks(toolbar) - } - if (threadRepliesEl) { - for (const toolbar of threadRepliesEl.querySelectorAll('.message-hover-actions')) { - renderQuickPicks(toolbar) - } - } - } - - let emojiPickerEl = null - let emojiPickerCurrentCat = 'smileys' - let emojiPickerTarget = null // msg_id the picker is for - - function buildEmojiPicker() { - emojiPickerEl = document.createElement('div') - emojiPickerEl.className = 'emoji-picker' - - const searchInput = document.createElement('input') - searchInput.type = 'search' - searchInput.className = 'emoji-picker-search' - searchInput.placeholder = 'Search emoji…' - searchInput.setAttribute('aria-label', 'Search emoji') - emojiPickerEl.appendChild(searchInput) - - const tabs = document.createElement('div') - tabs.className = 'emoji-picker-tabs' - for (const cat of CATEGORIES) { - const btn = document.createElement('button') - btn.type = 'button' - btn.className = 'emoji-picker-tab' + (cat.id === emojiPickerCurrentCat ? ' active' : '') - btn.dataset.catId = cat.id - btn.textContent = cat.label - btn.title = cat.id - tabs.appendChild(btn) - } - emojiPickerEl.appendChild(tabs) - - const grid = document.createElement('div') - grid.className = 'emoji-picker-grid' - emojiPickerEl.appendChild(grid) - - tabs.addEventListener('click', e => { - const btn = e.target.closest('.emoji-picker-tab') - if (!btn) return - emojiPickerCurrentCat = btn.dataset.catId - tabs.querySelectorAll('.emoji-picker-tab').forEach(b => b.classList.toggle('active', b.dataset.catId === emojiPickerCurrentCat)) - searchInput.value = '' - renderEmojiGrid(null) - }) - - searchInput.addEventListener('input', () => { - renderEmojiGrid(searchInput.value.trim().toLowerCase()) - }) - - grid.addEventListener('click', e => { - const btn = e.target.closest('button[data-emoji]') - if (!btn) return - const emoji = btn.dataset.emoji - saveRecentEmoji(emoji) - emojiPickerEl.dispatchEvent(new CustomEvent('emoji:pick', { bubbles: true, detail: { emoji } })) - }) - - renderEmojiGrid(null) - return emojiPickerEl - } - - function renderEmojiGrid(query) { - if (!emojiPickerEl) return - const grid = emojiPickerEl.querySelector('.emoji-picker-grid') - if (!grid) return - - let emojiList - if (query) { - // Search across all categories - const allEmoji = CATEGORIES.flatMap(c => c.emoji) - const unique = [...new Set(allEmoji)] - emojiList = unique.filter(e => { - const name = EMOJI_NAMES[e] ?? '' - return name.includes(query) || e.includes(query) - }) - } else { - if (emojiPickerCurrentCat === 'recent') { - emojiList = loadRecentEmoji() - } else { - const cat = CATEGORIES.find(c => c.id === emojiPickerCurrentCat) - emojiList = cat ? cat.emoji : [] - } - } - - grid.innerHTML = emojiList.map(e => - `` - ).join('') - } - - function getOrBuildEmojiPicker() { - if (!emojiPickerEl) buildEmojiPicker() - return emojiPickerEl - } - - function openEmojiPickerAt(anchorEl, msgId) { - emojiPickerTarget = msgId - const picker = getOrBuildEmojiPicker() - - // Refresh recent tab if active - if (emojiPickerCurrentCat === 'recent') renderEmojiGrid(null) - - // Attach handler once (use named function to avoid duplicates) - picker.onEmojiPickHandler = (e) => { - const { emoji } = e.detail - closeEmojiPicker() - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } - picker.removeEventListener('emoji:pick', picker._boundEmojiPick) - picker._boundEmojiPick = picker.onEmojiPickHandler - picker.addEventListener('emoji:pick', picker._boundEmojiPick) - - document.body.appendChild(picker) - picker.style.position = 'fixed' - picker.style.zIndex = '400' - - // Position below the anchor, viewport-aware - const rect = anchorEl.getBoundingClientRect() - picker.style.top = `${rect.bottom + 4}px` - picker.style.left = `${rect.left}px` - - // Force layout so getBoundingClientRect is accurate - requestAnimationFrame(() => { - const pickerRect = picker.getBoundingClientRect() - let left = rect.left - if (left + pickerRect.width > window.innerWidth - 8) { - left = window.innerWidth - 8 - pickerRect.width - } - if (left < 8) left = 8 - picker.style.left = `${left}px` - - // Flip above if not enough room below - if (rect.bottom + 4 + pickerRect.height > window.innerHeight - 8) { - picker.style.top = `${rect.top - 4 - pickerRect.height}px` - } - }) - } - - function closeEmojiPicker() { - if (emojiPickerEl && emojiPickerEl.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl) - emojiPickerTarget = null - } - - document.addEventListener('click', e => { - // Close emoji picker on click-outside - if (emojiPickerEl && emojiPickerEl.parentNode && !emojiPickerEl.contains(e.target)) { - const isReactionAddBtn = e.target.closest('.reaction-add') || e.target.closest('.btn-react') - if (!isReactionAddBtn) closeEmojiPicker() - } - }, { capture: true }) - - document.addEventListener('keydown', e => { - if (e.key === 'Escape') { - if (activeEditCancel) { activeEditCancel(); return } - if (composeOpen) { closeComposeMode(); return } - closeEmojiPicker() - } - }) - - // Delegated click on task-list checkboxes — toggle [ ] ↔ [x] and save via msg.edit - messages.addEventListener('click', e => { - const cb = e.target.closest('.task-list-item-checkbox') - if (!cb) return - e.preventDefault() // we control the toggle ourselves - const article = cb.closest('article.message') - if (!article) return - const rawText = article.dataset.rawText - if (!rawText) return - const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox')) - const idx = allCbs.indexOf(cb) - if (idx === -1) return - let count = 0 - const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => { - if (count++ !== idx) return match - return state.trim() === '' ? '[x]' : '[ ]' - }) - if (newText === rawText) return - cb.checked = !cb.checked // optimistic toggle - article.dataset.rawText = newText - ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } }) - }) - - // Delegated click on .btn-quick-react — send reaction without opening picker - messages.addEventListener('click', e => { - const btn = e.target.closest('.btn-quick-react') - if (!btn) return - e.stopPropagation() - const emoji = btn.dataset.emoji - const msgId = btn.closest('article.message')?.dataset.msgId - if (!emoji || !msgId) return - saveRecentEmoji(emoji) - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - }) - - // Delegated click on .reaction-pill — toggle reaction - messages.addEventListener('click', e => { - const pill = e.target.closest('.reaction-pill') - if (!pill) return - e.stopPropagation() - const emoji = pill.dataset.emoji - const msgId = pill.dataset.msgId - if (!emoji || !msgId) return - if (pill.classList.contains('reacted')) { - ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } else { - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } - }) - - // Delegated click on .reaction-add or .btn-react — open emoji picker - messages.addEventListener('click', e => { - const addBtn = e.target.closest('.reaction-add') - const reactBtn = e.target.closest('.btn-react') - const btn = addBtn ?? reactBtn - if (!btn) return - e.stopPropagation() - const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId - if (!msgId) return - // Toggle: close if already open for this message - if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) { - closeEmojiPicker() - return - } - openEmojiPickerAt(btn, msgId) - }) - - // ── Thread panel: mirror all message-level interaction handlers ─────────── - // threadRepliesEl is a sibling outside the island root, so handlers on - // `messages` don't reach it — duplicate the relevant delegated clicks here. - - threadRepliesEl?.addEventListener('click', e => { - const btn = e.target.closest('.btn-quick-react') - if (!btn) return - e.stopPropagation() - const emoji = btn.dataset.emoji - const msgId = btn.closest('article.message')?.dataset.msgId - if (!emoji || !msgId) return - saveRecentEmoji(emoji) - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - }) - - threadRepliesEl?.addEventListener('click', e => { - const pill = e.target.closest('.reaction-pill') - if (!pill) return - e.stopPropagation() - const emoji = pill.dataset.emoji - const msgId = pill.dataset.msgId - if (!emoji || !msgId) return - if (pill.classList.contains('reacted')) { - ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } else { - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } - }) - - threadRepliesEl?.addEventListener('click', e => { - const addBtn = e.target.closest('.reaction-add') - const reactBtn = e.target.closest('.btn-react') - const btn = addBtn ?? reactBtn - if (!btn) return - e.stopPropagation() - const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId - if (!msgId) return - if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) { - closeEmojiPicker() - return - } - openEmojiPickerAt(btn, msgId) - }) - - threadRepliesEl?.addEventListener('click', e => { - const btn = e.target.closest('.btn-msg-actions') - if (!btn) return - e.stopPropagation() - const article = btn.closest('article.message') - if (article) showContextMenu(article, btn) - }) - - threadRepliesEl?.addEventListener('click', e => { - const handle = e.target.closest('.dm-trigger') - if (!handle) return - const targetUserId = handle.dataset.userId - if (!targetUserId || targetUserId === userId) return - ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } }) - }) - - // ── Mobile long-press → action sheet with emoji picker ──────────────────── - - addLongPress(messages, (e) => { - const article = e.target.closest?.('article.message') - if (!article) return - const msgId = article.dataset.msgId - if (!msgId) return - - const itemsContainer = getItemsContainer() - itemsContainer.innerHTML = '' - - const pickerWrapper = document.createElement('div') - pickerWrapper.className = 'action-sheet-emoji-picker-wrap' - - const picker = getOrBuildEmojiPicker() - pickerWrapper.appendChild(picker) - itemsContainer.appendChild(pickerWrapper) - - picker.removeEventListener('emoji:pick', picker._boundEmojiPick) - picker._boundEmojiPick = (ev) => { - const { emoji } = ev.detail - saveRecentEmoji(emoji) - dismissActionSheet() - ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } }) - } - picker.addEventListener('emoji:pick', picker._boundEmojiPick) - emojiPickerTarget = msgId - - showActionSheet({ label: 'React to this message', items: [] }) - }) - - // ── Inline edit ──────────────────────────────────────────────────────────── - - let activeEditCancel = null - - function startInlineEdit(article) { - if (article.querySelector('.message-edit-wrap')) return - const textEl = article.querySelector('.message-text') - if (!textEl) return - const rawText = article.dataset.rawText ?? '' - - // Build the edit widget - const wrap = document.createElement('div') - wrap.className = 'message-edit-wrap' - - const tabStrip = document.createElement('div') - tabStrip.className = 'message-edit-tabs' - tabStrip.setAttribute('role', 'tablist') - tabStrip.innerHTML = ` - - ` - - const textarea = document.createElement('textarea') - textarea.className = 'message-edit-input' - textarea.value = rawText - - const preview = document.createElement('div') - preview.className = 'message-edit-preview message-text' - preview.hidden = true - preview.setAttribute('aria-live', 'polite') - - const toolbar = document.createElement('div') - toolbar.className = 'message-edit-toolbar' - toolbar.innerHTML = ` - Ctrl+Enter to save · Esc to cancel - - ` - - wrap.append(tabStrip, textarea, preview, toolbar) - textEl.replaceWith(wrap) - textarea.focus() - textarea.setSelectionRange(rawText.length, rawText.length) - - // Tab switching - tabStrip.addEventListener('click', async e => { - const btn = e.target.closest('.message-edit-tab') - if (!btn) return - const tab = btn.dataset.tab - tabStrip.querySelectorAll('.message-edit-tab').forEach(b => { - b.classList.toggle('message-edit-tab--active', b === btn) - b.setAttribute('aria-selected', String(b === btn)) - }) - textarea.hidden = tab !== 'write' - preview.hidden = tab !== 'preview' - if (tab === 'preview') { - const text = textarea.value.trim() - if (!text) { preview.innerHTML = '

Nothing to preview yet.

'; return } - try { - const res = await fetch(`${window.__BASE_PATH__}/api/preview`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text }), - }) - if (res.ok) { const { html } = await res.json(); preview.innerHTML = sanitizeHtml(html) } - } catch { preview.textContent = text } - } - }) - - function cancel() { - if (!wrap.parentNode) return - wrap.replaceWith(textEl) - activeEditCancel = null - } - - activeEditCancel = cancel - - function save() { - const newText = textarea.value.trim() - if (!newText || newText === rawText) { cancel(); return } - cancel() - article.dataset.rawText = newText - ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } }) - } - - toolbar.querySelector('.btn-edit-save').addEventListener('click', save) - toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel) - textarea.addEventListener('keydown', e => { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() } - if (e.key === 'Escape') cancel() - }) - } - - // ── Context menu (desktop hover → … button) ──────────────────────────────── - - let activeContextMenu = null - - function closeContextMenu() { - if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null } - } - - function showContextMenu(article, anchorEl) { - closeContextMenu() - const isAuthor = article.dataset.userId === userId - if (!isAuthor) return - - const menu = document.createElement('div') - menu.className = 'msg-context-menu' - const editBtn = document.createElement('button') - editBtn.className = 'msg-context-menu-item' - editBtn.type = 'button' - editBtn.textContent = 'Edit' - editBtn.addEventListener('click', () => { closeContextMenu(); startInlineEdit(article) }) - menu.appendChild(editBtn) - - const deleteBtn = document.createElement('button') - deleteBtn.className = 'msg-context-menu-item msg-context-menu-item--danger' - deleteBtn.type = 'button' - deleteBtn.textContent = 'Delete' - deleteBtn.addEventListener('click', () => { - closeContextMenu() - ws.send({ t: 'msg.delete', body: { msg_id: article.dataset.msgId, channel_id: channelId } }) - }) - menu.appendChild(deleteBtn) - - document.body.appendChild(menu) - activeContextMenu = menu - - const rect = anchorEl.getBoundingClientRect() - const menuRect = menu.getBoundingClientRect() - let top = rect.bottom + window.scrollY + 4 - let left = rect.right + window.scrollX - menuRect.width - if (left < 8) left = 8 - if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width - menu.style.top = `${top}px` - menu.style.left = `${left}px` - } - - document.addEventListener('click', e => { - if (activeContextMenu && !activeContextMenu.contains(e.target)) closeContextMenu() - }, { capture: true }) - - document.addEventListener('keydown', e => { - if (e.key === 'Escape') closeContextMenu() - }) - - // Delegated click: … button → open context menu - messages.addEventListener('click', e => { - const btn = e.target.closest('.btn-msg-actions') - if (!btn) return - e.stopPropagation() - const article = btn.closest('article.message') - if (article) showContextMenu(article, btn) - }) - - - ws.on('dm.opened', ({ channel_id, notify_only }) => { - if (notify_only) return // target user — sidebar handles the notification - window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}` - }) - - // ── Call: rtc.call_state — drives "N in call" row + sidebar badge ────────── - - ws.on('rtc.call_state', (body) => { - if (body.channel_id !== channelId) return - // Don't overwrite the active call's ID when browsing a different channel — - // callIdSig is what miniBarLeave uses to leave the call. - if (!inCall() || body.channel_id === callChannelId) { - callIdSig.set(body.call_id) - } - _updateCallStatusRow(body.call_id, body.count, body.users ?? []) - _updateChannelBadge(body.count) - }) - - function _updateCallStatusRow(activeCallId, count, users) { - if (!callStatusEl) return - if (inCall()) { - // Already in the call — just update peer count - if (peerCountEl) peerCountEl.textContent = count > 1 ? `${count} in call` : '' - callStatusEl.hidden = true - return - } - if (!activeCallId || count === 0) { - callStatusEl.hidden = true - return - } - callStatusEl.hidden = false - if (callStatusInfo) callStatusInfo.textContent = `${count} in call` - if (callStatusAvatars) { - callStatusAvatars.innerHTML = users.slice(0, 5).map(u => - `${escHtml(u.user_id.slice(0, 2).toUpperCase())}` - ).join('') - } - } - - function _updateChannelBadge(count) { - const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li') - if (!li) return - li.classList.toggle('call-active', count > 0) - const badge = li.querySelector('.call-badge') - if (badge) badge.textContent = count > 0 ? String(count) : '' - } - - // ── Call: start / join / leave ───────────────────────────────────────────── - - btnStartCall?.addEventListener('click', () => { - ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } }) - }) - - btnJoinCall?.addEventListener('click', () => { - const id = callIdSig() - if (id) ws.send({ t: 'rtc.join', body: { call_id: id } }) - }) - - btnLeaveCall?.addEventListener('click', leaveCall) - - function leaveCall() { - const id = callIdSig() - if (!inCall() || !id) return - ws.send({ t: 'rtc.leave', body: { call_id: id } }) - _teardownCall() - } - - // ── Call: WS message handlers ────────────────────────────────────────────── - - ws.on('rtc.call', (body) => { - // Server confirmed call creation / found existing call — now join it - if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) } - callIdSig.set(body.call_id) - ws.send({ t: 'rtc.join', body: { call_id: body.call_id } }) - }) - - ws.on('rtc.joined', async (body) => { - const { call_id, peer_id, peers } = body - if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) } - selfPeerId.set(peer_id) - callIdSig.set(call_id) - callChannelId = channelId - inCall.set(true) - _showCallControls() - _showTilePanel() - _attachDeviceChangeListener() - patchSettings({ last_channel_id: channelId }) - - // Start audio immediately; video is opt-in - await _startAudio() - - // Cache display names for existing peers, then connect as offerer - for (const peer of peers) { - if (peer.peer_id !== peer_id) { - rtcManager.setDisplayName(peer.peer_id, peer.display_name) - rtcManager.ensurePeer(peer.peer_id) - rtcManager.negotiate(peer.peer_id) - } - } - }) - - ws.on('rtc.peer_event', ({ call_id, kind, peer }) => { - if (kind === 'join' && peer.peer_id !== selfPeerId()) { - rtcManager.setDisplayName(peer.peer_id, peer.display_name) - // Existing peer receives new joiner's event — create answerer connection - // (new joiner will send us an offer) - rtcManager.ensurePeer(peer.peer_id) - } - if (kind === 'leave') { - rtcManager.closePeer(peer.peer_id) - } - }) - - ws.on('rtc.offer_event', async ({ call_id, from_peer_id, sdp }) => { - await rtcManager.handleRemoteOffer(from_peer_id, call_id, sdp) - }) - - ws.on('rtc.answer_event', async ({ call_id, from_peer_id, sdp }) => { - await rtcManager.handleRemoteAnswer(from_peer_id, sdp) - }) - - ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => { - await rtcManager.handleIceCandidate(from_peer_id, candidate) - }) - - ws.on('rtc.stream_event', () => { - // No pre-tile creation here — tile IDs must match between this handler - // (which uses kind: 'cam'/'screen') and ontrack (which uses transceiver.mid, - // a number like '1' or '2'). The mismatch left orphaned empty tiles. - // Tiles are created in ontrack once the actual stream track arrives. - }) - - ws.on('rtc.call_end', ({ call_id }) => { - if (call_id === callIdSig()) _teardownCall() - }) - - ws.on('rtc.left', () => { - // Server confirmed our leave - }) - - // ── Local media ──────────────────────────────────────────────────────────── - // Peer negotiation, transceiver slots, ICE queuing → RtcPeerManager - - async function _startAudio() { - if (audioStream) return - try { - const saved = loadSavedDevices() - audioStream = await navigator.mediaDevices.getUserMedia({ - audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true, - video: false, - }) - activeMicId = audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null - audioStream.getAudioTracks().forEach(t => { t.enabled = !micMuted() }) - await refreshDevices() // labels now available after permission granted - for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId) - } catch { - micMuted.set(true) - } - } - - async function toggleMic() { - micMuted.set(!micMuted()) - audioStream?.getAudioTracks().forEach(t => { t.enabled = !micMuted() }) - if (ctrlMic) ctrlMic.textContent = micMuted() ? '🔇' : '🎙' - if (miniBarMic) miniBarMic.textContent = micMuted() ? '🔇' : '🎙' - } - - async function toggleCamera() { - if (videoStream) { - videoStream.getTracks().forEach(t => t.stop()) - _removeTile('local-cam') - videoStream = null - camOff.set(true) - for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId) - if (ctrlCam) ctrlCam.textContent = '📷' - return - } - try { - const saved = loadSavedDevices() - const videoConstraint = saved.cameraId - ? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 } - : { width: 640, height: 360 } - videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false }) - activeCameraId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null - camOff.set(false) - _renderTile('local-cam', videoStream, true, `${userHandle ?? 'You'} (cam)`) - ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'camera' } } }) - for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId) - if (ctrlCam) ctrlCam.textContent = '📷✓' - } catch { - // Camera denied - } - } - - async function toggleScreen() { - if (screenStream) { - screenStream.getTracks().forEach(t => t.stop()) - _removeTile('local-screen') - screenStream = null - screenSharing.set(false) - for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId) - if (ctrlScreen) ctrlScreen.textContent = '🖥' - return - } - if (!navigator.mediaDevices?.getDisplayMedia) return - try { - screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }) - screenSharing.set(true) - _renderTile('local-screen', screenStream, true, `${userHandle ?? 'You'} (screen)`) - ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'screen' } } }) - screenStream.getVideoTracks()[0].addEventListener('ended', () => toggleScreen()) - for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId) - if (ctrlScreen) ctrlScreen.textContent = '🖥✓' - } catch { /* user cancelled */ } - } - - // ── Audio element for remote peers ───────────────────────────────────────── - - function _ensureRemoteAudio(stream, peerId) { - if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return - const audio = document.createElement('audio') - audio.autoplay = true - audio.dataset.peerId = peerId - audio.srcObject = stream - document.body.appendChild(audio) - } - - // ── Tile grid ────────────────────────────────────────────────────────────── - - function _captureFrame(tile, label) { - const video = tile.querySelector('video') - if (!video || !video.videoWidth) return - const canvas = document.createElement('canvas') - canvas.width = video.videoWidth - canvas.height = video.videoHeight - canvas.getContext('2d').drawImage(video, 0, 0) - const a = document.createElement('a') - a.href = canvas.toDataURL('image/png') - a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png` - a.click() - } - - function _startCapture(tile, label, delay) { - const countdown = tile.querySelector('.tile-countdown') - const captureBtn = tile.querySelector('.tile-capture') - if (tile._captureTimer) { - clearInterval(tile._captureTimer) - tile._captureTimer = null - countdown.hidden = true - captureBtn.textContent = '📸' - return - } - if (delay === 0) { _captureFrame(tile, label); return } - let remaining = delay - countdown.textContent = remaining - countdown.hidden = false - captureBtn.textContent = '✕' - tile._captureTimer = setInterval(() => { - remaining-- - if (remaining <= 0) { - clearInterval(tile._captureTimer) - tile._captureTimer = null - countdown.hidden = true - captureBtn.textContent = '📸' - _captureFrame(tile, label) - } else { - countdown.textContent = remaining - } - }, 1000) - } - - function _renderTile(tileId, stream, muted, label) { - if (!tileGridEl) return - let tile = tileGridEl.querySelector(`[data-peer="${tileId}"]`) - if (!tile) { - tile = document.createElement('div') - tile.className = 'stream-tile' - tile.dataset.peer = tileId - tile.innerHTML = `${escHtml(label)}
` - tile.querySelector('video').addEventListener('click', e => e.stopPropagation()) - tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); _pinTile(tileId) }) - const menu = tile.querySelector('.tile-capture-menu') - tile.querySelector('.tile-capture').addEventListener('click', e => { - e.stopPropagation() - if (tile._captureTimer) { _startCapture(tile, label, 0); return } - menu.hidden = !menu.hidden - }) - menu.querySelectorAll('.tile-capture-opt').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation() - menu.hidden = true - _startCapture(tile, label, parseInt(btn.dataset.delay)) - }) - }) - tileGridEl.appendChild(tile) - _updateTileLayout() - } - if (stream) tile.querySelector('video').srcObject = stream - return tile - } - - function _removeTile(tileId) { - tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove() - _updateTileLayout() - } - - function _updateTileLayout() { - if (!tileGridEl) return - const count = tileGridEl.querySelectorAll('.stream-tile').length - tileGridEl.classList.toggle('avatars-only', count >= 5) - } - - function _pinTile(tileId) { - if (pinnedPeerId === tileId) { - tileGridEl?.classList.remove('pinned') - tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile')) - pinnedPeerId = null - } else { - tileGridEl?.classList.add('pinned') - tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile')) - tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile') - pinnedPeerId = tileId - } - } - - // ── Device switching ─────────────────────────────────────────────────────── - - async function switchCamera(deviceId) { - const newStream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: { exact: deviceId } }, - }) - const newTrack = newStream.getVideoTracks()[0] - await rtcManager.replaceTrack('camera', newTrack) - - videoStream?.getTracks().forEach(t => t.stop()) - videoStream = newStream - activeCameraId = deviceId - saveDevices({ cameraId: deviceId }) - - const localTile = tileGridEl?.querySelector('[data-peer="local-cam"]') - if (localTile) localTile.querySelector('video').srcObject = newStream - } - - async function switchMic(deviceId) { - const newStream = await navigator.mediaDevices.getUserMedia({ - audio: { deviceId: { exact: deviceId } }, - }) - const newTrack = newStream.getAudioTracks()[0] - newTrack.enabled = !micMuted() - await rtcManager.replaceTrack('audio', newTrack) - - audioStream?.getTracks().forEach(t => t.stop()) - audioStream = newStream - activeMicId = deviceId - saveDevices({ micId: deviceId }) - } - - // ── Device change detection ──────────────────────────────────────────────── - - function _onDeviceChange() { - refreshDevices().then(({ cameras, mics }) => { - const cameraGone = activeCameraId && !cameras.find(d => d.deviceId === activeCameraId) - const micGone = activeMicId && !mics.find(d => d.deviceId === activeMicId) - if (cameraGone || micGone) _showDeviceWarning(cameraGone ? 'camera' : 'mic') - if (devicePickerEl?.classList.contains('open')) _populatePicker() - }) - } - - function _attachDeviceChangeListener() { - navigator.mediaDevices.addEventListener('devicechange', _onDeviceChange) - } - function _detachDeviceChangeListener() { - navigator.mediaDevices.removeEventListener('devicechange', _onDeviceChange) - } - - // ── Device picker ────────────────────────────────────────────────────────── - - let devicePickerEl = null - - function _buildPicker() { - devicePickerEl = document.createElement('div') - devicePickerEl.className = 'device-picker' - devicePickerEl.innerHTML = ` -
- - - -
-
- - - -
- - ` - callControlsEl?.after(devicePickerEl) - - devicePickerEl.querySelector('#dp-cancel').addEventListener('click', _closePicker) - devicePickerEl.querySelector('#dp-apply').addEventListener('click', _applyPicker) - - const cameraSelect = devicePickerEl.querySelector('#dp-camera') - const previewVideo = devicePickerEl.querySelector('#dp-preview') - - cameraSelect.addEventListener('change', async () => { - devicePickerEl._previewStream?.getTracks().forEach(t => t.stop()) - devicePickerEl._previewStream = null - if (!cameraSelect.value) return - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: { exact: cameraSelect.value } }, - }) - previewVideo.srcObject = stream - devicePickerEl._previewStream = stream - } catch { /* camera unavailable */ } - }) - } - - function _populatePicker() { - const { cameras, mics } = availableDevices - const cameraSelect = devicePickerEl?.querySelector('#dp-camera') - const micSelect = devicePickerEl?.querySelector('#dp-mic') - if (cameraSelect) { - cameraSelect.innerHTML = cameras - .map(d => ``) - .join('') - } - if (micSelect) { - micSelect.innerHTML = mics - .map(d => ``) - .join('') - } - } - - async function _openPicker() { - if (!devicePickerEl) _buildPicker() - await refreshDevices() - _populatePicker() - devicePickerEl.classList.add('open') - } - - function _closePicker() { - devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop()) - if (devicePickerEl) devicePickerEl._previewStream = null - devicePickerEl?.classList.remove('open') - } - - async function _applyPicker() { - const cameraId = devicePickerEl?.querySelector('#dp-camera')?.value - const micId = devicePickerEl?.querySelector('#dp-mic')?.value - try { - if (cameraId && cameraId !== activeCameraId && videoStream) await switchCamera(cameraId) - if (micId && micId !== activeMicId) await switchMic(micId) - ctrlDevices?.classList.remove('device-warning') - } catch { /* device unavailable — leave current stream in place */ } - _closePicker() - } - - ctrlDevices?.addEventListener('click', () => { - devicePickerEl?.classList.contains('open') ? _closePicker() : _openPicker() - }) - - // ── Device warning toast ─────────────────────────────────────────────────── - - function _showDeviceWarning(kind) { - const label = kind === 'camera' ? 'Camera' : 'Microphone' - const toast = document.createElement('div') - toast.className = 'device-warning-toast' - toast.textContent = `${label} disconnected — click ⚙ to switch` - document.body.appendChild(toast) - setTimeout(() => toast.remove(), 6000) - ctrlDevices?.classList.add('device-warning') - } - - // ── Controls visibility ──────────────────────────────────────────────────── - - function _showCallControls() { - callStatusEl && (callStatusEl.hidden = true) - callControlsEl?.classList.add('active') - if (btnStartCall) btnStartCall.hidden = true - } - - function _hideCallControls() { - callControlsEl?.classList.remove('active') - if (btnStartCall) btnStartCall.hidden = false - } - - // ── Tile panel show / hide ───────────────────────────────────────────────── - - const LAYOUT_KEY = 'devchitchat_tile_layout' - - function _showTilePanel() { - document.querySelector('.main-content')?.classList.add('has-call') - tilePanelEl?.classList.add('active') - } - - function _hideTilePanel() { - document.querySelector('.main-content')?.classList.remove('has-call') - tilePanelEl?.classList.remove('active') - tilePanelEl?.classList.remove('collapsed') - } - - // Restore collapse state from localStorage - try { - const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') - if (saved.collapsed) tilePanelEl?.classList.add('collapsed') - if (saved.overlayRight && saved.overlayTop && tilePanelEl) { - tilePanelEl.style.right = saved.overlayRight - tilePanelEl.style.top = saved.overlayTop - } - } catch { /* ignore */ } - - // Collapse toggle - document.getElementById('tile-panel-collapse')?.addEventListener('click', () => { - const collapsed = tilePanelEl?.classList.toggle('collapsed') - try { - const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') - localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed })) - } catch { /* ignore */ } - }) - - // Overlay drag (mobile only) - ;(function _attachOverlayDrag(panel) { - if (!panel) return - if (window.matchMedia('(min-width: 1025px)').matches) return - - const header = panel.querySelector('.tile-panel-header') - if (!header) return - - let startX, startY, startRight, startTop - - function onMove(e) { - e.preventDefault() // stop page scroll while dragging the tile panel - const clientX = e.touches ? e.touches[0].clientX : e.clientX - const clientY = e.touches ? e.touches[0].clientY : e.clientY - const dx = startX - clientX - const dy = clientY - startY - const newRight = Math.max(0, Math.min(startRight + dx, window.innerWidth - 60)) - const newTop = Math.max(0, Math.min(startTop + dy, window.innerHeight - 60)) - panel.style.right = newRight + 'px' - panel.style.top = newTop + 'px' - } - - function onEnd() { - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onEnd) - document.removeEventListener('touchmove', onMove) - document.removeEventListener('touchend', onEnd) - try { - const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}') - localStorage.setItem(LAYOUT_KEY, JSON.stringify({ - ...saved, - overlayRight: panel.style.right, - overlayTop: panel.style.top, - })) - } catch { /* ignore */ } - } - - header.addEventListener('mousedown', e => { - startX = e.clientX; startY = e.clientY - startRight = parseInt(panel.style.right) || 0 - startTop = parseInt(panel.style.top) || 0 - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onEnd) - }) - - header.addEventListener('touchstart', e => { - e.preventDefault() // prevent scroll from starting on the drag handle - startX = e.touches[0].clientX; startY = e.touches[0].clientY - startRight = parseInt(panel.style.right) || 0 - startTop = parseInt(panel.style.top) || 0 - document.addEventListener('touchmove', onMove, { passive: false }) - document.addEventListener('touchend', onEnd) - }, { passive: false }) - })(tilePanelEl) - - // ── Mini-bar (persists while navigating away during a call) ─────────────── - - function _showMiniBar() { - if (!miniBarEl) return - if (miniBarName) miniBarName.textContent = channelName() - miniBarEl.classList.add('active') - } - - function _hideMiniBar() { - miniBarEl?.classList.remove('active') - } - - ctrlMic?.addEventListener('click', toggleMic) - ctrlCam?.addEventListener('click', toggleCamera) - ctrlScreen?.addEventListener('click', toggleScreen) - miniBarMic?.addEventListener('click', toggleMic) - - miniBarReturn?.addEventListener('click', () => { - if (callChannelId) navigateTo(`${window.__BASE_PATH__}/channels/${callChannelId}`, false) - }) - - miniBarLeave?.addEventListener('click', () => { - leaveCall() - }) - - // Show mini-bar when user navigates to a different channel while in a call - document.addEventListener('channelnavigated', (e) => { - if (inCall() && e.detail?.channelId !== channelId) { - _showMiniBar() - } - }) - - // SPA navigation: router morphed .chat-panel and dispatched this event. - // Re-initialise chat state for the new channel without touching RTC. - document.addEventListener('chatpanel:navigated', (e) => { - const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail - - if (composeOpen && newId !== channelId) closeComposeMode() - - // Morph strips dynamically-added content (reactions, attachments, timestamps, etc.) - // but may preserve data-hydrated="1". Always clear and re-hydrate. - for (const a of messages.querySelectorAll('article.message[data-hydrated]')) { - delete a.dataset.hydrated - } - hydrateSeedMessages() - - if (newId === channelId) return // same channel — re-hydrate only, no WS channel change - - // Leave old channel subscription on the server - ws.send({ t: 'channel.leave', body: { channel_id: channelId } }) - - // Update local identity - channelId = newId - channelKind = kind - channelName.set(name) - channelTopic.set(topic) - afterSeq = newSeedSeq - - // Reset pagination state for new channel - oldestSeq = newFirstSeq ?? 0 - loadingMore = false - if (sentinelEl) { - sentinelEl.hidden = !newHasMore - if (newHasMore) loadMoreObserver.observe(sentinelEl) - else loadMoreObserver.unobserve(sentinelEl) - } - - // Update browser chrome - document.title = `#${name} — devchitchat` - const textarea = root.querySelector('#message-input') - if (textarea) textarea.placeholder = `Message in ${name}` - - closePicker() - - // Join new channel — server responds with channel.joined + rtc.call_state. - // channel.joined handler sends msg.list if afterSeq > 0, which will append - // any messages that arrived after the seed snapshot. - ws.send({ t: 'channel.join', body: { channel_id: channelId } }) - }) - - // ── Teardown ─────────────────────────────────────────────────────────────── - - function _teardownCall() { - rtcManager.teardown() - - audioStream?.getTracks().forEach(t => t.stop()); audioStream = null - videoStream?.getTracks().forEach(t => t.stop()); videoStream = null - screenStream?.getTracks().forEach(t => t.stop()); screenStream = null - - document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() }) - if (tileGridEl) tileGridEl.innerHTML = '' - _updateTileLayout() - _hideCallControls() - _hideTilePanel() - _hideMiniBar() - _closePicker() - _detachDeviceChangeListener() - ctrlDevices?.classList.remove('device-warning') - - micMuted.set(false) - camOff.set(false) - screenSharing.set(false) - inCall.set(false) - selfPeerId.set(null) - callChannelId = null - pinnedPeerId = null - activeCameraId = null - activeMicId = null - } - - // ── Mobile back button ───────────────────────────────────────────────────── - - root.querySelector('.btn-back-mobile')?.addEventListener('click', () => { - document.body.classList.add('sidebar-open') - patchSettings({ mobile_chat_open: false }) - }) - - // ── Exports (rdbljs bindings) ────────────────────────────────────────────── - - return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode, toggleComposeMode } -} diff --git a/pages/public/client/islands/sidebar.js b/pages/public/client/islands/sidebar.js deleted file mode 100644 index fc1a0f4..0000000 --- a/pages/public/client/islands/sidebar.js +++ /dev/null @@ -1,1198 +0,0 @@ -/** - * sidebar.js — rdbljs island for hub/channel navigation and online presence. - * - * Mounted on: