@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.
Files changed (39) hide show
  1. package/index.js +0 -9
  2. package/package.json +2 -3
  3. package/pages/_layout.html +0 -7
  4. package/pages/admin/_layout.html +0 -7
  5. package/pages/channels/[channelId].phtml +17 -42
  6. package/pages/design/_layout.html +349 -0
  7. package/pages/design/_layout.js +13 -0
  8. package/pages/design/components/index.js +3 -0
  9. package/pages/design/components/index.phtml +380 -0
  10. package/pages/design/index.js +3 -0
  11. package/pages/design/index.phtml +78 -0
  12. package/pages/design/principles/index.js +3 -0
  13. package/pages/design/principles/index.phtml +147 -0
  14. package/pages/design/tokens/index.js +3 -0
  15. package/pages/design/tokens/index.phtml +236 -0
  16. package/pages/public/client/app.js +171 -13
  17. package/pages/public/client/controllers/ChatController.js +204 -0
  18. package/pages/public/client/controllers/WebSocketController.js +191 -0
  19. package/pages/public/client/model/AppModel.js +351 -0
  20. package/pages/public/client/model/events.js +41 -0
  21. package/pages/public/client/resizable.js +74 -0
  22. package/pages/public/client/rtc-peer-manager.js +5 -2
  23. package/pages/public/client/settings-sync.js +45 -7
  24. package/pages/public/client/shared/messages.js +21 -9
  25. package/pages/public/client/theme.js +6 -4
  26. package/pages/public/client/views/CallView.js +754 -0
  27. package/pages/public/client/views/ChatHeaderView.js +67 -0
  28. package/pages/public/client/views/ComposerView.js +491 -0
  29. package/pages/public/client/views/MessageListView.js +461 -0
  30. package/pages/public/client/views/SidebarView.js +977 -0
  31. package/pages/public/client/views/ThreadPanelView.js +260 -0
  32. package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
  33. package/pages/public/client/views/shared/MentionPicker.js +139 -0
  34. package/pages/public/client/views/shared/MessageInteractions.js +353 -0
  35. package/pages/public/themes/base.css +29 -31
  36. package/src/ws/ChatServer.js +2 -1
  37. package/src/ws/handlers/rtcHandlers.js +7 -0
  38. package/pages/public/client/islands/call.js +0 -2282
  39. package/pages/public/client/islands/sidebar.js +0 -1198
@@ -0,0 +1,191 @@
1
+ /**
2
+ * WebSocketController.js — maps incoming WebSocket messages to AppModel mutations.
3
+ *
4
+ * This is the only place ws.on(...) calls appear for server→client events.
5
+ * It owns the WsClient instance and hands it to ChatController for sends.
6
+ *
7
+ * Rules:
8
+ * - Never touches the DOM.
9
+ * - Never dispatches CustomEvents directly — calls model mutators instead.
10
+ * - Filters messages by channelId when the event is channel-scoped.
11
+ */
12
+
13
+ import { WsClient } from '../ws.js'
14
+
15
+ export class WebSocketController {
16
+ #ws
17
+ #model
18
+
19
+ /**
20
+ * @param {AppModel} model
21
+ * @param {string} wsPath e.g. '/ws' or '/base/ws'
22
+ */
23
+ constructor(model, wsPath = '/ws') {
24
+ this.#model = model
25
+ this.#ws = new WsClient(wsPath)
26
+ this.#wire()
27
+ }
28
+
29
+ /** Expose WsClient so ChatController can call ws.send() */
30
+ get ws() { return this.#ws }
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────
33
+ // Private: wire all incoming WS events
34
+ // ─────────────────────────────────────────────────────────────────────────
35
+
36
+ #wire() {
37
+ const ws = this.#ws
38
+ const model = this.#model
39
+
40
+ // ── Session handshake ──────────────────────────────────────────────────
41
+ ws.on('open', () => {
42
+ ws.send({ t: 'hello', body: { client: 'devchitchat', resume: { session_token: null } } })
43
+ })
44
+
45
+ ws.on('hello_ack', () => {
46
+ // Join the current channel if one is already selected (e.g. on reconnect)
47
+ const channelId = model.currentChannelId
48
+ if (channelId) ws.send({ t: 'channel.join', body: { channel_id: channelId } })
49
+ })
50
+
51
+ ws.on('channel.joined', ({ channel_id }) => {
52
+ // Request users/bots for mention picker if not yet loaded
53
+ if (model.members.length === 0) {
54
+ ws.send({ t: 'user.list', body: {} })
55
+ ws.send({ t: 'bot.list', body: {} })
56
+ }
57
+
58
+ // Catch up on messages missed during disconnect.
59
+ // newestSeqFor returns the highest seq the client already has; request
60
+ // everything after that so the model stays current without a full reload.
61
+ const cid = channel_id ?? model.currentChannelId
62
+ if (cid) {
63
+ const afterSeq = model.newestSeqFor(cid)
64
+ if (afterSeq > 0) {
65
+ ws.send({ t: 'msg.list', body: { channel_id: cid, after_seq: afterSeq } })
66
+ }
67
+ }
68
+ })
69
+
70
+ // ── Member lists ───────────────────────────────────────────────────────
71
+ ws.on('user.list_result', ({ users }) => {
72
+ model.setMembers((users ?? []).filter(u => u.handle))
73
+ })
74
+
75
+ ws.on('bot.list_result', ({ bots }) => {
76
+ model.setBots((bots ?? []).filter(b => b.handle))
77
+ })
78
+
79
+ // ── Messages ───────────────────────────────────────────────────────────
80
+ ws.on('msg.list_result', ({ messages, next_after_seq, has_more, direction, channel_id }) => {
81
+ const channelId = channel_id ?? model.currentChannelId
82
+ if (!channelId) return
83
+
84
+ if (direction === 'before') {
85
+ model.prependMessages(channelId, messages ?? [], has_more ?? false)
86
+ return
87
+ }
88
+
89
+ // after_seq catch-up: append each message
90
+ for (const msg of (messages ?? [])) {
91
+ model.addMessage(channelId, msg)
92
+ }
93
+ })
94
+
95
+ ws.on('msg.event', (body) => {
96
+ const channelId = body.channel_id
97
+ if (!channelId) return
98
+ // Thread replies come via thread.reply_event; skip them here
99
+ if (body.parent_msg_id) return
100
+ model.addMessage(channelId, body)
101
+ })
102
+
103
+ ws.on('msg.edited', ({ msg_id, channel_id, text, edited_at, rendered_text }) => {
104
+ const channelId = channel_id ?? model.currentChannelId
105
+ if (!channelId) return
106
+ model.updateMessage(channelId, { msg_id, text, edited_at, rendered_text })
107
+
108
+ // If this msg is a thread reply that's currently open
109
+ if (model.threadParentId) {
110
+ model.updateThreadReply(model.threadParentId, { msg_id, text, edited_at, rendered_text })
111
+ }
112
+ })
113
+
114
+ ws.on('msg.deleted', ({ msg_id, channel_id }) => {
115
+ const channelId = channel_id ?? model.currentChannelId
116
+ if (channelId) model.deleteMessage(channelId, msg_id)
117
+ // Also try thread replies
118
+ if (model.threadParentId) {
119
+ model.deleteThreadReply(model.threadParentId, msg_id)
120
+ }
121
+ })
122
+
123
+ ws.on('reaction.event', ({ msg_id, channel_id, reactions }) => {
124
+ const channelId = channel_id ?? model.currentChannelId
125
+ if (!channelId) return
126
+ model.setReactions(msg_id, channelId, reactions ?? [])
127
+ })
128
+
129
+ // ── Channel metadata ───────────────────────────────────────────────────
130
+ ws.on('channel.updated', ({ channel }) => {
131
+ if (!channel?.channel_id) return
132
+ model.updateChannelMeta(channel.channel_id, {
133
+ name: channel.name,
134
+ topic: channel.topic ?? '',
135
+ })
136
+ // Keep sidebar channel list in sync
137
+ if (channel.hub_id) model.upsertChannel(channel)
138
+ })
139
+
140
+ ws.on('channel.created', ({ channel }) => {
141
+ if (channel?.hub_id) model.upsertChannel(channel)
142
+ })
143
+
144
+ ws.on('channel.deleted', ({ channel_id }) => {
145
+ model.removeChannel(channel_id)
146
+ })
147
+
148
+ // ── Hub events ─────────────────────────────────────────────────────────
149
+ ws.on('hub.created', ({ hub }) => {
150
+ model.upsertHub(hub)
151
+ })
152
+
153
+ ws.on('hub.updated', ({ hub }) => {
154
+ model.upsertHub(hub)
155
+ })
156
+
157
+ ws.on('hub.deleted', ({ hub_id }) => {
158
+ model.removeHub(hub_id)
159
+ })
160
+
161
+ // ── Thread ─────────────────────────────────────────────────────────────
162
+ ws.on('thread.list_result', ({ parent_msg_id, replies }) => {
163
+ model.loadThreadReplies(parent_msg_id, replies ?? [])
164
+ })
165
+
166
+ ws.on('thread.reply_event', ({ parent_msg_id, channel_id, reply }) => {
167
+ model.addThreadReply(parent_msg_id, reply)
168
+ })
169
+
170
+ // ── Presence ───────────────────────────────────────────────────────────
171
+ ws.on('presence.event', ({ user_id, status }) => {
172
+ model.setPresence(user_id, status)
173
+ })
174
+
175
+ ws.on('presence.list_result', ({ entries }) => {
176
+ model.setBulkPresence(entries ?? [])
177
+ })
178
+
179
+ // ── DMs ────────────────────────────────────────────────────────────────
180
+ ws.on('dm.opened', ({ channel }) => {
181
+ if (!channel) return
182
+ const dms = model.dms
183
+ const already = dms.some(d => d.channel_id === channel.channel_id)
184
+ if (!already) model.setDms([...dms, channel])
185
+ })
186
+
187
+ // ── Call events are forwarded as-is to the current call state object ───
188
+ // CallView registers its own ws.on() handlers; we don't touch call state
189
+ // here to keep call logic isolated in CallView / ChatController.
190
+ }
191
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * AppModel.js — single source of truth for all client-side state.
3
+ *
4
+ * Extends EventTarget so any object can call:
5
+ * model.addEventListener('message-added', handler)
6
+ *
7
+ * Rules:
8
+ * - No DOM imports. No ws.send(). Pure state + events.
9
+ * - Every mutator method ends by dispatching a CustomEvent.
10
+ * - Views read from the model only via events (or getters on initial render).
11
+ * - Controllers call mutators; they never dispatch events directly.
12
+ */
13
+
14
+ import * as Ev from './events.js'
15
+
16
+ export class AppModel extends EventTarget {
17
+ // ── Identity ────────────────────────────────────────────────────────────────
18
+ #userId = null
19
+ #userHandle = null
20
+
21
+ // ── Sidebar state ───────────────────────────────────────────────────────────
22
+ #hubs = [] // [{ hub_id, name, visibility, channels:[] }]
23
+ #dms = [] // [{ channel_id, name, user_id, handle, online }]
24
+ #presence = new Map() // userId → 'online'|'away'|'offline'
25
+
26
+ // ── Members (for @mention picker) ──────────────────────────────────────────
27
+ #members = [] // [{ user_id, handle, display_name }] — all users + bots
28
+ #bots = []
29
+
30
+ // ── Navigation ─────────────────────────────────────────────────────────────
31
+ #currentChannelId = null
32
+ #currentChannelMeta = {} // { name, topic, kind, visibility }
33
+
34
+ // ── Messages (cached per channel) ──────────────────────────────────────────
35
+ // channelId → Message[] (chronological, oldest first)
36
+ #messages = new Map()
37
+ #oldestSeq = new Map() // channelId → number (lowest seq seen)
38
+ #hasMore = new Map() // channelId → bool
39
+ #loadingMore = false
40
+
41
+ // ── Thread panel ───────────────────────────────────────────────────────────
42
+ #threadParentId = null
43
+ #threadParentMsg = null
44
+ #threads = new Map() // parentMsgId → Reply[]
45
+
46
+ // ── Call ────────────────────────────────────────────────────────────────────
47
+ #call = null // null = no active call; otherwise opaque object from WebSocketController
48
+
49
+ // ─────────────────────────────────────────────────────────────────────────
50
+ // Identity
51
+ // ─────────────────────────────────────────────────────────────────────────
52
+
53
+ get userId() { return this.#userId }
54
+ get userHandle() { return this.#userHandle }
55
+
56
+ setIdentity({ userId, userHandle }) {
57
+ this.#userId = userId
58
+ this.#userHandle = userHandle
59
+ }
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────
62
+ // Hubs & DMs
63
+ // ─────────────────────────────────────────────────────────────────────────
64
+
65
+ get hubs() { return this.#hubs }
66
+ get dms() { return this.#dms }
67
+
68
+ setHubs(hubs) {
69
+ this.#hubs = hubs
70
+ this.#dispatch(Ev.HUBS_CHANGED, { hubs })
71
+ }
72
+
73
+ setDms(dms) {
74
+ this.#dms = dms
75
+ this.#dispatch(Ev.DMS_CHANGED, { dms })
76
+ }
77
+
78
+ /** Add or update a single hub */
79
+ upsertHub(hub) {
80
+ const idx = this.#hubs.findIndex(h => h.hub_id === hub.hub_id)
81
+ if (idx === -1) {
82
+ this.#hubs = [...this.#hubs, { ...hub, channels: hub.channels ?? [] }]
83
+ } else {
84
+ const existing = this.#hubs[idx]
85
+ this.#hubs = [
86
+ ...this.#hubs.slice(0, idx),
87
+ { ...existing, ...hub, channels: hub.channels ?? existing.channels },
88
+ ...this.#hubs.slice(idx + 1),
89
+ ]
90
+ }
91
+ this.#dispatch(Ev.HUBS_CHANGED, { hubs: this.#hubs })
92
+ }
93
+
94
+ removeHub(hubId) {
95
+ this.#hubs = this.#hubs.filter(h => h.hub_id !== hubId)
96
+ this.#dispatch(Ev.HUBS_CHANGED, { hubs: this.#hubs })
97
+ }
98
+
99
+ /** Add or update a channel inside its hub */
100
+ upsertChannel(channel) {
101
+ const hubIdx = this.#hubs.findIndex(h => h.hub_id === channel.hub_id)
102
+ if (hubIdx === -1) return
103
+ const hub = this.#hubs[hubIdx]
104
+ const chIdx = hub.channels.findIndex(c => c.channel_id === channel.channel_id)
105
+ const channels = chIdx === -1
106
+ ? [...hub.channels, channel]
107
+ : hub.channels.map((c, i) => i === chIdx ? { ...c, ...channel } : c)
108
+ this.#hubs = this.#hubs.map((h, i) => i === hubIdx ? { ...h, channels } : h)
109
+ this.#dispatch(Ev.HUBS_CHANGED, { hubs: this.#hubs })
110
+ }
111
+
112
+ removeChannel(channelId) {
113
+ this.#hubs = this.#hubs.map(h => ({
114
+ ...h,
115
+ channels: h.channels.filter(c => c.channel_id !== channelId),
116
+ }))
117
+ this.#dispatch(Ev.HUBS_CHANGED, { hubs: this.#hubs })
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+ // Presence
122
+ // ─────────────────────────────────────────────────────────────────────────
123
+
124
+ get presence() { return this.#presence }
125
+
126
+ setPresence(userId, status) {
127
+ this.#presence.set(userId, status)
128
+ this.#dispatch(Ev.PRESENCE_UPDATED, { userId, status })
129
+ }
130
+
131
+ setBulkPresence(entries) {
132
+ for (const { user_id, status } of entries) {
133
+ this.#presence.set(user_id, status)
134
+ }
135
+ this.#dispatch(Ev.PRESENCE_UPDATED, { bulk: entries })
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────
139
+ // Members (mention picker)
140
+ // ─────────────────────────────────────────────────────────────────────────
141
+
142
+ get members() { return this.#members }
143
+ get bots() { return this.#bots }
144
+
145
+ /** Set of all known @handles (users + bots), lowercased, for mention validation. */
146
+ get knownHandles() {
147
+ return new Set([
148
+ ...this.#members.map(m => m.handle.toLowerCase()),
149
+ ...this.#bots.map(b => b.handle.toLowerCase()),
150
+ ])
151
+ }
152
+
153
+ setMembers(members) {
154
+ this.#members = members
155
+ this.#dispatch(Ev.MEMBERS_UPDATED, { members, bots: this.#bots })
156
+ }
157
+
158
+ setBots(bots) {
159
+ this.#bots = bots
160
+ this.#dispatch(Ev.MEMBERS_UPDATED, { members: this.#members, bots })
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+ // Navigation
165
+ // ─────────────────────────────────────────────────────────────────────────
166
+
167
+ get currentChannelId() { return this.#currentChannelId }
168
+ get currentChannelMeta() { return this.#currentChannelMeta }
169
+
170
+ selectChannel(channelId, meta = {}) {
171
+ const prev = this.#currentChannelId
172
+ this.#currentChannelId = channelId
173
+ this.#currentChannelMeta = meta
174
+ this.#dispatch(Ev.CHANNEL_SELECTED, { channelId, prev, meta })
175
+ }
176
+
177
+ updateChannelMeta(channelId, patch) {
178
+ if (channelId === this.#currentChannelId) {
179
+ this.#currentChannelMeta = { ...this.#currentChannelMeta, ...patch }
180
+ }
181
+ this.#dispatch(Ev.CHANNEL_META_UPDATED, { channelId, ...patch })
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────────
185
+ // Messages
186
+ // ─────────────────────────────────────────────────────────────────────────
187
+
188
+ messagesFor(channelId) {
189
+ return this.#messages.get(channelId) ?? []
190
+ }
191
+
192
+ oldestSeqFor(channelId) {
193
+ return this.#oldestSeq.get(channelId) ?? 0
194
+ }
195
+
196
+ newestSeqFor(channelId) {
197
+ const msgs = this.#messages.get(channelId) ?? []
198
+ if (msgs.length === 0) return 0
199
+ return Math.max(...msgs.map(m => m.seq ?? 0))
200
+ }
201
+
202
+ hasMoreFor(channelId) {
203
+ return this.#hasMore.get(channelId) ?? false
204
+ }
205
+
206
+ get loadingMore() { return this.#loadingMore }
207
+
208
+ /**
209
+ * Called on first load: seed messages from SSR are already in the DOM.
210
+ * We just record the sequence bookmarks; the view does not re-render them.
211
+ */
212
+ seedMessages(channelId, { oldestSeq, hasMore }) {
213
+ if (!this.#messages.has(channelId)) this.#messages.set(channelId, [])
214
+ this.#oldestSeq.set(channelId, oldestSeq)
215
+ this.#hasMore.set(channelId, hasMore)
216
+ }
217
+
218
+ addMessage(channelId, message) {
219
+ const msgs = this.#messages.get(channelId) ?? []
220
+ // Deduplicate by msg_id
221
+ if (msgs.some(m => m.msg_id === message.msg_id)) return
222
+ this.#messages.set(channelId, [...msgs, message])
223
+ this.#dispatch(Ev.MESSAGE_ADDED, { channelId, message })
224
+ }
225
+
226
+ updateMessage(channelId, message) {
227
+ const msgs = this.#messages.get(channelId) ?? []
228
+ this.#messages.set(channelId, msgs.map(m => m.msg_id === message.msg_id ? { ...m, ...message } : m))
229
+ this.#dispatch(Ev.MESSAGE_UPDATED, { channelId, message })
230
+ }
231
+
232
+ deleteMessage(channelId, msgId) {
233
+ const msgs = this.#messages.get(channelId) ?? []
234
+ this.#messages.set(channelId, msgs.filter(m => m.msg_id !== msgId))
235
+ this.#dispatch(Ev.MESSAGE_DELETED, { channelId, msgId })
236
+ }
237
+
238
+ setReactions(msgId, channelId, reactions) {
239
+ const msgs = this.#messages.get(channelId) ?? []
240
+ this.#messages.set(channelId, msgs.map(m =>
241
+ m.msg_id === msgId ? { ...m, reactions } : m
242
+ ))
243
+ this.#dispatch(Ev.REACTIONS_UPDATED, { msgId, channelId, reactions })
244
+ }
245
+
246
+ /**
247
+ * Older messages loaded by pagination (prepend direction).
248
+ * msgs: oldest-first array.
249
+ */
250
+ prependMessages(channelId, msgs, hasMore) {
251
+ const existing = this.#messages.get(channelId) ?? []
252
+ // Avoid duplicates that may have arrived via msg.event while loading
253
+ const existingIds = new Set(existing.map(m => m.msg_id))
254
+ const unique = msgs.filter(m => !existingIds.has(m.msg_id))
255
+ this.#messages.set(channelId, [...unique, ...existing])
256
+ if (msgs.length > 0) {
257
+ const minSeq = Math.min(...msgs.map(m => m.seq ?? Infinity))
258
+ const prev = this.#oldestSeq.get(channelId) ?? Infinity
259
+ if (minSeq < prev) this.#oldestSeq.set(channelId, minSeq)
260
+ }
261
+ this.#hasMore.set(channelId, hasMore)
262
+ this.setLoadingMore(false)
263
+ this.#dispatch(Ev.MESSAGES_PREPENDED, { channelId, messages: unique, hasMore })
264
+ }
265
+
266
+ setLoadingMore(loading) {
267
+ this.#loadingMore = loading
268
+ this.#dispatch(Ev.LOADING_MORE_CHANGED, { loading })
269
+ }
270
+
271
+ // ─────────────────────────────────────────────────────────────────────────
272
+ // Thread panel
273
+ // ─────────────────────────────────────────────────────────────────────────
274
+
275
+ get threadParentId() { return this.#threadParentId }
276
+ get threadParentMsg() { return this.#threadParentMsg }
277
+
278
+ threadRepliesFor(parentMsgId) {
279
+ return this.#threads.get(parentMsgId) ?? []
280
+ }
281
+
282
+ openThread(parentMsgId, parentMsg) {
283
+ this.#threadParentId = parentMsgId
284
+ this.#threadParentMsg = parentMsg
285
+ this.#dispatch(Ev.THREAD_OPENED, { parentMsgId, parentMsg })
286
+ }
287
+
288
+ closeThread() {
289
+ this.#threadParentId = null
290
+ this.#threadParentMsg = null
291
+ this.#dispatch(Ev.THREAD_CLOSED, {})
292
+ }
293
+
294
+ loadThreadReplies(parentMsgId, replies) {
295
+ this.#threads.set(parentMsgId, replies)
296
+ this.#dispatch(Ev.THREAD_LOADED, { parentMsgId, replies })
297
+ }
298
+
299
+ addThreadReply(parentMsgId, reply) {
300
+ const existing = this.#threads.get(parentMsgId) ?? []
301
+ if (existing.some(r => r.msg_id === reply.msg_id)) return
302
+ this.#threads.set(parentMsgId, [...existing, reply])
303
+
304
+ // Also update reply count on the parent message in every channel cache
305
+ for (const [channelId, msgs] of this.#messages) {
306
+ const parent = msgs.find(m => m.msg_id === parentMsgId)
307
+ if (parent) {
308
+ this.updateMessage(channelId, {
309
+ ...parent,
310
+ reply_count: (parent.reply_count ?? 0) + 1,
311
+ })
312
+ break
313
+ }
314
+ }
315
+
316
+ this.#dispatch(Ev.THREAD_REPLY_ADDED, { parentMsgId, reply })
317
+ }
318
+
319
+ updateThreadReply(parentMsgId, reply) {
320
+ const existing = this.#threads.get(parentMsgId) ?? []
321
+ this.#threads.set(parentMsgId, existing.map(r =>
322
+ r.msg_id === reply.msg_id ? { ...r, ...reply } : r
323
+ ))
324
+ this.#dispatch(Ev.THREAD_REPLY_UPDATED, { parentMsgId, reply })
325
+ }
326
+
327
+ deleteThreadReply(parentMsgId, msgId) {
328
+ const existing = this.#threads.get(parentMsgId) ?? []
329
+ this.#threads.set(parentMsgId, existing.filter(r => r.msg_id !== msgId))
330
+ this.#dispatch(Ev.THREAD_REPLY_DELETED, { parentMsgId, msgId })
331
+ }
332
+
333
+ // ─────────────────────────────────────────────────────────────────────────
334
+ // Call
335
+ // ─────────────────────────────────────────────────────────────────────────
336
+
337
+ get call() { return this.#call }
338
+
339
+ setCall(call) {
340
+ this.#call = call
341
+ this.#dispatch(Ev.CALL_CHANGED, { call })
342
+ }
343
+
344
+ // ─────────────────────────────────────────────────────────────────────────
345
+ // Private helpers
346
+ // ─────────────────────────────────────────────────────────────────────────
347
+
348
+ #dispatch(name, detail) {
349
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: false }))
350
+ }
351
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * events.js — AppModel event name constants.
3
+ *
4
+ * All CustomEvents dispatched by AppModel use these names.
5
+ * Import this wherever you listen to or dispatch model events.
6
+ *
7
+ * Convention: "<noun>-<verb>" in lowercase with hyphens (browser DOM style).
8
+ */
9
+
10
+ // ── Navigation ────────────────────────────────────────────────────────────────
11
+ export const CHANNEL_SELECTED = 'channel-selected' // { channelId, prev }
12
+
13
+ // ── Hub / channel list (sidebar) ──────────────────────────────────────────────
14
+ export const HUBS_CHANGED = 'hubs-changed' // { hubs }
15
+ export const DMS_CHANGED = 'dms-changed' // { dms }
16
+ export const PRESENCE_UPDATED = 'presence-updated' // { userId, status }
17
+ export const MEMBERS_UPDATED = 'members-updated' // { channelId, members, bots }
18
+
19
+ // ── Messages ──────────────────────────────────────────────────────────────────
20
+ export const MESSAGE_ADDED = 'message-added' // { channelId, message }
21
+ export const MESSAGE_UPDATED = 'message-updated' // { channelId, message }
22
+ export const MESSAGE_DELETED = 'message-deleted' // { channelId, msgId }
23
+ export const MESSAGES_PREPENDED = 'messages-prepended' // { channelId, messages, hasMore }
24
+ export const REACTIONS_UPDATED = 'reactions-updated' // { msgId, channelId, reactions }
25
+
26
+ // ── Thread panel ──────────────────────────────────────────────────────────────
27
+ export const THREAD_OPENED = 'thread-opened' // { parentMsgId, parentMsg }
28
+ export const THREAD_CLOSED = 'thread-closed' // {}
29
+ export const THREAD_LOADED = 'thread-loaded' // { parentMsgId, replies }
30
+ export const THREAD_REPLY_ADDED = 'thread-reply-added' // { parentMsgId, reply }
31
+ export const THREAD_REPLY_UPDATED = 'thread-reply-updated' // { parentMsgId, reply }
32
+ export const THREAD_REPLY_DELETED = 'thread-reply-deleted' // { parentMsgId, msgId }
33
+
34
+ // ── Pagination ────────────────────────────────────────────────────────────────
35
+ export const LOADING_MORE_CHANGED = 'loading-more-changed' // { loading }
36
+
37
+ // ── Channel metadata ──────────────────────────────────────────────────────────
38
+ export const CHANNEL_META_UPDATED = 'channel-meta-updated' // { channelId, name, topic }
39
+
40
+ // ── Call ──────────────────────────────────────────────────────────────────────
41
+ export const CALL_CHANGED = 'call-changed' // { call }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * resizable.js — attach a drag-to-resize handle to a panel edge.
3
+ *
4
+ * Usage:
5
+ * attachResizeHandle(el, {
6
+ * edge: 'right' | 'left', // which edge gets the handle
7
+ * cssVar: '--sidebar-width', // CSS custom property to update on :root
8
+ * min: 180, // minimum width in px
9
+ * max: 600, // maximum width in px
10
+ * prefKey: 'sidebar_width', // settings-sync pref key (omit to skip persistence)
11
+ * })
12
+ */
13
+
14
+ import { getPref, setPref } from './settings-sync.js'
15
+
16
+ export function attachResizeHandle(el, { edge, cssVar, min = 160, max = 700, prefKey } = {}) {
17
+ // Restore persisted width
18
+ if (prefKey) {
19
+ const saved = getPref(prefKey)
20
+ if (saved != null) {
21
+ const px = parseInt(saved, 10)
22
+ if (px >= min && px <= max) {
23
+ document.documentElement.style.setProperty(cssVar, `${px}px`)
24
+ }
25
+ }
26
+ }
27
+
28
+ const handle = document.createElement('div')
29
+ handle.className = `resize-handle resize-handle--${edge}`
30
+ handle.setAttribute('aria-hidden', 'true')
31
+ el.appendChild(handle)
32
+
33
+ let startX = 0
34
+ let startWidth = 0
35
+
36
+ const onMove = e => {
37
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
38
+ const dx = clientX - startX
39
+ const newWidth = edge === 'right'
40
+ ? Math.max(min, Math.min(max, startWidth + dx))
41
+ : Math.max(min, Math.min(max, startWidth - dx))
42
+ document.documentElement.style.setProperty(cssVar, `${newWidth}px`)
43
+ }
44
+
45
+ const onEnd = () => {
46
+ document.removeEventListener('mousemove', onMove)
47
+ document.removeEventListener('mouseup', onEnd)
48
+ document.removeEventListener('touchmove', onMove)
49
+ document.removeEventListener('touchend', onEnd)
50
+ document.body.style.userSelect = ''
51
+ document.body.style.cursor = ''
52
+
53
+ if (prefKey) {
54
+ const current = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
55
+ setPref(prefKey, parseInt(current, 10))
56
+ }
57
+ }
58
+
59
+ const onStart = e => {
60
+ if (e.button != null && e.button !== 0) return
61
+ e.preventDefault()
62
+ startX = e.touches ? e.touches[0].clientX : e.clientX
63
+ startWidth = el.getBoundingClientRect().width
64
+ document.body.style.userSelect = 'none'
65
+ document.body.style.cursor = 'col-resize'
66
+ document.addEventListener('mousemove', onMove)
67
+ document.addEventListener('mouseup', onEnd)
68
+ document.addEventListener('touchmove', onMove, { passive: false })
69
+ document.addEventListener('touchend', onEnd)
70
+ }
71
+
72
+ handle.addEventListener('mousedown', onStart)
73
+ handle.addEventListener('touchstart', onStart, { passive: false })
74
+ }
@@ -86,8 +86,11 @@ export class RtcPeerManager {
86
86
  }
87
87
 
88
88
  // Video stream → render tile + ensure audio (video stream may carry audio track)
89
- const tileId = `${peerId}-${event.transceiver?.mid ?? 'cam'}`
90
- const label = this.#displayNames.get(peerId) ?? peerId
89
+ // Use semantic slot name (cam/screen) so WS signaling can address tiles by kind.
90
+ const slots = this.#getTransceiverSlots(pc)
91
+ const slotName = event.transceiver === slots.screen ? 'screen' : 'cam'
92
+ const tileId = `${peerId}-${slotName}`
93
+ const label = this.#displayNames.get(peerId) ?? peerId
91
94
  this.#handlers.onTrack(peerId, tileId, stream, label)
92
95
  this.#handlers.onAudio(peerId, stream)
93
96
  }