@devchitchat/chat 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- package/styles.css +22 -0
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* call.js — combined chat + WebRTC island.
|
|
3
|
+
*
|
|
4
|
+
* Mounted on: <section class="chat-panel" island="/client/islands/call.js" …>
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Chat (messages, composer) — same as the old chat.js island
|
|
8
|
+
* - WebRTC calls: start, join, leave, tile grid, mini-bar, sidebar badge
|
|
9
|
+
*
|
|
10
|
+
* WebRTC patterns ported from v1 RtcCallService:
|
|
11
|
+
* - negotiationInFlight / negotiationQueued per-peer serialisation
|
|
12
|
+
* - Pre-allocated transceiver slots (1 audio + 2 video: camera + screen)
|
|
13
|
+
* - replaceTrack() + direction toggle rather than addTrack() for renegotiation
|
|
14
|
+
* - waitForStableSignaling() before every offer
|
|
15
|
+
* - ICE candidate queue (pendingIceByPeer) until remote description is set
|
|
16
|
+
* - New joiner is offerer toward all existing peers; existing peers are answerers
|
|
17
|
+
*/
|
|
18
|
+
import { signal, effect, computed, Context } from '@devchitchat/rdbljs'
|
|
19
|
+
import { WsClient } from '../ws.js'
|
|
20
|
+
import { patchSettings } from '../settings-sync.js'
|
|
21
|
+
import { navigateTo } from '../router.js'
|
|
22
|
+
import { escHtml, utcDateKey, formatDateLabel, makeDateSeparator, applyInlineRenderingToTextNodes, renderAttachment, makeMessageEl } from '../shared/messages.js'
|
|
23
|
+
import { RtcPeerManager } from '../rtc-peer-manager.js'
|
|
24
|
+
import { CATEGORIES, EMOJI_NAMES } from '../emoji-data.js'
|
|
25
|
+
import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../action-sheet.js'
|
|
26
|
+
import { addLongPress } from '../long-press.js'
|
|
27
|
+
|
|
28
|
+
export default function CallIsland(root) {
|
|
29
|
+
// ── Data from HTML ─────────────────────────────────────────────────────────
|
|
30
|
+
let channelId = root.dataset.id
|
|
31
|
+
let channelKind = root.dataset.kind ?? 'text'
|
|
32
|
+
const userId = root.dataset.userId
|
|
33
|
+
const userHandle = root.dataset.userHandle
|
|
34
|
+
const seedSeq = parseInt(root.dataset.seedSeq ?? '0', 10)
|
|
35
|
+
let oldestSeq = parseInt(root.dataset.seedFirstSeq ?? '0', 10)
|
|
36
|
+
let loadingMore = false
|
|
37
|
+
|
|
38
|
+
// ── DOM refs ───────────────────────────────────────────────────────────────
|
|
39
|
+
const messages = document.getElementById('messages')
|
|
40
|
+
const sentinelEl = document.getElementById('load-more-sentinel')
|
|
41
|
+
const tilePanelEl = document.getElementById('tile-panel')
|
|
42
|
+
const tileGridEl = document.getElementById('tile-grid')
|
|
43
|
+
const callStatusEl = document.getElementById('call-status')
|
|
44
|
+
const callStatusInfo = document.getElementById('call-status-info')
|
|
45
|
+
const callStatusAvatars = document.getElementById('call-status-avatars')
|
|
46
|
+
const callControlsEl = document.getElementById('call-controls-bar')
|
|
47
|
+
const peerCountEl = document.getElementById('call-peer-count')
|
|
48
|
+
const btnStartCall = document.getElementById('btn-start-call')
|
|
49
|
+
const btnJoinCall = document.getElementById('btn-join-call')
|
|
50
|
+
const btnLeaveCall = document.getElementById('btn-leave-call')
|
|
51
|
+
const ctrlMic = document.getElementById('ctrl-mic')
|
|
52
|
+
const ctrlCam = document.getElementById('ctrl-cam')
|
|
53
|
+
const ctrlScreen = document.getElementById('ctrl-screen')
|
|
54
|
+
const ctrlDevices = document.getElementById('ctrl-devices')
|
|
55
|
+
|
|
56
|
+
// Mini-bar (lives in sidebar footer — shared across channel navigations)
|
|
57
|
+
const miniBarEl = document.getElementById('call-mini-bar')
|
|
58
|
+
const miniBarName = document.getElementById('mini-bar-channel-name')
|
|
59
|
+
const miniBarMic = document.getElementById('mini-bar-mic')
|
|
60
|
+
const miniBarReturn = document.getElementById('mini-bar-return')
|
|
61
|
+
const miniBarLeave = document.getElementById('mini-bar-leave')
|
|
62
|
+
|
|
63
|
+
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
64
|
+
const ws = new WsClient(`${window.__BASE_PATH__}/ws`)
|
|
65
|
+
|
|
66
|
+
// ── Chat signals ───────────────────────────────────────────────────────────
|
|
67
|
+
const draft = signal('')
|
|
68
|
+
const channelName = signal(root.dataset.name ?? '')
|
|
69
|
+
const channelTopic = signal(root.dataset.topic ?? '')
|
|
70
|
+
let afterSeq = seedSeq
|
|
71
|
+
|
|
72
|
+
// ── @mention picker state ──────────────────────────────────────────────────
|
|
73
|
+
let channelMembers = [] // [{ user_id, handle, display_name }] — non-bot users
|
|
74
|
+
let channelBots = [] // [{ user_id, handle, display_name }] — bot users
|
|
75
|
+
let mentionFiltered = [] // current filtered subset
|
|
76
|
+
let mentionStart = -1 // index of '@' in textarea.value
|
|
77
|
+
let mentionSelIdx = 0 // keyboard-selected row
|
|
78
|
+
|
|
79
|
+
// ── Call state ─────────────────────────────────────────────────────────────
|
|
80
|
+
const inCall = signal(false)
|
|
81
|
+
const callIdSig = signal(null) // active call_id in this channel (may exist before we join)
|
|
82
|
+
let callChannelId = null // channel where the active call lives (may differ from channelId after navigation)
|
|
83
|
+
const selfPeerId = signal(null)
|
|
84
|
+
const micMuted = signal(false)
|
|
85
|
+
const camOff = signal(false)
|
|
86
|
+
const screenSharing = signal(false)
|
|
87
|
+
let pinnedPeerId = null
|
|
88
|
+
|
|
89
|
+
// ── Local media streams ────────────────────────────────────────────────────
|
|
90
|
+
let audioStream = null // local mic
|
|
91
|
+
let videoStream = null // local camera
|
|
92
|
+
let screenStream = null // local screen share
|
|
93
|
+
let iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
94
|
+
|
|
95
|
+
// ── RTC peer manager ───────────────────────────────────────────────────────
|
|
96
|
+
const rtcManager = new RtcPeerManager({
|
|
97
|
+
iceServers,
|
|
98
|
+
getLocalStreams: () => ({ audio: audioStream, video: videoStream, screen: screenStream }),
|
|
99
|
+
handlers: {
|
|
100
|
+
onOffer: (peerId, sdp) => ws.send({ t: 'rtc.offer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
|
|
101
|
+
onAnswer: (peerId, sdp) => ws.send({ t: 'rtc.answer', body: { call_id: callIdSig(), to_peer_id: peerId, sdp } }),
|
|
102
|
+
onIceCandidate: (peerId, candidate) => ws.send({ t: 'rtc.ice', body: { call_id: callIdSig(), to_peer_id: peerId, candidate } }),
|
|
103
|
+
onTrack: (peerId, tileId, stream, label) => { _renderTile(tileId, stream, false, label); _ensureRemoteAudio(stream, peerId) },
|
|
104
|
+
onAudio: (peerId, stream) => _ensureRemoteAudio(stream, peerId),
|
|
105
|
+
onPeerClosed: (peerId) => {
|
|
106
|
+
tileGridEl?.querySelectorAll(`[data-peer^="${peerId}"]`).forEach(t => t.remove())
|
|
107
|
+
document.querySelectorAll(`audio[data-peer-id="${peerId}"]`).forEach(a => { a.srcObject = null; a.remove() })
|
|
108
|
+
_updateTileLayout()
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ── Device state ───────────────────────────────────────────────────────────
|
|
114
|
+
const DEVICES_KEY = 'devchitchat_devices'
|
|
115
|
+
let availableDevices = { cameras: [], mics: [] }
|
|
116
|
+
let activeCameraId = null
|
|
117
|
+
let activeMicId = null
|
|
118
|
+
|
|
119
|
+
function loadSavedDevices() {
|
|
120
|
+
try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
|
|
121
|
+
}
|
|
122
|
+
function saveDevices(patch) {
|
|
123
|
+
localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...loadSavedDevices(), ...patch }))
|
|
124
|
+
}
|
|
125
|
+
async function refreshDevices() {
|
|
126
|
+
const devices = await navigator.mediaDevices.enumerateDevices()
|
|
127
|
+
availableDevices = {
|
|
128
|
+
cameras: devices.filter(d => d.kind === 'videoinput'),
|
|
129
|
+
mics: devices.filter(d => d.kind === 'audioinput'),
|
|
130
|
+
}
|
|
131
|
+
return availableDevices
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Reaction bar ───────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function renderReactionBar(article, reactions, msgId) {
|
|
137
|
+
const bar = article.querySelector('.reaction-bar')
|
|
138
|
+
if (!bar) return
|
|
139
|
+
bar.innerHTML = reactions.map(r => `
|
|
140
|
+
<button class="reaction-pill${r.reacted ? ' reacted' : ''}"
|
|
141
|
+
data-emoji="${escHtml(r.emoji)}" data-msg-id="${escHtml(msgId)}"
|
|
142
|
+
type="button" title="${r.count} reaction${r.count !== 1 ? 's' : ''}">
|
|
143
|
+
${r.emoji} <span class="reaction-count">${r.count}</span>
|
|
144
|
+
</button>`).join('') +
|
|
145
|
+
`<button class="reaction-add" data-msg-id="${escHtml(msgId)}" type="button"
|
|
146
|
+
title="Add reaction" aria-label="Add reaction">+</button>`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Hydrate seed message attachments ──────────────────────────────────────
|
|
150
|
+
// Seed messages are SSR'd without attachment HTML. Process data-attachments now.
|
|
151
|
+
// Called on initial mount and again after each SPA navigation morph.
|
|
152
|
+
function hydrateSeedMessages() {
|
|
153
|
+
const articles = Array.from(messages.querySelectorAll('article.message'))
|
|
154
|
+
let prevDateKey = null
|
|
155
|
+
|
|
156
|
+
for (const article of articles) {
|
|
157
|
+
if (article.dataset.hydrated) continue
|
|
158
|
+
article.dataset.hydrated = '1'
|
|
159
|
+
|
|
160
|
+
// Add dm-trigger to non-self sender handles; add … actions button to own messages
|
|
161
|
+
const handle = article.querySelector('.message-handle[data-user-id]')
|
|
162
|
+
if (handle && handle.dataset.userId !== userId) {
|
|
163
|
+
handle.classList.add('dm-trigger')
|
|
164
|
+
handle.title = 'Send a direct message'
|
|
165
|
+
} else if (article.dataset.userId === userId && !article.querySelector('.btn-msg-actions')) {
|
|
166
|
+
const btn = document.createElement('button')
|
|
167
|
+
btn.className = 'btn-msg-actions btn-icon'
|
|
168
|
+
btn.type = 'button'
|
|
169
|
+
btn.title = 'Message actions'
|
|
170
|
+
btn.textContent = '…'
|
|
171
|
+
article.appendChild(btn)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Apply inline rendering (URLs, @mentions) to server-rendered message text.
|
|
175
|
+
// Walk text nodes instead of replacing innerHTML so that <a> tags already
|
|
176
|
+
// rendered server-side (e.g. from markdown link syntax) are preserved.
|
|
177
|
+
const textEl = article.querySelector('.message-text')
|
|
178
|
+
if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle })
|
|
179
|
+
|
|
180
|
+
// Inject attachment HTML for seed messages that have attachments_json
|
|
181
|
+
const raw = article.dataset.attachments
|
|
182
|
+
if (raw) {
|
|
183
|
+
let attachments
|
|
184
|
+
try { attachments = JSON.parse(raw) } catch { attachments = null }
|
|
185
|
+
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
186
|
+
attachments.forEach(a => article.insertAdjacentHTML('beforeend', renderAttachment(a)))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Hydrate reaction bar for seed messages
|
|
191
|
+
const rawReactions = article.dataset.reactions
|
|
192
|
+
const msgId = article.dataset.msgId
|
|
193
|
+
if (msgId) {
|
|
194
|
+
let reactions = []
|
|
195
|
+
if (rawReactions) {
|
|
196
|
+
try { reactions = JSON.parse(rawReactions) } catch { reactions = [] }
|
|
197
|
+
}
|
|
198
|
+
renderReactionBar(article, reactions, msgId)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Date separator before this article if date changed
|
|
202
|
+
const ts = parseInt(article.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
203
|
+
if (ts) {
|
|
204
|
+
const dateKey = utcDateKey(ts)
|
|
205
|
+
if (prevDateKey && dateKey !== prevDateKey) {
|
|
206
|
+
article.before(makeDateSeparator(dateKey))
|
|
207
|
+
}
|
|
208
|
+
prevDateKey = dateKey
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
hydrateSeedMessages()
|
|
213
|
+
// Scroll to the bottom instantly on first load — requestAnimationFrame gives
|
|
214
|
+
// the browser one layout cycle to settle flex heights before we measure
|
|
215
|
+
// scrollHeight. behavior:'instant' bypasses scroll-behavior:smooth so there
|
|
216
|
+
// is no visible animation from top to bottom on mount.
|
|
217
|
+
requestAnimationFrame(() => messages.scrollTo({ top: messages.scrollHeight, behavior: 'instant' }))
|
|
218
|
+
|
|
219
|
+
// ── Load-more sentinel + pagination ───────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function showSentinel() { if (sentinelEl) sentinelEl.hidden = false }
|
|
222
|
+
function hideSentinel() { if (sentinelEl) sentinelEl.hidden = true }
|
|
223
|
+
|
|
224
|
+
if (root.dataset.seedHasMore === 'true') showSentinel()
|
|
225
|
+
|
|
226
|
+
const loadMoreObserver = new IntersectionObserver(entries => {
|
|
227
|
+
if (!entries[0].isIntersecting || loadingMore || oldestSeq <= 1) return
|
|
228
|
+
loadingMore = true
|
|
229
|
+
ws.send({ t: 'msg.list', body: { channel_id: channelId, before_seq: oldestSeq } })
|
|
230
|
+
}, { root: messages, threshold: 0.1 })
|
|
231
|
+
|
|
232
|
+
if (sentinelEl) loadMoreObserver.observe(sentinelEl)
|
|
233
|
+
|
|
234
|
+
// ── Date separator helpers ─────────────────────────────────────────────────
|
|
235
|
+
// utcDateKey, formatDateLabel, makeDateSeparator imported from shared/messages.js
|
|
236
|
+
|
|
237
|
+
function prependMessages(msgs) {
|
|
238
|
+
const prevHeight = messages.scrollHeight
|
|
239
|
+
const fragment = document.createDocumentFragment()
|
|
240
|
+
let prevDate = null
|
|
241
|
+
|
|
242
|
+
const firstExisting = messages.querySelector('article.message')
|
|
243
|
+
if (firstExisting) {
|
|
244
|
+
const ts = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
245
|
+
if (ts) prevDate = utcDateKey(ts)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const m of msgs) {
|
|
249
|
+
const dateKey = utcDateKey(m.ts)
|
|
250
|
+
if (prevDate && dateKey !== prevDate) {
|
|
251
|
+
fragment.appendChild(makeDateSeparator(prevDate))
|
|
252
|
+
}
|
|
253
|
+
fragment.appendChild(makeMessageEl(m, { userId, userHandle }))
|
|
254
|
+
prevDate = dateKey
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// If last prepended message is different day from first existing, insert separator before existing
|
|
258
|
+
if (firstExisting && prevDate) {
|
|
259
|
+
const existingTs = parseInt(firstExisting.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
260
|
+
const existingDateKey = existingTs ? utcDateKey(existingTs) : null
|
|
261
|
+
if (existingDateKey && prevDate !== existingDateKey) {
|
|
262
|
+
messages.insertBefore(makeDateSeparator(existingDateKey), firstExisting)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
sentinelEl ? sentinelEl.after(fragment) : messages.prepend(fragment)
|
|
267
|
+
messages.scrollTop += messages.scrollHeight - prevHeight
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── @mention picker ────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
const mentionPickerEl = document.createElement('div')
|
|
273
|
+
mentionPickerEl.id = 'mention-picker'
|
|
274
|
+
mentionPickerEl.className = 'mention-picker'
|
|
275
|
+
mentionPickerEl.hidden = true
|
|
276
|
+
root.querySelector('.composer')?.prepend(mentionPickerEl)
|
|
277
|
+
|
|
278
|
+
function openPicker(filtered, start) {
|
|
279
|
+
mentionFiltered = filtered
|
|
280
|
+
mentionStart = start
|
|
281
|
+
mentionSelIdx = 0
|
|
282
|
+
renderPicker()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function closePicker() {
|
|
286
|
+
mentionFiltered = []
|
|
287
|
+
mentionStart = -1
|
|
288
|
+
mentionPickerEl.hidden = true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderPicker() {
|
|
292
|
+
if (mentionFiltered.length === 0) { closePicker(); return }
|
|
293
|
+
mentionPickerEl.innerHTML = mentionFiltered.map((m, i) => `
|
|
294
|
+
<button class="mention-option${i === mentionSelIdx ? ' selected' : ''}"
|
|
295
|
+
data-idx="${i}" type="button">
|
|
296
|
+
<span class="mention-option-name">${escHtml(m.display_name || m.handle)}</span>
|
|
297
|
+
<span class="mention-option-handle">@${escHtml(m.handle)}</span>
|
|
298
|
+
</button>`).join('')
|
|
299
|
+
mentionPickerEl.hidden = false
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function selectMention(member) {
|
|
303
|
+
if (!member) return
|
|
304
|
+
const textarea = root.querySelector('#message-input')
|
|
305
|
+
if (!textarea) return
|
|
306
|
+
const cursor = textarea.selectionStart
|
|
307
|
+
const val = textarea.value
|
|
308
|
+
const insert = `@${member.handle} `
|
|
309
|
+
textarea.value = val.substring(0, mentionStart) + insert + val.substring(cursor)
|
|
310
|
+
draft.set(textarea.value)
|
|
311
|
+
const pos = mentionStart + insert.length
|
|
312
|
+
textarea.setSelectionRange(pos, pos)
|
|
313
|
+
closePicker()
|
|
314
|
+
textarea.focus()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
mentionPickerEl.addEventListener('mousedown', e => {
|
|
318
|
+
// mousedown instead of click so the textarea doesn't lose focus first
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
const btn = e.target.closest('.mention-option')
|
|
321
|
+
if (!btn) return
|
|
322
|
+
selectMention(mentionFiltered[parseInt(btn.dataset.idx, 10)])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
function handleComposerInput(e) {
|
|
326
|
+
const textarea = e.target
|
|
327
|
+
const cursor = textarea.selectionStart
|
|
328
|
+
const before = textarea.value.substring(0, cursor)
|
|
329
|
+
// Match a bare @ or @partial-handle with no space, anchored to end of text-so-far
|
|
330
|
+
const match = before.match(/@([a-zA-Z0-9_.-]*)$/)
|
|
331
|
+
if (!match) { closePicker(); return }
|
|
332
|
+
const query = match[1].toLowerCase()
|
|
333
|
+
const start = cursor - match[0].length
|
|
334
|
+
const filtered = [...channelMembers, ...channelBots]
|
|
335
|
+
.filter(m =>
|
|
336
|
+
m.handle.toLowerCase().startsWith(query) ||
|
|
337
|
+
(m.display_name ?? '').toLowerCase().startsWith(query)
|
|
338
|
+
)
|
|
339
|
+
.slice(0, 8)
|
|
340
|
+
if (filtered.length === 0) { closePicker(); return }
|
|
341
|
+
openPicker(filtered, start)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
root.querySelector('#message-input')?.addEventListener('input', handleComposerInput)
|
|
345
|
+
|
|
346
|
+
// ── Chat: connect + join channel ───────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
ws.on('open', () => {
|
|
349
|
+
ws.send({ t: 'hello', body: { client: 'devchitchat', resume: { session_token: null } } })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
ws.on('hello_ack', () => {
|
|
353
|
+
ws.send({ t: 'channel.join', body: { channel_id: channelId } })
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
ws.on('channel.joined', () => {
|
|
357
|
+
if (afterSeq > 0) {
|
|
358
|
+
ws.send({ t: 'msg.list', body: { channel_id: channelId, after_seq: afterSeq } })
|
|
359
|
+
}
|
|
360
|
+
if (channelMembers.length === 0) {
|
|
361
|
+
ws.send({ t: 'user.list', body: {} })
|
|
362
|
+
ws.send({ t: 'bot.list', body: {} })
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
ws.on('user.list_result', ({ users }) => {
|
|
367
|
+
channelMembers = (users ?? []).filter(m => m.handle)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
ws.on('bot.list_result', ({ bots }) => {
|
|
371
|
+
channelBots = (bots ?? []).filter(b => b.handle)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
ws.on('msg.list_result', ({ messages: msgs, next_after_seq, has_more, direction }) => {
|
|
375
|
+
if (direction === 'before') {
|
|
376
|
+
if (msgs.length === 0) {
|
|
377
|
+
hideSentinel()
|
|
378
|
+
if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
|
|
379
|
+
loadingMore = false
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
prependMessages(msgs)
|
|
383
|
+
if (msgs[0].seq < oldestSeq) oldestSeq = msgs[0].seq
|
|
384
|
+
if (!has_more || oldestSeq <= 1) {
|
|
385
|
+
hideSentinel()
|
|
386
|
+
if (sentinelEl) loadMoreObserver.unobserve(sentinelEl)
|
|
387
|
+
}
|
|
388
|
+
loadingMore = false
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
// after_seq catch-up path
|
|
392
|
+
msgs.forEach(appendMessage)
|
|
393
|
+
if (msgs.length) afterSeq = msgs[msgs.length - 1].seq
|
|
394
|
+
else if (next_after_seq != null) afterSeq = next_after_seq
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
ws.on('msg.event', (body) => {
|
|
398
|
+
if (body.channel_id !== channelId) return
|
|
399
|
+
appendMessage(body)
|
|
400
|
+
afterSeq = body.seq
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
ws.on('msg.deleted', ({ msg_id }) => {
|
|
404
|
+
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
405
|
+
if (article) article.remove()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
ws.on('msg.edited', ({ msg_id, text, edited_at, rendered_text }) => {
|
|
409
|
+
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
410
|
+
if (!article) return
|
|
411
|
+
const textEl = article.querySelector('.message-text')
|
|
412
|
+
if (textEl) textEl.innerHTML = sanitizeHtml(rendered_text)
|
|
413
|
+
article.dataset.rawText = text
|
|
414
|
+
article.dataset.editedAt = edited_at
|
|
415
|
+
const timeEl = article.querySelector('.message-time')
|
|
416
|
+
if (timeEl) {
|
|
417
|
+
let editedSpan = timeEl.querySelector('.message-edited')
|
|
418
|
+
if (!editedSpan) {
|
|
419
|
+
editedSpan = document.createElement('span')
|
|
420
|
+
editedSpan.className = 'message-edited'
|
|
421
|
+
editedSpan.textContent = '(edited)'
|
|
422
|
+
timeEl.appendChild(editedSpan)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
ws.on('reaction.event', ({ msg_id, reactions }) => {
|
|
428
|
+
const article = messages.querySelector(`[data-msg-id="${msg_id}"]`)
|
|
429
|
+
if (article) renderReactionBar(article, reactions ?? [], msg_id)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
ws.on('channel.updated', (body) => {
|
|
433
|
+
if (body.channel?.channel_id !== channelId) return
|
|
434
|
+
channelName.set(body.channel.name)
|
|
435
|
+
channelTopic.set(body.channel.topic ?? '')
|
|
436
|
+
document.title = `#${body.channel.name} — devchitchat`
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// ── Chat: composer ─────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
// Pending attachments: [{ upload_id, url, original_name, mime_type, size_bytes }]
|
|
442
|
+
let pendingAttachments = []
|
|
443
|
+
|
|
444
|
+
const composerEl = root.querySelector('.composer')
|
|
445
|
+
const textareaEl = root.querySelector('#message-input')
|
|
446
|
+
|
|
447
|
+
// Attachment chips container — injected above the textarea
|
|
448
|
+
const chipsEl = document.createElement('div')
|
|
449
|
+
chipsEl.className = 'attachment-chips'
|
|
450
|
+
composerEl?.insertBefore(chipsEl, textareaEl)
|
|
451
|
+
|
|
452
|
+
// Hidden file input
|
|
453
|
+
const fileInputEl = document.createElement('input')
|
|
454
|
+
fileInputEl.type = 'file'
|
|
455
|
+
fileInputEl.multiple = true
|
|
456
|
+
fileInputEl.style.display = 'none'
|
|
457
|
+
fileInputEl.setAttribute('aria-hidden', 'true')
|
|
458
|
+
composerEl?.appendChild(fileInputEl)
|
|
459
|
+
|
|
460
|
+
// Attach-file button (paperclip)
|
|
461
|
+
const btnAttachEl = document.createElement('button')
|
|
462
|
+
btnAttachEl.type = 'button'
|
|
463
|
+
btnAttachEl.className = 'btn-attach btn-icon'
|
|
464
|
+
btnAttachEl.title = 'Attach file'
|
|
465
|
+
btnAttachEl.setAttribute('aria-label', 'Attach file')
|
|
466
|
+
btnAttachEl.innerHTML = '📎'
|
|
467
|
+
// Insert before the send button
|
|
468
|
+
const btnSendEl = composerEl?.querySelector('.btn-send')
|
|
469
|
+
if (btnSendEl && composerEl) composerEl.insertBefore(btnAttachEl, btnSendEl)
|
|
470
|
+
|
|
471
|
+
btnAttachEl.addEventListener('click', () => fileInputEl.click())
|
|
472
|
+
fileInputEl.addEventListener('change', () => {
|
|
473
|
+
uploadFiles([...fileInputEl.files])
|
|
474
|
+
fileInputEl.value = ''
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Drag-and-drop onto the textarea
|
|
478
|
+
let dropOverlayEl = null
|
|
479
|
+
|
|
480
|
+
function ensureDropOverlay() {
|
|
481
|
+
if (dropOverlayEl) return dropOverlayEl
|
|
482
|
+
dropOverlayEl = document.createElement('div')
|
|
483
|
+
dropOverlayEl.className = 'drop-overlay'
|
|
484
|
+
dropOverlayEl.textContent = 'Drop to attach'
|
|
485
|
+
composerEl?.appendChild(dropOverlayEl)
|
|
486
|
+
return dropOverlayEl
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
composerEl?.addEventListener('dragover', e => {
|
|
490
|
+
if (!e.dataTransfer.types.includes('Files')) return
|
|
491
|
+
e.preventDefault()
|
|
492
|
+
ensureDropOverlay().hidden = false
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
composerEl?.addEventListener('dragleave', e => {
|
|
496
|
+
if (composerEl.contains(e.relatedTarget)) return
|
|
497
|
+
if (dropOverlayEl) dropOverlayEl.hidden = true
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
composerEl?.addEventListener('drop', e => {
|
|
501
|
+
e.preventDefault()
|
|
502
|
+
if (dropOverlayEl) dropOverlayEl.hidden = true
|
|
503
|
+
const files = [...(e.dataTransfer.files ?? [])]
|
|
504
|
+
if (files.length > 0) uploadFiles(files)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// Paste image from clipboard (screenshots, copied images)
|
|
508
|
+
textareaEl?.addEventListener('paste', e => {
|
|
509
|
+
const items = [...(e.clipboardData?.items ?? [])]
|
|
510
|
+
const imageFiles = items
|
|
511
|
+
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
|
|
512
|
+
.map(item => {
|
|
513
|
+
const file = item.getAsFile()
|
|
514
|
+
if (!file) return null
|
|
515
|
+
if (!file.name) {
|
|
516
|
+
const ext = item.type.split('/')[1] ?? 'png'
|
|
517
|
+
return new File([file], `paste-${Date.now()}.${ext}`, { type: item.type })
|
|
518
|
+
}
|
|
519
|
+
return file
|
|
520
|
+
})
|
|
521
|
+
.filter(Boolean)
|
|
522
|
+
if (imageFiles.length === 0) return
|
|
523
|
+
e.preventDefault()
|
|
524
|
+
uploadFiles(imageFiles)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
async function uploadFiles(files) {
|
|
528
|
+
for (const file of files) {
|
|
529
|
+
await uploadOneFile(file, channelId)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function uploadOneFile(file, targetChannelId) {
|
|
534
|
+
const formData = new FormData()
|
|
535
|
+
formData.append('file', file)
|
|
536
|
+
formData.append('channel_id', targetChannelId)
|
|
537
|
+
|
|
538
|
+
let res
|
|
539
|
+
try {
|
|
540
|
+
res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
|
|
541
|
+
} catch {
|
|
542
|
+
showComposerError(`Upload failed: network error`)
|
|
543
|
+
return null
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!res.ok) {
|
|
547
|
+
const body = await res.json().catch(() => ({}))
|
|
548
|
+
showComposerError(`Upload failed: ${body.error ?? res.statusText}`)
|
|
549
|
+
return null
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const attachment = await res.json()
|
|
553
|
+
pendingAttachments.push(attachment)
|
|
554
|
+
renderChips()
|
|
555
|
+
return attachment
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderChips() {
|
|
559
|
+
chipsEl.innerHTML = pendingAttachments.map((a, i) => `
|
|
560
|
+
<span class="attachment-chip" data-index="${i}">
|
|
561
|
+
<span class="attachment-chip-name">${escHtml(a.original_name)}</span>
|
|
562
|
+
<button type="button" class="attachment-chip-remove" data-index="${i}" aria-label="Remove ${escHtml(a.original_name)}">×</button>
|
|
563
|
+
</span>
|
|
564
|
+
`).join('')
|
|
565
|
+
chipsEl.hidden = pendingAttachments.length === 0
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
chipsEl.addEventListener('click', e => {
|
|
569
|
+
const btn = e.target.closest('.attachment-chip-remove')
|
|
570
|
+
if (!btn) return
|
|
571
|
+
const idx = parseInt(btn.dataset.index, 10)
|
|
572
|
+
pendingAttachments.splice(idx, 1)
|
|
573
|
+
renderChips()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
function showComposerError(msg) {
|
|
577
|
+
const chip = document.createElement('span')
|
|
578
|
+
chip.className = 'attachment-chip attachment-chip-error'
|
|
579
|
+
chip.textContent = msg
|
|
580
|
+
chipsEl.appendChild(chip)
|
|
581
|
+
chipsEl.hidden = false
|
|
582
|
+
setTimeout(() => chip.remove(), 5000)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Urgent send ───────────────────────────────────────────────────────────
|
|
586
|
+
const urgentMode = signal(false)
|
|
587
|
+
const composerFooter = root.querySelector('.composer')
|
|
588
|
+
|
|
589
|
+
const urgentClass = computed(() => ({ 'is-urgent': urgentMode() }))
|
|
590
|
+
|
|
591
|
+
function toggleUrgentMode() {
|
|
592
|
+
urgentMode.set(!urgentMode())
|
|
593
|
+
composerFooter?.classList.toggle('composer-urgent', urgentMode())
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function sendMessage({ priority } = {}) {
|
|
597
|
+
const text = draft().trim()
|
|
598
|
+
if (!text && pendingAttachments.length === 0) return
|
|
599
|
+
const resolvedPriority = priority ?? (urgentMode() ? 'now' : 'normal')
|
|
600
|
+
ws.send({
|
|
601
|
+
t: 'msg.send',
|
|
602
|
+
body: {
|
|
603
|
+
channel_id: channelId,
|
|
604
|
+
text,
|
|
605
|
+
client_msg_id: `local_${Date.now()}`,
|
|
606
|
+
priority: resolvedPriority,
|
|
607
|
+
attachments: pendingAttachments.map(a => ({
|
|
608
|
+
upload_id: a.upload_id,
|
|
609
|
+
url: a.url,
|
|
610
|
+
filename: a.original_name,
|
|
611
|
+
mime_type: a.mime_type,
|
|
612
|
+
size_bytes: a.size_bytes,
|
|
613
|
+
}))
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
draft.set('')
|
|
617
|
+
pendingAttachments = []
|
|
618
|
+
renderChips()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function handleComposerKey(e) {
|
|
622
|
+
if (!mentionPickerEl.hidden) {
|
|
623
|
+
if (e.key === 'ArrowDown') {
|
|
624
|
+
e.preventDefault()
|
|
625
|
+
mentionSelIdx = Math.min(mentionSelIdx + 1, mentionFiltered.length - 1)
|
|
626
|
+
renderPicker()
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if (e.key === 'ArrowUp') {
|
|
630
|
+
e.preventDefault()
|
|
631
|
+
mentionSelIdx = Math.max(mentionSelIdx - 1, 0)
|
|
632
|
+
renderPicker()
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
636
|
+
e.preventDefault()
|
|
637
|
+
selectMention(mentionFiltered[mentionSelIdx])
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
if (e.key === 'Escape') {
|
|
641
|
+
closePicker()
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
646
|
+
e.preventDefault()
|
|
647
|
+
const priority = e.ctrlKey || e.metaKey ? 'now' : undefined
|
|
648
|
+
sendMessage({ priority })
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function appendMessage({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments, reactions }) {
|
|
653
|
+
if (messages.querySelector(`[data-msg-id="${msg_id}"]`)) return
|
|
654
|
+
|
|
655
|
+
// Date separator if day changed
|
|
656
|
+
const dateKey = utcDateKey(ts)
|
|
657
|
+
const lastMsg = messages.querySelector('article.message:last-of-type')
|
|
658
|
+
if (lastMsg) {
|
|
659
|
+
const lastTs = parseInt(lastMsg.querySelector('time')?.getAttribute('datetime') ?? '0', 10)
|
|
660
|
+
if (lastTs && utcDateKey(lastTs) !== dateKey) {
|
|
661
|
+
messages.appendChild(makeDateSeparator(dateKey))
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const article = makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, attachments }, { userId, userHandle })
|
|
666
|
+
|
|
667
|
+
// Ensure reaction bar exists in dynamically created messages
|
|
668
|
+
if (!article.querySelector('.reaction-bar')) {
|
|
669
|
+
const bar = document.createElement('div')
|
|
670
|
+
bar.className = 'reaction-bar'
|
|
671
|
+
article.appendChild(bar)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
messages.appendChild(article)
|
|
675
|
+
renderReactionBar(article, reactions ?? [], msg_id)
|
|
676
|
+
messages.scrollTop = messages.scrollHeight
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// renderAttachment, formatBytes imported from shared/messages.js
|
|
680
|
+
|
|
681
|
+
function sanitizeHtml(html) {
|
|
682
|
+
return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Delegated click: message sender name → open DM
|
|
686
|
+
messages.addEventListener('click', e => {
|
|
687
|
+
const handle = e.target.closest('.dm-trigger')
|
|
688
|
+
if (!handle) return
|
|
689
|
+
const targetUserId = handle.dataset.userId
|
|
690
|
+
if (!targetUserId || targetUserId === userId) return
|
|
691
|
+
ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
// ── Emoji picker ──────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
const RECENT_KEY = 'devchitchat_recent_emoji'
|
|
697
|
+
const RECENT_MAX = 24
|
|
698
|
+
|
|
699
|
+
function loadRecentEmoji() {
|
|
700
|
+
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') } catch { return [] }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function saveRecentEmoji(emoji) {
|
|
704
|
+
let recents = loadRecentEmoji().filter(e => e !== emoji)
|
|
705
|
+
recents.unshift(emoji)
|
|
706
|
+
if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
|
|
707
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(recents))
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
let emojiPickerEl = null
|
|
711
|
+
let emojiPickerCurrentCat = 'smileys'
|
|
712
|
+
let emojiPickerTarget = null // msg_id the picker is for
|
|
713
|
+
|
|
714
|
+
function buildEmojiPicker() {
|
|
715
|
+
emojiPickerEl = document.createElement('div')
|
|
716
|
+
emojiPickerEl.className = 'emoji-picker'
|
|
717
|
+
|
|
718
|
+
const searchInput = document.createElement('input')
|
|
719
|
+
searchInput.type = 'search'
|
|
720
|
+
searchInput.className = 'emoji-picker-search'
|
|
721
|
+
searchInput.placeholder = 'Search emoji…'
|
|
722
|
+
searchInput.setAttribute('aria-label', 'Search emoji')
|
|
723
|
+
emojiPickerEl.appendChild(searchInput)
|
|
724
|
+
|
|
725
|
+
const tabs = document.createElement('div')
|
|
726
|
+
tabs.className = 'emoji-picker-tabs'
|
|
727
|
+
for (const cat of CATEGORIES) {
|
|
728
|
+
const btn = document.createElement('button')
|
|
729
|
+
btn.type = 'button'
|
|
730
|
+
btn.className = 'emoji-picker-tab' + (cat.id === emojiPickerCurrentCat ? ' active' : '')
|
|
731
|
+
btn.dataset.catId = cat.id
|
|
732
|
+
btn.textContent = cat.label
|
|
733
|
+
btn.title = cat.id
|
|
734
|
+
tabs.appendChild(btn)
|
|
735
|
+
}
|
|
736
|
+
emojiPickerEl.appendChild(tabs)
|
|
737
|
+
|
|
738
|
+
const grid = document.createElement('div')
|
|
739
|
+
grid.className = 'emoji-picker-grid'
|
|
740
|
+
emojiPickerEl.appendChild(grid)
|
|
741
|
+
|
|
742
|
+
tabs.addEventListener('click', e => {
|
|
743
|
+
const btn = e.target.closest('.emoji-picker-tab')
|
|
744
|
+
if (!btn) return
|
|
745
|
+
emojiPickerCurrentCat = btn.dataset.catId
|
|
746
|
+
tabs.querySelectorAll('.emoji-picker-tab').forEach(b => b.classList.toggle('active', b.dataset.catId === emojiPickerCurrentCat))
|
|
747
|
+
searchInput.value = ''
|
|
748
|
+
renderEmojiGrid(null)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
searchInput.addEventListener('input', () => {
|
|
752
|
+
renderEmojiGrid(searchInput.value.trim().toLowerCase())
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
grid.addEventListener('click', e => {
|
|
756
|
+
const btn = e.target.closest('button[data-emoji]')
|
|
757
|
+
if (!btn) return
|
|
758
|
+
const emoji = btn.dataset.emoji
|
|
759
|
+
saveRecentEmoji(emoji)
|
|
760
|
+
emojiPickerEl.dispatchEvent(new CustomEvent('emoji:pick', { bubbles: true, detail: { emoji } }))
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
renderEmojiGrid(null)
|
|
764
|
+
return emojiPickerEl
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function renderEmojiGrid(query) {
|
|
768
|
+
if (!emojiPickerEl) return
|
|
769
|
+
const grid = emojiPickerEl.querySelector('.emoji-picker-grid')
|
|
770
|
+
if (!grid) return
|
|
771
|
+
|
|
772
|
+
let emojiList
|
|
773
|
+
if (query) {
|
|
774
|
+
// Search across all categories
|
|
775
|
+
const allEmoji = CATEGORIES.flatMap(c => c.emoji)
|
|
776
|
+
const unique = [...new Set(allEmoji)]
|
|
777
|
+
emojiList = unique.filter(e => {
|
|
778
|
+
const name = EMOJI_NAMES[e] ?? ''
|
|
779
|
+
return name.includes(query) || e.includes(query)
|
|
780
|
+
})
|
|
781
|
+
} else {
|
|
782
|
+
if (emojiPickerCurrentCat === 'recent') {
|
|
783
|
+
emojiList = loadRecentEmoji()
|
|
784
|
+
} else {
|
|
785
|
+
const cat = CATEGORIES.find(c => c.id === emojiPickerCurrentCat)
|
|
786
|
+
emojiList = cat ? cat.emoji : []
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
grid.innerHTML = emojiList.map(e =>
|
|
791
|
+
`<button type="button" data-emoji="${escHtml(e)}" title="${escHtml(EMOJI_NAMES[e] ?? e)}">${e}</button>`
|
|
792
|
+
).join('')
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function getOrBuildEmojiPicker() {
|
|
796
|
+
if (!emojiPickerEl) buildEmojiPicker()
|
|
797
|
+
return emojiPickerEl
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function openEmojiPickerAt(anchorEl, msgId) {
|
|
801
|
+
emojiPickerTarget = msgId
|
|
802
|
+
const picker = getOrBuildEmojiPicker()
|
|
803
|
+
|
|
804
|
+
// Refresh recent tab if active
|
|
805
|
+
if (emojiPickerCurrentCat === 'recent') renderEmojiGrid(null)
|
|
806
|
+
|
|
807
|
+
// Attach handler once (use named function to avoid duplicates)
|
|
808
|
+
picker.onEmojiPickHandler = (e) => {
|
|
809
|
+
const { emoji } = e.detail
|
|
810
|
+
closeEmojiPicker()
|
|
811
|
+
closeReactionContextMenu()
|
|
812
|
+
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
813
|
+
}
|
|
814
|
+
picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
|
|
815
|
+
picker._boundEmojiPick = picker.onEmojiPickHandler
|
|
816
|
+
picker.addEventListener('emoji:pick', picker._boundEmojiPick)
|
|
817
|
+
|
|
818
|
+
document.body.appendChild(picker)
|
|
819
|
+
picker.style.position = 'fixed'
|
|
820
|
+
picker.style.zIndex = '400'
|
|
821
|
+
|
|
822
|
+
// Position below the anchor, viewport-aware
|
|
823
|
+
const rect = anchorEl.getBoundingClientRect()
|
|
824
|
+
picker.style.top = `${rect.bottom + 4}px`
|
|
825
|
+
picker.style.left = `${rect.left}px`
|
|
826
|
+
|
|
827
|
+
// Force layout so getBoundingClientRect is accurate
|
|
828
|
+
requestAnimationFrame(() => {
|
|
829
|
+
const pickerRect = picker.getBoundingClientRect()
|
|
830
|
+
let left = rect.left
|
|
831
|
+
if (left + pickerRect.width > window.innerWidth - 8) {
|
|
832
|
+
left = window.innerWidth - 8 - pickerRect.width
|
|
833
|
+
}
|
|
834
|
+
if (left < 8) left = 8
|
|
835
|
+
picker.style.left = `${left}px`
|
|
836
|
+
|
|
837
|
+
// Flip above if not enough room below
|
|
838
|
+
if (rect.bottom + 4 + pickerRect.height > window.innerHeight - 8) {
|
|
839
|
+
picker.style.top = `${rect.top - 4 - pickerRect.height}px`
|
|
840
|
+
}
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function closeEmojiPicker() {
|
|
845
|
+
if (emojiPickerEl && emojiPickerEl.parentNode) emojiPickerEl.parentNode.removeChild(emojiPickerEl)
|
|
846
|
+
emojiPickerTarget = null
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ── Reaction context menu (desktop right-click) ────────────────────────────
|
|
850
|
+
|
|
851
|
+
let activeReactionContextMenu = null
|
|
852
|
+
|
|
853
|
+
function closeReactionContextMenu() {
|
|
854
|
+
if (activeReactionContextMenu) { activeReactionContextMenu.remove(); activeReactionContextMenu = null }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function showReactionContextMenu(article, x, y) {
|
|
858
|
+
closeReactionContextMenu()
|
|
859
|
+
const msgId = article.dataset.msgId
|
|
860
|
+
if (!msgId) return
|
|
861
|
+
|
|
862
|
+
const menu = document.createElement('div')
|
|
863
|
+
menu.className = 'msg-context-menu msg-context-menu--reaction'
|
|
864
|
+
|
|
865
|
+
const reactBtn = document.createElement('button')
|
|
866
|
+
reactBtn.className = 'msg-context-menu-item'
|
|
867
|
+
reactBtn.type = 'button'
|
|
868
|
+
reactBtn.textContent = 'React'
|
|
869
|
+
reactBtn.addEventListener('click', (e) => {
|
|
870
|
+
e.stopPropagation()
|
|
871
|
+
openEmojiPickerAt(reactBtn, msgId)
|
|
872
|
+
})
|
|
873
|
+
menu.appendChild(reactBtn)
|
|
874
|
+
|
|
875
|
+
document.body.appendChild(menu)
|
|
876
|
+
activeReactionContextMenu = menu
|
|
877
|
+
|
|
878
|
+
// Position near cursor, viewport-aware
|
|
879
|
+
const menuRect = menu.getBoundingClientRect()
|
|
880
|
+
let left = x + window.scrollX
|
|
881
|
+
let top = y + window.scrollY
|
|
882
|
+
if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
|
|
883
|
+
if (left < 8) left = 8
|
|
884
|
+
menu.style.top = `${top}px`
|
|
885
|
+
menu.style.left = `${left}px`
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Right-click on a message article
|
|
889
|
+
messages.addEventListener('contextmenu', e => {
|
|
890
|
+
const article = e.target.closest('article.message')
|
|
891
|
+
if (!article) return
|
|
892
|
+
e.preventDefault()
|
|
893
|
+
showReactionContextMenu(article, e.clientX, e.clientY)
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
document.addEventListener('click', e => {
|
|
897
|
+
if (activeReactionContextMenu && !activeReactionContextMenu.contains(e.target)) closeReactionContextMenu()
|
|
898
|
+
// Close emoji picker on click-outside
|
|
899
|
+
if (emojiPickerEl && emojiPickerEl.parentNode && !emojiPickerEl.contains(e.target)) {
|
|
900
|
+
const isReactionAddBtn = e.target.closest('.reaction-add')
|
|
901
|
+
if (!isReactionAddBtn) closeEmojiPicker()
|
|
902
|
+
}
|
|
903
|
+
}, { capture: true })
|
|
904
|
+
|
|
905
|
+
document.addEventListener('keydown', e => {
|
|
906
|
+
if (e.key === 'Escape') {
|
|
907
|
+
closeReactionContextMenu()
|
|
908
|
+
closeEmojiPicker()
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
// Delegated click on .reaction-pill — toggle reaction
|
|
913
|
+
messages.addEventListener('click', e => {
|
|
914
|
+
const pill = e.target.closest('.reaction-pill')
|
|
915
|
+
if (!pill) return
|
|
916
|
+
e.stopPropagation()
|
|
917
|
+
const emoji = pill.dataset.emoji
|
|
918
|
+
const msgId = pill.dataset.msgId
|
|
919
|
+
if (!emoji || !msgId) return
|
|
920
|
+
if (pill.classList.contains('reacted')) {
|
|
921
|
+
ws.send({ t: 'reaction.remove', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
922
|
+
} else {
|
|
923
|
+
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
924
|
+
}
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
// Delegated click on .reaction-add — open emoji picker
|
|
928
|
+
messages.addEventListener('click', e => {
|
|
929
|
+
const btn = e.target.closest('.reaction-add')
|
|
930
|
+
if (!btn) return
|
|
931
|
+
e.stopPropagation()
|
|
932
|
+
const msgId = btn.dataset.msgId
|
|
933
|
+
if (!msgId) return
|
|
934
|
+
// Toggle: close if already open for this message
|
|
935
|
+
if (emojiPickerEl && emojiPickerEl.parentNode && emojiPickerTarget === msgId) {
|
|
936
|
+
closeEmojiPicker()
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
openEmojiPickerAt(btn, msgId)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
// ── Mobile long-press → action sheet with emoji picker ────────────────────
|
|
943
|
+
|
|
944
|
+
addLongPress(messages, (e) => {
|
|
945
|
+
const article = e.target.closest?.('article.message')
|
|
946
|
+
if (!article) return
|
|
947
|
+
const msgId = article.dataset.msgId
|
|
948
|
+
if (!msgId) return
|
|
949
|
+
|
|
950
|
+
const itemsContainer = getItemsContainer()
|
|
951
|
+
itemsContainer.innerHTML = ''
|
|
952
|
+
|
|
953
|
+
const pickerWrapper = document.createElement('div')
|
|
954
|
+
pickerWrapper.className = 'action-sheet-emoji-picker-wrap'
|
|
955
|
+
|
|
956
|
+
const picker = getOrBuildEmojiPicker()
|
|
957
|
+
pickerWrapper.appendChild(picker)
|
|
958
|
+
itemsContainer.appendChild(pickerWrapper)
|
|
959
|
+
|
|
960
|
+
picker.removeEventListener('emoji:pick', picker._boundEmojiPick)
|
|
961
|
+
picker._boundEmojiPick = (ev) => {
|
|
962
|
+
const { emoji } = ev.detail
|
|
963
|
+
saveRecentEmoji(emoji)
|
|
964
|
+
dismissActionSheet()
|
|
965
|
+
ws.send({ t: 'reaction.add', body: { msg_id: msgId, channel_id: channelId, emoji } })
|
|
966
|
+
}
|
|
967
|
+
picker.addEventListener('emoji:pick', picker._boundEmojiPick)
|
|
968
|
+
emojiPickerTarget = msgId
|
|
969
|
+
|
|
970
|
+
showActionSheet({ label: 'React to this message', items: [] })
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
// ── Inline edit ────────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
function startInlineEdit(article) {
|
|
976
|
+
if (article.querySelector('.message-edit-input')) return // already editing
|
|
977
|
+
const textEl = article.querySelector('.message-text')
|
|
978
|
+
if (!textEl) return
|
|
979
|
+
const rawText = article.dataset.rawText ?? ''
|
|
980
|
+
|
|
981
|
+
const textarea = document.createElement('textarea')
|
|
982
|
+
textarea.className = 'message-edit-input'
|
|
983
|
+
textarea.value = rawText
|
|
984
|
+
textEl.replaceWith(textarea)
|
|
985
|
+
textarea.focus()
|
|
986
|
+
textarea.setSelectionRange(rawText.length, rawText.length)
|
|
987
|
+
|
|
988
|
+
const toolbar = document.createElement('div')
|
|
989
|
+
toolbar.className = 'message-edit-toolbar'
|
|
990
|
+
toolbar.innerHTML = '<button class="btn-edit-save btn-primary" type="button">Save</button><button class="btn-edit-cancel btn-ghost" type="button">Cancel</button>'
|
|
991
|
+
textarea.after(toolbar)
|
|
992
|
+
|
|
993
|
+
function cancel() {
|
|
994
|
+
textarea.replaceWith(textEl)
|
|
995
|
+
toolbar.remove()
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function save() {
|
|
999
|
+
const newText = textarea.value.trim()
|
|
1000
|
+
if (!newText) { cancel(); return }
|
|
1001
|
+
if (newText === rawText) { cancel(); return }
|
|
1002
|
+
// Optimistic: show new text immediately (unrendered), server will push rendered version via msg.edited
|
|
1003
|
+
textEl.textContent = newText
|
|
1004
|
+
cancel()
|
|
1005
|
+
ws.send({ t: 'msg.edit', body: { msg_id: article.dataset.msgId, channel_id: channelId, text: newText } })
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
|
|
1009
|
+
toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
|
|
1010
|
+
textarea.addEventListener('keydown', e => {
|
|
1011
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); save() }
|
|
1012
|
+
if (e.key === 'Escape') cancel()
|
|
1013
|
+
})
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ── Context menu (desktop hover → … button) ────────────────────────────────
|
|
1017
|
+
|
|
1018
|
+
let activeContextMenu = null
|
|
1019
|
+
|
|
1020
|
+
function closeContextMenu() {
|
|
1021
|
+
if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null }
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function showContextMenu(article, anchorEl) {
|
|
1025
|
+
closeContextMenu()
|
|
1026
|
+
const isAuthor = article.dataset.userId === userId
|
|
1027
|
+
if (!isAuthor) return
|
|
1028
|
+
|
|
1029
|
+
const menu = document.createElement('div')
|
|
1030
|
+
menu.className = 'msg-context-menu'
|
|
1031
|
+
const editBtn = document.createElement('button')
|
|
1032
|
+
editBtn.className = 'msg-context-menu-item'
|
|
1033
|
+
editBtn.type = 'button'
|
|
1034
|
+
editBtn.textContent = 'Edit'
|
|
1035
|
+
editBtn.addEventListener('click', () => { closeContextMenu(); startInlineEdit(article) })
|
|
1036
|
+
menu.appendChild(editBtn)
|
|
1037
|
+
|
|
1038
|
+
const deleteBtn = document.createElement('button')
|
|
1039
|
+
deleteBtn.className = 'msg-context-menu-item msg-context-menu-item--danger'
|
|
1040
|
+
deleteBtn.type = 'button'
|
|
1041
|
+
deleteBtn.textContent = 'Delete'
|
|
1042
|
+
deleteBtn.addEventListener('click', () => {
|
|
1043
|
+
closeContextMenu()
|
|
1044
|
+
ws.send({ t: 'msg.delete', body: { msg_id: article.dataset.msgId, channel_id: channelId } })
|
|
1045
|
+
})
|
|
1046
|
+
menu.appendChild(deleteBtn)
|
|
1047
|
+
|
|
1048
|
+
document.body.appendChild(menu)
|
|
1049
|
+
activeContextMenu = menu
|
|
1050
|
+
|
|
1051
|
+
const rect = anchorEl.getBoundingClientRect()
|
|
1052
|
+
const menuRect = menu.getBoundingClientRect()
|
|
1053
|
+
let top = rect.bottom + window.scrollY + 4
|
|
1054
|
+
let left = rect.right + window.scrollX - menuRect.width
|
|
1055
|
+
if (left < 8) left = 8
|
|
1056
|
+
if (left + menuRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - menuRect.width
|
|
1057
|
+
menu.style.top = `${top}px`
|
|
1058
|
+
menu.style.left = `${left}px`
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
document.addEventListener('click', e => {
|
|
1062
|
+
if (activeContextMenu && !activeContextMenu.contains(e.target)) closeContextMenu()
|
|
1063
|
+
}, { capture: true })
|
|
1064
|
+
|
|
1065
|
+
document.addEventListener('keydown', e => {
|
|
1066
|
+
if (e.key === 'Escape') closeContextMenu()
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
// Delegated click: … button → open context menu
|
|
1070
|
+
messages.addEventListener('click', e => {
|
|
1071
|
+
const btn = e.target.closest('.btn-msg-actions')
|
|
1072
|
+
if (!btn) return
|
|
1073
|
+
e.stopPropagation()
|
|
1074
|
+
const article = btn.closest('article.message')
|
|
1075
|
+
if (article) showContextMenu(article, btn)
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
ws.on('dm.opened', ({ channel_id, notify_only }) => {
|
|
1080
|
+
if (notify_only) return // target user — sidebar handles the notification
|
|
1081
|
+
window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
// ── Call: rtc.call_state — drives "N in call" row + sidebar badge ──────────
|
|
1085
|
+
|
|
1086
|
+
ws.on('rtc.call_state', (body) => {
|
|
1087
|
+
if (body.channel_id !== channelId) return
|
|
1088
|
+
// Don't overwrite the active call's ID when browsing a different channel —
|
|
1089
|
+
// callIdSig is what miniBarLeave uses to leave the call.
|
|
1090
|
+
if (!inCall() || body.channel_id === callChannelId) {
|
|
1091
|
+
callIdSig.set(body.call_id)
|
|
1092
|
+
}
|
|
1093
|
+
_updateCallStatusRow(body.call_id, body.count, body.users ?? [])
|
|
1094
|
+
_updateChannelBadge(body.count)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
function _updateCallStatusRow(activeCallId, count, users) {
|
|
1098
|
+
if (!callStatusEl) return
|
|
1099
|
+
if (inCall()) {
|
|
1100
|
+
// Already in the call — just update peer count
|
|
1101
|
+
if (peerCountEl) peerCountEl.textContent = count > 1 ? `${count} in call` : ''
|
|
1102
|
+
callStatusEl.hidden = true
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
if (!activeCallId || count === 0) {
|
|
1106
|
+
callStatusEl.hidden = true
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
callStatusEl.hidden = false
|
|
1110
|
+
if (callStatusInfo) callStatusInfo.textContent = `${count} in call`
|
|
1111
|
+
if (callStatusAvatars) {
|
|
1112
|
+
callStatusAvatars.innerHTML = users.slice(0, 5).map(u =>
|
|
1113
|
+
`<span class="call-status-avatar" title="${escHtml(u.user_id)}">${escHtml(u.user_id.slice(0, 2).toUpperCase())}</span>`
|
|
1114
|
+
).join('')
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function _updateChannelBadge(count) {
|
|
1119
|
+
const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li')
|
|
1120
|
+
if (!li) return
|
|
1121
|
+
li.classList.toggle('call-active', count > 0)
|
|
1122
|
+
const badge = li.querySelector('.call-badge')
|
|
1123
|
+
if (badge) badge.textContent = count > 0 ? String(count) : ''
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ── Call: start / join / leave ─────────────────────────────────────────────
|
|
1127
|
+
|
|
1128
|
+
btnStartCall?.addEventListener('click', () => {
|
|
1129
|
+
ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
btnJoinCall?.addEventListener('click', () => {
|
|
1133
|
+
const id = callIdSig()
|
|
1134
|
+
if (id) ws.send({ t: 'rtc.join', body: { call_id: id } })
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
btnLeaveCall?.addEventListener('click', leaveCall)
|
|
1138
|
+
|
|
1139
|
+
function leaveCall() {
|
|
1140
|
+
const id = callIdSig()
|
|
1141
|
+
if (!inCall() || !id) return
|
|
1142
|
+
ws.send({ t: 'rtc.leave', body: { call_id: id } })
|
|
1143
|
+
_teardownCall()
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ── Call: WS message handlers ──────────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
ws.on('rtc.call', (body) => {
|
|
1149
|
+
// Server confirmed call creation / found existing call — now join it
|
|
1150
|
+
if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
|
|
1151
|
+
callIdSig.set(body.call_id)
|
|
1152
|
+
ws.send({ t: 'rtc.join', body: { call_id: body.call_id } })
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
ws.on('rtc.joined', async (body) => {
|
|
1156
|
+
const { call_id, peer_id, peers } = body
|
|
1157
|
+
if (body.ice_servers?.length) { iceServers = body.ice_servers; rtcManager.setIceServers(iceServers) }
|
|
1158
|
+
selfPeerId.set(peer_id)
|
|
1159
|
+
callIdSig.set(call_id)
|
|
1160
|
+
callChannelId = channelId
|
|
1161
|
+
inCall.set(true)
|
|
1162
|
+
_showCallControls()
|
|
1163
|
+
_showTilePanel()
|
|
1164
|
+
_attachDeviceChangeListener()
|
|
1165
|
+
patchSettings({ last_channel_id: channelId })
|
|
1166
|
+
|
|
1167
|
+
// Start audio immediately; video is opt-in
|
|
1168
|
+
await _startAudio()
|
|
1169
|
+
|
|
1170
|
+
// Cache display names for existing peers, then connect as offerer
|
|
1171
|
+
for (const peer of peers) {
|
|
1172
|
+
if (peer.peer_id !== peer_id) {
|
|
1173
|
+
rtcManager.setDisplayName(peer.peer_id, peer.display_name)
|
|
1174
|
+
rtcManager.ensurePeer(peer.peer_id)
|
|
1175
|
+
rtcManager.negotiate(peer.peer_id)
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
ws.on('rtc.peer_event', ({ call_id, kind, peer }) => {
|
|
1181
|
+
if (kind === 'join' && peer.peer_id !== selfPeerId()) {
|
|
1182
|
+
rtcManager.setDisplayName(peer.peer_id, peer.display_name)
|
|
1183
|
+
// Existing peer receives new joiner's event — create answerer connection
|
|
1184
|
+
// (new joiner will send us an offer)
|
|
1185
|
+
rtcManager.ensurePeer(peer.peer_id)
|
|
1186
|
+
}
|
|
1187
|
+
if (kind === 'leave') {
|
|
1188
|
+
rtcManager.closePeer(peer.peer_id)
|
|
1189
|
+
}
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
ws.on('rtc.offer_event', async ({ call_id, from_peer_id, sdp }) => {
|
|
1193
|
+
await rtcManager.handleRemoteOffer(from_peer_id, call_id, sdp)
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
ws.on('rtc.answer_event', async ({ call_id, from_peer_id, sdp }) => {
|
|
1197
|
+
await rtcManager.handleRemoteAnswer(from_peer_id, sdp)
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => {
|
|
1201
|
+
await rtcManager.handleIceCandidate(from_peer_id, candidate)
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
ws.on('rtc.stream_event', () => {
|
|
1205
|
+
// No pre-tile creation here — tile IDs must match between this handler
|
|
1206
|
+
// (which uses kind: 'cam'/'screen') and ontrack (which uses transceiver.mid,
|
|
1207
|
+
// a number like '1' or '2'). The mismatch left orphaned empty tiles.
|
|
1208
|
+
// Tiles are created in ontrack once the actual stream track arrives.
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
ws.on('rtc.call_end', ({ call_id }) => {
|
|
1212
|
+
if (call_id === callIdSig()) _teardownCall()
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
ws.on('rtc.left', () => {
|
|
1216
|
+
// Server confirmed our leave
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
// ── Local media ────────────────────────────────────────────────────────────
|
|
1220
|
+
// Peer negotiation, transceiver slots, ICE queuing → RtcPeerManager
|
|
1221
|
+
|
|
1222
|
+
async function _startAudio() {
|
|
1223
|
+
if (audioStream) return
|
|
1224
|
+
try {
|
|
1225
|
+
const saved = loadSavedDevices()
|
|
1226
|
+
audioStream = await navigator.mediaDevices.getUserMedia({
|
|
1227
|
+
audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true,
|
|
1228
|
+
video: false,
|
|
1229
|
+
})
|
|
1230
|
+
activeMicId = audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null
|
|
1231
|
+
audioStream.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
|
|
1232
|
+
await refreshDevices() // labels now available after permission granted
|
|
1233
|
+
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1234
|
+
} catch {
|
|
1235
|
+
micMuted.set(true)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function toggleMic() {
|
|
1240
|
+
micMuted.set(!micMuted())
|
|
1241
|
+
audioStream?.getAudioTracks().forEach(t => { t.enabled = !micMuted() })
|
|
1242
|
+
if (ctrlMic) ctrlMic.textContent = micMuted() ? '🔇' : '🎙'
|
|
1243
|
+
if (miniBarMic) miniBarMic.textContent = micMuted() ? '🔇' : '🎙'
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function toggleCamera() {
|
|
1247
|
+
if (videoStream) {
|
|
1248
|
+
videoStream.getTracks().forEach(t => t.stop())
|
|
1249
|
+
_removeTile('local-cam')
|
|
1250
|
+
videoStream = null
|
|
1251
|
+
camOff.set(true)
|
|
1252
|
+
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1253
|
+
if (ctrlCam) ctrlCam.textContent = '📷'
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
const saved = loadSavedDevices()
|
|
1258
|
+
const videoConstraint = saved.cameraId
|
|
1259
|
+
? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 }
|
|
1260
|
+
: { width: 640, height: 360 }
|
|
1261
|
+
videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false })
|
|
1262
|
+
activeCameraId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
|
|
1263
|
+
camOff.set(false)
|
|
1264
|
+
_renderTile('local-cam', videoStream, true, `${userHandle ?? 'You'} (cam)`)
|
|
1265
|
+
ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'camera' } } })
|
|
1266
|
+
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1267
|
+
if (ctrlCam) ctrlCam.textContent = '📷✓'
|
|
1268
|
+
} catch {
|
|
1269
|
+
// Camera denied
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function toggleScreen() {
|
|
1274
|
+
if (screenStream) {
|
|
1275
|
+
screenStream.getTracks().forEach(t => t.stop())
|
|
1276
|
+
_removeTile('local-screen')
|
|
1277
|
+
screenStream = null
|
|
1278
|
+
screenSharing.set(false)
|
|
1279
|
+
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1280
|
+
if (ctrlScreen) ctrlScreen.textContent = '🖥'
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
if (!navigator.mediaDevices?.getDisplayMedia) return
|
|
1284
|
+
try {
|
|
1285
|
+
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
|
1286
|
+
screenSharing.set(true)
|
|
1287
|
+
_renderTile('local-screen', screenStream, true, `${userHandle ?? 'You'} (screen)`)
|
|
1288
|
+
ws.send({ t: 'rtc.stream_publish', body: { call_id: callIdSig(), stream: { kind: 'screen' } } })
|
|
1289
|
+
screenStream.getVideoTracks()[0].addEventListener('ended', () => toggleScreen())
|
|
1290
|
+
for (const peerId of rtcManager.peerIds()) rtcManager.negotiate(peerId)
|
|
1291
|
+
if (ctrlScreen) ctrlScreen.textContent = '🖥✓'
|
|
1292
|
+
} catch { /* user cancelled */ }
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ── Audio element for remote peers ─────────────────────────────────────────
|
|
1296
|
+
|
|
1297
|
+
function _ensureRemoteAudio(stream, peerId) {
|
|
1298
|
+
if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return
|
|
1299
|
+
const audio = document.createElement('audio')
|
|
1300
|
+
audio.autoplay = true
|
|
1301
|
+
audio.dataset.peerId = peerId
|
|
1302
|
+
audio.srcObject = stream
|
|
1303
|
+
document.body.appendChild(audio)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// ── Tile grid ──────────────────────────────────────────────────────────────
|
|
1307
|
+
|
|
1308
|
+
function _captureFrame(tile, label) {
|
|
1309
|
+
const video = tile.querySelector('video')
|
|
1310
|
+
if (!video || !video.videoWidth) return
|
|
1311
|
+
const canvas = document.createElement('canvas')
|
|
1312
|
+
canvas.width = video.videoWidth
|
|
1313
|
+
canvas.height = video.videoHeight
|
|
1314
|
+
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
1315
|
+
const a = document.createElement('a')
|
|
1316
|
+
a.href = canvas.toDataURL('image/png')
|
|
1317
|
+
a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png`
|
|
1318
|
+
a.click()
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function _startCapture(tile, label, delay) {
|
|
1322
|
+
const countdown = tile.querySelector('.tile-countdown')
|
|
1323
|
+
const captureBtn = tile.querySelector('.tile-capture')
|
|
1324
|
+
if (tile._captureTimer) {
|
|
1325
|
+
clearInterval(tile._captureTimer)
|
|
1326
|
+
tile._captureTimer = null
|
|
1327
|
+
countdown.hidden = true
|
|
1328
|
+
captureBtn.textContent = '📸'
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
if (delay === 0) { _captureFrame(tile, label); return }
|
|
1332
|
+
let remaining = delay
|
|
1333
|
+
countdown.textContent = remaining
|
|
1334
|
+
countdown.hidden = false
|
|
1335
|
+
captureBtn.textContent = '✕'
|
|
1336
|
+
tile._captureTimer = setInterval(() => {
|
|
1337
|
+
remaining--
|
|
1338
|
+
if (remaining <= 0) {
|
|
1339
|
+
clearInterval(tile._captureTimer)
|
|
1340
|
+
tile._captureTimer = null
|
|
1341
|
+
countdown.hidden = true
|
|
1342
|
+
captureBtn.textContent = '📸'
|
|
1343
|
+
_captureFrame(tile, label)
|
|
1344
|
+
} else {
|
|
1345
|
+
countdown.textContent = remaining
|
|
1346
|
+
}
|
|
1347
|
+
}, 1000)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function _renderTile(tileId, stream, muted, label) {
|
|
1351
|
+
if (!tileGridEl) return
|
|
1352
|
+
let tile = tileGridEl.querySelector(`[data-peer="${tileId}"]`)
|
|
1353
|
+
if (!tile) {
|
|
1354
|
+
tile = document.createElement('div')
|
|
1355
|
+
tile.className = 'stream-tile'
|
|
1356
|
+
tile.dataset.peer = tileId
|
|
1357
|
+
tile.innerHTML = `<video autoplay playsinline controls ${muted ? 'muted' : ''}></video><span class="tile-label">${escHtml(label)}</span><div class="tile-capture-wrap"><button class="tile-pin" title="Move to top">⬆</button><button class="tile-capture" title="Capture photo">📸</button><div class="tile-capture-menu" hidden><button class="tile-capture-opt" data-delay="0">0s</button><button class="tile-capture-opt" data-delay="1">1s</button><button class="tile-capture-opt" data-delay="3">3s</button><button class="tile-capture-opt" data-delay="5">5s</button></div></div><div class="tile-countdown" hidden></div>`
|
|
1358
|
+
tile.querySelector('video').addEventListener('click', e => e.stopPropagation())
|
|
1359
|
+
tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); _pinTile(tileId) })
|
|
1360
|
+
const menu = tile.querySelector('.tile-capture-menu')
|
|
1361
|
+
tile.querySelector('.tile-capture').addEventListener('click', e => {
|
|
1362
|
+
e.stopPropagation()
|
|
1363
|
+
if (tile._captureTimer) { _startCapture(tile, label, 0); return }
|
|
1364
|
+
menu.hidden = !menu.hidden
|
|
1365
|
+
})
|
|
1366
|
+
menu.querySelectorAll('.tile-capture-opt').forEach(btn => {
|
|
1367
|
+
btn.addEventListener('click', e => {
|
|
1368
|
+
e.stopPropagation()
|
|
1369
|
+
menu.hidden = true
|
|
1370
|
+
_startCapture(tile, label, parseInt(btn.dataset.delay))
|
|
1371
|
+
})
|
|
1372
|
+
})
|
|
1373
|
+
tileGridEl.appendChild(tile)
|
|
1374
|
+
_updateTileLayout()
|
|
1375
|
+
}
|
|
1376
|
+
if (stream) tile.querySelector('video').srcObject = stream
|
|
1377
|
+
return tile
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function _removeTile(tileId) {
|
|
1381
|
+
tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove()
|
|
1382
|
+
_updateTileLayout()
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function _updateTileLayout() {
|
|
1386
|
+
if (!tileGridEl) return
|
|
1387
|
+
const count = tileGridEl.querySelectorAll('.stream-tile').length
|
|
1388
|
+
tileGridEl.classList.toggle('avatars-only', count >= 5)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function _pinTile(tileId) {
|
|
1392
|
+
if (pinnedPeerId === tileId) {
|
|
1393
|
+
tileGridEl?.classList.remove('pinned')
|
|
1394
|
+
tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
|
|
1395
|
+
pinnedPeerId = null
|
|
1396
|
+
} else {
|
|
1397
|
+
tileGridEl?.classList.add('pinned')
|
|
1398
|
+
tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
|
|
1399
|
+
tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile')
|
|
1400
|
+
pinnedPeerId = tileId
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// ── Device switching ───────────────────────────────────────────────────────
|
|
1405
|
+
|
|
1406
|
+
async function switchCamera(deviceId) {
|
|
1407
|
+
const newStream = await navigator.mediaDevices.getUserMedia({
|
|
1408
|
+
video: { deviceId: { exact: deviceId } },
|
|
1409
|
+
})
|
|
1410
|
+
const newTrack = newStream.getVideoTracks()[0]
|
|
1411
|
+
await rtcManager.replaceTrack('camera', newTrack)
|
|
1412
|
+
|
|
1413
|
+
videoStream?.getTracks().forEach(t => t.stop())
|
|
1414
|
+
videoStream = newStream
|
|
1415
|
+
activeCameraId = deviceId
|
|
1416
|
+
saveDevices({ cameraId: deviceId })
|
|
1417
|
+
|
|
1418
|
+
const localTile = tileGridEl?.querySelector('[data-peer="local-cam"]')
|
|
1419
|
+
if (localTile) localTile.querySelector('video').srcObject = newStream
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function switchMic(deviceId) {
|
|
1423
|
+
const newStream = await navigator.mediaDevices.getUserMedia({
|
|
1424
|
+
audio: { deviceId: { exact: deviceId } },
|
|
1425
|
+
})
|
|
1426
|
+
const newTrack = newStream.getAudioTracks()[0]
|
|
1427
|
+
newTrack.enabled = !micMuted()
|
|
1428
|
+
await rtcManager.replaceTrack('audio', newTrack)
|
|
1429
|
+
|
|
1430
|
+
audioStream?.getTracks().forEach(t => t.stop())
|
|
1431
|
+
audioStream = newStream
|
|
1432
|
+
activeMicId = deviceId
|
|
1433
|
+
saveDevices({ micId: deviceId })
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// ── Device change detection ────────────────────────────────────────────────
|
|
1437
|
+
|
|
1438
|
+
function _onDeviceChange() {
|
|
1439
|
+
refreshDevices().then(({ cameras, mics }) => {
|
|
1440
|
+
const cameraGone = activeCameraId && !cameras.find(d => d.deviceId === activeCameraId)
|
|
1441
|
+
const micGone = activeMicId && !mics.find(d => d.deviceId === activeMicId)
|
|
1442
|
+
if (cameraGone || micGone) _showDeviceWarning(cameraGone ? 'camera' : 'mic')
|
|
1443
|
+
if (devicePickerEl?.classList.contains('open')) _populatePicker()
|
|
1444
|
+
})
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function _attachDeviceChangeListener() {
|
|
1448
|
+
navigator.mediaDevices.addEventListener('devicechange', _onDeviceChange)
|
|
1449
|
+
}
|
|
1450
|
+
function _detachDeviceChangeListener() {
|
|
1451
|
+
navigator.mediaDevices.removeEventListener('devicechange', _onDeviceChange)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// ── Device picker ──────────────────────────────────────────────────────────
|
|
1455
|
+
|
|
1456
|
+
let devicePickerEl = null
|
|
1457
|
+
|
|
1458
|
+
function _buildPicker() {
|
|
1459
|
+
devicePickerEl = document.createElement('div')
|
|
1460
|
+
devicePickerEl.className = 'device-picker'
|
|
1461
|
+
devicePickerEl.innerHTML = `
|
|
1462
|
+
<div class="device-picker-row">
|
|
1463
|
+
<label>Camera</label>
|
|
1464
|
+
<select id="dp-camera"></select>
|
|
1465
|
+
<video id="dp-preview" autoplay playsinline muted></video>
|
|
1466
|
+
</div>
|
|
1467
|
+
<div class="device-picker-row">
|
|
1468
|
+
<label>Microphone</label>
|
|
1469
|
+
<select id="dp-mic"></select>
|
|
1470
|
+
<canvas id="dp-level" width="80" height="12"></canvas>
|
|
1471
|
+
</div>
|
|
1472
|
+
<div class="device-picker-footer">
|
|
1473
|
+
<button id="dp-cancel" class="btn-ghost" type="button">Cancel</button>
|
|
1474
|
+
<button id="dp-apply" class="btn-primary" type="button">Switch</button>
|
|
1475
|
+
</div>
|
|
1476
|
+
`
|
|
1477
|
+
callControlsEl?.after(devicePickerEl)
|
|
1478
|
+
|
|
1479
|
+
devicePickerEl.querySelector('#dp-cancel').addEventListener('click', _closePicker)
|
|
1480
|
+
devicePickerEl.querySelector('#dp-apply').addEventListener('click', _applyPicker)
|
|
1481
|
+
|
|
1482
|
+
const cameraSelect = devicePickerEl.querySelector('#dp-camera')
|
|
1483
|
+
const previewVideo = devicePickerEl.querySelector('#dp-preview')
|
|
1484
|
+
|
|
1485
|
+
cameraSelect.addEventListener('change', async () => {
|
|
1486
|
+
devicePickerEl._previewStream?.getTracks().forEach(t => t.stop())
|
|
1487
|
+
devicePickerEl._previewStream = null
|
|
1488
|
+
if (!cameraSelect.value) return
|
|
1489
|
+
try {
|
|
1490
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
1491
|
+
video: { deviceId: { exact: cameraSelect.value } },
|
|
1492
|
+
})
|
|
1493
|
+
previewVideo.srcObject = stream
|
|
1494
|
+
devicePickerEl._previewStream = stream
|
|
1495
|
+
} catch { /* camera unavailable */ }
|
|
1496
|
+
})
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function _populatePicker() {
|
|
1500
|
+
const { cameras, mics } = availableDevices
|
|
1501
|
+
const cameraSelect = devicePickerEl?.querySelector('#dp-camera')
|
|
1502
|
+
const micSelect = devicePickerEl?.querySelector('#dp-mic')
|
|
1503
|
+
if (cameraSelect) {
|
|
1504
|
+
cameraSelect.innerHTML = cameras
|
|
1505
|
+
.map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeCameraId ? ' selected' : ''}>${escHtml(d.label || 'Camera')}</option>`)
|
|
1506
|
+
.join('')
|
|
1507
|
+
}
|
|
1508
|
+
if (micSelect) {
|
|
1509
|
+
micSelect.innerHTML = mics
|
|
1510
|
+
.map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === activeMicId ? ' selected' : ''}>${escHtml(d.label || 'Microphone')}</option>`)
|
|
1511
|
+
.join('')
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async function _openPicker() {
|
|
1516
|
+
if (!devicePickerEl) _buildPicker()
|
|
1517
|
+
await refreshDevices()
|
|
1518
|
+
_populatePicker()
|
|
1519
|
+
devicePickerEl.classList.add('open')
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function _closePicker() {
|
|
1523
|
+
devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop())
|
|
1524
|
+
if (devicePickerEl) devicePickerEl._previewStream = null
|
|
1525
|
+
devicePickerEl?.classList.remove('open')
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function _applyPicker() {
|
|
1529
|
+
const cameraId = devicePickerEl?.querySelector('#dp-camera')?.value
|
|
1530
|
+
const micId = devicePickerEl?.querySelector('#dp-mic')?.value
|
|
1531
|
+
try {
|
|
1532
|
+
if (cameraId && cameraId !== activeCameraId && videoStream) await switchCamera(cameraId)
|
|
1533
|
+
if (micId && micId !== activeMicId) await switchMic(micId)
|
|
1534
|
+
ctrlDevices?.classList.remove('device-warning')
|
|
1535
|
+
} catch { /* device unavailable — leave current stream in place */ }
|
|
1536
|
+
_closePicker()
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
ctrlDevices?.addEventListener('click', () => {
|
|
1540
|
+
devicePickerEl?.classList.contains('open') ? _closePicker() : _openPicker()
|
|
1541
|
+
})
|
|
1542
|
+
|
|
1543
|
+
// ── Device warning toast ───────────────────────────────────────────────────
|
|
1544
|
+
|
|
1545
|
+
function _showDeviceWarning(kind) {
|
|
1546
|
+
const label = kind === 'camera' ? 'Camera' : 'Microphone'
|
|
1547
|
+
const toast = document.createElement('div')
|
|
1548
|
+
toast.className = 'device-warning-toast'
|
|
1549
|
+
toast.textContent = `${label} disconnected — click ⚙ to switch`
|
|
1550
|
+
document.body.appendChild(toast)
|
|
1551
|
+
setTimeout(() => toast.remove(), 6000)
|
|
1552
|
+
ctrlDevices?.classList.add('device-warning')
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ── Controls visibility ────────────────────────────────────────────────────
|
|
1556
|
+
|
|
1557
|
+
function _showCallControls() {
|
|
1558
|
+
callStatusEl && (callStatusEl.hidden = true)
|
|
1559
|
+
callControlsEl?.classList.add('active')
|
|
1560
|
+
if (btnStartCall) btnStartCall.hidden = true
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function _hideCallControls() {
|
|
1564
|
+
callControlsEl?.classList.remove('active')
|
|
1565
|
+
if (btnStartCall) btnStartCall.hidden = false
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ── Tile panel show / hide ─────────────────────────────────────────────────
|
|
1569
|
+
|
|
1570
|
+
const LAYOUT_KEY = 'devchitchat_tile_layout'
|
|
1571
|
+
|
|
1572
|
+
function _showTilePanel() {
|
|
1573
|
+
document.querySelector('.main-content')?.classList.add('has-call')
|
|
1574
|
+
tilePanelEl?.classList.add('active')
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function _hideTilePanel() {
|
|
1578
|
+
document.querySelector('.main-content')?.classList.remove('has-call')
|
|
1579
|
+
tilePanelEl?.classList.remove('active')
|
|
1580
|
+
tilePanelEl?.classList.remove('collapsed')
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// Restore collapse state from localStorage
|
|
1584
|
+
try {
|
|
1585
|
+
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
1586
|
+
if (saved.collapsed) tilePanelEl?.classList.add('collapsed')
|
|
1587
|
+
if (saved.overlayRight && saved.overlayTop && tilePanelEl) {
|
|
1588
|
+
tilePanelEl.style.right = saved.overlayRight
|
|
1589
|
+
tilePanelEl.style.top = saved.overlayTop
|
|
1590
|
+
}
|
|
1591
|
+
} catch { /* ignore */ }
|
|
1592
|
+
|
|
1593
|
+
// Collapse toggle
|
|
1594
|
+
document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
|
|
1595
|
+
const collapsed = tilePanelEl?.classList.toggle('collapsed')
|
|
1596
|
+
try {
|
|
1597
|
+
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
1598
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
|
|
1599
|
+
} catch { /* ignore */ }
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
// Overlay drag (mobile only)
|
|
1603
|
+
;(function _attachOverlayDrag(panel) {
|
|
1604
|
+
if (!panel) return
|
|
1605
|
+
if (window.matchMedia('(min-width: 1025px)').matches) return
|
|
1606
|
+
|
|
1607
|
+
const header = panel.querySelector('.tile-panel-header')
|
|
1608
|
+
if (!header) return
|
|
1609
|
+
|
|
1610
|
+
let startX, startY, startRight, startTop
|
|
1611
|
+
|
|
1612
|
+
function onMove(e) {
|
|
1613
|
+
e.preventDefault() // stop page scroll while dragging the tile panel
|
|
1614
|
+
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
|
1615
|
+
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
|
1616
|
+
const dx = startX - clientX
|
|
1617
|
+
const dy = clientY - startY
|
|
1618
|
+
const newRight = Math.max(0, Math.min(startRight + dx, window.innerWidth - 60))
|
|
1619
|
+
const newTop = Math.max(0, Math.min(startTop + dy, window.innerHeight - 60))
|
|
1620
|
+
panel.style.right = newRight + 'px'
|
|
1621
|
+
panel.style.top = newTop + 'px'
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function onEnd() {
|
|
1625
|
+
document.removeEventListener('mousemove', onMove)
|
|
1626
|
+
document.removeEventListener('mouseup', onEnd)
|
|
1627
|
+
document.removeEventListener('touchmove', onMove)
|
|
1628
|
+
document.removeEventListener('touchend', onEnd)
|
|
1629
|
+
try {
|
|
1630
|
+
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
|
|
1631
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify({
|
|
1632
|
+
...saved,
|
|
1633
|
+
overlayRight: panel.style.right,
|
|
1634
|
+
overlayTop: panel.style.top,
|
|
1635
|
+
}))
|
|
1636
|
+
} catch { /* ignore */ }
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
header.addEventListener('mousedown', e => {
|
|
1640
|
+
startX = e.clientX; startY = e.clientY
|
|
1641
|
+
startRight = parseInt(panel.style.right) || 0
|
|
1642
|
+
startTop = parseInt(panel.style.top) || 0
|
|
1643
|
+
document.addEventListener('mousemove', onMove)
|
|
1644
|
+
document.addEventListener('mouseup', onEnd)
|
|
1645
|
+
})
|
|
1646
|
+
|
|
1647
|
+
header.addEventListener('touchstart', e => {
|
|
1648
|
+
e.preventDefault() // prevent scroll from starting on the drag handle
|
|
1649
|
+
startX = e.touches[0].clientX; startY = e.touches[0].clientY
|
|
1650
|
+
startRight = parseInt(panel.style.right) || 0
|
|
1651
|
+
startTop = parseInt(panel.style.top) || 0
|
|
1652
|
+
document.addEventListener('touchmove', onMove, { passive: false })
|
|
1653
|
+
document.addEventListener('touchend', onEnd)
|
|
1654
|
+
}, { passive: false })
|
|
1655
|
+
})(tilePanelEl)
|
|
1656
|
+
|
|
1657
|
+
// ── Mini-bar (persists while navigating away during a call) ───────────────
|
|
1658
|
+
|
|
1659
|
+
function _showMiniBar() {
|
|
1660
|
+
if (!miniBarEl) return
|
|
1661
|
+
if (miniBarName) miniBarName.textContent = channelName()
|
|
1662
|
+
miniBarEl.classList.add('active')
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function _hideMiniBar() {
|
|
1666
|
+
miniBarEl?.classList.remove('active')
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
ctrlMic?.addEventListener('click', toggleMic)
|
|
1670
|
+
ctrlCam?.addEventListener('click', toggleCamera)
|
|
1671
|
+
ctrlScreen?.addEventListener('click', toggleScreen)
|
|
1672
|
+
miniBarMic?.addEventListener('click', toggleMic)
|
|
1673
|
+
|
|
1674
|
+
miniBarReturn?.addEventListener('click', () => {
|
|
1675
|
+
if (callChannelId) navigateTo(`${window.__BASE_PATH__}/channels/${callChannelId}`, false)
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
miniBarLeave?.addEventListener('click', () => {
|
|
1679
|
+
leaveCall()
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
// Show mini-bar when user navigates to a different channel while in a call
|
|
1683
|
+
document.addEventListener('channelnavigated', (e) => {
|
|
1684
|
+
if (inCall() && e.detail?.channelId !== channelId) {
|
|
1685
|
+
_showMiniBar()
|
|
1686
|
+
}
|
|
1687
|
+
})
|
|
1688
|
+
|
|
1689
|
+
// SPA navigation: router morphed .chat-panel and dispatched this event.
|
|
1690
|
+
// Re-initialise chat state for the new channel without touching RTC.
|
|
1691
|
+
document.addEventListener('chatpanel:navigated', (e) => {
|
|
1692
|
+
const { channelId: newId, name, topic, kind, seedSeq: newSeedSeq, seedFirstSeq: newFirstSeq, seedHasMore: newHasMore } = e.detail
|
|
1693
|
+
if (newId === channelId) return // same channel — nothing to do
|
|
1694
|
+
|
|
1695
|
+
// Leave old channel subscription on the server
|
|
1696
|
+
ws.send({ t: 'channel.leave', body: { channel_id: channelId } })
|
|
1697
|
+
|
|
1698
|
+
// Update local identity
|
|
1699
|
+
channelId = newId
|
|
1700
|
+
channelKind = kind
|
|
1701
|
+
channelName.set(name)
|
|
1702
|
+
channelTopic.set(topic)
|
|
1703
|
+
afterSeq = newSeedSeq
|
|
1704
|
+
|
|
1705
|
+
// Reset pagination state for new channel
|
|
1706
|
+
oldestSeq = newFirstSeq ?? 0
|
|
1707
|
+
loadingMore = false
|
|
1708
|
+
if (sentinelEl) {
|
|
1709
|
+
sentinelEl.hidden = !newHasMore
|
|
1710
|
+
if (newHasMore) loadMoreObserver.observe(sentinelEl)
|
|
1711
|
+
else loadMoreObserver.unobserve(sentinelEl)
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Update browser chrome
|
|
1715
|
+
document.title = `#${name} — devchitchat`
|
|
1716
|
+
const textarea = root.querySelector('#message-input')
|
|
1717
|
+
if (textarea) textarea.placeholder = `Message in ${name}`
|
|
1718
|
+
|
|
1719
|
+
// Hydrate attachments + dm-trigger on the freshly morphed seed articles
|
|
1720
|
+
hydrateSeedMessages()
|
|
1721
|
+
|
|
1722
|
+
closePicker()
|
|
1723
|
+
|
|
1724
|
+
// Join new channel — server responds with channel.joined + rtc.call_state.
|
|
1725
|
+
// channel.joined handler sends msg.list if afterSeq > 0, which will append
|
|
1726
|
+
// any messages that arrived after the seed snapshot.
|
|
1727
|
+
ws.send({ t: 'channel.join', body: { channel_id: channelId } })
|
|
1728
|
+
})
|
|
1729
|
+
|
|
1730
|
+
// ── Teardown ───────────────────────────────────────────────────────────────
|
|
1731
|
+
|
|
1732
|
+
function _teardownCall() {
|
|
1733
|
+
rtcManager.teardown()
|
|
1734
|
+
|
|
1735
|
+
audioStream?.getTracks().forEach(t => t.stop()); audioStream = null
|
|
1736
|
+
videoStream?.getTracks().forEach(t => t.stop()); videoStream = null
|
|
1737
|
+
screenStream?.getTracks().forEach(t => t.stop()); screenStream = null
|
|
1738
|
+
|
|
1739
|
+
document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() })
|
|
1740
|
+
if (tileGridEl) tileGridEl.innerHTML = ''
|
|
1741
|
+
_updateTileLayout()
|
|
1742
|
+
_hideCallControls()
|
|
1743
|
+
_hideTilePanel()
|
|
1744
|
+
_hideMiniBar()
|
|
1745
|
+
_closePicker()
|
|
1746
|
+
_detachDeviceChangeListener()
|
|
1747
|
+
ctrlDevices?.classList.remove('device-warning')
|
|
1748
|
+
|
|
1749
|
+
micMuted.set(false)
|
|
1750
|
+
camOff.set(false)
|
|
1751
|
+
screenSharing.set(false)
|
|
1752
|
+
inCall.set(false)
|
|
1753
|
+
selfPeerId.set(null)
|
|
1754
|
+
callChannelId = null
|
|
1755
|
+
pinnedPeerId = null
|
|
1756
|
+
activeCameraId = null
|
|
1757
|
+
activeMicId = null
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// ── Mobile back button ─────────────────────────────────────────────────────
|
|
1761
|
+
|
|
1762
|
+
root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
|
|
1763
|
+
document.body.classList.add('sidebar-open')
|
|
1764
|
+
patchSettings({ mobile_chat_open: false })
|
|
1765
|
+
})
|
|
1766
|
+
|
|
1767
|
+
// ── Exports (rdbljs bindings) ──────────────────────────────────────────────
|
|
1768
|
+
|
|
1769
|
+
return { draft, channelName, channelTopic, urgentMode, urgentClass, sendMessage, handleComposerKey, toggleUrgentMode }
|
|
1770
|
+
}
|