@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.
@@ -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 }
@@ -33,8 +33,9 @@ export function makeDateSeparator(dateKey) {
33
33
 
34
34
  /**
35
35
  * Escape HTML then wrap @handles in <span class="mention"> (or mention-self for current user).
36
+ * Only handles that appear in `knownHandles` are styled; unrecognised @words are left as plain text.
36
37
  * @param {string} text
37
- * @param {{ userHandle?: string }} [opts]
38
+ * @param {{ userHandle?: string, knownHandles?: Set<string> }} [opts]
38
39
  */
39
40
  // Combined regex (operates on raw text before HTML-escaping):
40
41
  // group 1 (+ inner 2, 3) — markdown link: [text](url)
@@ -42,7 +43,7 @@ export function makeDateSeparator(dateKey) {
42
43
  // group 5 — @mention
43
44
  const INLINE_RE = /(\[([^\]]*)\]\((https?:\/\/[^)]+)\))|(https?:\/\/[^\s<>"'[\]()*]+)|(@[a-zA-Z0-9_.-]+)/g
44
45
 
45
- export function renderText(text, { userHandle } = {}) {
46
+ export function renderText(text, { userHandle, knownHandles } = {}) {
46
47
  let result = ''
47
48
  let lastIndex = 0
48
49
  INLINE_RE.lastIndex = 0
@@ -58,9 +59,14 @@ export function renderText(text, { userHandle } = {}) {
58
59
  const trailing = bareUrl.slice(trimmed.length)
59
60
  result += `<a href="${escHtml(trimmed)}" target="_blank" rel="noopener noreferrer">${escHtml(trimmed)}</a>${escHtml(trailing)}`
60
61
  } else if (mention) {
61
- const handle = mention.slice(1)
62
- const isSelf = userHandle && handle.toLowerCase() === userHandle.toLowerCase()
63
- result += `<span class="mention${isSelf ? ' mention-self' : ''}">${escHtml(mention)}</span>`
62
+ const handle = mention.slice(1).toLowerCase()
63
+ const known = !knownHandles || knownHandles.has(handle)
64
+ if (known) {
65
+ const isSelf = userHandle && handle === userHandle.toLowerCase()
66
+ result += `<span class="mention${isSelf ? ' mention-self' : ''}">${escHtml(mention)}</span>`
67
+ } else {
68
+ result += escHtml(mention)
69
+ }
64
70
  }
65
71
  lastIndex = m.index + full.length
66
72
  }
@@ -75,7 +81,7 @@ export function renderText(text, { userHandle } = {}) {
75
81
  * @param {Element} el
76
82
  * @param {{ userHandle?: string }} [opts]
77
83
  */
78
- export function applyInlineRenderingToTextNodes(el, { userHandle } = {}) {
84
+ export function applyInlineRenderingToTextNodes(el, { userHandle, knownHandles } = {}) {
79
85
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
80
86
  const nodes = []
81
87
  let n
@@ -86,7 +92,7 @@ export function applyInlineRenderingToTextNodes(el, { userHandle } = {}) {
86
92
  for (const textNode of nodes) {
87
93
  const raw = textNode.textContent
88
94
  if (!raw.trim()) continue
89
- const rendered = renderText(raw, { userHandle })
95
+ const rendered = renderText(raw, { userHandle, knownHandles })
90
96
  if (rendered === escHtml(raw)) continue // nothing changed
91
97
  const span = document.createElement('span')
92
98
  span.innerHTML = rendered
@@ -123,7 +129,7 @@ export function renderAttachment(a) {
123
129
  * @param {{ userId?: string, userHandle?: string, isThreadReply?: boolean }} [ctx]
124
130
  * isThreadReply — omits the "Reply in thread" button (threads can't be nested)
125
131
  */
126
- export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle, isThreadReply = false } = {}) {
132
+ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle, knownHandles, isThreadReply = false } = {}) {
127
133
  const article = document.createElement('article')
128
134
  article.className = 'message'
129
135
  article.dataset.seq = seq
@@ -137,7 +143,7 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
137
143
  const editedHtml = edited_at ? '<span class="message-edited">(edited)</span>' : ''
138
144
  const replyBtn = isThreadReply ? '' : '<button class="btn-reply btn-icon" type="button" title="Reply in thread" aria-label="Reply in thread">&#x21A9;</button>'
139
145
  const actionsHtml = `<div class="message-hover-actions"><span class="quick-picks"></span>${replyBtn}<button class="btn-react btn-icon" type="button" title="Add reaction" aria-label="Add reaction">🙂</button>${isSelf ? '<button class="btn-msg-actions btn-icon" type="button" title="Message actions">…</button>' : ''}</div>`
140
- const textHtml = rendered_text ?? (text ? renderText(text, { userHandle }) : '')
146
+ const textHtml = rendered_text ?? (text ? renderText(text, { userHandle, knownHandles }) : '')
141
147
  article.innerHTML = `
142
148
  <span class="message-handle${isSelf ? '' : ' dm-trigger'}" data-user-id="${escHtml(user_id)}" title="${isSelf ? '' : 'Send a direct message'}">${escHtml(user_display_name ?? user_id)}</span>
143
149
  <time class="message-time" datetime="${ts}">${time}${editedHtml}</time>
@@ -146,5 +152,11 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
146
152
  <div class="reaction-bar"></div>
147
153
  ${actionsHtml}
148
154
  `
155
+ // When the server provides rendered_text, @mention styling is not included.
156
+ // Apply it now so every code path gets consistent output.
157
+ if (rendered_text) {
158
+ const textEl = article.querySelector('.message-text')
159
+ if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle, knownHandles })
160
+ }
149
161
  return article
150
162
  }