@devchitchat/chat 5.0.0 → 5.0.1
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/package.json +1 -1
- package/pages/channels/[channelId].phtml +0 -2
- package/pages/design/_layout.html +349 -0
- package/pages/design/_layout.js +13 -0
- package/pages/design/components/index.js +3 -0
- package/pages/design/components/index.phtml +380 -0
- package/pages/design/index.js +3 -0
- package/pages/design/index.phtml +78 -0
- package/pages/design/principles/index.js +3 -0
- package/pages/design/principles/index.phtml +147 -0
- package/pages/design/tokens/index.js +3 -0
- package/pages/design/tokens/index.phtml +236 -0
- package/pages/public/client/app.js +38 -3
- package/pages/public/client/resizable.js +74 -0
- package/pages/public/client/rtc-peer-manager.js +5 -2
- package/pages/public/client/settings-sync.js +45 -7
- package/pages/public/client/theme.js +6 -4
- package/pages/public/client/views/CallView.js +24 -31
- package/pages/public/client/views/ChatHeaderView.js +67 -0
- package/pages/public/client/views/ComposerView.js +5 -2
- package/pages/public/client/views/MessageListView.js +2 -2
- package/pages/public/client/views/SidebarView.js +122 -47
- package/pages/public/client/views/shared/EmojiPickerSingleton.js +3 -3
- package/pages/public/themes/base.css +25 -2
- package/src/ws/ChatServer.js +2 -1
- package/src/ws/handlers/rtcHandlers.js +7 -0
|
@@ -14,17 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import { escHtml } from '../shared/messages.js'
|
|
16
16
|
import { RtcPeerManager } from '../rtc-peer-manager.js'
|
|
17
|
-
import { patchSettings } from '../settings-sync.js'
|
|
17
|
+
import { patchSettings, getPref, setPref } from '../settings-sync.js'
|
|
18
18
|
import { navigateTo } from '../router.js'
|
|
19
19
|
import * as Ev from '../model/events.js'
|
|
20
20
|
|
|
21
|
-
const DEVICES_KEY = 'devchitchat_devices'
|
|
22
|
-
const LAYOUT_KEY = 'devchitchat_tile_layout'
|
|
23
|
-
|
|
24
21
|
export class CallView {
|
|
25
22
|
#model
|
|
26
23
|
#ws
|
|
27
|
-
#root // .chat-panel
|
|
28
24
|
|
|
29
25
|
// DOM refs
|
|
30
26
|
#tilePanelEl
|
|
@@ -34,7 +30,6 @@ export class CallView {
|
|
|
34
30
|
#callStatusAvatars
|
|
35
31
|
#callControlsEl
|
|
36
32
|
#peerCountEl
|
|
37
|
-
#btnStartCall
|
|
38
33
|
#btnJoinCall
|
|
39
34
|
#btnLeaveCall
|
|
40
35
|
#ctrlMic
|
|
@@ -77,10 +72,9 @@ export class CallView {
|
|
|
77
72
|
* @param {WsClient} ws
|
|
78
73
|
* @param {HTMLElement} rootEl — .chat-panel
|
|
79
74
|
*/
|
|
80
|
-
constructor(model, ws
|
|
75
|
+
constructor(model, ws) {
|
|
81
76
|
this.#model = model
|
|
82
77
|
this.#ws = ws
|
|
83
|
-
this.#root = rootEl
|
|
84
78
|
|
|
85
79
|
this.#grabDomRefs()
|
|
86
80
|
this.#buildRtcManager()
|
|
@@ -103,7 +97,6 @@ export class CallView {
|
|
|
103
97
|
this.#callStatusAvatars = q('call-status-avatars')
|
|
104
98
|
this.#callControlsEl = q('call-controls-bar')
|
|
105
99
|
this.#peerCountEl = q('call-peer-count')
|
|
106
|
-
this.#btnStartCall = q('btn-start-call')
|
|
107
100
|
this.#btnJoinCall = q('btn-join-call')
|
|
108
101
|
this.#btnLeaveCall = q('btn-leave-call')
|
|
109
102
|
this.#ctrlMic = q('ctrl-mic')
|
|
@@ -142,14 +135,12 @@ export class CallView {
|
|
|
142
135
|
}
|
|
143
136
|
|
|
144
137
|
#restoreLayoutState() {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
} catch { /* ignore */ }
|
|
138
|
+
const saved = getPref('tile_layout', {})
|
|
139
|
+
if (saved.collapsed) this.#tilePanelEl?.classList.add('collapsed')
|
|
140
|
+
if (saved.overlayRight && saved.overlayTop && this.#tilePanelEl) {
|
|
141
|
+
this.#tilePanelEl.style.right = saved.overlayRight
|
|
142
|
+
this.#tilePanelEl.style.top = saved.overlayTop
|
|
143
|
+
}
|
|
153
144
|
this.#attachOverlayDrag(this.#tilePanelEl)
|
|
154
145
|
}
|
|
155
146
|
|
|
@@ -158,12 +149,7 @@ export class CallView {
|
|
|
158
149
|
// ─────────────────────────────────────────────────────────────────────────
|
|
159
150
|
|
|
160
151
|
#bindControls() {
|
|
161
|
-
|
|
162
|
-
this.#root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
|
|
163
|
-
document.body.classList.add('sidebar-open')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
this.#btnStartCall?.addEventListener('click', () => {
|
|
152
|
+
document.addEventListener('call:start-requested', () => {
|
|
167
153
|
const channelId = this.#model.currentChannelId
|
|
168
154
|
this.#ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
|
|
169
155
|
})
|
|
@@ -188,8 +174,8 @@ export class CallView {
|
|
|
188
174
|
document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
|
|
189
175
|
const collapsed = this.#tilePanelEl?.classList.toggle('collapsed')
|
|
190
176
|
try {
|
|
191
|
-
const saved =
|
|
192
|
-
|
|
177
|
+
const saved = getPref('tile_layout', {})
|
|
178
|
+
setPref('tile_layout', { ...saved, collapsed: !!collapsed })
|
|
193
179
|
} catch { /* ignore */ }
|
|
194
180
|
})
|
|
195
181
|
}
|
|
@@ -268,6 +254,11 @@ export class CallView {
|
|
|
268
254
|
await this.#rtcManager.handleIceCandidate(from_peer_id, candidate)
|
|
269
255
|
})
|
|
270
256
|
|
|
257
|
+
ws.on('rtc.stream_removed_event', ({ peer_id, kind }) => {
|
|
258
|
+
const slotName = kind === 'screen' ? 'screen' : 'cam'
|
|
259
|
+
this.#removeTile(`${peer_id}-${slotName}`)
|
|
260
|
+
})
|
|
261
|
+
|
|
271
262
|
ws.on('rtc.call_end', ({ call_id }) => {
|
|
272
263
|
if (call_id === this.#callId) this.#teardownCall()
|
|
273
264
|
})
|
|
@@ -323,12 +314,12 @@ export class CallView {
|
|
|
323
314
|
#showCallControls() {
|
|
324
315
|
if (this.#callStatusEl) this.#callStatusEl.hidden = true
|
|
325
316
|
this.#callControlsEl?.classList.add('active')
|
|
326
|
-
|
|
317
|
+
document.dispatchEvent(new CustomEvent('call:state-changed', { detail: { inCall: true } }))
|
|
327
318
|
}
|
|
328
319
|
|
|
329
320
|
#hideCallControls() {
|
|
330
321
|
this.#callControlsEl?.classList.remove('active')
|
|
331
|
-
|
|
322
|
+
document.dispatchEvent(new CustomEvent('call:state-changed', { detail: { inCall: false } }))
|
|
332
323
|
}
|
|
333
324
|
|
|
334
325
|
#showTilePanel() {
|
|
@@ -384,6 +375,7 @@ export class CallView {
|
|
|
384
375
|
this.#removeTile('local-cam')
|
|
385
376
|
this.#videoStream = null
|
|
386
377
|
this.#camOff = true
|
|
378
|
+
this.#ws.send({ t: 'rtc.stream_removed', body: { call_id: this.#callId, kind: 'camera' } })
|
|
387
379
|
for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
|
|
388
380
|
if (this.#ctrlCam) this.#ctrlCam.textContent = '📷'
|
|
389
381
|
return
|
|
@@ -410,6 +402,7 @@ export class CallView {
|
|
|
410
402
|
this.#removeTile('local-screen')
|
|
411
403
|
this.#screenStream = null
|
|
412
404
|
this.#screenSharing = false
|
|
405
|
+
this.#ws.send({ t: 'rtc.stream_removed', body: { call_id: this.#callId, kind: 'screen' } })
|
|
413
406
|
for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
|
|
414
407
|
if (this.#ctrlScreen) this.#ctrlScreen.textContent = '🖥'
|
|
415
408
|
return
|
|
@@ -547,11 +540,11 @@ export class CallView {
|
|
|
547
540
|
// ─────────────────────────────────────────────────────────────────────────
|
|
548
541
|
|
|
549
542
|
#loadSavedDevices() {
|
|
550
|
-
|
|
543
|
+
return getPref('devices', {})
|
|
551
544
|
}
|
|
552
545
|
|
|
553
546
|
#saveDevices(patch) {
|
|
554
|
-
|
|
547
|
+
setPref('devices', { ...this.#loadSavedDevices(), ...patch })
|
|
555
548
|
}
|
|
556
549
|
|
|
557
550
|
async #refreshDevices() {
|
|
@@ -740,8 +733,8 @@ export class CallView {
|
|
|
740
733
|
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd)
|
|
741
734
|
document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd)
|
|
742
735
|
try {
|
|
743
|
-
const saved =
|
|
744
|
-
|
|
736
|
+
const saved = getPref('tile_layout', {})
|
|
737
|
+
setPref('tile_layout', { ...saved, overlayRight: panel.style.right, overlayTop: panel.style.top })
|
|
745
738
|
} catch { /* ignore */ }
|
|
746
739
|
}
|
|
747
740
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatHeaderView.js — owns the <header class="chat-header"> element.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Update channel title and topic on CHANNEL_SELECTED
|
|
6
|
+
* - Dispatch 'call:start-requested' when the Start Call button is clicked
|
|
7
|
+
* - Show/hide the Start Call button in response to 'call:state-changed'
|
|
8
|
+
* - Handle the mobile back button (show sidebar)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as Ev from '../model/events.js'
|
|
12
|
+
|
|
13
|
+
export class ChatHeaderView {
|
|
14
|
+
#model
|
|
15
|
+
#headerEl
|
|
16
|
+
#titleEl
|
|
17
|
+
#topicEl
|
|
18
|
+
#startCallBtn
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {AppModel} model
|
|
22
|
+
* @param {HTMLElement} headerEl — <header class="chat-header">
|
|
23
|
+
*/
|
|
24
|
+
constructor(model, headerEl) {
|
|
25
|
+
this.#model = model
|
|
26
|
+
this.#headerEl = headerEl
|
|
27
|
+
this.#titleEl = headerEl.querySelector('.chat-title')
|
|
28
|
+
this.#topicEl = headerEl.querySelector('.chat-topic')
|
|
29
|
+
this.#startCallBtn = headerEl.querySelector('#btn-start-call')
|
|
30
|
+
|
|
31
|
+
this.#bindModelEvents()
|
|
32
|
+
this.#bindInteractions()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Model events
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
#bindModelEvents() {
|
|
40
|
+
this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
|
|
41
|
+
const { name = '', topic = '' } = e.detail.meta ?? {}
|
|
42
|
+
if (this.#titleEl) this.#titleEl.textContent = name
|
|
43
|
+
if (this.#topicEl) this.#topicEl.textContent = topic
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// User interactions
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
#bindInteractions() {
|
|
52
|
+
// Mobile: back to sidebar
|
|
53
|
+
this.#headerEl.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
|
|
54
|
+
document.body.classList.add('sidebar-open')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Start Call button → let CallView handle the WebRTC side
|
|
58
|
+
this.#startCallBtn?.addEventListener('click', () => {
|
|
59
|
+
document.dispatchEvent(new CustomEvent('call:start-requested'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// CallView signals when a call is entered or left
|
|
63
|
+
document.addEventListener('call:state-changed', e => {
|
|
64
|
+
if (this.#startCallBtn) this.#startCallBtn.hidden = e.detail?.inCall ?? false
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -82,11 +82,14 @@ export class ComposerView {
|
|
|
82
82
|
.filter(m => m.handle)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
this.#model.addEventListener(Ev.CHANNEL_SELECTED,
|
|
85
|
+
this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
|
|
86
86
|
// Clear pending state on navigation
|
|
87
87
|
this.#pendingAttachments = []
|
|
88
88
|
this.#renderChips()
|
|
89
|
-
|
|
89
|
+
if (this.#textareaEl) {
|
|
90
|
+
this.#textareaEl.value = ''
|
|
91
|
+
this.#textareaEl.placeholder = `Message in ${e.detail?.meta?.name ?? ''}`
|
|
92
|
+
}
|
|
90
93
|
})
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -180,7 +180,7 @@ export class MessageListView {
|
|
|
180
180
|
// Event handlers
|
|
181
181
|
// ─────────────────────────────────────────────────────────────────────────
|
|
182
182
|
|
|
183
|
-
#onChannelSelected({ channelId
|
|
183
|
+
#onChannelSelected({ channelId }) {
|
|
184
184
|
this.#channelId = channelId
|
|
185
185
|
cancelActiveEdit()
|
|
186
186
|
|
|
@@ -281,7 +281,7 @@ export class MessageListView {
|
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
#onLoadingMoreChanged(
|
|
284
|
+
#onLoadingMoreChanged(_e) {
|
|
285
285
|
// The sentinel visibility is managed in #onMessagesPrepended.
|
|
286
286
|
// Nothing to do here unless we want a loading spinner.
|
|
287
287
|
}
|
|
@@ -31,6 +31,7 @@ export class SidebarView {
|
|
|
31
31
|
#ws
|
|
32
32
|
#root // <aside>
|
|
33
33
|
#dmListEl // #dm-list
|
|
34
|
+
#canManage = false // false for Guests
|
|
34
35
|
#mentionedChannels = new Set() // channelId → mentioned
|
|
35
36
|
#urgentChannels = new Set() // channelId → urgent
|
|
36
37
|
#dmUnread = new Set() // channelId → unread DM
|
|
@@ -46,6 +47,22 @@ export class SidebarView {
|
|
|
46
47
|
this.#root = rootEl
|
|
47
48
|
this.#dmListEl = rootEl.querySelector('#dm-list')
|
|
48
49
|
|
|
50
|
+
const roles = document.querySelector('.chat-panel')?.dataset.userRoles ?? ''
|
|
51
|
+
this.#canManage = !roles.toLowerCase().includes('guest')
|
|
52
|
+
|
|
53
|
+
// Stamp data-channel-id / data-hub-id onto SSR-rendered <li> elements
|
|
54
|
+
// so drag-and-drop and context-menu handlers can read them before #renderHubs runs.
|
|
55
|
+
for (const li of rootEl.querySelectorAll('li.channel-item')) {
|
|
56
|
+
if (!li.dataset.channelId) {
|
|
57
|
+
const link = li.querySelector('[data-channel-id]')
|
|
58
|
+
if (link?.dataset.channelId) li.dataset.channelId = link.dataset.channelId
|
|
59
|
+
}
|
|
60
|
+
if (!li.dataset.hubId) {
|
|
61
|
+
const details = li.closest('details[data-hub-id]')
|
|
62
|
+
if (details?.dataset.hubId) li.dataset.hubId = details.dataset.hubId
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
// Seed model from DOM on first load
|
|
50
67
|
const hubs = _populateHubsFromDom(rootEl)
|
|
51
68
|
const dms = _populateDmsFromDom(rootEl)
|
|
@@ -193,20 +210,18 @@ export class SidebarView {
|
|
|
193
210
|
data-channel-topic="${escHtml(ch.topic ?? '')}"
|
|
194
211
|
data-channel-visibility="${escHtml(ch.visibility ?? 'public')}"
|
|
195
212
|
data-hub-id="${escHtml(hub.hub_id)}">
|
|
196
|
-
|
|
213
|
+
${escHtml(ch.name)}
|
|
197
214
|
</a>
|
|
198
|
-
<button class="btn-channel-gear btn-icon" type="button"
|
|
199
|
-
title="Channel settings" aria-label="Channel settings">⚙</button>
|
|
200
215
|
</li>`
|
|
201
216
|
}).join('')
|
|
217
|
+
const addBtn = this.#canManage
|
|
218
|
+
? `<button class="btn-hub-add btn-icon" type="button" title="Add channel" aria-label="Add channel">+</button>`
|
|
219
|
+
: ''
|
|
202
220
|
return `
|
|
203
221
|
<details class="hub-header" data-hub-id="${escHtml(hub.hub_id)}" ${open}>
|
|
204
222
|
<summary class="hub-name" data-hub-id="${escHtml(hub.hub_id)}">
|
|
205
223
|
<span>${escHtml(hub.name)}</span>
|
|
206
|
-
|
|
207
|
-
title="Hub settings" aria-label="Hub settings">⚙</button>
|
|
208
|
-
<button class="btn-hub-add btn-icon" type="button"
|
|
209
|
-
title="Add channel" aria-label="Add channel">+</button>
|
|
224
|
+
${addBtn}
|
|
210
225
|
</summary>
|
|
211
226
|
<ul class="channel-list">${channels}</ul>
|
|
212
227
|
</details>`
|
|
@@ -326,56 +341,65 @@ export class SidebarView {
|
|
|
326
341
|
// ─────────────────────────────────────────────────────────────────────────
|
|
327
342
|
|
|
328
343
|
#bindAdminHandlers() {
|
|
329
|
-
const root
|
|
330
|
-
const ws
|
|
344
|
+
const root = this.#root
|
|
345
|
+
const ws = this.#ws
|
|
331
346
|
const model = this.#model
|
|
332
347
|
|
|
333
|
-
// New hub button
|
|
348
|
+
// New hub button (always visible — creating a hub is not a management action)
|
|
334
349
|
root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
|
|
335
|
-
isTouch()
|
|
336
|
-
? _openCreateHubSheet(ws)
|
|
337
|
-
: _openCreateHubModal(ws)
|
|
350
|
+
isTouch() ? _openCreateHubSheet(ws) : _openCreateHubModal(ws)
|
|
338
351
|
})
|
|
339
352
|
|
|
340
|
-
|
|
353
|
+
if (!this.#canManage) return
|
|
354
|
+
|
|
355
|
+
// Add-channel button (delegated — rendered only for non-guests)
|
|
341
356
|
root.addEventListener('click', e => {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
357
|
+
const btn = e.target.closest('.btn-hub-add')
|
|
358
|
+
if (!btn) return
|
|
359
|
+
e.stopPropagation()
|
|
360
|
+
const hubId = btn.closest('.hub-name')?.dataset.hubId
|
|
361
|
+
if (!hubId) return
|
|
362
|
+
const hub = model.hubs.find(h => h.hub_id === hubId)
|
|
363
|
+
isTouch()
|
|
364
|
+
? (() => { showActionSheet({ label: `New channel in ${hub?.name ?? ''}`, items: [] }); _buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet }) })()
|
|
365
|
+
: _openCreateChannelModal(hubId, hub?.name ?? '', ws)
|
|
366
|
+
})
|
|
351
367
|
|
|
352
|
-
|
|
353
|
-
|
|
368
|
+
// Desktop: right-click context menus
|
|
369
|
+
if (!isTouch()) {
|
|
370
|
+
root.addEventListener('contextmenu', e => {
|
|
354
371
|
const summary = e.target.closest('.hub-name')
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const channelId = link?.dataset.channelId
|
|
367
|
-
if (!channelId) return
|
|
368
|
-
let ch = null
|
|
369
|
-
for (const hub of model.hubs) {
|
|
370
|
-
ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
|
|
371
|
-
if (ch) break
|
|
372
|
+
if (summary) {
|
|
373
|
+
e.preventDefault()
|
|
374
|
+
const hubId = summary.dataset.hubId
|
|
375
|
+
if (!hubId) return
|
|
376
|
+
const hub = model.hubs.find(h => h.hub_id === hubId)
|
|
377
|
+
_showSidebarPopover(e, [
|
|
378
|
+
{ label: 'Edit hub', action: () => _openHubModal(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws) },
|
|
379
|
+
{ label: 'New channel', action: () => _openCreateChannelModal(hubId, hub?.name ?? '', ws) },
|
|
380
|
+
{ label: 'Delete hub', danger: true, action: () => { ws.send({ t: 'hub.delete', body: { hub_id: hubId } }) } },
|
|
381
|
+
])
|
|
382
|
+
return
|
|
372
383
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
384
|
+
const li = e.target.closest('.channel-item')
|
|
385
|
+
if (li) {
|
|
386
|
+
e.preventDefault()
|
|
387
|
+
const channelId = li.dataset.channelId
|
|
388
|
+
if (!channelId) return
|
|
389
|
+
let ch = null
|
|
390
|
+
for (const hub of model.hubs) {
|
|
391
|
+
ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
|
|
392
|
+
if (ch) break
|
|
393
|
+
}
|
|
394
|
+
_showSidebarPopover(e, [
|
|
395
|
+
{ label: 'Edit channel', action: () => _openChannelModal(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws) },
|
|
396
|
+
{ label: 'Delete channel', danger: true, action: () => { ws.send({ t: 'channel.delete', body: { channel_id: channelId } }) } },
|
|
397
|
+
])
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
}
|
|
377
401
|
|
|
378
|
-
//
|
|
402
|
+
// Mobile: long-press → action sheet
|
|
379
403
|
if (isTouch()) {
|
|
380
404
|
addLongPress(root, e => {
|
|
381
405
|
const target = e.target ?? e.touches?.[0]?.target
|
|
@@ -850,6 +874,57 @@ function _buildCreateChannelForm(container, { hubId, ws, dismiss }) {
|
|
|
850
874
|
requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
|
|
851
875
|
}
|
|
852
876
|
|
|
877
|
+
// ─── Desktop context-menu popover ────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
let _popoverEl = null
|
|
880
|
+
let _popoverCleanup = null
|
|
881
|
+
|
|
882
|
+
function _dismissSidebarPopover() {
|
|
883
|
+
_popoverEl?.remove()
|
|
884
|
+
_popoverEl = null
|
|
885
|
+
_popoverCleanup?.()
|
|
886
|
+
_popoverCleanup = null
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function _showSidebarPopover(mouseEvent, items) {
|
|
890
|
+
_dismissSidebarPopover()
|
|
891
|
+
|
|
892
|
+
const el = document.createElement('div')
|
|
893
|
+
el.className = 'msg-context-menu'
|
|
894
|
+
el.setAttribute('role', 'menu')
|
|
895
|
+
for (const item of items) {
|
|
896
|
+
const btn = document.createElement('button')
|
|
897
|
+
btn.type = 'button'
|
|
898
|
+
btn.className = 'msg-context-menu-item' + (item.danger ? ' msg-context-menu-item--danger' : '')
|
|
899
|
+
btn.setAttribute('role', 'menuitem')
|
|
900
|
+
btn.textContent = item.label
|
|
901
|
+
btn.addEventListener('click', () => { _dismissSidebarPopover(); item.action() })
|
|
902
|
+
el.appendChild(btn)
|
|
903
|
+
}
|
|
904
|
+
document.body.appendChild(el)
|
|
905
|
+
_popoverEl = el
|
|
906
|
+
|
|
907
|
+
// Position at cursor, flip if needed
|
|
908
|
+
const gap = 4
|
|
909
|
+
let top = mouseEvent.clientY + gap
|
|
910
|
+
let left = mouseEvent.clientX + gap
|
|
911
|
+
el.style.left = `${left}px`
|
|
912
|
+
el.style.top = `${top}px`
|
|
913
|
+
|
|
914
|
+
const rect = el.getBoundingClientRect()
|
|
915
|
+
if (rect.right > window.innerWidth - 8) el.style.left = `${mouseEvent.clientX - rect.width - gap}px`
|
|
916
|
+
if (rect.bottom > window.innerHeight - 8) el.style.top = `${mouseEvent.clientY - rect.height - gap}px`
|
|
917
|
+
|
|
918
|
+
const onKey = e => { if (e.key === 'Escape') _dismissSidebarPopover() }
|
|
919
|
+
const onClick = e => { if (!el.contains(e.target)) _dismissSidebarPopover() }
|
|
920
|
+
document.addEventListener('keydown', onKey, { capture: true })
|
|
921
|
+
document.addEventListener('click', onClick, { capture: true })
|
|
922
|
+
_popoverCleanup = () => {
|
|
923
|
+
document.removeEventListener('keydown', onKey, { capture: true })
|
|
924
|
+
document.removeEventListener('click', onClick, { capture: true })
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
853
928
|
// ─── Modal / sheet openers ────────────────────────────────────────────────────
|
|
854
929
|
|
|
855
930
|
function _openCreateHubModal(ws) {
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import { CATEGORIES, EMOJI_NAMES } from '../../emoji-data.js'
|
|
16
16
|
import { escHtml } from '../../shared/messages.js'
|
|
17
|
+
import { getPref, setPref } from '../../settings-sync.js'
|
|
17
18
|
|
|
18
|
-
const RECENT_KEY = 'devchitchat_recent_emoji'
|
|
19
19
|
const RECENT_MAX = 24
|
|
20
20
|
const QUICK_PICKS_COUNT = 4
|
|
21
21
|
|
|
@@ -29,14 +29,14 @@ let currentOnPick = null
|
|
|
29
29
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
30
30
|
|
|
31
31
|
export function loadRecentEmoji() {
|
|
32
|
-
|
|
32
|
+
return getPref('recent_emoji', [])
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function saveRecentEmoji(emoji) {
|
|
36
36
|
let recents = loadRecentEmoji().filter(e => e !== emoji)
|
|
37
37
|
recents.unshift(emoji)
|
|
38
38
|
if (recents.length > RECENT_MAX) recents = recents.slice(0, RECENT_MAX)
|
|
39
|
-
|
|
39
|
+
setPref('recent_emoji', recents)
|
|
40
40
|
refreshAllQuickPicks()
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -19,6 +19,8 @@ input, textarea, select, button { font: inherit; }
|
|
|
19
19
|
--radius-pill: 999px;
|
|
20
20
|
|
|
21
21
|
--sidebar-width: 260px;
|
|
22
|
+
--thread-panel-width: 380px;
|
|
23
|
+
--tile-panel-width: 360px;
|
|
22
24
|
--composer-height: 80px;
|
|
23
25
|
--call-bar-height: 0px; /* expands when a call is active */
|
|
24
26
|
|
|
@@ -62,6 +64,8 @@ details summary {
|
|
|
62
64
|
top: 0;
|
|
63
65
|
z-index: 1;
|
|
64
66
|
padding-top: env(safe-area-inset-top);
|
|
67
|
+
/* needed for the resize handle to anchor correctly */
|
|
68
|
+
overflow-x: visible;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
.hub-group { padding: 0 0 4px; }
|
|
@@ -996,6 +1000,23 @@ details summary {
|
|
|
996
1000
|
.mention-option-name { font-weight: 600; color: var(--text-primary); }
|
|
997
1001
|
.mention-option-handle { color: var(--text-secondary); font-size: 13px; }
|
|
998
1002
|
|
|
1003
|
+
/* ── Resize handles ──────────────────────────────────────────────────────── */
|
|
1004
|
+
.resize-handle {
|
|
1005
|
+
position: absolute;
|
|
1006
|
+
top: 0;
|
|
1007
|
+
bottom: 0;
|
|
1008
|
+
width: 5px;
|
|
1009
|
+
cursor: col-resize;
|
|
1010
|
+
z-index: 10;
|
|
1011
|
+
}
|
|
1012
|
+
.resize-handle--right { right: 0; }
|
|
1013
|
+
.resize-handle--left { left: 0; }
|
|
1014
|
+
.resize-handle:hover,
|
|
1015
|
+
.resize-handle:active {
|
|
1016
|
+
background: var(--accent);
|
|
1017
|
+
opacity: 0.4;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
999
1020
|
/* ── Auth page — Apple-style centered card ───────────────────────────────── */
|
|
1000
1021
|
.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1001
1022
|
.auth-shell { width: 100%; max-width: 420px; padding: 40px 32px; background: var(--bg-sidebar); border-radius: var(--radius-xl); border: none; box-shadow: var(--shadow-lg); }
|
|
@@ -1260,6 +1281,8 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1260
1281
|
background: var(--bg-sidebar);
|
|
1261
1282
|
border-left: 1px solid var(--border);
|
|
1262
1283
|
overflow: hidden;
|
|
1284
|
+
/* position: relative so the .resize-handle--left child anchors to this element */
|
|
1285
|
+
position: relative;
|
|
1263
1286
|
}
|
|
1264
1287
|
.tile-panel.active { display: flex; }
|
|
1265
1288
|
|
|
@@ -1293,7 +1316,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1293
1316
|
height: 100%;
|
|
1294
1317
|
}
|
|
1295
1318
|
.tile-panel {
|
|
1296
|
-
width:
|
|
1319
|
+
width: var(--tile-panel-width);
|
|
1297
1320
|
flex-shrink: 0;
|
|
1298
1321
|
height: 100vh;
|
|
1299
1322
|
overflow-y: auto;
|
|
@@ -1440,7 +1463,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1440
1463
|
position: absolute;
|
|
1441
1464
|
top: 0;
|
|
1442
1465
|
right: 0;
|
|
1443
|
-
width:
|
|
1466
|
+
width: var(--thread-panel-width);
|
|
1444
1467
|
height: 100%;
|
|
1445
1468
|
z-index: 30;
|
|
1446
1469
|
overflow: hidden;
|
package/src/ws/ChatServer.js
CHANGED
|
@@ -23,7 +23,7 @@ import { handleHello, handleInviteRedeem, handleSignIn, handleSignOut, handleAdm
|
|
|
23
23
|
import { handleHubList, handleHubCreate, handleHubUpdate, handleHubDelete, handleHubAddMember, handleHubRemoveMember, handleHubListMembers, handleHubReorder } from './handlers/hubHandlers.js'
|
|
24
24
|
import { handleChannelList, handleChannelCreate, handleChannelUpdate, handleChannelDelete, handleChannelJoin, handleChannelLeave, handleChannelReorder, handleChannelAddMember, handleChannelRemoveMember, handleChannelListMembers, handleUserList, handleBotList, handleDmOpen, handleDmList } from './handlers/channelHandlers.js'
|
|
25
25
|
import { handleMsgSend, handleMsgList, handleMsgEdit, handleMsgDelete, handleThreadList, handleSearchQuery, handlePresenceSubscribe } from './handlers/messageHandlers.js'
|
|
26
|
-
import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
|
|
26
|
+
import { handleRtcCallCreate, handleRtcJoin, handleRtcOffer, handleRtcAnswer, handleRtcIce, handleRtcStreamPublish, handleRtcStreamRemoved, handleRtcLeave, handleRtcEndCall } from './handlers/rtcHandlers.js'
|
|
27
27
|
import { handlePushSubscribe, handlePushUnsubscribe } from './handlers/pushHandlers.js'
|
|
28
28
|
import { handleReactionAdd, handleReactionRemove } from './handlers/reactionHandlers.js'
|
|
29
29
|
import { WebPushService } from '../services/WebPushService.js'
|
|
@@ -234,6 +234,7 @@ export class ChatServer {
|
|
|
234
234
|
case 'rtc.answer': return handleRtcAnswer(ws, msg, ctx)
|
|
235
235
|
case 'rtc.ice': return handleRtcIce(ws, msg, ctx)
|
|
236
236
|
case 'rtc.stream_publish': return handleRtcStreamPublish(ws, msg, ctx)
|
|
237
|
+
case 'rtc.stream_removed': return handleRtcStreamRemoved(ws, msg, ctx)
|
|
237
238
|
case 'rtc.leave': return handleRtcLeave(ws, msg, ctx)
|
|
238
239
|
case 'rtc.end_call': return handleRtcEndCall(ws, msg, ctx)
|
|
239
240
|
// Web Push
|
|
@@ -91,6 +91,13 @@ export function handleRtcStreamPublish(ws, msg, ctx) {
|
|
|
91
91
|
publishCall(call_id, { t: 'rtc.stream_event', ok: true, body: { call_id, peer_id: ws.data.peerId, stream } })
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export function handleRtcStreamRemoved(ws, msg, ctx) {
|
|
95
|
+
const { publishCall } = ctx
|
|
96
|
+
const { call_id, kind } = msg.body || {}
|
|
97
|
+
if (!ws.data.peerId || ws.data.callId !== call_id) return
|
|
98
|
+
publishCall(call_id, { t: 'rtc.stream_removed_event', ok: true, body: { call_id, peer_id: ws.data.peerId, kind } })
|
|
99
|
+
}
|
|
100
|
+
|
|
94
101
|
export function handleRtcLeave(ws, msg, ctx) {
|
|
95
102
|
const { signalingService, sendWs, publishCall, publishChannel, publishCallState, peerConnections } = ctx
|
|
96
103
|
const { call_id } = msg.body || {}
|