@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
|
@@ -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
|
|
63
|
-
|
|
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">↩</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
|
}
|