@devchitchat/chat 4.5.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +0 -9
- package/package.json +2 -3
- package/pages/_layout.html +0 -7
- package/pages/admin/_layout.html +0 -7
- package/pages/channels/[channelId].phtml +17 -42
- package/pages/design/_layout.html +349 -0
- package/pages/design/_layout.js +13 -0
- package/pages/design/components/index.js +3 -0
- package/pages/design/components/index.phtml +380 -0
- package/pages/design/index.js +3 -0
- package/pages/design/index.phtml +78 -0
- package/pages/design/principles/index.js +3 -0
- package/pages/design/principles/index.phtml +147 -0
- package/pages/design/tokens/index.js +3 -0
- package/pages/design/tokens/index.phtml +236 -0
- package/pages/public/client/app.js +171 -13
- package/pages/public/client/controllers/ChatController.js +204 -0
- package/pages/public/client/controllers/WebSocketController.js +191 -0
- package/pages/public/client/model/AppModel.js +351 -0
- package/pages/public/client/model/events.js +41 -0
- package/pages/public/client/resizable.js +74 -0
- package/pages/public/client/rtc-peer-manager.js +5 -2
- package/pages/public/client/settings-sync.js +45 -7
- package/pages/public/client/shared/messages.js +21 -9
- package/pages/public/client/theme.js +6 -4
- package/pages/public/client/views/CallView.js +754 -0
- package/pages/public/client/views/ChatHeaderView.js +67 -0
- package/pages/public/client/views/ComposerView.js +491 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +977 -0
- package/pages/public/client/views/ThreadPanelView.js +260 -0
- package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
- package/pages/public/client/views/shared/MentionPicker.js +139 -0
- package/pages/public/client/views/shared/MessageInteractions.js +353 -0
- package/pages/public/themes/base.css +29 -31
- package/src/ws/ChatServer.js +2 -1
- package/src/ws/handlers/rtcHandlers.js +7 -0
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
|
@@ -0,0 +1,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
|
-
|
|
90
|
-
const
|
|
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
|
}
|