@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,977 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SidebarView.js — hub/channel/DM sidebar.
|
|
3
|
+
*
|
|
4
|
+
* Replaces islands/sidebar.js. No rdbljs; pure EventTarget + CustomEvent.
|
|
5
|
+
*
|
|
6
|
+
* Model events handled:
|
|
7
|
+
* hubs-changed → re-render hub/channel list (preserve <details> open state)
|
|
8
|
+
* dms-changed → re-render DM list
|
|
9
|
+
* channel-selected → mark active channel, clear mention dots
|
|
10
|
+
* presence-updated → update online dot for a specific user
|
|
11
|
+
*
|
|
12
|
+
* User actions dispatched as document CustomEvents → ChatController:
|
|
13
|
+
* 'select-channel' { channelId, meta }
|
|
14
|
+
*
|
|
15
|
+
* Admin actions (hub/channel CRUD, drag-reorder, file-drop) call ws.send()
|
|
16
|
+
* directly because they are one-off form interactions that don't need to go
|
|
17
|
+
* through the model — the server response events keep the model in sync.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as Ev from '../model/events.js'
|
|
21
|
+
import { escHtml } from '../shared/messages.js'
|
|
22
|
+
import { showActionSheet, dismiss as dismissSheet, getItemsContainer } from '../action-sheet.js'
|
|
23
|
+
import { showModal, dismiss as dismissModal } from '../modal.js'
|
|
24
|
+
import { addLongPress } from '../long-press.js'
|
|
25
|
+
|
|
26
|
+
const BASE = () => window.__BASE_PATH__ ?? ''
|
|
27
|
+
const isTouch = () => window.matchMedia('(pointer: coarse)').matches
|
|
28
|
+
|
|
29
|
+
export class SidebarView {
|
|
30
|
+
#model
|
|
31
|
+
#ws
|
|
32
|
+
#root // <aside>
|
|
33
|
+
#dmListEl // #dm-list
|
|
34
|
+
#canManage = false // false for Guests
|
|
35
|
+
#mentionedChannels = new Set() // channelId → mentioned
|
|
36
|
+
#urgentChannels = new Set() // channelId → urgent
|
|
37
|
+
#dmUnread = new Set() // channelId → unread DM
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {AppModel} model
|
|
41
|
+
* @param {WsClient} ws — for admin CRUD operations
|
|
42
|
+
* @param {HTMLElement} rootEl — <aside>
|
|
43
|
+
*/
|
|
44
|
+
constructor(model, ws, rootEl) {
|
|
45
|
+
this.#model = model
|
|
46
|
+
this.#ws = ws
|
|
47
|
+
this.#root = rootEl
|
|
48
|
+
this.#dmListEl = rootEl.querySelector('#dm-list')
|
|
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
|
+
|
|
66
|
+
// Seed model from DOM on first load
|
|
67
|
+
const hubs = _populateHubsFromDom(rootEl)
|
|
68
|
+
const dms = _populateDmsFromDom(rootEl)
|
|
69
|
+
if (hubs.length > 0) model.setHubs(hubs)
|
|
70
|
+
if (dms.length > 0) model.setDms(dms)
|
|
71
|
+
|
|
72
|
+
this.#bindModelEvents()
|
|
73
|
+
this.#bindInteractions()
|
|
74
|
+
this.#bindAdminHandlers()
|
|
75
|
+
this.#bindPushSubscription()
|
|
76
|
+
|
|
77
|
+
// Fetch DM list on open — session cookie already authenticates the socket
|
|
78
|
+
ws.on('open', () => ws.send({ t: 'dm.list', body: {} }))
|
|
79
|
+
|
|
80
|
+
ws.on('dm.list_result', ({ dms: list }) => {
|
|
81
|
+
model.setDms(list ?? [])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
ws.on('dm.opened', ({ channel_id, with_user, notify_only }) => {
|
|
85
|
+
const dms = model.dms
|
|
86
|
+
if (!dms.some(d => d.channel_id === channel_id)) {
|
|
87
|
+
model.setDms([{ channel_id, with_user }, ...dms])
|
|
88
|
+
}
|
|
89
|
+
if (notify_only) {
|
|
90
|
+
this.#dmUnread.add(channel_id)
|
|
91
|
+
this.#renderDms()
|
|
92
|
+
} else {
|
|
93
|
+
window.location.href = `${BASE()}/channels/${channel_id}`
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
ws.on('msg.event', ({ channel_id }) => {
|
|
98
|
+
if (channel_id === model.currentChannelId) return
|
|
99
|
+
if (!model.dms.some(d => d.channel_id === channel_id)) return
|
|
100
|
+
this.#dmUnread.add(channel_id)
|
|
101
|
+
this.#renderDms()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
ws.on('notification.mention', ({ channel_id, priority }) => {
|
|
105
|
+
if (channel_id === model.currentChannelId) return
|
|
106
|
+
if (priority === 'now') {
|
|
107
|
+
this.#urgentChannels.add(channel_id)
|
|
108
|
+
} else {
|
|
109
|
+
this.#mentionedChannels.add(channel_id)
|
|
110
|
+
}
|
|
111
|
+
this.#updateMentionDots()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
ws.on('notification.digest', ({ channels }) => {
|
|
115
|
+
for (const c of channels ?? []) {
|
|
116
|
+
if (c.urgent) this.#urgentChannels.add(c.channel_id)
|
|
117
|
+
else if (c.mentions > 0) this.#mentionedChannels.add(c.channel_id)
|
|
118
|
+
}
|
|
119
|
+
this.#updateMentionDots()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
ws.on('hub.member_added', ({ hub_id, user_id }) => {
|
|
123
|
+
if (user_id !== model.userId) return
|
|
124
|
+
ws.once('hub.list_result', ({ hubs: serverHubs }) => {
|
|
125
|
+
const existing = new Set(model.hubs.map(h => h.hub_id))
|
|
126
|
+
const newHubs = (serverHubs ?? []).filter(h => !existing.has(h.hub_id))
|
|
127
|
+
if (newHubs.length > 0) {
|
|
128
|
+
model.setHubs([...model.hubs, ...newHubs.map(h => ({ ...h, channels: [] }))])
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
ws.send({ t: 'hub.list', body: {} })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
ws.on('hub.member_removed', ({ hub_id, user_id }) => {
|
|
135
|
+
if (user_id !== model.userId) return
|
|
136
|
+
const removedHub = model.hubs.find(h => h.hub_id === hub_id)
|
|
137
|
+
const affectsCurrent = (removedHub?.channels ?? []).some(c => c.channel_id === model.currentChannelId)
|
|
138
|
+
model.removeHub(hub_id)
|
|
139
|
+
if (affectsCurrent) _navigateAfterDeletion(model.hubs)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
ws.on('hub.reordered', ({ hubs: updated }) => {
|
|
143
|
+
const channelMap = new Map(model.hubs.map(h => [h.hub_id, h.channels]))
|
|
144
|
+
model.setHubs((updated ?? []).map(h => ({ ...h, channels: channelMap.get(h.hub_id) ?? [] })))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
ws.on('channel.reordered', ({ hub_id, channels }) => {
|
|
148
|
+
const hub = model.hubs.find(h => h.hub_id === hub_id)
|
|
149
|
+
if (!hub) return
|
|
150
|
+
const channelMap = new Map((hub.channels ?? []).map(c => [c.channel_id, c]))
|
|
151
|
+
const reordered = (channels ?? []).map(c => ({
|
|
152
|
+
...channelMap.get(c.channel_id),
|
|
153
|
+
...c,
|
|
154
|
+
url: `${BASE()}/channels/${c.channel_id}`,
|
|
155
|
+
}))
|
|
156
|
+
model.setHubs(model.hubs.map(h =>
|
|
157
|
+
h.hub_id === hub_id ? { ...h, channels: reordered } : h
|
|
158
|
+
))
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// Model event bindings
|
|
164
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
#bindModelEvents() {
|
|
167
|
+
const m = this.#model
|
|
168
|
+
|
|
169
|
+
m.addEventListener(Ev.HUBS_CHANGED, e => this.#renderHubs(e.detail.hubs))
|
|
170
|
+
m.addEventListener(Ev.DMS_CHANGED, () => this.#renderDms())
|
|
171
|
+
m.addEventListener(Ev.CHANNEL_SELECTED, e => this.#onChannelSelected(e.detail))
|
|
172
|
+
m.addEventListener(Ev.PRESENCE_UPDATED, e => this.#onPresenceUpdated(e.detail))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// Rendering
|
|
177
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
#renderHubs(hubs) {
|
|
180
|
+
// Preserve open/closed state of each <details> by hub_id.
|
|
181
|
+
// Initial DOM uses data-key on <details>; re-renders use data-hub-id.
|
|
182
|
+
const openHubIds = new Set(
|
|
183
|
+
[...this.#root.querySelectorAll('details.hub-header[open]')]
|
|
184
|
+
.map(d => d.dataset.hubId ?? d.querySelector('summary[data-hub-id]')?.dataset.hubId ?? d.dataset.key)
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// Replace hub list HTML — hub details live inside .hub-group section
|
|
189
|
+
const hubListEl = this.#root.querySelector('.hub-group') ?? this.#root.querySelector('section')
|
|
190
|
+
if (!hubListEl) return
|
|
191
|
+
|
|
192
|
+
const currentChannelId = this.#model.currentChannelId
|
|
193
|
+
hubListEl.innerHTML = hubs.map(hub => {
|
|
194
|
+
const open = openHubIds.has(hub.hub_id) || openHubIds.size === 0 ? 'open' : ''
|
|
195
|
+
const channels = (hub.channels ?? []).map(ch => {
|
|
196
|
+
const isActive = ch.channel_id === currentChannelId
|
|
197
|
+
const hasMention = this.#mentionedChannels.has(ch.channel_id)
|
|
198
|
+
const hasUrgent = this.#urgentChannels.has(ch.channel_id)
|
|
199
|
+
const mentionAttr = hasUrgent ? ' data-urgent=""' : hasMention ? ' data-mention=""' : ''
|
|
200
|
+
return `
|
|
201
|
+
<li class="channel-item${isActive ? ' active' : ''}"
|
|
202
|
+
data-channel-id="${escHtml(ch.channel_id)}"
|
|
203
|
+
data-hub-id="${escHtml(hub.hub_id)}"
|
|
204
|
+
draggable="true"
|
|
205
|
+
${mentionAttr}>
|
|
206
|
+
<a class="channel-link"
|
|
207
|
+
href="${escHtml(ch.url ?? `${BASE()}/channels/${ch.channel_id}`)}"
|
|
208
|
+
data-channel-id="${escHtml(ch.channel_id)}"
|
|
209
|
+
data-channel-name="${escHtml(ch.name)}"
|
|
210
|
+
data-channel-topic="${escHtml(ch.topic ?? '')}"
|
|
211
|
+
data-channel-visibility="${escHtml(ch.visibility ?? 'public')}"
|
|
212
|
+
data-hub-id="${escHtml(hub.hub_id)}">
|
|
213
|
+
${escHtml(ch.name)}
|
|
214
|
+
</a>
|
|
215
|
+
</li>`
|
|
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
|
+
: ''
|
|
220
|
+
return `
|
|
221
|
+
<details class="hub-header" data-hub-id="${escHtml(hub.hub_id)}" ${open}>
|
|
222
|
+
<summary class="hub-name" data-hub-id="${escHtml(hub.hub_id)}">
|
|
223
|
+
<span>${escHtml(hub.name)}</span>
|
|
224
|
+
${addBtn}
|
|
225
|
+
</summary>
|
|
226
|
+
<ul class="channel-list">${channels}</ul>
|
|
227
|
+
</details>`
|
|
228
|
+
}).join('')
|
|
229
|
+
|
|
230
|
+
this.#attachDragHandlers()
|
|
231
|
+
this.#attachFileDropHandlers()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#renderDms() {
|
|
235
|
+
const dmListEl = this.#dmListEl
|
|
236
|
+
if (!dmListEl) return
|
|
237
|
+
const list = this.#model.dms
|
|
238
|
+
const current = this.#model.currentChannelId
|
|
239
|
+
|
|
240
|
+
if (list.length === 0) {
|
|
241
|
+
dmListEl.innerHTML = '<li class="dm-empty">No messages yet.</li>'
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
dmListEl.innerHTML = list.map(d => {
|
|
245
|
+
const name = escHtml(d.with_user?.display_name ?? d.channel_id)
|
|
246
|
+
const selected = d.channel_id === current ? ' dm-selected' : ''
|
|
247
|
+
const unread = this.#dmUnread.has(d.channel_id) ? ' data-mention=""' : ''
|
|
248
|
+
return `
|
|
249
|
+
<li class="dm-item${selected}" data-channel-id="${escHtml(d.channel_id)}"${unread}>
|
|
250
|
+
<a class="dm-link channel-link"
|
|
251
|
+
href="${BASE()}/channels/${escHtml(d.channel_id)}"
|
|
252
|
+
data-channel-id="${escHtml(d.channel_id)}">
|
|
253
|
+
<span class="dm-name">${name}</span>
|
|
254
|
+
</a>
|
|
255
|
+
</li>`
|
|
256
|
+
}).join('')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#onChannelSelected({ channelId, prev }) {
|
|
260
|
+
// Update active channel in hub list
|
|
261
|
+
this.#root.querySelectorAll('.channel-item').forEach(li => {
|
|
262
|
+
li.classList.toggle('active', li.dataset.channelId === channelId)
|
|
263
|
+
})
|
|
264
|
+
// Update active DM
|
|
265
|
+
this.#root.querySelectorAll('.dm-item').forEach(li => {
|
|
266
|
+
li.classList.toggle('dm-selected', li.dataset.channelId === channelId)
|
|
267
|
+
})
|
|
268
|
+
// Clear mention/urgent dots for newly selected channel
|
|
269
|
+
if (channelId) {
|
|
270
|
+
this.#mentionedChannels.delete(channelId)
|
|
271
|
+
this.#urgentChannels.delete(channelId)
|
|
272
|
+
this.#dmUnread.delete(channelId)
|
|
273
|
+
this.#updateMentionDots()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#onPresenceUpdated({ userId, status, bulk }) {
|
|
278
|
+
const entries = bulk ?? [{ user_id: userId, status }]
|
|
279
|
+
for (const { user_id, status: st } of entries) {
|
|
280
|
+
for (const el of this.#root.querySelectorAll(`[data-user-id="${user_id}"] .presence-dot`)) {
|
|
281
|
+
el.dataset.status = st
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#updateMentionDots() {
|
|
287
|
+
this.#root.querySelectorAll('.channel-item').forEach(li => {
|
|
288
|
+
const channelId = li.dataset.channelId
|
|
289
|
+
if (!channelId) return
|
|
290
|
+
if (this.#urgentChannels.has(channelId)) {
|
|
291
|
+
li.dataset.urgent = ''
|
|
292
|
+
delete li.dataset.mention
|
|
293
|
+
} else if (this.#mentionedChannels.has(channelId)) {
|
|
294
|
+
li.dataset.mention = ''
|
|
295
|
+
delete li.dataset.urgent
|
|
296
|
+
} else {
|
|
297
|
+
delete li.dataset.mention
|
|
298
|
+
delete li.dataset.urgent
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
304
|
+
// Interaction (navigation + DM)
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
#bindInteractions() {
|
|
308
|
+
this.#root.addEventListener('click', e => {
|
|
309
|
+
// Channel or DM link
|
|
310
|
+
const link = e.target.closest('.channel-link')
|
|
311
|
+
if (!link) return
|
|
312
|
+
|
|
313
|
+
const channelId = link.dataset.channelId
|
|
314
|
+
if (!channelId) return
|
|
315
|
+
|
|
316
|
+
// Clear dots
|
|
317
|
+
this.#mentionedChannels.delete(channelId)
|
|
318
|
+
this.#urgentChannels.delete(channelId)
|
|
319
|
+
this.#dmUnread.delete(channelId)
|
|
320
|
+
this.#updateMentionDots()
|
|
321
|
+
|
|
322
|
+
// Mobile: close sidebar
|
|
323
|
+
if (window.matchMedia('(max-width: 700px)').matches) {
|
|
324
|
+
document.body.classList.remove('sidebar-open')
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Handle navigation event fired by router.js (SPA navigation)
|
|
329
|
+
document.addEventListener('chatpanel:navigated', e => {
|
|
330
|
+
const { channelId } = e.detail
|
|
331
|
+
this.#onChannelSelected({ channelId })
|
|
332
|
+
if (this.#dmUnread.has(channelId)) {
|
|
333
|
+
this.#dmUnread.delete(channelId)
|
|
334
|
+
this.#renderDms()
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
340
|
+
// Admin CRUD (delegated, wired once)
|
|
341
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
#bindAdminHandlers() {
|
|
344
|
+
const root = this.#root
|
|
345
|
+
const ws = this.#ws
|
|
346
|
+
const model = this.#model
|
|
347
|
+
|
|
348
|
+
// New hub button (always visible — creating a hub is not a management action)
|
|
349
|
+
root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
|
|
350
|
+
isTouch() ? _openCreateHubSheet(ws) : _openCreateHubModal(ws)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
if (!this.#canManage) return
|
|
354
|
+
|
|
355
|
+
// Add-channel button (delegated — rendered only for non-guests)
|
|
356
|
+
root.addEventListener('click', e => {
|
|
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
|
+
})
|
|
367
|
+
|
|
368
|
+
// Desktop: right-click context menus
|
|
369
|
+
if (!isTouch()) {
|
|
370
|
+
root.addEventListener('contextmenu', e => {
|
|
371
|
+
const summary = e.target.closest('.hub-name')
|
|
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
|
|
383
|
+
}
|
|
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
|
+
}
|
|
401
|
+
|
|
402
|
+
// Mobile: long-press → action sheet
|
|
403
|
+
if (isTouch()) {
|
|
404
|
+
addLongPress(root, e => {
|
|
405
|
+
const target = e.target ?? e.touches?.[0]?.target
|
|
406
|
+
const summary = target?.closest?.('.hub-name')
|
|
407
|
+
if (summary) {
|
|
408
|
+
const hubId = summary.dataset.hubId
|
|
409
|
+
if (!hubId) return
|
|
410
|
+
const hub = model.hubs.find(h => h.hub_id === hubId)
|
|
411
|
+
_openHubSheet(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
const link = target?.closest?.('.channel-link')
|
|
415
|
+
if (link) {
|
|
416
|
+
const channelId = link.dataset.channelId
|
|
417
|
+
if (!channelId) return
|
|
418
|
+
let ch = null
|
|
419
|
+
for (const hub of model.hubs) {
|
|
420
|
+
ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
|
|
421
|
+
if (ch) break
|
|
422
|
+
}
|
|
423
|
+
_openChannelSheet(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
430
|
+
// Drag-and-drop reordering
|
|
431
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
#attachDragHandlers() {
|
|
434
|
+
const root = this.#root
|
|
435
|
+
const ws = this.#ws
|
|
436
|
+
const model = this.#model
|
|
437
|
+
|
|
438
|
+
let dragSrcChannelId = null
|
|
439
|
+
let dragSrcHubId = null
|
|
440
|
+
let dragSrcHubHub = null // hub-level drag
|
|
441
|
+
|
|
442
|
+
const clearIndicators = () => {
|
|
443
|
+
root.querySelectorAll('.drop-before, .drop-after, .dragging').forEach(el => {
|
|
444
|
+
el.classList.remove('drop-before', 'drop-after', 'dragging')
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const before = (e, el) => e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2
|
|
449
|
+
|
|
450
|
+
root.addEventListener('dragstart', e => {
|
|
451
|
+
// Hub drag (from summary)
|
|
452
|
+
const hubSummary = e.target.closest('.hub-header > summary')
|
|
453
|
+
if (hubSummary && !e.target.closest('.channel-item')) {
|
|
454
|
+
const details = hubSummary.closest('.hub-header')
|
|
455
|
+
dragSrcHubHub = details?.dataset.hubId ?? null
|
|
456
|
+
dragSrcChannelId = null
|
|
457
|
+
if (!dragSrcHubHub) return
|
|
458
|
+
details.classList.add('dragging')
|
|
459
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
460
|
+
e.stopPropagation()
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
// Channel drag
|
|
464
|
+
const li = e.target.closest('.channel-item')
|
|
465
|
+
if (!li) return
|
|
466
|
+
dragSrcChannelId = li.dataset.channelId
|
|
467
|
+
dragSrcHubId = li.dataset.hubId
|
|
468
|
+
dragSrcHubHub = null
|
|
469
|
+
if (!dragSrcChannelId) return
|
|
470
|
+
li.classList.add('dragging')
|
|
471
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
root.addEventListener('dragend', () => {
|
|
475
|
+
clearIndicators()
|
|
476
|
+
dragSrcChannelId = null
|
|
477
|
+
dragSrcHubId = null
|
|
478
|
+
dragSrcHubHub = null
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
root.addEventListener('dragover', e => {
|
|
482
|
+
if (dragSrcHubHub) {
|
|
483
|
+
// Hub-level drag
|
|
484
|
+
if (e.target.closest('.channel-item')) return
|
|
485
|
+
const targetDetails = e.target.closest('.hub-header')
|
|
486
|
+
if (!targetDetails) return
|
|
487
|
+
const targetHubId = targetDetails.dataset.hubId
|
|
488
|
+
if (!targetHubId || targetHubId === dragSrcHubHub) return
|
|
489
|
+
e.preventDefault()
|
|
490
|
+
clearIndicators()
|
|
491
|
+
targetDetails.querySelector('summary')?.classList.add(before(e, targetDetails) ? 'drop-before' : 'drop-after')
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
if (dragSrcChannelId) {
|
|
495
|
+
const targetLi = e.target.closest('.channel-item')
|
|
496
|
+
if (!targetLi || targetLi.dataset.channelId === dragSrcChannelId) return
|
|
497
|
+
if (targetLi.dataset.hubId !== dragSrcHubId) return
|
|
498
|
+
e.preventDefault()
|
|
499
|
+
clearIndicators()
|
|
500
|
+
targetLi.classList.add(before(e, targetLi) ? 'drop-before' : 'drop-after')
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
root.addEventListener('dragleave', e => {
|
|
505
|
+
const li = e.target.closest('.channel-item')
|
|
506
|
+
if (li) li.classList.remove('drop-before', 'drop-after')
|
|
507
|
+
const summary = e.target.closest('.hub-header > summary')
|
|
508
|
+
if (summary) summary.classList.remove('drop-before', 'drop-after')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
root.addEventListener('drop', e => {
|
|
512
|
+
clearIndicators()
|
|
513
|
+
|
|
514
|
+
if (dragSrcHubHub) {
|
|
515
|
+
if (e.target.closest('.channel-item')) return
|
|
516
|
+
const targetDetails = e.target.closest('.hub-header')
|
|
517
|
+
const targetHubId = targetDetails?.dataset.hubId
|
|
518
|
+
if (!targetHubId || targetHubId === dragSrcHubHub) return
|
|
519
|
+
e.preventDefault()
|
|
520
|
+
const ids = model.hubs.map(h => h.hub_id)
|
|
521
|
+
const fromIdx = ids.indexOf(dragSrcHubHub)
|
|
522
|
+
const toIdx = ids.indexOf(targetHubId)
|
|
523
|
+
if (fromIdx === -1 || toIdx === -1) return
|
|
524
|
+
const isBefore = before(e, targetDetails)
|
|
525
|
+
ids.splice(fromIdx, 1)
|
|
526
|
+
ids.splice(isBefore ? ids.indexOf(targetHubId) : ids.indexOf(targetHubId) + 1, 0, dragSrcHubHub)
|
|
527
|
+
ws.send({ t: 'hub.reorder', body: { hub_ids: ids } })
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (dragSrcChannelId) {
|
|
532
|
+
const targetLi = e.target.closest('.channel-item')
|
|
533
|
+
const targetChannelId = targetLi?.dataset.channelId
|
|
534
|
+
if (!targetChannelId || targetChannelId === dragSrcChannelId) return
|
|
535
|
+
if (targetLi.dataset.hubId !== dragSrcHubId) return
|
|
536
|
+
e.preventDefault()
|
|
537
|
+
const hub = model.hubs.find(h => h.hub_id === dragSrcHubId)
|
|
538
|
+
if (!hub) return
|
|
539
|
+
const ids = (hub.channels ?? []).map(c => c.channel_id)
|
|
540
|
+
const fromIdx = ids.indexOf(dragSrcChannelId)
|
|
541
|
+
const toIdx = ids.indexOf(targetChannelId)
|
|
542
|
+
if (fromIdx === -1 || toIdx === -1) return
|
|
543
|
+
const isBefore = before(e, targetLi)
|
|
544
|
+
ids.splice(fromIdx, 1)
|
|
545
|
+
ids.splice(isBefore ? ids.indexOf(targetChannelId) : ids.indexOf(targetChannelId) + 1, 0, dragSrcChannelId)
|
|
546
|
+
ws.send({ t: 'channel.reorder', body: { hub_id: dragSrcHubId, channel_ids: ids } })
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
552
|
+
// File-drop on channel links
|
|
553
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
#attachFileDropHandlers() {
|
|
556
|
+
const root = this.#root
|
|
557
|
+
const ws = this.#ws
|
|
558
|
+
let hoverTimer = null
|
|
559
|
+
let hoverTarget = null
|
|
560
|
+
|
|
561
|
+
const clearHover = () => {
|
|
562
|
+
clearTimeout(hoverTimer)
|
|
563
|
+
hoverTimer = null
|
|
564
|
+
if (hoverTarget) { hoverTarget.classList.remove('file-drop-hover'); hoverTarget = null }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const showToast = text => {
|
|
568
|
+
const toast = document.createElement('div')
|
|
569
|
+
toast.className = 'sidebar-toast'
|
|
570
|
+
toast.textContent = text
|
|
571
|
+
root.appendChild(toast)
|
|
572
|
+
setTimeout(() => toast.remove(), 3000)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
root.addEventListener('dragover', e => {
|
|
576
|
+
const link = e.target.closest('.channel-link')
|
|
577
|
+
if (!link) { clearHover(); return }
|
|
578
|
+
if (!e.dataTransfer.types.includes('Files')) return
|
|
579
|
+
e.preventDefault()
|
|
580
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
581
|
+
if (link !== hoverTarget) {
|
|
582
|
+
clearHover()
|
|
583
|
+
hoverTarget = link
|
|
584
|
+
hoverTimer = setTimeout(() => link.classList.add('file-drop-hover'), 600)
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
root.addEventListener('dragleave', e => {
|
|
589
|
+
if (hoverTarget && !hoverTarget.contains(e.relatedTarget)) clearHover()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
root.addEventListener('drop', async e => {
|
|
593
|
+
const link = e.target.closest('.channel-link')
|
|
594
|
+
clearHover()
|
|
595
|
+
if (!link) return
|
|
596
|
+
if (!e.dataTransfer.types.includes('Files')) return
|
|
597
|
+
e.preventDefault()
|
|
598
|
+
e.stopPropagation()
|
|
599
|
+
|
|
600
|
+
const targetChannelId = link.dataset.channelId
|
|
601
|
+
const targetChannelName = link.dataset.channelName ?? targetChannelId
|
|
602
|
+
if (!targetChannelId) return
|
|
603
|
+
|
|
604
|
+
const files = [...e.dataTransfer.files]
|
|
605
|
+
if (files.length === 0) return
|
|
606
|
+
|
|
607
|
+
ws.send({ t: 'channel.join', body: { channel_id: targetChannelId } })
|
|
608
|
+
|
|
609
|
+
const uploaded = []
|
|
610
|
+
for (const file of files) {
|
|
611
|
+
const formData = new FormData()
|
|
612
|
+
formData.append('file', file)
|
|
613
|
+
formData.append('channel_id', targetChannelId)
|
|
614
|
+
try {
|
|
615
|
+
const res = await fetch(`${BASE()}/api/uploads`, { method: 'POST', body: formData })
|
|
616
|
+
if (!res.ok) {
|
|
617
|
+
const body = await res.json().catch(() => ({}))
|
|
618
|
+
showToast(`Upload failed: ${body.error ?? res.statusText}`)
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
const a = await res.json()
|
|
622
|
+
uploaded.push({ upload_id: a.upload_id, url: a.url, filename: a.original_name, mime_type: a.mime_type, size_bytes: a.size_bytes })
|
|
623
|
+
} catch { showToast('Upload failed: network error') }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (uploaded.length === 0) return
|
|
627
|
+
|
|
628
|
+
ws.send({
|
|
629
|
+
t: 'msg.send',
|
|
630
|
+
body: { channel_id: targetChannelId, text: '', client_msg_id: `drop_${Date.now()}`, attachments: uploaded },
|
|
631
|
+
})
|
|
632
|
+
showToast(`Sent to #${targetChannelName}`)
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
637
|
+
// Web push subscription
|
|
638
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
#bindPushSubscription() {
|
|
641
|
+
const root = this.#root
|
|
642
|
+
const ws = this.#ws
|
|
643
|
+
const vapidKey = root.dataset.vapidKey ?? ''
|
|
644
|
+
if (!vapidKey || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return
|
|
645
|
+
|
|
646
|
+
const toUint8 = b64url => {
|
|
647
|
+
const padded = b64url + '==='.slice((b64url.length + 3) % 4)
|
|
648
|
+
return Uint8Array.from(atob(padded.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const subscribe = async swReg => {
|
|
652
|
+
try {
|
|
653
|
+
const sub = await swReg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toUint8(vapidKey) })
|
|
654
|
+
ws.send({ t: 'push.subscribe', body: { subscription: sub.toJSON() } })
|
|
655
|
+
} catch { /* user blocked — ignore */ }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let swReg = null
|
|
659
|
+
let enableBtn = null
|
|
660
|
+
|
|
661
|
+
const showEnableButton = () => {
|
|
662
|
+
if (enableBtn || Notification.permission === 'granted') return
|
|
663
|
+
const footer = root.querySelector('.sidebar-footer') ?? root
|
|
664
|
+
enableBtn = document.createElement('button')
|
|
665
|
+
enableBtn.className = 'btn-enable-notifications'
|
|
666
|
+
enableBtn.textContent = '🔔 Enable notifications'
|
|
667
|
+
footer.appendChild(enableBtn)
|
|
668
|
+
enableBtn.addEventListener('click', async () => {
|
|
669
|
+
const perm = await Notification.requestPermission()
|
|
670
|
+
if (perm === 'granted' && swReg) {
|
|
671
|
+
await subscribe(swReg)
|
|
672
|
+
enableBtn?.remove()
|
|
673
|
+
enableBtn = null
|
|
674
|
+
} else if (perm === 'denied') {
|
|
675
|
+
if (enableBtn) enableBtn.textContent = '🔕 Notifications blocked in browser settings'
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
navigator.serviceWorker
|
|
681
|
+
.register(`${BASE()}/sw.js`, { scope: `${BASE()}/` })
|
|
682
|
+
.then(async reg => {
|
|
683
|
+
swReg = reg
|
|
684
|
+
try { await reg.pushManager.getSubscription() } catch { return }
|
|
685
|
+
if (Notification.permission === 'granted') subscribe(reg)
|
|
686
|
+
else showEnableButton()
|
|
687
|
+
})
|
|
688
|
+
.catch(() => {})
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
693
|
+
// DOM → model seed helpers
|
|
694
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
function _populateHubsFromDom(root) {
|
|
697
|
+
return Array.from(root.querySelectorAll('details.hub-header')).map(el => {
|
|
698
|
+
// Initial SSR uses data-key; re-renders use data-hub-id; summary has data-hub-id
|
|
699
|
+
const hub_id = el.dataset.hubId
|
|
700
|
+
?? el.querySelector('summary[data-hub-id]')?.dataset.hubId
|
|
701
|
+
?? el.dataset.key
|
|
702
|
+
return {
|
|
703
|
+
hub_id,
|
|
704
|
+
name: el.querySelector('.hub-name span')?.textContent.trim() ?? '',
|
|
705
|
+
visibility: el.dataset.visibility ?? 'public',
|
|
706
|
+
description: el.dataset.description ?? null,
|
|
707
|
+
channels: Array.from(el.querySelectorAll('li.channel-item, li[data-key]')).map(li => {
|
|
708
|
+
const link = li.querySelector('a.channel-link, a[data-channel-id], a')
|
|
709
|
+
return {
|
|
710
|
+
channel_id: li.dataset.channelId ?? link?.dataset.channelId ?? li.dataset.key,
|
|
711
|
+
hub_id,
|
|
712
|
+
name: (link?.textContent.trim() ?? '').replace(/^#\s*/, ''),
|
|
713
|
+
url: link?.href ?? '',
|
|
714
|
+
topic: link?.dataset.channelTopic ?? null,
|
|
715
|
+
visibility: link?.dataset.channelVisibility ?? 'public',
|
|
716
|
+
selected: li.dataset.selected === 'true' || li.classList.contains('active'),
|
|
717
|
+
}
|
|
718
|
+
}),
|
|
719
|
+
}
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function _populateDmsFromDom(root) {
|
|
724
|
+
return Array.from(root.querySelectorAll('.dm-item')).map(li => ({
|
|
725
|
+
channel_id: li.dataset.channelId,
|
|
726
|
+
with_user: { display_name: li.querySelector('.dm-name')?.textContent.trim() ?? '' },
|
|
727
|
+
}))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function _navigateAfterDeletion(remainingHubs) {
|
|
731
|
+
const first = remainingHubs.flatMap(h => h.channels ?? []).find(Boolean)
|
|
732
|
+
window.location.href = first ? `${BASE()}/channels/${first.channel_id}` : `${BASE()}/`
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
736
|
+
// Admin form builders (module-private, called by button handlers)
|
|
737
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
function _buildHubForm(container, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss }) {
|
|
740
|
+
const currentVisibility = hubVisibility ?? 'public'
|
|
741
|
+
container.innerHTML = `
|
|
742
|
+
<div class="field">
|
|
743
|
+
<label for="hub-name-input">Hub name</label>
|
|
744
|
+
<input id="hub-name-input" type="text" value="${escHtml(hubName)}" maxlength="80" autocomplete="off">
|
|
745
|
+
</div>
|
|
746
|
+
<div class="field">
|
|
747
|
+
<label for="hub-desc-input">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
748
|
+
<input id="hub-desc-input" type="text" value="${escHtml(hubDescription ?? '')}" maxlength="240" autocomplete="off">
|
|
749
|
+
</div>
|
|
750
|
+
<div class="field">
|
|
751
|
+
<label for="hub-visibility-input">Visibility</label>
|
|
752
|
+
<select id="hub-visibility-input">
|
|
753
|
+
<option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public</option>
|
|
754
|
+
<option value="restricted" ${currentVisibility === 'restricted' ? 'selected' : ''}>Restricted</option>
|
|
755
|
+
</select>
|
|
756
|
+
</div>
|
|
757
|
+
<div class="modal-footer">
|
|
758
|
+
<button class="btn-ghost" id="hub-cancel-btn" type="button">Cancel</button>
|
|
759
|
+
<button class="btn-primary" id="hub-save-btn" type="button">Save</button>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="modal-danger-zone">
|
|
762
|
+
<p>Deleting this hub removes it and all its channels permanently.</p>
|
|
763
|
+
<button class="btn-danger" id="hub-delete-btn" type="button">Delete hub</button>
|
|
764
|
+
</div>`
|
|
765
|
+
|
|
766
|
+
container.querySelector('#hub-cancel-btn').addEventListener('click', dismiss)
|
|
767
|
+
container.querySelector('#hub-save-btn').addEventListener('click', () => {
|
|
768
|
+
const name = container.querySelector('#hub-name-input').value.trim()
|
|
769
|
+
if (!name) return
|
|
770
|
+
ws.send({ t: 'hub.update', body: {
|
|
771
|
+
hub_id: hubId, name,
|
|
772
|
+
description: container.querySelector('#hub-desc-input').value.trim() || null,
|
|
773
|
+
visibility: container.querySelector('#hub-visibility-input').value,
|
|
774
|
+
} })
|
|
775
|
+
dismiss()
|
|
776
|
+
})
|
|
777
|
+
container.querySelector('#hub-delete-btn').addEventListener('click', () => {
|
|
778
|
+
ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
|
|
779
|
+
dismiss()
|
|
780
|
+
})
|
|
781
|
+
requestAnimationFrame(() => container.querySelector('#hub-name-input')?.focus())
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function _buildChannelForm(container, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss }) {
|
|
785
|
+
container.innerHTML = `
|
|
786
|
+
<div class="field">
|
|
787
|
+
<label for="ch-name-input">Channel name</label>
|
|
788
|
+
<input id="ch-name-input" type="text" value="${escHtml(channelName)}" maxlength="80" autocomplete="off">
|
|
789
|
+
</div>
|
|
790
|
+
<div class="field">
|
|
791
|
+
<label for="ch-topic-input">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
792
|
+
<input id="ch-topic-input" type="text" value="${escHtml(channelTopic ?? '')}" maxlength="240" autocomplete="off">
|
|
793
|
+
</div>
|
|
794
|
+
<div class="modal-footer">
|
|
795
|
+
<button class="btn-ghost" id="ch-cancel-btn" type="button">Cancel</button>
|
|
796
|
+
<button class="btn-primary" id="ch-save-btn" type="button">Save</button>
|
|
797
|
+
</div>
|
|
798
|
+
<div class="modal-danger-zone">
|
|
799
|
+
<p>Deleting this channel removes all its messages permanently.</p>
|
|
800
|
+
<button class="btn-danger" id="ch-delete-btn" type="button">Delete channel</button>
|
|
801
|
+
</div>`
|
|
802
|
+
|
|
803
|
+
container.querySelector('#ch-cancel-btn').addEventListener('click', dismiss)
|
|
804
|
+
container.querySelector('#ch-save-btn').addEventListener('click', () => {
|
|
805
|
+
const name = container.querySelector('#ch-name-input').value.trim()
|
|
806
|
+
if (!name) return
|
|
807
|
+
ws.send({ t: 'channel.update', body: {
|
|
808
|
+
channel_id: channelId, name,
|
|
809
|
+
topic: container.querySelector('#ch-topic-input').value.trim() || null,
|
|
810
|
+
visibility: channelVisibility,
|
|
811
|
+
} })
|
|
812
|
+
dismiss()
|
|
813
|
+
})
|
|
814
|
+
container.querySelector('#ch-delete-btn').addEventListener('click', () => {
|
|
815
|
+
ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
|
|
816
|
+
dismiss()
|
|
817
|
+
})
|
|
818
|
+
requestAnimationFrame(() => container.querySelector('#ch-name-input')?.focus())
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function _buildCreateHubForm(container, { ws, dismiss }) {
|
|
822
|
+
container.innerHTML = `
|
|
823
|
+
<div class="field">
|
|
824
|
+
<label for="new-hub-name">Hub name</label>
|
|
825
|
+
<input id="new-hub-name" type="text" placeholder="e.g. Engineering" maxlength="80" autocomplete="off">
|
|
826
|
+
</div>
|
|
827
|
+
<div class="field">
|
|
828
|
+
<label for="new-hub-desc">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
829
|
+
<input id="new-hub-desc" type="text" maxlength="240" autocomplete="off">
|
|
830
|
+
</div>
|
|
831
|
+
<div class="modal-footer">
|
|
832
|
+
<button class="btn-ghost" id="new-hub-cancel" type="button">Cancel</button>
|
|
833
|
+
<button class="btn-primary" id="new-hub-save" type="button">Create</button>
|
|
834
|
+
</div>`
|
|
835
|
+
container.querySelector('#new-hub-cancel').addEventListener('click', dismiss)
|
|
836
|
+
container.querySelector('#new-hub-save').addEventListener('click', () => {
|
|
837
|
+
const name = container.querySelector('#new-hub-name').value.trim()
|
|
838
|
+
if (!name) return
|
|
839
|
+
ws.send({ t: 'hub.create', body: {
|
|
840
|
+
name,
|
|
841
|
+
description: container.querySelector('#new-hub-desc').value.trim() || null,
|
|
842
|
+
visibility: 'public',
|
|
843
|
+
} })
|
|
844
|
+
dismiss()
|
|
845
|
+
})
|
|
846
|
+
requestAnimationFrame(() => container.querySelector('#new-hub-name')?.focus())
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function _buildCreateChannelForm(container, { hubId, ws, dismiss }) {
|
|
850
|
+
container.innerHTML = `
|
|
851
|
+
<div class="field">
|
|
852
|
+
<label for="new-ch-name">Channel name</label>
|
|
853
|
+
<input id="new-ch-name" type="text" placeholder="e.g. general" maxlength="80" autocomplete="off">
|
|
854
|
+
</div>
|
|
855
|
+
<div class="field">
|
|
856
|
+
<label for="new-ch-topic">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
857
|
+
<input id="new-ch-topic" type="text" maxlength="240" autocomplete="off">
|
|
858
|
+
</div>
|
|
859
|
+
<div class="modal-footer">
|
|
860
|
+
<button class="btn-ghost" id="new-ch-cancel" type="button">Cancel</button>
|
|
861
|
+
<button class="btn-primary" id="new-ch-save" type="button">Create</button>
|
|
862
|
+
</div>`
|
|
863
|
+
container.querySelector('#new-ch-cancel').addEventListener('click', dismiss)
|
|
864
|
+
container.querySelector('#new-ch-save').addEventListener('click', () => {
|
|
865
|
+
const name = container.querySelector('#new-ch-name').value.trim()
|
|
866
|
+
if (!name) return
|
|
867
|
+
ws.send({ t: 'channel.create', body: {
|
|
868
|
+
hub_id: hubId, kind: 'text', name,
|
|
869
|
+
topic: container.querySelector('#new-ch-topic').value.trim() || null,
|
|
870
|
+
visibility: 'public',
|
|
871
|
+
} })
|
|
872
|
+
dismiss()
|
|
873
|
+
})
|
|
874
|
+
requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
|
|
875
|
+
}
|
|
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
|
+
|
|
928
|
+
// ─── Modal / sheet openers ────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
function _openCreateHubModal(ws) {
|
|
931
|
+
showModal({ title: 'New hub', build: body => _buildCreateHubForm(body, { ws, dismiss: dismissModal }) })
|
|
932
|
+
}
|
|
933
|
+
function _openCreateHubSheet(ws) {
|
|
934
|
+
showActionSheet({ label: 'New hub', items: [] })
|
|
935
|
+
_buildCreateHubForm(getItemsContainer(), { ws, dismiss: dismissSheet })
|
|
936
|
+
}
|
|
937
|
+
function _openHubModal(hubId, hubName, hubDescription, hubVisibility, ws) {
|
|
938
|
+
showModal({ title: 'Hub settings', build: body => _buildHubForm(body, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissModal }) })
|
|
939
|
+
}
|
|
940
|
+
function _openHubSheet(hubId, hubName, hubDescription, hubVisibility, ws) {
|
|
941
|
+
showActionSheet({ label: hubName, items: [
|
|
942
|
+
{ label: 'Edit hub', action: () => {
|
|
943
|
+
showActionSheet({ label: 'Edit hub', items: [] })
|
|
944
|
+
_buildHubForm(getItemsContainer(), { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissSheet })
|
|
945
|
+
}},
|
|
946
|
+
{ label: 'Create channel', action: () => {
|
|
947
|
+
showActionSheet({ label: `New channel in ${hubName}`, items: [] })
|
|
948
|
+
_buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet })
|
|
949
|
+
}},
|
|
950
|
+
{ label: 'Delete hub', danger: true, action: () => {
|
|
951
|
+
showActionSheet({ label: `Delete "${hubName}"?`, items: [
|
|
952
|
+
{ label: 'Cancel', action: () => {} },
|
|
953
|
+
{ label: 'Delete hub', danger: true, action: () => { ws.send({ t: 'hub.delete', body: { hub_id: hubId } }); dismissSheet() } },
|
|
954
|
+
]})
|
|
955
|
+
}},
|
|
956
|
+
]})
|
|
957
|
+
}
|
|
958
|
+
function _openCreateChannelModal(hubId, hubName, ws) {
|
|
959
|
+
showModal({ title: `New channel in ${hubName}`, build: body => _buildCreateChannelForm(body, { hubId, ws, dismiss: dismissModal }) })
|
|
960
|
+
}
|
|
961
|
+
function _openChannelModal(channelId, channelName, channelTopic, channelVisibility, ws) {
|
|
962
|
+
showModal({ title: 'Channel settings', build: body => _buildChannelForm(body, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissModal }) })
|
|
963
|
+
}
|
|
964
|
+
function _openChannelSheet(channelId, channelName, channelTopic, channelVisibility, ws) {
|
|
965
|
+
showActionSheet({ label: channelName, items: [
|
|
966
|
+
{ label: 'Edit channel', action: () => {
|
|
967
|
+
showActionSheet({ label: 'Edit channel', items: [] })
|
|
968
|
+
_buildChannelForm(getItemsContainer(), { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissSheet })
|
|
969
|
+
}},
|
|
970
|
+
{ label: 'Delete channel', danger: true, action: () => {
|
|
971
|
+
showActionSheet({ label: `Delete "#${channelName}"?`, items: [
|
|
972
|
+
{ label: 'Cancel', action: () => {} },
|
|
973
|
+
{ label: 'Delete channel', danger: true, action: () => { ws.send({ t: 'channel.delete', body: { channel_id: channelId } }); dismissSheet() } },
|
|
974
|
+
]})
|
|
975
|
+
}},
|
|
976
|
+
]})
|
|
977
|
+
}
|