@devchitchat/chat 4.5.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/index.js +0 -9
- package/package.json +2 -3
- package/pages/_layout.html +0 -7
- package/pages/admin/_layout.html +0 -7
- package/pages/channels/[channelId].phtml +17 -42
- 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 +171 -13
- package/pages/public/client/controllers/ChatController.js +204 -0
- package/pages/public/client/controllers/WebSocketController.js +191 -0
- package/pages/public/client/model/AppModel.js +351 -0
- package/pages/public/client/model/events.js +41 -0
- 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/shared/messages.js +21 -9
- package/pages/public/client/theme.js +6 -4
- package/pages/public/client/views/CallView.js +754 -0
- package/pages/public/client/views/ChatHeaderView.js +67 -0
- package/pages/public/client/views/ComposerView.js +491 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +977 -0
- package/pages/public/client/views/ThreadPanelView.js +260 -0
- package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
- package/pages/public/client/views/shared/MentionPicker.js +139 -0
- package/pages/public/client/views/shared/MessageInteractions.js +353 -0
- package/pages/public/themes/base.css +29 -31
- package/src/ws/ChatServer.js +2 -1
- package/src/ws/handlers/rtcHandlers.js +7 -0
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageInteractions.js — shared click delegation for message containers.
|
|
3
|
+
*
|
|
4
|
+
* Attach to any container that holds <article class="message"> elements:
|
|
5
|
+
* attachMessageInteractions(containerEl, { model, isThread })
|
|
6
|
+
*
|
|
7
|
+
* Both MessageListView and ThreadPanelView call this — zero duplication.
|
|
8
|
+
* Actions are dispatched as document CustomEvents so ChatController handles them.
|
|
9
|
+
*
|
|
10
|
+
* Dispatched events:
|
|
11
|
+
* 'react' { msgId, channelId, emoji }
|
|
12
|
+
* 'unreact' { msgId, channelId, emoji }
|
|
13
|
+
* 'open-thread' { msgId } — only when !isThread
|
|
14
|
+
* 'open-dm' { targetUserId }
|
|
15
|
+
* 'delete-message' { msgId, channelId }
|
|
16
|
+
* 'task-toggle' { msgId, channelId, text }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
openEmojiPicker,
|
|
21
|
+
saveRecentEmoji,
|
|
22
|
+
} from './EmojiPickerSingleton.js'
|
|
23
|
+
import { escHtml } from '../../shared/messages.js'
|
|
24
|
+
import { showActionSheet, dismiss as dismissActionSheet, getItemsContainer } from '../../action-sheet.js'
|
|
25
|
+
import { addLongPress } from '../../long-press.js'
|
|
26
|
+
import { dispatch } from '../../controllers/ChatController.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {HTMLElement} containerEl
|
|
30
|
+
* @param {{ model: AppModel, isThread?: boolean }} opts
|
|
31
|
+
*/
|
|
32
|
+
export function attachMessageInteractions(containerEl, { model, isThread = false }) {
|
|
33
|
+
// ── Quick-react buttons (recent emoji, no picker) ──────────────────────────
|
|
34
|
+
containerEl.addEventListener('click', e => {
|
|
35
|
+
const btn = e.target.closest('.btn-quick-react')
|
|
36
|
+
if (!btn) return
|
|
37
|
+
e.stopPropagation()
|
|
38
|
+
const emoji = btn.dataset.emoji
|
|
39
|
+
const article = btn.closest('article.message')
|
|
40
|
+
const msgId = article?.dataset.msgId
|
|
41
|
+
const channelId = _channelId(model)
|
|
42
|
+
if (!emoji || !msgId) return
|
|
43
|
+
saveRecentEmoji(emoji)
|
|
44
|
+
dispatch('react', { msgId, channelId, emoji })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ── Reaction pills — toggle react/unreact ──────────────────────────────────
|
|
48
|
+
containerEl.addEventListener('click', e => {
|
|
49
|
+
const pill = e.target.closest('.reaction-pill')
|
|
50
|
+
if (!pill) return
|
|
51
|
+
e.stopPropagation()
|
|
52
|
+
const emoji = pill.dataset.emoji
|
|
53
|
+
const msgId = pill.dataset.msgId
|
|
54
|
+
const channelId = _channelId(model)
|
|
55
|
+
if (!emoji || !msgId) return
|
|
56
|
+
if (pill.classList.contains('reacted')) {
|
|
57
|
+
dispatch('unreact', { msgId, channelId, emoji })
|
|
58
|
+
} else {
|
|
59
|
+
dispatch('react', { msgId, channelId, emoji })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ── Emoji picker trigger (.btn-react / .reaction-add) ─────────────────────
|
|
64
|
+
containerEl.addEventListener('click', e => {
|
|
65
|
+
const addBtn = e.target.closest('.reaction-add')
|
|
66
|
+
const reactBtn = e.target.closest('.btn-react')
|
|
67
|
+
const btn = addBtn ?? reactBtn
|
|
68
|
+
if (!btn) return
|
|
69
|
+
e.stopPropagation()
|
|
70
|
+
const msgId = addBtn?.dataset.msgId ?? btn.closest('article.message')?.dataset.msgId
|
|
71
|
+
const channelId = _channelId(model)
|
|
72
|
+
if (!msgId) return
|
|
73
|
+
openEmojiPicker(btn, msgId, emoji => {
|
|
74
|
+
dispatch('react', { msgId, channelId, emoji })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ── Reply button → open thread panel ──────────────────────────────────────
|
|
79
|
+
if (!isThread) {
|
|
80
|
+
containerEl.addEventListener('click', e => {
|
|
81
|
+
const btn = e.target.closest('.btn-reply')
|
|
82
|
+
if (!btn) return
|
|
83
|
+
e.stopPropagation()
|
|
84
|
+
const msgId = btn.closest('article.message')?.dataset.msgId
|
|
85
|
+
if (!msgId) return
|
|
86
|
+
dispatch('open-thread', { msgId })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// "View N replies" link → open thread panel
|
|
90
|
+
containerEl.addEventListener('click', e => {
|
|
91
|
+
const link = e.target.closest('.thread-replies-link')
|
|
92
|
+
if (!link) return
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
e.stopPropagation()
|
|
95
|
+
const msgId = link.dataset.msgId ?? link.closest('article.message')?.dataset.msgId
|
|
96
|
+
if (!msgId) return
|
|
97
|
+
dispatch('open-thread', { msgId })
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── DM trigger (sender handle → open DM) ──────────────────────────────────
|
|
102
|
+
containerEl.addEventListener('click', e => {
|
|
103
|
+
const handle = e.target.closest('.dm-trigger')
|
|
104
|
+
if (!handle) return
|
|
105
|
+
const targetUserId = handle.dataset.userId
|
|
106
|
+
if (!targetUserId || targetUserId === model.userId) return
|
|
107
|
+
dispatch('open-dm', { targetUserId })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ── Task-list checkboxes ───────────────────────────────────────────────────
|
|
111
|
+
containerEl.addEventListener('click', e => {
|
|
112
|
+
const cb = e.target.closest('.task-list-item-checkbox')
|
|
113
|
+
if (!cb) return
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
const article = cb.closest('article.message')
|
|
116
|
+
if (!article) return
|
|
117
|
+
const rawText = article.dataset.rawText
|
|
118
|
+
if (!rawText) return
|
|
119
|
+
const allCbs = Array.from(article.querySelectorAll('.task-list-item-checkbox'))
|
|
120
|
+
const idx = allCbs.indexOf(cb)
|
|
121
|
+
if (idx === -1) return
|
|
122
|
+
let count = 0
|
|
123
|
+
const newText = rawText.replace(/\[([ xX])\]/g, (match, state) => {
|
|
124
|
+
if (count++ !== idx) return match
|
|
125
|
+
return state.trim() === '' ? '[x]' : '[ ]'
|
|
126
|
+
})
|
|
127
|
+
if (newText === rawText) return
|
|
128
|
+
cb.checked = !cb.checked
|
|
129
|
+
article.dataset.rawText = newText
|
|
130
|
+
dispatch('task-toggle', {
|
|
131
|
+
msgId: article.dataset.msgId,
|
|
132
|
+
channelId: _channelId(model),
|
|
133
|
+
text: newText,
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── … (message actions) button → context menu ─────────────────────────────
|
|
138
|
+
containerEl.addEventListener('click', e => {
|
|
139
|
+
const btn = e.target.closest('.btn-msg-actions')
|
|
140
|
+
if (!btn) return
|
|
141
|
+
e.stopPropagation()
|
|
142
|
+
const article = btn.closest('article.message')
|
|
143
|
+
if (article) _showContextMenu(article, btn, model)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ── Mobile long-press → action sheet ─────────────────────────────────────
|
|
147
|
+
addLongPress(containerEl, e => {
|
|
148
|
+
const article = e.target.closest?.('article.message')
|
|
149
|
+
const msgId = article?.dataset.msgId
|
|
150
|
+
const channelId = _channelId(model)
|
|
151
|
+
if (!msgId) return
|
|
152
|
+
|
|
153
|
+
// Open sheet first (which clears the container), then populate it.
|
|
154
|
+
showActionSheet({ label: 'React to this message', items: [] })
|
|
155
|
+
|
|
156
|
+
const wrapper = document.createElement('div')
|
|
157
|
+
wrapper.className = 'action-sheet-emoji-picker-wrap'
|
|
158
|
+
getItemsContainer().appendChild(wrapper)
|
|
159
|
+
|
|
160
|
+
openEmojiPicker(wrapper, msgId, emoji => {
|
|
161
|
+
saveRecentEmoji(emoji)
|
|
162
|
+
dismissActionSheet()
|
|
163
|
+
dispatch('react', { msgId, channelId, emoji })
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// Inline edit (called from context menu or keyboard shortcut)
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
let activeEditCancel = null
|
|
173
|
+
|
|
174
|
+
export function startInlineEdit(article, model) {
|
|
175
|
+
if (article.querySelector('.message-edit-wrap')) return
|
|
176
|
+
const textEl = article.querySelector('.message-text')
|
|
177
|
+
if (!textEl) return
|
|
178
|
+
const rawText = article.dataset.rawText ?? ''
|
|
179
|
+
|
|
180
|
+
const wrap = document.createElement('div')
|
|
181
|
+
wrap.className = 'message-edit-wrap'
|
|
182
|
+
|
|
183
|
+
const tabStrip = document.createElement('div')
|
|
184
|
+
tabStrip.className = 'message-edit-tabs'
|
|
185
|
+
tabStrip.setAttribute('role', 'tablist')
|
|
186
|
+
tabStrip.innerHTML = `
|
|
187
|
+
<button class="message-edit-tab message-edit-tab--active" data-tab="write"
|
|
188
|
+
role="tab" aria-selected="true" type="button">Write</button>
|
|
189
|
+
<button class="message-edit-tab" data-tab="preview"
|
|
190
|
+
role="tab" aria-selected="false" type="button">Preview</button>`
|
|
191
|
+
|
|
192
|
+
const textarea = document.createElement('textarea')
|
|
193
|
+
textarea.className = 'message-edit-input'
|
|
194
|
+
textarea.value = rawText
|
|
195
|
+
|
|
196
|
+
const preview = document.createElement('div')
|
|
197
|
+
preview.className = 'message-edit-preview message-text'
|
|
198
|
+
preview.hidden = true
|
|
199
|
+
preview.setAttribute('aria-live', 'polite')
|
|
200
|
+
|
|
201
|
+
const toolbar = document.createElement('div')
|
|
202
|
+
toolbar.className = 'message-edit-toolbar'
|
|
203
|
+
toolbar.innerHTML = `
|
|
204
|
+
<span class="message-edit-hint">Ctrl+Enter to save · Esc to cancel</span>
|
|
205
|
+
<button class="btn-ghost btn-edit-cancel" type="button">Cancel</button>
|
|
206
|
+
<button class="btn-primary btn-edit-save" type="button">Save</button>`
|
|
207
|
+
|
|
208
|
+
wrap.append(tabStrip, textarea, preview, toolbar)
|
|
209
|
+
textEl.replaceWith(wrap)
|
|
210
|
+
textarea.focus()
|
|
211
|
+
textarea.setSelectionRange(rawText.length, rawText.length)
|
|
212
|
+
|
|
213
|
+
const cancel = () => {
|
|
214
|
+
wrap.replaceWith(textEl)
|
|
215
|
+
activeEditCancel = null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const save = () => {
|
|
219
|
+
const text = textarea.value.trim()
|
|
220
|
+
if (!text) return
|
|
221
|
+
dispatch('edit-message', {
|
|
222
|
+
msgId: article.dataset.msgId,
|
|
223
|
+
channelId: _channelId(model),
|
|
224
|
+
text,
|
|
225
|
+
})
|
|
226
|
+
cancel()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
activeEditCancel = cancel
|
|
230
|
+
|
|
231
|
+
// Tab switching
|
|
232
|
+
tabStrip.addEventListener('click', async e => {
|
|
233
|
+
const btn = e.target.closest('.message-edit-tab')
|
|
234
|
+
if (!btn) return
|
|
235
|
+
const tab = btn.dataset.tab
|
|
236
|
+
tabStrip.querySelectorAll('.message-edit-tab').forEach(b => {
|
|
237
|
+
b.classList.toggle('message-edit-tab--active', b === btn)
|
|
238
|
+
b.setAttribute('aria-selected', String(b === btn))
|
|
239
|
+
})
|
|
240
|
+
textarea.hidden = tab !== 'write'
|
|
241
|
+
preview.hidden = tab !== 'preview'
|
|
242
|
+
if (tab === 'preview') {
|
|
243
|
+
const text = textarea.value.trim()
|
|
244
|
+
if (!text) { preview.innerHTML = '<p style="color:var(--text-muted)">Nothing to preview yet.</p>'; return }
|
|
245
|
+
let html = null
|
|
246
|
+
await new Promise(resolve => {
|
|
247
|
+
dispatch('preview-text', { text, resolve: h => { html = h; resolve() } })
|
|
248
|
+
})
|
|
249
|
+
preview.innerHTML = html ? _sanitize(html) : escHtml(text)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
toolbar.querySelector('.btn-edit-cancel').addEventListener('click', cancel)
|
|
254
|
+
toolbar.querySelector('.btn-edit-save').addEventListener('click', save)
|
|
255
|
+
|
|
256
|
+
textarea.addEventListener('keydown', e => {
|
|
257
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save() }
|
|
258
|
+
if (e.key === 'Escape') { e.preventDefault(); cancel() }
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function cancelActiveEdit() {
|
|
263
|
+
activeEditCancel?.()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
// Private helpers
|
|
268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function _channelId(model) {
|
|
271
|
+
return model.currentChannelId
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _sanitize(html) {
|
|
275
|
+
return String(html ?? '').replaceAll('<script>', '').replaceAll('</script>', '')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function _showContextMenu(article, anchorEl, model) {
|
|
279
|
+
const msgId = article.dataset.msgId
|
|
280
|
+
const channelId = _channelId(model)
|
|
281
|
+
const items = [
|
|
282
|
+
{ label: 'Edit', action: () => startInlineEdit(article, model) },
|
|
283
|
+
{ label: 'Delete', danger: true, action: () => dispatch('delete-message', { msgId, channelId }) },
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
// Touch devices → bottom sheet; pointer devices → floating popover
|
|
287
|
+
if (window.matchMedia('(pointer: coarse)').matches) {
|
|
288
|
+
showActionSheet({ label: 'Message actions', items })
|
|
289
|
+
} else {
|
|
290
|
+
_showPopover(anchorEl, items)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
// Floating popover for desktop (replaces action sheet on pointer devices)
|
|
296
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
let _popoverEl = null
|
|
299
|
+
let _popoverCleanup = null
|
|
300
|
+
|
|
301
|
+
function _showPopover(anchorEl, items) {
|
|
302
|
+
_dismissPopover()
|
|
303
|
+
|
|
304
|
+
const el = document.createElement('div')
|
|
305
|
+
el.className = 'msg-context-menu'
|
|
306
|
+
el.setAttribute('role', 'menu')
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
const btn = document.createElement('button')
|
|
309
|
+
btn.type = 'button'
|
|
310
|
+
btn.className = 'msg-context-menu-item' + (item.danger ? ' msg-context-menu-item--danger' : '')
|
|
311
|
+
btn.setAttribute('role', 'menuitem')
|
|
312
|
+
btn.textContent = item.label
|
|
313
|
+
btn.addEventListener('click', () => {
|
|
314
|
+
_dismissPopover()
|
|
315
|
+
item.action()
|
|
316
|
+
})
|
|
317
|
+
el.appendChild(btn)
|
|
318
|
+
}
|
|
319
|
+
document.body.appendChild(el)
|
|
320
|
+
_popoverEl = el
|
|
321
|
+
|
|
322
|
+
// Position: fixed, so coordinates are viewport-relative (no scroll offset needed)
|
|
323
|
+
const rect = anchorEl.getBoundingClientRect()
|
|
324
|
+
const gap = 4
|
|
325
|
+
let top = rect.bottom + gap
|
|
326
|
+
let left = rect.right - el.offsetWidth
|
|
327
|
+
if (left < 4) left = 4
|
|
328
|
+
|
|
329
|
+
el.style.left = `${left}px`
|
|
330
|
+
el.style.top = `${top}px`
|
|
331
|
+
|
|
332
|
+
// Flip up if the menu overflows the bottom of the viewport
|
|
333
|
+
const elRect = el.getBoundingClientRect()
|
|
334
|
+
if (elRect.bottom > window.innerHeight - 8) {
|
|
335
|
+
el.style.top = `${rect.top - el.offsetHeight - gap}px`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const onKey = e => { if (e.key === 'Escape') _dismissPopover() }
|
|
339
|
+
const onClick = e => { if (!el.contains(e.target)) _dismissPopover() }
|
|
340
|
+
document.addEventListener('keydown', onKey, { capture: true })
|
|
341
|
+
document.addEventListener('click', onClick, { capture: true })
|
|
342
|
+
_popoverCleanup = () => {
|
|
343
|
+
document.removeEventListener('keydown', onKey, { capture: true })
|
|
344
|
+
document.removeEventListener('click', onClick, { capture: true })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function _dismissPopover() {
|
|
349
|
+
_popoverEl?.remove()
|
|
350
|
+
_popoverEl = null
|
|
351
|
+
_popoverCleanup?.()
|
|
352
|
+
_popoverCleanup = null
|
|
353
|
+
}
|
|
@@ -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; }
|
|
@@ -588,6 +592,8 @@ details summary {
|
|
|
588
592
|
.result-handle { font-weight: 600; margin-right: 8px; }
|
|
589
593
|
|
|
590
594
|
.messages {
|
|
595
|
+
flex: 1;
|
|
596
|
+
min-height: 0;
|
|
591
597
|
overflow-y: auto;
|
|
592
598
|
overflow-x: clip;
|
|
593
599
|
touch-action: pan-y;
|
|
@@ -697,34 +703,6 @@ details summary {
|
|
|
697
703
|
.reaction-add:hover { background: var(--fill-tertiary); color: var(--accent); }
|
|
698
704
|
|
|
699
705
|
/* ── Message context menu (right-click) — Apple-style popover ────────────── */
|
|
700
|
-
.msg-context-menu {
|
|
701
|
-
position: absolute;
|
|
702
|
-
background: var(--bg-sidebar);
|
|
703
|
-
border: none;
|
|
704
|
-
border-radius: var(--radius-lg);
|
|
705
|
-
box-shadow: var(--shadow-md);
|
|
706
|
-
z-index: 300;
|
|
707
|
-
min-width: 160px;
|
|
708
|
-
overflow: hidden;
|
|
709
|
-
}
|
|
710
|
-
.msg-context-menu-item {
|
|
711
|
-
display: block;
|
|
712
|
-
width: 100%;
|
|
713
|
-
padding: 0 16px;
|
|
714
|
-
height: 40px;
|
|
715
|
-
background: none;
|
|
716
|
-
border: none;
|
|
717
|
-
border-top: 0.5px solid var(--border);
|
|
718
|
-
cursor: pointer;
|
|
719
|
-
text-align: left;
|
|
720
|
-
color: var(--text-primary);
|
|
721
|
-
font-size: 15px;
|
|
722
|
-
transition: background var(--transition);
|
|
723
|
-
}
|
|
724
|
-
.msg-context-menu-item:first-child { border-top: none; }
|
|
725
|
-
.msg-context-menu-item:hover { background: var(--bg-hover); }
|
|
726
|
-
.msg-context-menu-item--danger { color: var(--color-danger); }
|
|
727
|
-
.msg-context-menu-item--danger:hover { background: color-mix(in srgb, var(--color-danger) 8%, transparent); }
|
|
728
706
|
|
|
729
707
|
/* ── Emoji picker ────────────────────────────────────────────────────────── */
|
|
730
708
|
.emoji-picker {
|
|
@@ -1022,6 +1000,23 @@ details summary {
|
|
|
1022
1000
|
.mention-option-name { font-weight: 600; color: var(--text-primary); }
|
|
1023
1001
|
.mention-option-handle { color: var(--text-secondary); font-size: 13px; }
|
|
1024
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
|
+
|
|
1025
1020
|
/* ── Auth page — Apple-style centered card ───────────────────────────────── */
|
|
1026
1021
|
.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1027
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); }
|
|
@@ -1286,6 +1281,8 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1286
1281
|
background: var(--bg-sidebar);
|
|
1287
1282
|
border-left: 1px solid var(--border);
|
|
1288
1283
|
overflow: hidden;
|
|
1284
|
+
/* position: relative so the .resize-handle--left child anchors to this element */
|
|
1285
|
+
position: relative;
|
|
1289
1286
|
}
|
|
1290
1287
|
.tile-panel.active { display: flex; }
|
|
1291
1288
|
|
|
@@ -1319,7 +1316,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1319
1316
|
height: 100%;
|
|
1320
1317
|
}
|
|
1321
1318
|
.tile-panel {
|
|
1322
|
-
width:
|
|
1319
|
+
width: var(--tile-panel-width);
|
|
1323
1320
|
flex-shrink: 0;
|
|
1324
1321
|
height: 100vh;
|
|
1325
1322
|
overflow-y: auto;
|
|
@@ -1466,7 +1463,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1466
1463
|
position: absolute;
|
|
1467
1464
|
top: 0;
|
|
1468
1465
|
right: 0;
|
|
1469
|
-
width:
|
|
1466
|
+
width: var(--thread-panel-width);
|
|
1470
1467
|
height: 100%;
|
|
1471
1468
|
z-index: 30;
|
|
1472
1469
|
overflow: hidden;
|
|
@@ -1543,6 +1540,7 @@ mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: va
|
|
|
1543
1540
|
}
|
|
1544
1541
|
|
|
1545
1542
|
.thread-composer {
|
|
1543
|
+
position: relative;
|
|
1546
1544
|
display: flex;
|
|
1547
1545
|
flex-direction: column;
|
|
1548
1546
|
gap: 8px;
|
|
@@ -2312,7 +2310,7 @@ article.message:hover .message-hover-actions { opacity: 1; pointer-events: auto;
|
|
|
2312
2310
|
|
|
2313
2311
|
/* Context menu */
|
|
2314
2312
|
.msg-context-menu {
|
|
2315
|
-
position:
|
|
2313
|
+
position: fixed;
|
|
2316
2314
|
z-index: 300;
|
|
2317
2315
|
background: var(--bg-sidebar);
|
|
2318
2316
|
border: 1px solid var(--border);
|
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 || {}
|