@devchitchat/chat 4.5.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 -40
- package/pages/public/client/app.js +136 -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/shared/messages.js +21 -9
- package/pages/public/client/views/CallView.js +761 -0
- package/pages/public/client/views/ComposerView.js +488 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +902 -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 +4 -29
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
|
@@ -1,1198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* sidebar.js — rdbljs island for hub/channel navigation and online presence.
|
|
3
|
-
*
|
|
4
|
-
* Mounted on: <aside island="/client/islands/sidebar.js" ...>
|
|
5
|
-
*/
|
|
6
|
-
import { signal, getItemContext, effect, computed, Context } from '@devchitchat/rdbljs'
|
|
7
|
-
import { WsClient } from '../ws.js'
|
|
8
|
-
import { escHtml } from '../shared/messages.js'
|
|
9
|
-
import { addLongPress } from '../long-press.js'
|
|
10
|
-
import { showActionSheet, dismiss as dismissSheet, getItemsContainer } from '../action-sheet.js'
|
|
11
|
-
import { showModal, dismiss as dismissModal } from '../modal.js'
|
|
12
|
-
|
|
13
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
const isTouch = () => window.matchMedia('(pointer: coarse)').matches
|
|
16
|
-
|
|
17
|
-
function populateFromDom(root) {
|
|
18
|
-
return Array.from(root.querySelectorAll('details')).map(el => {
|
|
19
|
-
const hub_id = el.dataset.key
|
|
20
|
-
return {
|
|
21
|
-
hub_id,
|
|
22
|
-
name: el.querySelector('.hub-name span').textContent.trim(),
|
|
23
|
-
visibility: el.dataset.visibility ?? 'public',
|
|
24
|
-
description: el.dataset.description ?? null,
|
|
25
|
-
channels: Array.from(el.querySelectorAll('li')).map(li => {
|
|
26
|
-
const link = li.querySelector('a')
|
|
27
|
-
return {
|
|
28
|
-
channel_id: li.dataset.key,
|
|
29
|
-
hub_id,
|
|
30
|
-
name: link.textContent.trim(),
|
|
31
|
-
url: link.href,
|
|
32
|
-
topic: link.dataset.channelTopic ?? null,
|
|
33
|
-
visibility: link.dataset.channelVisibility ?? 'public',
|
|
34
|
-
selected: li.dataset.selected === 'true',
|
|
35
|
-
className: li.className.trim()
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── Form builders ─────────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
function buildHubForm(container, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss }) {
|
|
45
|
-
const currentVisibility = hubVisibility ?? 'public'
|
|
46
|
-
container.innerHTML = `
|
|
47
|
-
<div class="field">
|
|
48
|
-
<label for="hub-name-input">Hub name</label>
|
|
49
|
-
<input id="hub-name-input" type="text" value="${escHtml(hubName)}" maxlength="80" autocomplete="off">
|
|
50
|
-
</div>
|
|
51
|
-
<div class="field">
|
|
52
|
-
<label for="hub-desc-input">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
53
|
-
<input id="hub-desc-input" type="text" value="${escHtml(hubDescription ?? '')}" maxlength="240" autocomplete="off">
|
|
54
|
-
</div>
|
|
55
|
-
<div class="field">
|
|
56
|
-
<label for="hub-visibility-input">Visibility</label>
|
|
57
|
-
<select id="hub-visibility-input">
|
|
58
|
-
<option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public — visible to everyone on this instance</option>
|
|
59
|
-
<option value="restricted" ${currentVisibility === 'restricted' ? 'selected' : ''}>Restricted — only added members can see it</option>
|
|
60
|
-
</select>
|
|
61
|
-
</div>
|
|
62
|
-
<div id="hub-members-section" style="display:${currentVisibility === 'restricted' ? 'block' : 'none'}">
|
|
63
|
-
<div class="field">
|
|
64
|
-
<label>Members</label>
|
|
65
|
-
<div id="hub-members-list" class="members-list"><em style="color:var(--text-muted);font-size:13px">Loading…</em></div>
|
|
66
|
-
</div>
|
|
67
|
-
<div class="field">
|
|
68
|
-
<label for="hub-add-member-select">Add member</label>
|
|
69
|
-
<div style="display:flex;gap:8px;align-items:center">
|
|
70
|
-
<select id="hub-add-member-select" style="flex:1"><option value="">— select a user —</option></select>
|
|
71
|
-
<button id="hub-add-member-btn" type="button" class="btn-primary" style="white-space:nowrap">Add</button>
|
|
72
|
-
</div>
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
<div class="modal-footer">
|
|
76
|
-
<button class="btn-ghost" id="hub-cancel-btn" type="button">Cancel</button>
|
|
77
|
-
<button class="btn-primary" id="hub-save-btn" type="button">Save</button>
|
|
78
|
-
</div>
|
|
79
|
-
<div class="modal-danger-zone">
|
|
80
|
-
<p>Deleting this hub removes it and all its channels permanently.</p>
|
|
81
|
-
<button class="btn-danger" id="hub-delete-btn" type="button">Delete hub</button>
|
|
82
|
-
</div>
|
|
83
|
-
`
|
|
84
|
-
|
|
85
|
-
const visibilitySelect = container.querySelector('#hub-visibility-input')
|
|
86
|
-
const membersSection = container.querySelector('#hub-members-section')
|
|
87
|
-
|
|
88
|
-
// Show/hide members section when visibility changes
|
|
89
|
-
visibilitySelect.addEventListener('change', () => {
|
|
90
|
-
const isRestricted = visibilitySelect.value === 'restricted'
|
|
91
|
-
membersSection.style.display = isRestricted ? 'block' : 'none'
|
|
92
|
-
if (isRestricted) loadMembers()
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
let membersLoaded = false
|
|
96
|
-
function loadMembers() {
|
|
97
|
-
if (membersLoaded) return
|
|
98
|
-
membersLoaded = true
|
|
99
|
-
|
|
100
|
-
let members = []
|
|
101
|
-
let allUsers = []
|
|
102
|
-
|
|
103
|
-
function render() {
|
|
104
|
-
const memberIds = new Set(members.map(m => m.user_id))
|
|
105
|
-
|
|
106
|
-
const listEl = container.querySelector('#hub-members-list')
|
|
107
|
-
if (members.length === 0) {
|
|
108
|
-
listEl.innerHTML = '<em style="color:var(--text-muted);font-size:13px">No members yet.</em>'
|
|
109
|
-
} else {
|
|
110
|
-
listEl.innerHTML = members.map(m => `
|
|
111
|
-
<div class="member-row" data-user-id="${escHtml(m.user_id)}" style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
|
112
|
-
<span>${escHtml(m.display_name ?? m.handle ?? m.user_id)}</span>
|
|
113
|
-
<button type="button" class="btn-ghost btn-sm hub-remove-member" data-user-id="${escHtml(m.user_id)}" style="font-size:12px">Remove</button>
|
|
114
|
-
</div>
|
|
115
|
-
`).join('')
|
|
116
|
-
listEl.querySelectorAll('.hub-remove-member').forEach(btn => {
|
|
117
|
-
btn.addEventListener('click', () => {
|
|
118
|
-
const uid = btn.dataset.userId
|
|
119
|
-
ws.send({ t: 'hub.remove_member', body: { hub_id: hubId, user_id: uid } })
|
|
120
|
-
members = members.filter(m => m.user_id !== uid)
|
|
121
|
-
render()
|
|
122
|
-
})
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const sel = container.querySelector('#hub-add-member-select')
|
|
127
|
-
const available = allUsers.filter(u => !memberIds.has(u.user_id))
|
|
128
|
-
sel.innerHTML = '<option value="">— select a user —</option>' +
|
|
129
|
-
available.map(u => `<option value="${escHtml(u.user_id)}">${escHtml(u.display_name ?? u.handle)}</option>`).join('')
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
ws.once('hub.list_members_result', ({ hub_id, members: m }) => {
|
|
133
|
-
if (hub_id !== hubId) return
|
|
134
|
-
members = m
|
|
135
|
-
render()
|
|
136
|
-
})
|
|
137
|
-
ws.once('user.list_result', ({ users }) => {
|
|
138
|
-
allUsers = users
|
|
139
|
-
render()
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
ws.send({ t: 'hub.list_members', body: { hub_id: hubId } })
|
|
143
|
-
ws.send({ t: 'user.list', body: {} })
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Load immediately if already restricted
|
|
147
|
-
if (currentVisibility === 'restricted') loadMembers()
|
|
148
|
-
|
|
149
|
-
container.querySelector('#hub-add-member-btn').addEventListener('click', () => {
|
|
150
|
-
const sel = container.querySelector('#hub-add-member-select')
|
|
151
|
-
const userId = sel.value
|
|
152
|
-
if (!userId) return
|
|
153
|
-
ws.send({ t: 'hub.add_member', body: { hub_id: hubId, user_id: userId } })
|
|
154
|
-
membersLoaded = false
|
|
155
|
-
loadMembers()
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
container.querySelector('#hub-cancel-btn').addEventListener('click', dismiss)
|
|
159
|
-
container.querySelector('#hub-save-btn').addEventListener('click', () => {
|
|
160
|
-
const name = container.querySelector('#hub-name-input').value.trim()
|
|
161
|
-
if (!name) return
|
|
162
|
-
ws.send({ t: 'hub.update', body: {
|
|
163
|
-
hub_id: hubId,
|
|
164
|
-
name,
|
|
165
|
-
description: container.querySelector('#hub-desc-input').value.trim() || null,
|
|
166
|
-
visibility: visibilitySelect.value,
|
|
167
|
-
} })
|
|
168
|
-
dismiss()
|
|
169
|
-
})
|
|
170
|
-
container.querySelector('#hub-delete-btn').addEventListener('click', () => {
|
|
171
|
-
ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
|
|
172
|
-
dismiss()
|
|
173
|
-
})
|
|
174
|
-
requestAnimationFrame(() => container.querySelector('#hub-name-input')?.focus())
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function buildChannelForm(container, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss }) {
|
|
178
|
-
const currentVisibility = channelVisibility ?? 'public'
|
|
179
|
-
container.innerHTML = `
|
|
180
|
-
<div class="field">
|
|
181
|
-
<label for="ch-name-input">Channel name</label>
|
|
182
|
-
<input id="ch-name-input" type="text" value="${escHtml(channelName)}" maxlength="80" autocomplete="off">
|
|
183
|
-
</div>
|
|
184
|
-
<div class="field">
|
|
185
|
-
<label for="ch-topic-input">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
186
|
-
<input id="ch-topic-input" type="text" value="${escHtml(channelTopic ?? '')}" maxlength="240" autocomplete="off">
|
|
187
|
-
</div>
|
|
188
|
-
<div class="field">
|
|
189
|
-
<label for="ch-visibility-input">Visibility</label>
|
|
190
|
-
<select id="ch-visibility-input">
|
|
191
|
-
<option value="public" ${currentVisibility === 'public' ? 'selected' : ''}>Public — visible to everyone in this hub</option>
|
|
192
|
-
<option value="private" ${currentVisibility === 'private' ? 'selected' : ''}>Private — only added members can see it</option>
|
|
193
|
-
</select>
|
|
194
|
-
</div>
|
|
195
|
-
<div id="ch-members-section" style="display:${currentVisibility === 'private' ? 'block' : 'none'}">
|
|
196
|
-
<div class="field">
|
|
197
|
-
<label>Members</label>
|
|
198
|
-
<div id="ch-members-list" class="members-list"><em style="color:var(--text-muted);font-size:13px">Loading…</em></div>
|
|
199
|
-
</div>
|
|
200
|
-
<div class="field">
|
|
201
|
-
<label for="ch-add-member-select">Add member</label>
|
|
202
|
-
<div style="display:flex;gap:8px;align-items:center">
|
|
203
|
-
<select id="ch-add-member-select" style="flex:1"><option value="">— select a user —</option></select>
|
|
204
|
-
<button id="ch-add-member-btn" type="button" class="btn-primary" style="white-space:nowrap">Add</button>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
</div>
|
|
208
|
-
<div class="modal-footer">
|
|
209
|
-
<button class="btn-ghost" id="ch-cancel-btn" type="button">Cancel</button>
|
|
210
|
-
<button class="btn-primary" id="ch-save-btn" type="button">Save</button>
|
|
211
|
-
</div>
|
|
212
|
-
<div class="modal-danger-zone">
|
|
213
|
-
<p>Deleting this channel removes all its messages permanently.</p>
|
|
214
|
-
<button class="btn-danger" id="ch-delete-btn" type="button">Delete channel</button>
|
|
215
|
-
</div>
|
|
216
|
-
`
|
|
217
|
-
|
|
218
|
-
const visibilitySelect = container.querySelector('#ch-visibility-input')
|
|
219
|
-
const membersSection = container.querySelector('#ch-members-section')
|
|
220
|
-
|
|
221
|
-
// Show/hide members section when visibility changes
|
|
222
|
-
visibilitySelect.addEventListener('change', () => {
|
|
223
|
-
const isPrivate = visibilitySelect.value === 'private'
|
|
224
|
-
membersSection.style.display = isPrivate ? 'block' : 'none'
|
|
225
|
-
if (isPrivate) loadMembers()
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
// Load members and user list for the picker
|
|
229
|
-
let membersLoaded = false
|
|
230
|
-
function loadMembers() {
|
|
231
|
-
if (membersLoaded) return
|
|
232
|
-
membersLoaded = true
|
|
233
|
-
|
|
234
|
-
let members = []
|
|
235
|
-
let allUsers = []
|
|
236
|
-
|
|
237
|
-
function render() {
|
|
238
|
-
const memberIds = new Set(members.map(m => m.user_id))
|
|
239
|
-
|
|
240
|
-
// Render current members list
|
|
241
|
-
const listEl = container.querySelector('#ch-members-list')
|
|
242
|
-
if (members.length === 0) {
|
|
243
|
-
listEl.innerHTML = '<em style="color:var(--text-muted);font-size:13px">No members yet.</em>'
|
|
244
|
-
} else {
|
|
245
|
-
listEl.innerHTML = members.map(m => `
|
|
246
|
-
<div class="member-row" data-user-id="${escHtml(m.user_id)}" style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
|
247
|
-
<span>${escHtml(m.display_name ?? m.handle)} <span style="color:var(--text-muted);font-size:12px">${escHtml(m.role)}</span></span>
|
|
248
|
-
${m.role !== 'owner' ? `<button type="button" class="btn-ghost btn-sm ch-remove-member" data-user-id="${escHtml(m.user_id)}" style="font-size:12px">Remove</button>` : ''}
|
|
249
|
-
</div>
|
|
250
|
-
`).join('')
|
|
251
|
-
listEl.querySelectorAll('.ch-remove-member').forEach(btn => {
|
|
252
|
-
btn.addEventListener('click', () => {
|
|
253
|
-
const uid = btn.dataset.userId
|
|
254
|
-
ws.send({ t: 'channel.remove_member', body: { channel_id: channelId, user_id: uid } })
|
|
255
|
-
members = members.filter(m => m.user_id !== uid)
|
|
256
|
-
render()
|
|
257
|
-
})
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Render add-member picker (exclude existing members)
|
|
262
|
-
const sel = container.querySelector('#ch-add-member-select')
|
|
263
|
-
const available = allUsers.filter(u => !memberIds.has(u.user_id))
|
|
264
|
-
sel.innerHTML = '<option value="">— select a user —</option>' +
|
|
265
|
-
available.map(u => `<option value="${escHtml(u.user_id)}">${escHtml(u.display_name ?? u.handle)}</option>`).join('')
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
ws.once('channel.list_members_result', ({ channel_id, members: m }) => {
|
|
269
|
-
if (channel_id !== channelId) return
|
|
270
|
-
members = m
|
|
271
|
-
render()
|
|
272
|
-
})
|
|
273
|
-
ws.once('user.list_result', ({ users }) => {
|
|
274
|
-
allUsers = users
|
|
275
|
-
render()
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
ws.send({ t: 'channel.list_members', body: { channel_id: channelId } })
|
|
279
|
-
ws.send({ t: 'user.list', body: {} })
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Load immediately if already private
|
|
283
|
-
if (currentVisibility === 'private') loadMembers()
|
|
284
|
-
|
|
285
|
-
container.querySelector('#ch-add-member-btn').addEventListener('click', () => {
|
|
286
|
-
const sel = container.querySelector('#ch-add-member-select')
|
|
287
|
-
const userId = sel.value
|
|
288
|
-
if (!userId) return
|
|
289
|
-
ws.send({ t: 'channel.add_member', body: { channel_id: channelId, user_id: userId } })
|
|
290
|
-
// Optimistically reload
|
|
291
|
-
membersLoaded = false
|
|
292
|
-
loadMembers()
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
container.querySelector('#ch-cancel-btn').addEventListener('click', dismiss)
|
|
296
|
-
container.querySelector('#ch-save-btn').addEventListener('click', () => {
|
|
297
|
-
const name = container.querySelector('#ch-name-input').value.trim()
|
|
298
|
-
if (!name) return
|
|
299
|
-
ws.send({ t: 'channel.update', body: {
|
|
300
|
-
channel_id: channelId,
|
|
301
|
-
name,
|
|
302
|
-
topic: container.querySelector('#ch-topic-input').value.trim() || null,
|
|
303
|
-
visibility: visibilitySelect.value,
|
|
304
|
-
} })
|
|
305
|
-
dismiss()
|
|
306
|
-
})
|
|
307
|
-
container.querySelector('#ch-delete-btn').addEventListener('click', () => {
|
|
308
|
-
ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
|
|
309
|
-
dismiss()
|
|
310
|
-
})
|
|
311
|
-
requestAnimationFrame(() => container.querySelector('#ch-name-input')?.focus())
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function buildCreateHubForm(container, { ws, dismiss }) {
|
|
315
|
-
container.innerHTML = `
|
|
316
|
-
<div class="field">
|
|
317
|
-
<label for="new-hub-name">Hub name</label>
|
|
318
|
-
<input id="new-hub-name" type="text" placeholder="e.g. Engineering" maxlength="80" autocomplete="off">
|
|
319
|
-
</div>
|
|
320
|
-
<div class="field">
|
|
321
|
-
<label for="new-hub-desc">Description <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
322
|
-
<input id="new-hub-desc" type="text" maxlength="240" autocomplete="off">
|
|
323
|
-
</div>
|
|
324
|
-
<div class="field">
|
|
325
|
-
<label for="new-hub-visibility">Visibility</label>
|
|
326
|
-
<select id="new-hub-visibility">
|
|
327
|
-
<option value="public">Public — visible to everyone on this instance</option>
|
|
328
|
-
<option value="restricted">Restricted — only added members can see it</option>
|
|
329
|
-
</select>
|
|
330
|
-
</div>
|
|
331
|
-
<div class="modal-footer">
|
|
332
|
-
<button class="btn-ghost" id="new-hub-cancel" type="button">Cancel</button>
|
|
333
|
-
<button class="btn-primary" id="new-hub-save" type="button">Create</button>
|
|
334
|
-
</div>
|
|
335
|
-
`
|
|
336
|
-
container.querySelector('#new-hub-cancel').addEventListener('click', dismiss)
|
|
337
|
-
container.querySelector('#new-hub-save').addEventListener('click', () => {
|
|
338
|
-
const name = container.querySelector('#new-hub-name').value.trim()
|
|
339
|
-
if (!name) return
|
|
340
|
-
ws.send({ t: 'hub.create', body: {
|
|
341
|
-
name,
|
|
342
|
-
description: container.querySelector('#new-hub-desc').value.trim() || null,
|
|
343
|
-
visibility: container.querySelector('#new-hub-visibility').value,
|
|
344
|
-
} })
|
|
345
|
-
dismiss()
|
|
346
|
-
})
|
|
347
|
-
requestAnimationFrame(() => container.querySelector('#new-hub-name')?.focus())
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function buildCreateChannelForm(container, { hubId, ws, dismiss }) {
|
|
351
|
-
container.innerHTML = `
|
|
352
|
-
<div class="field">
|
|
353
|
-
<label for="new-ch-name">Channel name</label>
|
|
354
|
-
<input id="new-ch-name" type="text" placeholder="e.g. general" maxlength="80" autocomplete="off">
|
|
355
|
-
</div>
|
|
356
|
-
<div class="field">
|
|
357
|
-
<label for="new-ch-topic">Topic <span style="font-weight:400;color:var(--text-muted)">(optional)</span></label>
|
|
358
|
-
<input id="new-ch-topic" type="text" maxlength="240" autocomplete="off">
|
|
359
|
-
</div>
|
|
360
|
-
<div class="field">
|
|
361
|
-
<label for="new-ch-visibility">Visibility</label>
|
|
362
|
-
<select id="new-ch-visibility">
|
|
363
|
-
<option value="public">Public — visible to everyone in this hub</option>
|
|
364
|
-
<option value="private">Private — only added members can see it</option>
|
|
365
|
-
</select>
|
|
366
|
-
</div>
|
|
367
|
-
<div class="modal-footer">
|
|
368
|
-
<button class="btn-ghost" id="new-ch-cancel" type="button">Cancel</button>
|
|
369
|
-
<button class="btn-primary" id="new-ch-save" type="button">Create</button>
|
|
370
|
-
</div>
|
|
371
|
-
`
|
|
372
|
-
container.querySelector('#new-ch-cancel').addEventListener('click', dismiss)
|
|
373
|
-
container.querySelector('#new-ch-save').addEventListener('click', () => {
|
|
374
|
-
const name = container.querySelector('#new-ch-name').value.trim()
|
|
375
|
-
if (!name) return
|
|
376
|
-
ws.send({ t: 'channel.create', body: {
|
|
377
|
-
hub_id: hubId,
|
|
378
|
-
kind: 'text',
|
|
379
|
-
name,
|
|
380
|
-
topic: container.querySelector('#new-ch-topic').value.trim() || null,
|
|
381
|
-
visibility: container.querySelector('#new-ch-visibility').value,
|
|
382
|
-
} })
|
|
383
|
-
dismiss()
|
|
384
|
-
})
|
|
385
|
-
requestAnimationFrame(() => container.querySelector('#new-ch-name')?.focus())
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── Sheet/modal openers ───────────────────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
function openCreateHubModal(ws) {
|
|
391
|
-
showModal({
|
|
392
|
-
title: 'New hub',
|
|
393
|
-
build: body => buildCreateHubForm(body, { ws, dismiss: dismissModal })
|
|
394
|
-
})
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function openCreateHubSheet(ws) {
|
|
398
|
-
showActionSheet({ label: 'New hub', items: [] })
|
|
399
|
-
buildCreateHubForm(getItemsContainer(), { ws, dismiss: dismissSheet })
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function openHubSheet(hubId, hubName, hubDescription, hubVisibility, ws) {
|
|
403
|
-
showActionSheet({
|
|
404
|
-
label: hubName,
|
|
405
|
-
items: [
|
|
406
|
-
{ label: 'Edit hub', action: () => {
|
|
407
|
-
showActionSheet({ label: 'Edit hub', items: [] })
|
|
408
|
-
buildHubForm(getItemsContainer(), { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissSheet })
|
|
409
|
-
}
|
|
410
|
-
},
|
|
411
|
-
{ label: 'Create channel', action: () => {
|
|
412
|
-
showActionSheet({ label: `New channel in ${hubName}`, items: [] })
|
|
413
|
-
buildCreateChannelForm(getItemsContainer(), { hubId, ws, dismiss: dismissSheet })
|
|
414
|
-
}
|
|
415
|
-
},
|
|
416
|
-
{ label: 'Delete hub', danger: true, action: () => {
|
|
417
|
-
showActionSheet({
|
|
418
|
-
label: `Delete "${hubName}"?`,
|
|
419
|
-
items: [
|
|
420
|
-
{ label: 'Cancel', action: () => {} },
|
|
421
|
-
{ label: 'Delete hub', danger: true, action: () => {
|
|
422
|
-
ws.send({ t: 'hub.delete', body: { hub_id: hubId } })
|
|
423
|
-
dismissSheet()
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
]
|
|
427
|
-
})
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
]
|
|
431
|
-
})
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function openHubModal(hubId, hubName, hubDescription, hubVisibility, ws) {
|
|
435
|
-
showModal({
|
|
436
|
-
title: 'Hub settings',
|
|
437
|
-
build: body => buildHubForm(body, { hubId, hubName, hubDescription, hubVisibility, ws, dismiss: dismissModal })
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function openCreateChannelModal(hubId, hubName, ws) {
|
|
442
|
-
showModal({
|
|
443
|
-
title: `New channel in ${hubName}`,
|
|
444
|
-
build: body => buildCreateChannelForm(body, { hubId, ws, dismiss: dismissModal })
|
|
445
|
-
})
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function openChannelSheet(channelId, channelName, channelTopic, channelVisibility, ws) {
|
|
449
|
-
showActionSheet({
|
|
450
|
-
label: channelName,
|
|
451
|
-
items: [
|
|
452
|
-
{ label: 'Edit channel', action: () => {
|
|
453
|
-
showActionSheet({ label: 'Edit channel', items: [] })
|
|
454
|
-
buildChannelForm(getItemsContainer(), { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissSheet })
|
|
455
|
-
}
|
|
456
|
-
},
|
|
457
|
-
{ label: 'Delete channel', danger: true, action: () => {
|
|
458
|
-
showActionSheet({
|
|
459
|
-
label: `Delete "#${channelName}"?`,
|
|
460
|
-
items: [
|
|
461
|
-
{ label: 'Cancel', action: () => {} },
|
|
462
|
-
{ label: 'Delete channel', danger: true, action: () => {
|
|
463
|
-
ws.send({ t: 'channel.delete', body: { channel_id: channelId } })
|
|
464
|
-
dismissSheet()
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
]
|
|
468
|
-
})
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
]
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function openChannelModal(channelId, channelName, channelTopic, channelVisibility, ws) {
|
|
476
|
-
showModal({
|
|
477
|
-
title: 'Channel settings',
|
|
478
|
-
build: body => buildChannelForm(body, { channelId, channelName, channelTopic, channelVisibility, ws, dismiss: dismissModal })
|
|
479
|
-
})
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ── Drag-and-drop channel reordering (desktop only) ──────────────────────────
|
|
483
|
-
|
|
484
|
-
function attachDragHandlers(sidebarEl, { ws, hubs }) {
|
|
485
|
-
// data-key is stripped from template nodes by rdbljs (clearNodeForTemplate removes it),
|
|
486
|
-
// so dataset.key is undefined on re-rendered items. Use getItemContext instead —
|
|
487
|
-
// rdbljs stores { item, key } in a WeakMap on every entry node and it survives re-renders.
|
|
488
|
-
let dragSrcId = null
|
|
489
|
-
let dragHubId = null
|
|
490
|
-
|
|
491
|
-
sidebarEl.addEventListener('dragstart', e => {
|
|
492
|
-
const li = e.target.closest('.channel-item')
|
|
493
|
-
if (!li) return
|
|
494
|
-
const ctx = getItemContext(li)
|
|
495
|
-
dragSrcId = ctx?.key ?? null
|
|
496
|
-
dragHubId = ctx?.item?.hub_id ?? null
|
|
497
|
-
if (!dragSrcId) return
|
|
498
|
-
li.classList.add('dragging')
|
|
499
|
-
e.dataTransfer.effectAllowed = 'move'
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
function clearDropIndicators() {
|
|
503
|
-
sidebarEl.querySelectorAll('.drop-before, .drop-after').forEach(el => {
|
|
504
|
-
el.classList.remove('drop-before', 'drop-after')
|
|
505
|
-
})
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function insertBefore(e, li) {
|
|
509
|
-
return e.clientY < li.getBoundingClientRect().top + li.offsetHeight / 2
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
sidebarEl.addEventListener('dragend', e => {
|
|
513
|
-
const li = e.target.closest('.channel-item')
|
|
514
|
-
if (li) li.classList.remove('dragging')
|
|
515
|
-
clearDropIndicators()
|
|
516
|
-
dragSrcId = null
|
|
517
|
-
dragHubId = null
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
sidebarEl.addEventListener('dragover', e => {
|
|
521
|
-
const li = e.target.closest('.channel-item')
|
|
522
|
-
if (!li || !dragSrcId) return
|
|
523
|
-
const ctx = getItemContext(li)
|
|
524
|
-
if (!ctx || ctx.key === dragSrcId || ctx.item?.hub_id !== dragHubId) return
|
|
525
|
-
e.preventDefault()
|
|
526
|
-
e.dataTransfer.dropEffect = 'move'
|
|
527
|
-
clearDropIndicators()
|
|
528
|
-
li.classList.add(insertBefore(e, li) ? 'drop-before' : 'drop-after')
|
|
529
|
-
})
|
|
530
|
-
|
|
531
|
-
sidebarEl.addEventListener('dragleave', e => {
|
|
532
|
-
const li = e.target.closest('.channel-item')
|
|
533
|
-
if (li) { li.classList.remove('drop-before', 'drop-after') }
|
|
534
|
-
})
|
|
535
|
-
|
|
536
|
-
sidebarEl.addEventListener('drop', e => {
|
|
537
|
-
const targetLi = e.target.closest('.channel-item')
|
|
538
|
-
if (!targetLi || !dragSrcId || !dragHubId) return
|
|
539
|
-
const targetCtx = getItemContext(targetLi)
|
|
540
|
-
const targetChannelId = targetCtx?.key
|
|
541
|
-
if (!targetChannelId || targetCtx.item?.hub_id !== dragHubId) return
|
|
542
|
-
e.preventDefault()
|
|
543
|
-
clearDropIndicators()
|
|
544
|
-
|
|
545
|
-
const hub = hubs().find(h => h.hub_id === dragHubId)
|
|
546
|
-
if (!hub) return
|
|
547
|
-
|
|
548
|
-
const ids = (hub.channels ?? []).map(c => c.channel_id)
|
|
549
|
-
const fromIdx = ids.indexOf(dragSrcId)
|
|
550
|
-
const toIdx = ids.indexOf(targetChannelId)
|
|
551
|
-
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
|
552
|
-
|
|
553
|
-
const before = insertBefore(e, targetLi)
|
|
554
|
-
ids.splice(fromIdx, 1)
|
|
555
|
-
// After removing the source, find target's new index and insert accordingly
|
|
556
|
-
const newToIdx = ids.indexOf(targetChannelId)
|
|
557
|
-
ids.splice(before ? newToIdx : newToIdx + 1, 0, dragSrcId)
|
|
558
|
-
|
|
559
|
-
ws.send({ t: 'channel.reorder', body: { hub_id: dragHubId, channel_ids: ids } })
|
|
560
|
-
})
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// ── Drag-and-drop hub reordering (desktop only) ───────────────────────────────
|
|
564
|
-
|
|
565
|
-
function attachHubDragHandlers(sidebarEl, { ws, hubs }) {
|
|
566
|
-
// Uses the same WeakMap-based getItemContext pattern as channel drag handlers.
|
|
567
|
-
// Hub drag targets are details.hub-header elements; draggable="true" is set in the template.
|
|
568
|
-
let dragSrcHubId = null
|
|
569
|
-
|
|
570
|
-
function clearDropIndicators() {
|
|
571
|
-
sidebarEl.querySelectorAll('.hub-header.drop-before, .hub-header.drop-after').forEach(el => {
|
|
572
|
-
el.classList.remove('drop-before', 'drop-after')
|
|
573
|
-
})
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function insertBefore(e, details) {
|
|
577
|
-
return e.clientY < details.getBoundingClientRect().top + details.offsetHeight / 2
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
sidebarEl.addEventListener('dragstart', e => {
|
|
581
|
-
const details = e.target.closest('.hub-header')
|
|
582
|
-
if (!details) return
|
|
583
|
-
// Ignore if the drag actually started on a channel item inside the hub
|
|
584
|
-
if (e.target.closest('.channel-item')) return
|
|
585
|
-
const ctx = getItemContext(details)
|
|
586
|
-
dragSrcHubId = ctx?.key ?? null
|
|
587
|
-
if (!dragSrcHubId) return
|
|
588
|
-
details.classList.add('dragging')
|
|
589
|
-
e.dataTransfer.effectAllowed = 'move'
|
|
590
|
-
e.stopPropagation()
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
sidebarEl.addEventListener('dragend', e => {
|
|
594
|
-
const details = e.target.closest('.hub-header')
|
|
595
|
-
if (details) details.classList.remove('dragging')
|
|
596
|
-
clearDropIndicators()
|
|
597
|
-
dragSrcHubId = null
|
|
598
|
-
})
|
|
599
|
-
|
|
600
|
-
sidebarEl.addEventListener('dragover', e => {
|
|
601
|
-
if (!dragSrcHubId) return
|
|
602
|
-
// Ignore drags over channel items — those belong to the channel drag handler
|
|
603
|
-
if (e.target.closest('.channel-item')) return
|
|
604
|
-
const details = e.target.closest('.hub-header')
|
|
605
|
-
if (!details) return
|
|
606
|
-
const ctx = getItemContext(details)
|
|
607
|
-
if (!ctx || ctx.key === dragSrcHubId) return
|
|
608
|
-
e.preventDefault()
|
|
609
|
-
e.dataTransfer.dropEffect = 'move'
|
|
610
|
-
clearDropIndicators()
|
|
611
|
-
details.classList.add(insertBefore(e, details) ? 'drop-before' : 'drop-after')
|
|
612
|
-
})
|
|
613
|
-
|
|
614
|
-
sidebarEl.addEventListener('dragleave', e => {
|
|
615
|
-
if (e.target.closest('.channel-item')) return
|
|
616
|
-
const details = e.target.closest('.hub-header')
|
|
617
|
-
if (details) details.classList.remove('drop-before', 'drop-after')
|
|
618
|
-
})
|
|
619
|
-
|
|
620
|
-
sidebarEl.addEventListener('drop', e => {
|
|
621
|
-
if (!dragSrcHubId) return
|
|
622
|
-
if (e.target.closest('.channel-item')) return
|
|
623
|
-
const targetDetails = e.target.closest('.hub-header')
|
|
624
|
-
if (!targetDetails) return
|
|
625
|
-
const targetCtx = getItemContext(targetDetails)
|
|
626
|
-
const targetHubId = targetCtx?.key
|
|
627
|
-
if (!targetHubId || targetHubId === dragSrcHubId) return
|
|
628
|
-
e.preventDefault()
|
|
629
|
-
clearDropIndicators()
|
|
630
|
-
|
|
631
|
-
const ids = hubs().map(h => h.hub_id)
|
|
632
|
-
const fromIdx = ids.indexOf(dragSrcHubId)
|
|
633
|
-
const toIdx = ids.indexOf(targetHubId)
|
|
634
|
-
if (fromIdx === -1 || toIdx === -1) return
|
|
635
|
-
|
|
636
|
-
const before = insertBefore(e, targetDetails)
|
|
637
|
-
ids.splice(fromIdx, 1)
|
|
638
|
-
const newToIdx = ids.indexOf(targetHubId)
|
|
639
|
-
ids.splice(before ? newToIdx : newToIdx + 1, 0, dragSrcHubId)
|
|
640
|
-
|
|
641
|
-
ws.send({ t: 'hub.reorder', body: { hub_ids: ids } })
|
|
642
|
-
})
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// ── File-drop onto channel links ─────────────────────────────────────────────
|
|
646
|
-
|
|
647
|
-
function attachFileDropHandlers(sidebarEl, { ws }) {
|
|
648
|
-
let hoverTimer = null
|
|
649
|
-
let hoverTarget = null
|
|
650
|
-
|
|
651
|
-
function clearHover() {
|
|
652
|
-
clearTimeout(hoverTimer)
|
|
653
|
-
hoverTimer = null
|
|
654
|
-
if (hoverTarget) {
|
|
655
|
-
hoverTarget.classList.remove('file-drop-hover')
|
|
656
|
-
hoverTarget = null
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function showToast(text) {
|
|
661
|
-
const toast = document.createElement('div')
|
|
662
|
-
toast.className = 'sidebar-toast'
|
|
663
|
-
toast.textContent = text
|
|
664
|
-
sidebarEl.appendChild(toast)
|
|
665
|
-
setTimeout(() => toast.remove(), 3000)
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
sidebarEl.addEventListener('dragover', e => {
|
|
669
|
-
const link = e.target.closest('.channel-link')
|
|
670
|
-
if (!link) { clearHover(); return }
|
|
671
|
-
// Only act on file drags (not channel-reordering drags)
|
|
672
|
-
if (!e.dataTransfer.types.includes('Files')) return
|
|
673
|
-
e.preventDefault()
|
|
674
|
-
e.dataTransfer.dropEffect = 'copy'
|
|
675
|
-
|
|
676
|
-
if (link !== hoverTarget) {
|
|
677
|
-
clearHover()
|
|
678
|
-
hoverTarget = link
|
|
679
|
-
hoverTimer = setTimeout(() => link.classList.add('file-drop-hover'), 600)
|
|
680
|
-
}
|
|
681
|
-
})
|
|
682
|
-
|
|
683
|
-
sidebarEl.addEventListener('dragleave', e => {
|
|
684
|
-
if (hoverTarget && !hoverTarget.contains(e.relatedTarget)) clearHover()
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
sidebarEl.addEventListener('drop', async e => {
|
|
688
|
-
const link = e.target.closest('.channel-link')
|
|
689
|
-
clearHover()
|
|
690
|
-
if (!link) return
|
|
691
|
-
if (!e.dataTransfer.types.includes('Files')) return
|
|
692
|
-
e.preventDefault()
|
|
693
|
-
e.stopPropagation()
|
|
694
|
-
|
|
695
|
-
const targetChannelId = link.dataset.channelId
|
|
696
|
-
const targetChannelName = link.dataset.channelName ?? targetChannelId
|
|
697
|
-
if (!targetChannelId) return
|
|
698
|
-
|
|
699
|
-
const files = [...e.dataTransfer.files]
|
|
700
|
-
if (files.length === 0) return
|
|
701
|
-
|
|
702
|
-
// Join the channel first (needed for delivery cursor)
|
|
703
|
-
ws.send({ t: 'channel.join', body: { channel_id: targetChannelId } })
|
|
704
|
-
|
|
705
|
-
const uploaded = []
|
|
706
|
-
for (const file of files) {
|
|
707
|
-
const formData = new FormData()
|
|
708
|
-
formData.append('file', file)
|
|
709
|
-
formData.append('channel_id', targetChannelId)
|
|
710
|
-
try {
|
|
711
|
-
const res = await fetch(`${window.__BASE_PATH__}/api/uploads`, { method: 'POST', body: formData })
|
|
712
|
-
if (!res.ok) {
|
|
713
|
-
const body = await res.json().catch(() => ({}))
|
|
714
|
-
showToast(`Upload failed: ${body.error ?? res.statusText}`)
|
|
715
|
-
continue
|
|
716
|
-
}
|
|
717
|
-
const a = await res.json()
|
|
718
|
-
uploaded.push({
|
|
719
|
-
upload_id: a.upload_id,
|
|
720
|
-
url: a.url,
|
|
721
|
-
filename: a.original_name,
|
|
722
|
-
mime_type: a.mime_type,
|
|
723
|
-
size_bytes: a.size_bytes,
|
|
724
|
-
})
|
|
725
|
-
} catch {
|
|
726
|
-
showToast('Upload failed: network error')
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (uploaded.length === 0) return
|
|
731
|
-
|
|
732
|
-
ws.send({
|
|
733
|
-
t: 'msg.send',
|
|
734
|
-
body: {
|
|
735
|
-
channel_id: targetChannelId,
|
|
736
|
-
text: '',
|
|
737
|
-
client_msg_id: `drop_${Date.now()}`,
|
|
738
|
-
attachments: uploaded,
|
|
739
|
-
}
|
|
740
|
-
})
|
|
741
|
-
|
|
742
|
-
showToast(`Sent to #${targetChannelName}`)
|
|
743
|
-
})
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// ── Attach management handlers (event delegation — safe across re-renders) ───
|
|
747
|
-
|
|
748
|
-
function attachManagementHandlers(sidebarEl, { ws, hubs }) {
|
|
749
|
-
// Gear buttons and add button: single delegated click listener
|
|
750
|
-
sidebarEl.addEventListener('click', e => {
|
|
751
|
-
// Hub gear
|
|
752
|
-
if (e.target.closest('.btn-hub-gear')) {
|
|
753
|
-
e.stopPropagation()
|
|
754
|
-
const summary = e.target.closest('.hub-name')
|
|
755
|
-
const hubId = summary?.dataset.hubId
|
|
756
|
-
if (!hubId) return
|
|
757
|
-
const hub = hubs().find(h => h.hub_id === hubId)
|
|
758
|
-
openHubModal(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
|
|
759
|
-
return
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Hub add-channel button
|
|
763
|
-
if (e.target.closest('.btn-hub-add')) {
|
|
764
|
-
e.stopPropagation()
|
|
765
|
-
const summary = e.target.closest('.hub-name')
|
|
766
|
-
const hubId = summary?.dataset.hubId
|
|
767
|
-
if (!hubId) return
|
|
768
|
-
const hub = hubs().find(h => h.hub_id === hubId)
|
|
769
|
-
openCreateChannelModal(hubId, hub?.name ?? '', ws)
|
|
770
|
-
return
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// Channel gear
|
|
774
|
-
if (e.target.closest('.btn-channel-gear')) {
|
|
775
|
-
e.preventDefault()
|
|
776
|
-
const li = e.target.closest('.channel-item')
|
|
777
|
-
const link = li?.querySelector('.channel-link')
|
|
778
|
-
const channelId = link?.dataset.channelId
|
|
779
|
-
if (!channelId) return
|
|
780
|
-
let ch = null
|
|
781
|
-
for (const hub of hubs()) {
|
|
782
|
-
ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
|
|
783
|
-
if (ch) break
|
|
784
|
-
}
|
|
785
|
-
openChannelModal(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
|
|
786
|
-
return
|
|
787
|
-
}
|
|
788
|
-
})
|
|
789
|
-
|
|
790
|
-
// Touch: long-press delegation on the sidebar
|
|
791
|
-
if (isTouch()) {
|
|
792
|
-
addLongPress(sidebarEl, (e) => {
|
|
793
|
-
const target = e.target ?? e.touches?.[0]?.target
|
|
794
|
-
|
|
795
|
-
// Long-press on hub summary
|
|
796
|
-
const summary = target?.closest?.('.hub-name')
|
|
797
|
-
if (summary) {
|
|
798
|
-
const hubId = summary.dataset.hubId
|
|
799
|
-
if (!hubId) return
|
|
800
|
-
const hub = hubs().find(h => h.hub_id === hubId)
|
|
801
|
-
openHubSheet(hubId, hub?.name ?? '', hub?.description ?? null, hub?.visibility ?? 'public', ws)
|
|
802
|
-
return
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Long-press on channel link
|
|
806
|
-
const link = target?.closest?.('.channel-link')
|
|
807
|
-
if (link) {
|
|
808
|
-
const channelId = link.dataset.channelId
|
|
809
|
-
if (!channelId) return
|
|
810
|
-
let ch = null
|
|
811
|
-
for (const hub of hubs()) {
|
|
812
|
-
ch = (hub.channels ?? []).find(c => c.channel_id === channelId)
|
|
813
|
-
if (ch) break
|
|
814
|
-
}
|
|
815
|
-
openChannelSheet(channelId, ch?.name ?? '', ch?.topic ?? null, ch?.visibility ?? 'public', ws)
|
|
816
|
-
}
|
|
817
|
-
})
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// ── Navigation after deletion ─────────────────────────────────────────────────
|
|
822
|
-
|
|
823
|
-
function navigateAfterDeletion(remainingHubs) {
|
|
824
|
-
const first = remainingHubs.flatMap(h => h.channels ?? []).find(Boolean)
|
|
825
|
-
window.location.href = first ? `${window.__BASE_PATH__}/channels/${first.channel_id}` : `${window.__BASE_PATH__}/`
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// ── Island ────────────────────────────────────────────────────────────────────
|
|
829
|
-
|
|
830
|
-
function populateDmsFromDom(root) {
|
|
831
|
-
return Array.from(root.querySelectorAll('.dm-item')).map(li => ({
|
|
832
|
-
channel_id: li.dataset.channelId,
|
|
833
|
-
with_user: { display_name: li.querySelector('.dm-name')?.textContent.trim() ?? '' }
|
|
834
|
-
}))
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
export default function SidebarIsland(root) {
|
|
838
|
-
let currentChannelId = root.dataset.currentchannel
|
|
839
|
-
const currentUserId = root.dataset.userid ?? null
|
|
840
|
-
const hubs = signal(populateFromDom(root))
|
|
841
|
-
const dms = signal(populateDmsFromDom(root))
|
|
842
|
-
// channelId → true if this channel has an unread @mention
|
|
843
|
-
const mentionedChannels = signal(new Set())
|
|
844
|
-
// channelId → true if this channel has an unread urgent (@mention + now priority)
|
|
845
|
-
const urgentChannels = signal(new Set())
|
|
846
|
-
const dmUnread = signal(new Set())
|
|
847
|
-
const ws = new WsClient(`${window.__BASE_PATH__}/ws`)
|
|
848
|
-
|
|
849
|
-
ws.on('hub.created', ({ hub }) => {
|
|
850
|
-
hubs.set([...hubs(), { ...hub, channels: [] }])
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
ws.on('hub.updated', ({ hub }) => {
|
|
854
|
-
hubs.set(hubs().map(h => h.hub_id === hub.hub_id ? { ...h, ...hub } : h))
|
|
855
|
-
})
|
|
856
|
-
|
|
857
|
-
ws.on('notification.mention', ({ channel_id, priority }) => {
|
|
858
|
-
if (channel_id === currentChannelId) return // already viewing — no dot needed
|
|
859
|
-
if (priority === 'now') {
|
|
860
|
-
const next = new Set(urgentChannels())
|
|
861
|
-
next.add(channel_id)
|
|
862
|
-
urgentChannels.set(next)
|
|
863
|
-
} else {
|
|
864
|
-
const next = new Set(mentionedChannels())
|
|
865
|
-
next.add(channel_id)
|
|
866
|
-
mentionedChannels.set(next)
|
|
867
|
-
}
|
|
868
|
-
})
|
|
869
|
-
|
|
870
|
-
ws.on('notification.digest', ({ channels }) => {
|
|
871
|
-
if (!channels?.length) return
|
|
872
|
-
const nextMentioned = new Set(mentionedChannels())
|
|
873
|
-
const nextUrgent = new Set(urgentChannels())
|
|
874
|
-
for (const c of channels) {
|
|
875
|
-
if (c.urgent) nextUrgent.add(c.channel_id)
|
|
876
|
-
else if (c.mentions > 0) nextMentioned.add(c.channel_id)
|
|
877
|
-
}
|
|
878
|
-
mentionedChannels.set(nextMentioned)
|
|
879
|
-
urgentChannels.set(nextUrgent)
|
|
880
|
-
})
|
|
881
|
-
|
|
882
|
-
ws.on('hub.member_added', ({ hub_id, user_id }) => {
|
|
883
|
-
if (user_id !== currentUserId) return
|
|
884
|
-
// Current user was added to a hub — fetch the hub list and merge new hubs in
|
|
885
|
-
ws.once('hub.list_result', ({ hubs: serverHubs }) => {
|
|
886
|
-
const existing = new Set(hubs().map(h => h.hub_id))
|
|
887
|
-
const newHubs = serverHubs.filter(h => !existing.has(h.hub_id))
|
|
888
|
-
if (newHubs.length > 0) {
|
|
889
|
-
hubs.set([...hubs(), ...newHubs.map(h => ({ ...h, channels: [] }))])
|
|
890
|
-
}
|
|
891
|
-
})
|
|
892
|
-
ws.send({ t: 'hub.list', body: {} })
|
|
893
|
-
})
|
|
894
|
-
|
|
895
|
-
ws.on('hub.member_removed', ({ hub_id, user_id }) => {
|
|
896
|
-
if (user_id !== currentUserId) return
|
|
897
|
-
// Current user was removed from a hub — drop it from the signal
|
|
898
|
-
const removedHub = hubs().find(h => h.hub_id === hub_id)
|
|
899
|
-
const affectsCurrentChannel = (removedHub?.channels ?? []).some(c => c.channel_id === currentChannelId)
|
|
900
|
-
hubs.set(hubs().filter(h => h.hub_id !== hub_id))
|
|
901
|
-
if (affectsCurrentChannel) navigateAfterDeletion(hubs())
|
|
902
|
-
})
|
|
903
|
-
|
|
904
|
-
ws.on('dm.list_result', ({ dms: list }) => {
|
|
905
|
-
dms.set(list)
|
|
906
|
-
})
|
|
907
|
-
|
|
908
|
-
ws.on('dm.opened', ({ channel_id, with_user, notify_only }) => {
|
|
909
|
-
// Add to DM list if not already present
|
|
910
|
-
if (!dms().some(d => d.channel_id === channel_id)) {
|
|
911
|
-
dms.set([{ channel_id, with_user }, ...dms()])
|
|
912
|
-
}
|
|
913
|
-
if (notify_only) {
|
|
914
|
-
// Target user — show unread dot, don't navigate
|
|
915
|
-
const next = new Set(dmUnread()); next.add(channel_id); dmUnread.set(next)
|
|
916
|
-
} else {
|
|
917
|
-
// Initiating user — navigate to the DM channel
|
|
918
|
-
window.location.href = `${window.__BASE_PATH__}/channels/${channel_id}`
|
|
919
|
-
}
|
|
920
|
-
})
|
|
921
|
-
|
|
922
|
-
// Highlight DM list item when a message arrives in a DM channel not currently open
|
|
923
|
-
ws.on('msg.event', ({ channel_id }) => {
|
|
924
|
-
if (channel_id === currentChannelId) return
|
|
925
|
-
if (!dms().some(d => d.channel_id === channel_id)) return
|
|
926
|
-
const next = new Set(dmUnread()); next.add(channel_id); dmUnread.set(next)
|
|
927
|
-
})
|
|
928
|
-
|
|
929
|
-
// Clear dot when user clicks a DM link
|
|
930
|
-
root.addEventListener('click', e => {
|
|
931
|
-
const link = e.target.closest('.dm-link')
|
|
932
|
-
if (!link) return
|
|
933
|
-
const id = link.dataset.channelId
|
|
934
|
-
if (id) { const next = new Set(dmUnread()); next.delete(id); dmUnread.set(next) }
|
|
935
|
-
})
|
|
936
|
-
|
|
937
|
-
// Track current channel across SPA navigations and clear DM dot when landing on a DM
|
|
938
|
-
document.addEventListener('chatpanel:navigated', e => {
|
|
939
|
-
const { channelId: newId } = e.detail
|
|
940
|
-
const prevId = currentChannelId
|
|
941
|
-
currentChannelId = newId
|
|
942
|
-
// Only create new objects for hubs/channels that actually changed (prev active → new
|
|
943
|
-
// active). Returning the same object reference for unchanged hubs lets rdbljs skip
|
|
944
|
-
// destroying and recreating those DOM rows, which prevents a brief empty-sidebar flash
|
|
945
|
-
// on iOS during the CSS transition animation.
|
|
946
|
-
if (prevId !== newId) {
|
|
947
|
-
hubs.set(hubs().map(h => {
|
|
948
|
-
const channels = h.channels ?? []
|
|
949
|
-
const affected = channels.some(c => c.channel_id === newId || c.channel_id === prevId)
|
|
950
|
-
if (!affected) return h // same reference — rdbljs skips this hub entirely
|
|
951
|
-
return {
|
|
952
|
-
...h,
|
|
953
|
-
channels: channels.map(c => {
|
|
954
|
-
if (c.channel_id !== newId && c.channel_id !== prevId) return c
|
|
955
|
-
const base = (c.className ?? 'channel-item').replace(/\bactive\b/g, '').trim()
|
|
956
|
-
const next = c.channel_id === newId ? `${base} active` : base
|
|
957
|
-
return next === c.className ? c : { ...c, className: next }
|
|
958
|
-
})
|
|
959
|
-
}
|
|
960
|
-
}))
|
|
961
|
-
}
|
|
962
|
-
renderDms()
|
|
963
|
-
if (dmUnread().has(newId)) {
|
|
964
|
-
const next = new Set(dmUnread()); next.delete(newId); dmUnread.set(next)
|
|
965
|
-
}
|
|
966
|
-
})
|
|
967
|
-
|
|
968
|
-
ws.on('hub.reordered', ({ hubs: updated }) => {
|
|
969
|
-
// Merge server-authoritative order into local state, preserving loaded channel arrays
|
|
970
|
-
const channelMap = new Map(hubs().map(h => [h.hub_id, h.channels]))
|
|
971
|
-
hubs.set(updated.map(h => ({ ...h, channels: channelMap.get(h.hub_id) ?? [] })))
|
|
972
|
-
})
|
|
973
|
-
|
|
974
|
-
ws.on('hub.deleted', ({ hub_id }) => {
|
|
975
|
-
const deletedHub = hubs().find(h => h.hub_id === hub_id)
|
|
976
|
-
const affectsCurrentChannel = (deletedHub?.channels ?? []).some(c => c.channel_id === currentChannelId)
|
|
977
|
-
hubs.set(hubs().filter(h => h.hub_id !== hub_id))
|
|
978
|
-
if (affectsCurrentChannel) navigateAfterDeletion(hubs())
|
|
979
|
-
})
|
|
980
|
-
|
|
981
|
-
ws.on('channel.created', ({ channel }) => {
|
|
982
|
-
hubs.set(hubs().map(h => {
|
|
983
|
-
if (h.hub_id !== channel.hub_id) return h
|
|
984
|
-
const channels = [...(h.channels ?? []), {
|
|
985
|
-
...channel,
|
|
986
|
-
url: `${window.__BASE_PATH__}/channels/${channel.channel_id}`,
|
|
987
|
-
label: `# ${channel.name}`,
|
|
988
|
-
className: 'channel-item'
|
|
989
|
-
}]
|
|
990
|
-
return { ...h, channels }
|
|
991
|
-
}))
|
|
992
|
-
})
|
|
993
|
-
|
|
994
|
-
ws.on('channel.updated', ({ channel }) => {
|
|
995
|
-
hubs.set(hubs().map(h => {
|
|
996
|
-
if (h.hub_id !== channel.hub_id) return h
|
|
997
|
-
return {
|
|
998
|
-
...h,
|
|
999
|
-
channels: (h.channels ?? []).map(c =>
|
|
1000
|
-
c.channel_id === channel.channel_id
|
|
1001
|
-
? { ...c, ...channel, label: `# ${channel.name}` }
|
|
1002
|
-
: c
|
|
1003
|
-
)
|
|
1004
|
-
}
|
|
1005
|
-
}))
|
|
1006
|
-
})
|
|
1007
|
-
|
|
1008
|
-
ws.on('channel.reordered', ({ hub_id, channels }) => {
|
|
1009
|
-
hubs.set(hubs().map(h => {
|
|
1010
|
-
if (h.hub_id !== hub_id) return h
|
|
1011
|
-
const channelMap = new Map((h.channels ?? []).map(c => [c.channel_id, c]))
|
|
1012
|
-
const reordered = channels.map(c => ({ ...channelMap.get(c.channel_id), ...c, url: `${window.__BASE_PATH__}/channels/${c.channel_id}` }))
|
|
1013
|
-
return { ...h, channels: reordered }
|
|
1014
|
-
}))
|
|
1015
|
-
})
|
|
1016
|
-
|
|
1017
|
-
ws.on('channel.deleted', ({ channel_id }) => {
|
|
1018
|
-
const wasCurrentChannel = channel_id === currentChannelId
|
|
1019
|
-
hubs.set(hubs().map(h => ({
|
|
1020
|
-
...h,
|
|
1021
|
-
channels: (h.channels ?? []).filter(c => c.channel_id !== channel_id)
|
|
1022
|
-
})))
|
|
1023
|
-
if (wasCurrentChannel) navigateAfterDeletion(hubs())
|
|
1024
|
-
})
|
|
1025
|
-
|
|
1026
|
-
// Channel link clicks: dispatch channelnavigated + mobile sidebar hide + clear mention dot
|
|
1027
|
-
root.addEventListener('click', e => {
|
|
1028
|
-
const link = e.target.closest('.channel-link')
|
|
1029
|
-
if (!link) return
|
|
1030
|
-
const clickedChannelId = link.dataset.channelId
|
|
1031
|
-
document.dispatchEvent(new CustomEvent('channelnavigated', {
|
|
1032
|
-
detail: { channelId: clickedChannelId }
|
|
1033
|
-
}))
|
|
1034
|
-
// Clear mention/urgent dot for this channel
|
|
1035
|
-
if (clickedChannelId && (mentionedChannels().has(clickedChannelId) || urgentChannels().has(clickedChannelId))) {
|
|
1036
|
-
const nextMentioned = new Set(mentionedChannels())
|
|
1037
|
-
const nextUrgent = new Set(urgentChannels())
|
|
1038
|
-
nextMentioned.delete(clickedChannelId)
|
|
1039
|
-
nextUrgent.delete(clickedChannelId)
|
|
1040
|
-
mentionedChannels.set(nextMentioned)
|
|
1041
|
-
urgentChannels.set(nextUrgent)
|
|
1042
|
-
}
|
|
1043
|
-
if (window.matchMedia('(max-width: 700px)').matches) {
|
|
1044
|
-
document.body.classList.remove('sidebar-open')
|
|
1045
|
-
}
|
|
1046
|
-
})
|
|
1047
|
-
|
|
1048
|
-
// Mention dot management.
|
|
1049
|
-
// Uses data-mention / data-urgent attributes instead of CSS classes so that
|
|
1050
|
-
// rdbljs className re-renders (el.className = ...) never wipe the dot state.
|
|
1051
|
-
function updateMentionDots() {
|
|
1052
|
-
const mentioned = mentionedChannels()
|
|
1053
|
-
const urgent = urgentChannels()
|
|
1054
|
-
root.querySelectorAll('.channel-item').forEach(li => {
|
|
1055
|
-
const link = li.querySelector('.channel-link')
|
|
1056
|
-
const channelId = link?.dataset.channelId
|
|
1057
|
-
if (!channelId) return
|
|
1058
|
-
if (urgent.has(channelId)) {
|
|
1059
|
-
li.dataset.urgent = ''
|
|
1060
|
-
delete li.dataset.mention
|
|
1061
|
-
} else if (mentioned.has(channelId)) {
|
|
1062
|
-
li.dataset.mention = ''
|
|
1063
|
-
delete li.dataset.urgent
|
|
1064
|
-
} else {
|
|
1065
|
-
delete li.dataset.mention
|
|
1066
|
-
delete li.dataset.urgent
|
|
1067
|
-
}
|
|
1068
|
-
})
|
|
1069
|
-
}
|
|
1070
|
-
effect(() => updateMentionDots())
|
|
1071
|
-
|
|
1072
|
-
// DMs list rendering — driven by dms + dmUnread signals via effect below
|
|
1073
|
-
const dmListEl = root.querySelector('#dm-list')
|
|
1074
|
-
function renderDms() {
|
|
1075
|
-
if (!dmListEl) return
|
|
1076
|
-
const list = dms()
|
|
1077
|
-
const unread = dmUnread()
|
|
1078
|
-
if (list.length === 0) {
|
|
1079
|
-
dmListEl.innerHTML = '<li class="dm-empty" style="padding:4px 8px;color:var(--text-muted);font-size:13px">No messages yet.</li>'
|
|
1080
|
-
return
|
|
1081
|
-
}
|
|
1082
|
-
dmListEl.innerHTML = list.map(d => {
|
|
1083
|
-
const name = escHtml(d.with_user?.display_name ?? d.channel_id)
|
|
1084
|
-
const selected = d.channel_id === currentChannelId ? ' dm-selected' : ''
|
|
1085
|
-
const mentionAttr = unread.has(d.channel_id) ? ' data-mention=""' : ''
|
|
1086
|
-
return `<li class="dm-item${selected}"${mentionAttr} data-channel-id="${escHtml(d.channel_id)}">
|
|
1087
|
-
<a class="dm-link channel-link" href="${window.__BASE_PATH__}/channels/${escHtml(d.channel_id)}" data-channel-id="${escHtml(d.channel_id)}">
|
|
1088
|
-
<span class="dm-name">${name}</span>
|
|
1089
|
-
</a>
|
|
1090
|
-
</li>`
|
|
1091
|
-
}).join('')
|
|
1092
|
-
}
|
|
1093
|
-
effect(() => renderDms())
|
|
1094
|
-
|
|
1095
|
-
// Fetch DM list as soon as the socket opens — the connection is already
|
|
1096
|
-
// authenticated via the session cookie at upgrade time, so no hello needed.
|
|
1097
|
-
ws.on('open', () => ws.send({ t: 'dm.list', body: {} }))
|
|
1098
|
-
|
|
1099
|
-
// New hub button
|
|
1100
|
-
root.querySelector('#btn-new-hub')?.addEventListener('click', () => {
|
|
1101
|
-
isTouch() ? openCreateHubSheet(ws) : openCreateHubModal(ws)
|
|
1102
|
-
})
|
|
1103
|
-
|
|
1104
|
-
// Wire management handlers (event delegation — attached once, survives re-renders)
|
|
1105
|
-
attachManagementHandlers(root, { ws, hubs })
|
|
1106
|
-
|
|
1107
|
-
// Wire drag-and-drop reordering (desktop only — touch uses action sheet)
|
|
1108
|
-
attachDragHandlers(root, { ws, hubs })
|
|
1109
|
-
attachHubDragHandlers(root, { ws, hubs })
|
|
1110
|
-
|
|
1111
|
-
// Wire file-drop onto channel links
|
|
1112
|
-
attachFileDropHandlers(root, { ws })
|
|
1113
|
-
|
|
1114
|
-
// ── Web Push subscription ─────────────────────────────────────────────────
|
|
1115
|
-
// Browsers require a user gesture to call Notification.requestPermission().
|
|
1116
|
-
// Strategy: show a small "Enable notifications" button in the sidebar footer.
|
|
1117
|
-
// It appears when VAPID is configured + browser supports push + permission is
|
|
1118
|
-
// 'default'. Clicking it (user gesture) requests permission then subscribes.
|
|
1119
|
-
const vapidKey = root.dataset.vapidKey ?? ''
|
|
1120
|
-
if (vapidKey && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window) {
|
|
1121
|
-
function vapidKeyToUint8Array(b64url) {
|
|
1122
|
-
const padded = b64url + '==='.slice((b64url.length + 3) % 4)
|
|
1123
|
-
const b64 = padded.replace(/-/g, '+').replace(/_/g, '/')
|
|
1124
|
-
return Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
async function subscribeToPush(swReg) {
|
|
1128
|
-
try {
|
|
1129
|
-
const sub = await swReg.pushManager.subscribe({
|
|
1130
|
-
userVisibleOnly: true,
|
|
1131
|
-
applicationServerKey: vapidKeyToUint8Array(vapidKey),
|
|
1132
|
-
})
|
|
1133
|
-
ws.send({ t: 'push.subscribe', body: { subscription: sub.toJSON() } })
|
|
1134
|
-
} catch { /* subscribe failed or user blocked — ignore */ }
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// Register SW once and hold a reference for later use.
|
|
1138
|
-
// After registration, probe pushManager to confirm push actually works in
|
|
1139
|
-
// this browser context — Safari Private windows register a SW fine but
|
|
1140
|
-
// silently refuse push subscriptions, so we skip the button there.
|
|
1141
|
-
let swReg = null
|
|
1142
|
-
navigator.serviceWorker.register(`${window.__BASE_PATH__}/sw.js`, { scope: `${window.__BASE_PATH__}/` })
|
|
1143
|
-
.then(async reg => {
|
|
1144
|
-
swReg = reg
|
|
1145
|
-
// Confirm push is functional by checking the subscription API
|
|
1146
|
-
try { await reg.pushManager.getSubscription() } catch {
|
|
1147
|
-
return // push not available in this context (e.g. Safari Private)
|
|
1148
|
-
}
|
|
1149
|
-
if (Notification.permission === 'granted') subscribeToPush(reg)
|
|
1150
|
-
if (Notification.permission !== 'granted') showEnableButton()
|
|
1151
|
-
})
|
|
1152
|
-
.catch(() => { /* http in dev, or SW blocked entirely */ })
|
|
1153
|
-
|
|
1154
|
-
// Inject a small "Enable notifications" button into the sidebar footer.
|
|
1155
|
-
// This is the only place we can legally call requestPermission() — inside
|
|
1156
|
-
// a synchronous click handler (user gesture).
|
|
1157
|
-
//
|
|
1158
|
-
// Three states:
|
|
1159
|
-
// 'default' → clickable, opens browser permission dialog
|
|
1160
|
-
// 'denied' → non-clickable, tells user to update browser settings
|
|
1161
|
-
// 'granted' → button is removed entirely
|
|
1162
|
-
let enableBtn = null
|
|
1163
|
-
|
|
1164
|
-
function updateEnableButton() {
|
|
1165
|
-
if (!enableBtn) return
|
|
1166
|
-
const perm = Notification.permission
|
|
1167
|
-
if (perm === 'granted') {
|
|
1168
|
-
enableBtn.remove()
|
|
1169
|
-
enableBtn = null
|
|
1170
|
-
return
|
|
1171
|
-
}
|
|
1172
|
-
if (perm === 'denied') {
|
|
1173
|
-
enableBtn.textContent = '🔕 Notifications blocked in browser settings'
|
|
1174
|
-
enableBtn.dataset.blocked = 'true'
|
|
1175
|
-
} else {
|
|
1176
|
-
enableBtn.textContent = '🔔 Enable notifications'
|
|
1177
|
-
delete enableBtn.dataset.blocked
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
function showEnableButton() {
|
|
1182
|
-
if (enableBtn) return
|
|
1183
|
-
enableBtn = document.createElement('button')
|
|
1184
|
-
enableBtn.className = 'btn-enable-push'
|
|
1185
|
-
enableBtn.type = 'button'
|
|
1186
|
-
enableBtn.addEventListener('click', async () => {
|
|
1187
|
-
if (enableBtn.dataset.blocked) return // denied — browser won't show dialog
|
|
1188
|
-
const permission = await Notification.requestPermission()
|
|
1189
|
-
updateEnableButton()
|
|
1190
|
-
if (permission === 'granted' && swReg) subscribeToPush(swReg)
|
|
1191
|
-
})
|
|
1192
|
-
root.querySelector('.sidebar-footer')?.prepend(enableBtn)
|
|
1193
|
-
updateEnableButton()
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
return { hubs }
|
|
1198
|
-
}
|