@devchitchat/chat 4.4.4 → 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 CHANGED
@@ -108,11 +108,6 @@ export async function setup(config = {}) {
108
108
  return new Response('WebSocket upgrade required', { status: 426 })
109
109
  }
110
110
 
111
- routes[p('/vendor/rdbl.js')] = () =>
112
- new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
113
- headers: { 'Content-Type': 'text/javascript' },
114
- })
115
-
116
111
  routes[p('/sw.js')] = () => new Response(
117
112
  Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
118
113
  { headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
@@ -191,10 +186,6 @@ export async function start(config = {}) {
191
186
  })) return
192
187
  return new Response('WebSocket upgrade required', { status: 426 })
193
188
  },
194
- [p('/vendor/rdbl.js')]: () =>
195
- new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
196
- headers: { 'Content-Type': 'text/javascript' },
197
- }),
198
189
  [p('/sw.js')]: () => new Response(
199
190
  Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
200
191
  { headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devchitchat/chat",
3
- "version": "4.4.4",
3
+ "version": "5.0.0",
4
4
  "description": "A small chat app. p2p video and screenshare.",
5
5
  "scripts": {
6
6
  "dev": "bun --watch index.js",
@@ -29,8 +29,7 @@
29
29
  "styles.css"
30
30
  ],
31
31
  "dependencies": {
32
- "@devchitchat/index97": "^3.3.0",
33
- "@devchitchat/rdbljs": "^2.0.1"
32
+ "@devchitchat/index97": "^3.3.0"
34
33
  },
35
34
  "main": "index.js",
36
35
  "module": "index.js",
@@ -12,13 +12,6 @@
12
12
  <link rel="stylesheet" href="{{base}}/themes/base.css">
13
13
  <link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
14
14
  <script>window.__BASE_PATH__ = "{{base}}"</script>
15
- <script type="importmap">
16
- {
17
- "imports": {
18
- "@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
19
- }
20
- }
21
- </script>
22
15
  </head>
23
16
  <body>
24
17
  <main class="app-shell">
@@ -12,13 +12,6 @@
12
12
  <link rel="stylesheet" href="{{base}}/themes/base.css">
13
13
  <link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
14
14
  <script>window.__BASE_PATH__ = "{{base}}"</script>
15
- <script type="importmap">
16
- {
17
- "imports": {
18
- "@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
19
- }
20
- }
21
- </script>
22
15
  </head>
23
16
  <body>
24
17
  <header class="admin-topbar">
@@ -1,29 +1,16 @@
1
- <!-- Sidebar island: hub/channel nav + online presence -->
2
- <aside class="sidebar" id="sidebar" role="navigation" aria-label="Channels" island="{{base}}/client/islands/sidebar.js" data-currentChannel="{{currentChannelId}}" data-userid="{{user.user_id}}" data-vapid-key="{{vapidPublicKey}}">
3
- <section each="hubs" key="hub_id" class="hub-group">
1
+ <aside class="sidebar" id="sidebar" role="navigation" aria-label="Channels" data-currentChannel="{{currentChannelId}}" data-userid="{{user.user_id}}" data-vapid-key="{{vapidPublicKey}}">
2
+ <section class="hub-group">
4
3
  {{#each hubs}}
5
- <details class="hub-header" data-key="{{hub_id}}" data-visibility="{{visibility}}" data-description="{{description}}" draggable="true" open>
4
+ <details class="hub-header" data-hub-id="{{hub_id}}" data-visibility="{{visibility}}" data-description="{{description}}" draggable="true" open>
6
5
  <summary class="hub-name" data-hub-id="{{hub_id}}">
7
- <span text="name">{{name}}</span>
6
+ <span>{{name}}</span>
8
7
  <button class="btn-hub-gear btn-icon" title="Hub settings" aria-label="Hub settings" type="button">&#9881;</button>
9
8
  <button class="btn-hub-add btn-icon" title="Create a channel" aria-label="Create a channel" type="button">+</button>
10
9
  </summary>
11
- <ul each="channels" key="channel_id" class="channel-list">
12
- <template>
13
- <li class="channel-item" cls="className" draggable="true">
14
- <a attr="href:url; title:name" text="name" class="channel-link"
15
- data-channel-id=""
16
- data-channel-name=""
17
- data-channel-topic=""
18
- data-channel-kind=""
19
- data-channel-visibility=""></a>
20
- <span class="call-badge" aria-label="Call active"></span>
21
- <button class="btn-channel-gear btn-icon" title="Channel settings" aria-label="Channel settings" type="button">&#9881;</button>
22
- </li>
23
- </template>
10
+ <ul class="channel-list">
24
11
  {{#each channels}}
25
- <li data-key="{{channel_id}}" class="channel-item {{className}}" cls="className" draggable="true">
26
- <a attr="href:url; title:name" text="name" class="channel-link"
12
+ <li class="channel-item {{className}}" draggable="true">
13
+ <a class="channel-link"
27
14
  href="{{base}}/channels/{{channel_id}}"
28
15
  title="{{name}}"
29
16
  data-channel-id="{{channel_id}}"
@@ -43,7 +30,7 @@
43
30
  <section class="dm-section">
44
31
  <h3 class="dm-section-title">Direct Messages</h3>
45
32
  <ul class="dm-list" id="dm-list">
46
- <!-- populated by sidebar.js via dm.list_result -->
33
+ <!-- populated by SidebarView via dm.list_result -->
47
34
  </ul>
48
35
  </section>
49
36
 
@@ -83,8 +70,7 @@
83
70
  <!-- Main content wrapper: slides in from right on mobile -->
84
71
  <div class="main-content">
85
72
 
86
- <!-- Chat + call island: messages, composer, and WebRTC -->
87
- <section class="chat-panel" island="{{base}}/client/islands/call.js"
73
+ <section class="chat-panel"
88
74
  data-id="{{channel.channel_id}}"
89
75
  data-name="{{channel.name}}"
90
76
  data-topic="{{channel.topic}}"
@@ -101,8 +87,8 @@
101
87
  <header class="chat-header">
102
88
  <button class="btn-back-mobile" aria-label="Back to channels" type="button">&#8592;</button>
103
89
  <hgroup>
104
- <h2 class="chat-title" text="channelName">{{channel.name}}</h2>
105
- <p class="chat-topic" text="channelTopic">{{channel.topic}}</p>
90
+ <h2 class="chat-title">{{channel.name}}</h2>
91
+ <p class="chat-topic">{{channel.topic}}</p>
106
92
  </hgroup>
107
93
  <div class="chat-header-actions">
108
94
  <button class="btn-ghost btn-start-call" id="btn-start-call" aria-label="Start call" type="button">
@@ -135,7 +121,7 @@
135
121
  <div class="messages" id="messages" aria-live="polite" aria-label="Messages" role="log">
136
122
  <div class="load-more-sentinel" id="load-more-sentinel" aria-hidden="true" hidden></div>
137
123
  {{#each seedMessages}}
138
- <article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-key="message_id" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}" data-reply-count="{{reply_count}}">
124
+ <article class="message" data-seq="{{seq}}" data-msg-id="{{msg_id}}" data-user-id="{{user_id}}" data-raw-text="{{raw_text}}" data-edited-at="{{edited_at}}" data-attachments="{{attachments_json}}" data-reactions="{{reactions_json}}" data-reply-count="{{reply_count}}">
139
125
  <span class="message-handle" data-user-id="{{user_id}}">{{user_display_name}}</span>
140
126
  <time class="message-time" datetime="{{ts}}">{{ts_fmt}}{{#if edited_at}}<span class="message-edited">(edited)</span>{{/if}}</time>
141
127
  <div class="message-text">{{{text}}}</div>
@@ -143,25 +129,16 @@
143
129
  <div class="reaction-bar"></div>
144
130
  </article>
145
131
  {{/each}}
146
- <template>
147
- <article class="message">
148
- <span class="message-handle"></span>
149
- <time class="message-time" datetime=""></time>
150
- <p class="message-text" text="text"></p>
151
- </article>
152
- </template>
153
132
  </div>
154
133
 
155
134
  <footer class="composer">
156
- <button onclick="toggleUrgentMode" class="btn-urgent-toggle" type="button"
157
- cls="urgentClass"
135
+ <button class="btn-urgent-toggle" type="button"
158
136
  title="Toggle urgent mode — Enter sends as urgent (Ctrl+Enter for one-shot)" aria-label="Toggle urgent send">&#x1F514;</button>
159
- <textarea model="draft" id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
160
- rows="1" onkeydown="handleComposerKey">
161
- </textarea>
162
- <button onclick="toggleComposeMode" class="btn-compose-expand btn-icon" type="button"
137
+ <textarea id="message-input" placeholder="Message in {{channel.name}}" aria-label="Write a message"
138
+ rows="1"></textarea>
139
+ <button class="btn-compose-expand btn-icon" type="button"
163
140
  title="Compose mode (Ctrl+E)" aria-label="Open compose mode">&#x26F6;</button>
164
- <button onclick="sendMessage" class="btn-send" aria-label="Send message" type="button">Send</button>
141
+ <button class="btn-send" aria-label="Send message" type="button">Send</button>
165
142
  </footer>
166
143
 
167
144
  <!-- Compose overlay — full-panel editor with Write / Preview tabs -->
@@ -1,38 +1,161 @@
1
1
  /**
2
- * app.js — boots rdbljs islands and page-level setup.
2
+ * app.js — MVC bootstrap.
3
3
  *
4
- * Loaded as <script type="module" src="{{base}}/client/app.js"> from _layout.html.
5
- * init(window) from rdbljs discovers all [island] elements, imports each
6
- * module, calls the default export factory with (root, window), and wires
7
- * up bind(root, scope) so all directives (model=, onclick=, each=, etc.) work.
4
+ * Wiring order (inside-out):
5
+ * 1. AppModel — single source of truth (EventTarget hub)
6
+ * 2. WebSocketController maps WS events to model mutations
7
+ * 3. ChatController — maps document CustomEvents to ws.send / model
8
+ * 4. Views — listen to model, render DOM, dispatch controller events
9
+ * 5. Router + swipe nav — SPA navigation; notifies model on channel change
10
+ *
11
+ * No rdbljs. No islands. Plain browser APIs throughout.
8
12
  */
9
- import { init } from '@devchitchat/rdbljs'
13
+
14
+ import { AppModel } from './model/AppModel.js'
15
+ import { WebSocketController } from './controllers/WebSocketController.js'
16
+ import { ChatController } from './controllers/ChatController.js'
17
+ import { MessageListView } from './views/MessageListView.js'
18
+ import { ThreadPanelView } from './views/ThreadPanelView.js'
19
+ import { ComposerView } from './views/ComposerView.js'
20
+ import { SidebarView } from './views/SidebarView.js'
21
+ import { CallView } from './views/CallView.js'
22
+ import { cancelActiveEdit } from './views/shared/MessageInteractions.js'
23
+ import { closeEmojiPicker } from './views/shared/EmojiPickerSingleton.js'
10
24
  import { getSettings, syncFromServer, patchSettings } from './settings-sync.js'
11
- import { initSwipeNav } from './swipe-nav.js'
12
- import { initRouter } from './router.js'
25
+ import { initSwipeNav } from './swipe-nav.js'
26
+ import { initRouter } from './router.js'
13
27
 
14
28
  const BASE_PATH = window.__BASE_PATH__ ?? ''
15
29
 
16
- // On a channel page the intent is always to view the channel — persist the
17
- // current channel and ensure mobile shows the chat panel, not the hub panel.
30
+ // ── 1. Pre-mount: apply settings to prevent layout flash ─────────────────────
31
+
18
32
  if (location.pathname.startsWith(`${BASE_PATH}/channels/`)) {
19
33
  const channelId = location.pathname.split('/').pop()
20
34
  if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
21
35
  }
22
36
 
23
- // Apply settings before islands mount (prevent layout flash)
24
37
  const settings = getSettings()
25
38
  if (window.matchMedia('(max-width: 1024px)').matches && settings.mobile_chat_open === false) {
26
39
  document.body.classList.add('sidebar-open')
27
40
  }
28
41
 
29
- await init(window)
42
+ // ── 2. Model ──────────────────────────────────────────────────────────────────
43
+
44
+ const model = new AppModel()
45
+
46
+ // Seed identity from the chat panel's data attributes (written by SSR)
47
+ const chatPanelEl = document.querySelector('.chat-panel')
48
+ if (chatPanelEl) {
49
+ model.setIdentity({
50
+ userId: chatPanelEl.dataset.userId ?? null,
51
+ userHandle: chatPanelEl.dataset.userHandle ?? null,
52
+ })
53
+ // Seed the current channel
54
+ const channelId = chatPanelEl.dataset.id ?? null
55
+ const seedFirstSeq = parseInt(chatPanelEl.dataset.seedFirstSeq ?? '0', 10)
56
+ const seedHasMore = chatPanelEl.dataset.seedHasMore === 'true'
57
+ if (channelId) {
58
+ model.selectChannel(channelId, {
59
+ name: chatPanelEl.dataset.name ?? '',
60
+ topic: chatPanelEl.dataset.topic ?? '',
61
+ kind: chatPanelEl.dataset.kind ?? 'text',
62
+ })
63
+ model.seedMessages(channelId, { oldestSeq: seedFirstSeq, hasMore: seedHasMore })
64
+ }
65
+ }
66
+
67
+ // ── 3. Controllers ────────────────────────────────────────────────────────────
68
+
69
+ const wsController = new WebSocketController(model, `${BASE_PATH}/ws`)
70
+ const ws = wsController.ws
71
+ const chatController = new ChatController(model, ws)
72
+
73
+ // ── 4. Views ──────────────────────────────────────────────────────────────────
74
+
75
+ // Sidebar
76
+ const sidebarEl = document.querySelector('aside[data-sidebar], aside.sidebar, aside')
77
+ if (sidebarEl) {
78
+ new SidebarView(model, ws, sidebarEl)
79
+ }
80
+
81
+ // Chat panel views (only mount if the chat panel exists on this page)
82
+ if (chatPanelEl) {
83
+ const messagesEl = document.getElementById('messages')
84
+ const sentinelEl = document.getElementById('load-more-sentinel')
85
+ const composerEl = chatPanelEl.querySelector('.composer')
86
+ const threadPanelEl = document.getElementById('thread-panel')
87
+
88
+ if (messagesEl) {
89
+ new MessageListView(model, messagesEl, sentinelEl)
90
+ }
91
+
92
+ if (threadPanelEl) {
93
+ new ThreadPanelView(model, threadPanelEl)
94
+ }
95
+
96
+ if (composerEl) {
97
+ new ComposerView(model, composerEl)
98
+ }
99
+
100
+ // CallView (WebRTC) — only if call UI elements are present
101
+ if (document.getElementById('tile-panel') || document.getElementById('btn-start-call')) {
102
+ new CallView(model, ws, chatPanelEl)
103
+ }
104
+ }
105
+
106
+ // ── 5. SPA navigation ─────────────────────────────────────────────────────────
107
+
108
+ // router.js morphs the DOM and dispatches 'chatpanel:navigated'.
109
+ // We update the model here so views react automatically.
110
+ document.addEventListener('chatpanel:navigated', e => {
111
+ const { channelId, name, topic, kind, seedFirstSeq, seedHasMore } = e.detail
112
+
113
+ // Close transient UI on navigation
114
+ cancelActiveEdit()
115
+ closeEmojiPicker()
116
+ if (model.threadParentId) model.closeThread()
117
+
118
+ // Update identity if it changed (shouldn't, but guard anyway)
119
+ const panel = document.querySelector('.chat-panel')
120
+ if (panel?.dataset.userId) {
121
+ model.setIdentity({ userId: panel.dataset.userId, userHandle: panel.dataset.userHandle ?? null })
122
+ }
123
+
124
+ // Seed pagination bookmarks for the new channel
125
+ if (channelId) {
126
+ model.seedMessages(channelId, {
127
+ oldestSeq: parseInt(seedFirstSeq ?? '0', 10),
128
+ hasMore: seedHasMore ?? false,
129
+ })
130
+ }
131
+
132
+ // Navigate — fires 'channel-selected' → all views update
133
+ model.selectChannel(channelId, { name, topic, kind })
134
+
135
+ // Join the new channel on the server (WebSocketController handles the response)
136
+ ws.send({ t: 'channel.join', body: { channel_id: channelId } })
137
+
138
+ // Persist for PWA restore
139
+ if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
140
+ })
141
+
142
+ // ── 6. Router + swipe nav ────────────────────────────────────────────────────
30
143
 
31
144
  initRouter()
32
145
  initSwipeNav()
33
146
 
34
- // Reconcile with server in background after islands mount
147
+ // ── 7. Post-mount: reconcile settings with server ─────────────────────────────
148
+
35
149
  syncFromServer().then(remoteSettings => {
36
150
  if (!remoteSettings || !window.matchMedia('(max-width: 1024px)').matches) return
37
151
  document.body.classList.toggle('sidebar-open', remoteSettings.mobile_chat_open === false)
38
152
  })
153
+
154
+ // ── 8. Global keyboard shortcuts ──────────────────────────────────────────────
155
+
156
+ document.addEventListener('keydown', e => {
157
+ if (e.key === 'Escape') {
158
+ cancelActiveEdit()
159
+ closeEmojiPicker()
160
+ }
161
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * ChatController.js — translates user actions into WS sends and model mutations.
3
+ *
4
+ * Views dispatch CustomEvents on themselves; ChatController listens to those
5
+ * events and calls ws.send(). This keeps ws.send() out of views entirely.
6
+ *
7
+ * Wired events (views → controller):
8
+ * 'send-message' { channelId, text, attachments, priority }
9
+ * 'send-thread-reply' { channelId, parentMsgId, text, attachments }
10
+ * 'react' { msgId, channelId, emoji }
11
+ * 'unreact' { msgId, channelId, emoji }
12
+ * 'edit-message' { msgId, channelId, text }
13
+ * 'delete-message' { msgId, channelId }
14
+ * 'open-thread' { msgId, channelId }
15
+ * 'close-thread' {}
16
+ * 'load-more' { channelId, beforeSeq }
17
+ * 'open-dm' { targetUserId }
18
+ * 'task-toggle' { msgId, channelId, checkboxIndex, checked }
19
+ *
20
+ * Each of these is dispatched on `document` so any view can trigger them
21
+ * without needing a direct reference to the controller.
22
+ */
23
+
24
+ import * as Ev from '../model/events.js'
25
+
26
+ export class ChatController {
27
+ #ws
28
+ #model
29
+
30
+ /**
31
+ * @param {AppModel} model
32
+ * @param {WsClient} ws — from WebSocketController.ws
33
+ */
34
+ constructor(model, ws) {
35
+ this.#model = model
36
+ this.#ws = ws
37
+ this.#wire()
38
+ }
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────
41
+ // Wire view → controller events
42
+ // ─────────────────────────────────────────────────────────────────────────
43
+
44
+ #wire() {
45
+ const listen = (name, fn) =>
46
+ document.addEventListener(name, e => fn(e.detail))
47
+
48
+ listen('send-message', d => this.#sendMessage(d))
49
+ listen('send-thread-reply', d => this.#sendThreadReply(d))
50
+ listen('react', d => this.#react(d))
51
+ listen('unreact', d => this.#unreact(d))
52
+ listen('edit-message', d => this.#editMessage(d))
53
+ listen('delete-message', d => this.#deleteMessage(d))
54
+ listen('open-thread', d => this.#openThread(d))
55
+ listen('close-thread', () => this.#closeThread())
56
+ listen('load-more', d => this.#loadMore(d))
57
+ listen('open-dm', d => this.#openDm(d))
58
+ listen('select-channel', d => this.#selectChannel(d))
59
+ listen('task-toggle', d => this.#taskToggle(d))
60
+ listen('preview-text', d => this.#previewText(d))
61
+ }
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────
64
+ // Handlers
65
+ // ─────────────────────────────────────────────────────────────────────────
66
+
67
+ #sendMessage({ channelId, text, attachments = [], priority = 'normal' }) {
68
+ if (!text?.trim() && attachments.length === 0) return
69
+ this.#ws.send({
70
+ t: 'msg.send',
71
+ body: {
72
+ channel_id: channelId,
73
+ text: text?.trim() ?? '',
74
+ client_msg_id: `local_${Date.now()}`,
75
+ priority,
76
+ attachments: attachments.map(a => ({
77
+ upload_id: a.upload_id,
78
+ url: a.url,
79
+ filename: a.original_name,
80
+ mime_type: a.mime_type,
81
+ size_bytes: a.size_bytes,
82
+ })),
83
+ },
84
+ })
85
+ }
86
+
87
+ #sendThreadReply({ channelId, parentMsgId, text, attachments = [] }) {
88
+ if (!text?.trim()) return
89
+ this.#ws.send({
90
+ t: 'msg.send',
91
+ body: {
92
+ channel_id: channelId,
93
+ text: text.trim(),
94
+ parent_msg_id: parentMsgId,
95
+ attachments: attachments.map(a => ({
96
+ upload_id: a.upload_id,
97
+ url: a.url,
98
+ filename: a.original_name,
99
+ mime_type: a.mime_type,
100
+ size_bytes: a.size_bytes,
101
+ })),
102
+ },
103
+ })
104
+ }
105
+
106
+ #react({ msgId, channelId, emoji }) {
107
+ this.#ws.send({ t: 'reaction.add', body: { msg_id: msgId, emoji, channel_id: channelId } })
108
+ }
109
+
110
+ #unreact({ msgId, channelId, emoji }) {
111
+ this.#ws.send({ t: 'reaction.remove', body: { msg_id: msgId, emoji, channel_id: channelId } })
112
+ }
113
+
114
+ #editMessage({ msgId, channelId, text }) {
115
+ this.#ws.send({ t: 'msg.edit', body: { msg_id: msgId, channel_id: channelId, text } })
116
+ }
117
+
118
+ #deleteMessage({ msgId, channelId }) {
119
+ this.#ws.send({ t: 'msg.delete', body: { msg_id: msgId, channel_id: channelId } })
120
+ }
121
+
122
+ #openThread({ msgId }) {
123
+ const model = this.#model
124
+ // Find the parent message in the model cache
125
+ const channelId = model.currentChannelId
126
+ const msgs = model.messagesFor(channelId)
127
+ const parentMsg = msgs.find(m => m.msg_id === msgId) ?? null
128
+ model.openThread(msgId, parentMsg)
129
+ // Fetch replies
130
+ this.#ws.send({ t: 'thread.list', body: { parent_msg_id: msgId, channel_id: channelId } })
131
+ }
132
+
133
+ #closeThread() {
134
+ this.#model.closeThread()
135
+ }
136
+
137
+ #loadMore({ channelId, beforeSeq }) {
138
+ if (this.#model.loadingMore) return
139
+ this.#model.setLoadingMore(true)
140
+ this.#ws.send({ t: 'msg.list', body: { channel_id: channelId, before_seq: beforeSeq } })
141
+ }
142
+
143
+ #openDm({ targetUserId }) {
144
+ this.#ws.send({ t: 'dm.open', body: { target_user_id: targetUserId } })
145
+ }
146
+
147
+ #selectChannel({ channelId, meta }) {
148
+ const model = this.#model
149
+ const prev = model.currentChannelId
150
+
151
+ model.selectChannel(channelId, meta ?? {})
152
+
153
+ if (prev && prev !== channelId) {
154
+ this.#ws.send({ t: 'channel.leave', body: { channel_id: prev } })
155
+ }
156
+
157
+ this.#ws.send({ t: 'channel.join', body: { channel_id: channelId } })
158
+
159
+ // Request messages since seed (0 = fetch latest 50)
160
+ // We only fetch if we don't already have a cache for this channel
161
+ if (model.messagesFor(channelId).length === 0) {
162
+ this.#ws.send({ t: 'msg.list', body: { channel_id: channelId, after_seq: 0 } })
163
+ }
164
+
165
+ if (model.members.length === 0) {
166
+ this.#ws.send({ t: 'user.list', body: {} })
167
+ this.#ws.send({ t: 'bot.list', body: {} })
168
+ }
169
+ }
170
+
171
+ #taskToggle({ msgId, channelId, text }) {
172
+ // Task checkboxes: send the full updated text with checkbox toggled
173
+ this.#ws.send({ t: 'msg.edit', body: { msg_id: msgId, channel_id: channelId, text } })
174
+ }
175
+
176
+ async #previewText({ text, resolve }) {
177
+ try {
178
+ const base = window.__BASE_PATH__ ?? ''
179
+ const res = await fetch(`${base}/api/preview`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ text }),
183
+ })
184
+ if (res.ok) {
185
+ const { html } = await res.json()
186
+ resolve?.(html)
187
+ } else {
188
+ resolve?.(null)
189
+ }
190
+ } catch {
191
+ resolve?.(null)
192
+ }
193
+ }
194
+ }
195
+
196
+ // ─────────────────────────────────────────────────────────────────────────────
197
+ // Helper: dispatch a controller event from anywhere in the view layer.
198
+ // Views call e.g. dispatch('open-thread', { msgId }) instead of importing
199
+ // ChatController directly.
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+
202
+ export function dispatch(name, detail = {}) {
203
+ document.dispatchEvent(new CustomEvent(name, { detail, bubbles: false }))
204
+ }