@devchitchat/chat 4.5.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/index.js +0 -9
  2. package/package.json +2 -3
  3. package/pages/_layout.html +0 -7
  4. package/pages/admin/_layout.html +0 -7
  5. package/pages/channels/[channelId].phtml +17 -42
  6. package/pages/design/_layout.html +349 -0
  7. package/pages/design/_layout.js +13 -0
  8. package/pages/design/components/index.js +3 -0
  9. package/pages/design/components/index.phtml +380 -0
  10. package/pages/design/index.js +3 -0
  11. package/pages/design/index.phtml +78 -0
  12. package/pages/design/principles/index.js +3 -0
  13. package/pages/design/principles/index.phtml +147 -0
  14. package/pages/design/tokens/index.js +3 -0
  15. package/pages/design/tokens/index.phtml +236 -0
  16. package/pages/public/client/app.js +171 -13
  17. package/pages/public/client/controllers/ChatController.js +204 -0
  18. package/pages/public/client/controllers/WebSocketController.js +191 -0
  19. package/pages/public/client/model/AppModel.js +351 -0
  20. package/pages/public/client/model/events.js +41 -0
  21. package/pages/public/client/resizable.js +74 -0
  22. package/pages/public/client/rtc-peer-manager.js +5 -2
  23. package/pages/public/client/settings-sync.js +45 -7
  24. package/pages/public/client/shared/messages.js +21 -9
  25. package/pages/public/client/theme.js +6 -4
  26. package/pages/public/client/views/CallView.js +754 -0
  27. package/pages/public/client/views/ChatHeaderView.js +67 -0
  28. package/pages/public/client/views/ComposerView.js +491 -0
  29. package/pages/public/client/views/MessageListView.js +461 -0
  30. package/pages/public/client/views/SidebarView.js +977 -0
  31. package/pages/public/client/views/ThreadPanelView.js +260 -0
  32. package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
  33. package/pages/public/client/views/shared/MentionPicker.js +139 -0
  34. package/pages/public/client/views/shared/MessageInteractions.js +353 -0
  35. package/pages/public/themes/base.css +29 -31
  36. package/src/ws/ChatServer.js +2 -1
  37. package/src/ws/handlers/rtcHandlers.js +7 -0
  38. package/pages/public/client/islands/call.js +0 -2282
  39. package/pages/public/client/islands/sidebar.js +0 -1198
@@ -0,0 +1,236 @@
1
+ <h1 class="ds-page-title">Tokens</h1>
2
+ <p class="ds-page-subtitle">
3
+ CSS custom properties that define the visual language. All tokens are defined on
4
+ <code>:root</code> and override per theme. Switch the theme picker to see them update live.
5
+ </p>
6
+
7
+ <!-- ── Colors ────────────────────────────────────────────────────────────── -->
8
+ <div class="ds-section">
9
+ <h2 class="ds-section-title">Backgrounds</h2>
10
+ <p class="ds-section-desc">Layered surfaces create the sense of depth (HIG: Depth).</p>
11
+ <div class="token-grid">
12
+ <div class="token-swatch">
13
+ <div class="token-swatch-preview" style="background: var(--bg-base)"></div>
14
+ <div class="token-swatch-label">
15
+ <span class="token-swatch-name">--bg-base</span>
16
+ <span class="token-swatch-value" data-css-var="--bg-base">—</span>
17
+ </div>
18
+ </div>
19
+ <div class="token-swatch">
20
+ <div class="token-swatch-preview" style="background: var(--bg-sidebar)"></div>
21
+ <div class="token-swatch-label">
22
+ <span class="token-swatch-name">--bg-sidebar</span>
23
+ <span class="token-swatch-value" data-css-var="--bg-sidebar">—</span>
24
+ </div>
25
+ </div>
26
+ <div class="token-swatch">
27
+ <div class="token-swatch-preview" style="background: var(--bg-elevated)"></div>
28
+ <div class="token-swatch-label">
29
+ <span class="token-swatch-name">--bg-elevated</span>
30
+ <span class="token-swatch-value" data-css-var="--bg-elevated">—</span>
31
+ </div>
32
+ </div>
33
+ <div class="token-swatch">
34
+ <div class="token-swatch-preview" style="background: var(--bg-input)"></div>
35
+ <div class="token-swatch-label">
36
+ <span class="token-swatch-name">--bg-input</span>
37
+ <span class="token-swatch-value" data-css-var="--bg-input">—</span>
38
+ </div>
39
+ </div>
40
+ <div class="token-swatch">
41
+ <div class="token-swatch-preview" style="background: var(--bg-hover)"></div>
42
+ <div class="token-swatch-label">
43
+ <span class="token-swatch-name">--bg-hover</span>
44
+ <span class="token-swatch-value" data-css-var="--bg-hover">—</span>
45
+ </div>
46
+ </div>
47
+ <div class="token-swatch">
48
+ <div class="token-swatch-preview" style="background: var(--bg-active)"></div>
49
+ <div class="token-swatch-label">
50
+ <span class="token-swatch-name">--bg-active</span>
51
+ <span class="token-swatch-value" data-css-var="--bg-active">—</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="ds-section">
58
+ <h2 class="ds-section-title">Text</h2>
59
+ <p class="ds-section-desc">Three levels of hierarchy. Use muted only for metadata, never for actionable content.</p>
60
+ <div class="ds-canvas">
61
+ <p style="font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 8px;">
62
+ --text-primary — Channel name, message handle, body text
63
+ </p>
64
+ <p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 8px;">
65
+ --text-secondary — Descriptions, labels, nav items at rest
66
+ </p>
67
+ <p style="font-size: 13px; color: var(--text-muted); margin: 0;">
68
+ --text-muted — Timestamps, section headers, placeholder text
69
+ </p>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="ds-section">
74
+ <h2 class="ds-section-title">Accent &amp; Status</h2>
75
+ <div class="token-grid">
76
+ <div class="token-swatch">
77
+ <div class="token-swatch-preview" style="background: var(--accent)"></div>
78
+ <div class="token-swatch-label">
79
+ <span class="token-swatch-name">--accent</span>
80
+ <span class="token-swatch-value" data-css-var="--accent">—</span>
81
+ </div>
82
+ </div>
83
+ <div class="token-swatch">
84
+ <div class="token-swatch-preview" style="background: var(--color-success)"></div>
85
+ <div class="token-swatch-label">
86
+ <span class="token-swatch-name">--color-success</span>
87
+ <span class="token-swatch-value" data-css-var="--color-success">—</span>
88
+ </div>
89
+ </div>
90
+ <div class="token-swatch">
91
+ <div class="token-swatch-preview" style="background: var(--color-danger)"></div>
92
+ <div class="token-swatch-label">
93
+ <span class="token-swatch-name">--color-danger</span>
94
+ <span class="token-swatch-value" data-css-var="--color-danger">—</span>
95
+ </div>
96
+ </div>
97
+ <div class="token-swatch">
98
+ <div class="token-swatch-preview" style="background: var(--color-warning)"></div>
99
+ <div class="token-swatch-label">
100
+ <span class="token-swatch-name">--color-warning</span>
101
+ <span class="token-swatch-value" data-css-var="--color-warning">—</span>
102
+ </div>
103
+ </div>
104
+ <div class="token-swatch">
105
+ <div class="token-swatch-preview" style="background: var(--border)"></div>
106
+ <div class="token-swatch-label">
107
+ <span class="token-swatch-name">--border</span>
108
+ <span class="token-swatch-value" data-css-var="--border">—</span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <hr class="ds-divider">
115
+
116
+ <!-- ── Typography ─────────────────────────────────────────────────────────── -->
117
+ <div class="ds-section">
118
+ <h2 class="ds-section-title">Typography</h2>
119
+ <p class="ds-section-desc">System fonts. SF Pro on Apple, Segoe UI on Windows — no web font download required.</p>
120
+
121
+ <div class="ds-canvas">
122
+ <div class="token-row">
123
+ <div class="token-row-preview" style="background: var(--bg-hover); display: flex; align-items: center; justify-content: center;">
124
+ <span style="font-family: var(--font-sans); font-size: 13px; font-weight: 600;">Aa</span>
125
+ </div>
126
+ <span class="token-row-name">--font-sans</span>
127
+ <span class="token-row-value">-apple-system, SF Pro Text, system-ui, Segoe UI, sans-serif</span>
128
+ </div>
129
+ <div class="token-row">
130
+ <div class="token-row-preview" style="background: var(--bg-hover); display: flex; align-items: center; justify-content: center;">
131
+ <span style="font-family: var(--font-mono); font-size: 13px; font-weight: 600;">01</span>
132
+ </div>
133
+ <span class="token-row-name">--font-mono</span>
134
+ <span class="token-row-value">SF Mono, ui-monospace, Cascadia Code, Fira Code, JetBrains Mono</span>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="ds-canvas" style="margin-top: 12px;">
139
+ <p style="font-size: 20px; font-weight: 700; margin: 0 0 6px; line-height: 1.2;">Page title — 20px/700</p>
140
+ <p style="font-size: 17px; font-weight: 600; margin: 0 0 6px; line-height: 1.3;">Section title — 17px/600</p>
141
+ <p style="font-size: 15px; font-weight: 500; margin: 0 0 6px;">Message handle — 15px/500</p>
142
+ <p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 6px; line-height: 1.5;">Body text — 14px/400, --text-secondary for descriptions</p>
143
+ <p style="font-size: 13px; color: var(--text-secondary); margin: 0 0 6px;">UI label — 13px/400, most controls</p>
144
+ <p style="font-size: 12px; color: var(--text-muted); margin: 0 0 6px;">Timestamp / metadata — 12px/400, --text-muted</p>
145
+ <p style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin: 0;">Section label — 11px/600 uppercase</p>
146
+ </div>
147
+ </div>
148
+
149
+ <hr class="ds-divider">
150
+
151
+ <!-- ── Radii ──────────────────────────────────────────────────────────────── -->
152
+ <div class="ds-section">
153
+ <h2 class="ds-section-title">Border Radius</h2>
154
+ <p class="ds-section-desc">Consistent rounding communicates surface type and elevation.</p>
155
+ <div class="ds-canvas ds-canvas--grid">
156
+ <div style="text-align: center;">
157
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-sm); margin: 0 auto 8px;"></div>
158
+ <code style="font-size: 11px;">--radius-sm<br>6px</code>
159
+ </div>
160
+ <div style="text-align: center;">
161
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-md); margin: 0 auto 8px;"></div>
162
+ <code style="font-size: 11px;">--radius-md<br>10px</code>
163
+ </div>
164
+ <div style="text-align: center;">
165
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-lg); margin: 0 auto 8px;"></div>
166
+ <code style="font-size: 11px;">--radius-lg<br>14px</code>
167
+ </div>
168
+ <div style="text-align: center;">
169
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-xl); margin: 0 auto 8px;"></div>
170
+ <code style="font-size: 11px;">--radius-xl<br>20px</code>
171
+ </div>
172
+ <div style="text-align: center;">
173
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-pill); margin: 0 auto 8px;"></div>
174
+ <code style="font-size: 11px;">--radius-pill<br>999px</code>
175
+ </div>
176
+ </div>
177
+ <div class="ds-annotation">
178
+ <strong>Usage guide:</strong> sm → nav items, code blocks, small chips. md → inputs, popovers.
179
+ lg → panels, modals, cards. xl → large overlays, sheets. pill → tags, reaction pills, primary buttons.
180
+ </div>
181
+ </div>
182
+
183
+ <hr class="ds-divider">
184
+
185
+ <!-- ── Shadows ─────────────────────────────────────────────────────────────── -->
186
+ <div class="ds-section">
187
+ <h2 class="ds-section-title">Shadows</h2>
188
+ <p class="ds-section-desc">Used for elevation — overlays, floating elements, modals.</p>
189
+ <div class="ds-canvas ds-canvas--grid" style="gap: 32px;">
190
+ <div style="text-align: center;">
191
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); margin: 0 auto 12px;"></div>
192
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-sm<br>nav items, small chips</code>
193
+ </div>
194
+ <div style="text-align: center;">
195
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-md); margin: 0 auto 12px;"></div>
196
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-md<br>popovers, context menus</code>
197
+ </div>
198
+ <div style="text-align: center;">
199
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); margin: 0 auto 12px;"></div>
200
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-lg<br>modals, sheets</code>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <hr class="ds-divider">
206
+
207
+ <!-- ── Motion ─────────────────────────────────────────────────────────────── -->
208
+ <div class="ds-section">
209
+ <h2 class="ds-section-title">Motion</h2>
210
+ <p class="ds-section-desc">One transition token. Use it for all interactive state changes.</p>
211
+ <div class="ds-canvas">
212
+ <div class="token-row" style="border: none; padding: 0;">
213
+ <span class="token-row-name">--transition</span>
214
+ <span class="token-row-value">200ms cubic-bezier(0.25, 0.1, 0.25, 1)</span>
215
+ </div>
216
+ </div>
217
+ <div class="ds-annotation">
218
+ <strong>Refactoring opportunity:</strong> Several components hardcode their own transition values
219
+ instead of using <code>var(--transition)</code>. When adding new interactive elements, always
220
+ use the token. Consider a pass through <code>base.css</code> to replace hardcoded transitions.
221
+ </div>
222
+ </div>
223
+
224
+ <script>
225
+ // Read computed CSS variable values and show them under swatches
226
+ document.querySelectorAll('[data-css-var]').forEach(el => {
227
+ const update = () => {
228
+ const val = getComputedStyle(document.documentElement)
229
+ .getPropertyValue(el.dataset.cssVar).trim()
230
+ el.textContent = val || '—'
231
+ }
232
+ update()
233
+ // Re-read on theme change (theme.js swaps the stylesheet link)
234
+ document.getElementById('theme-stylesheet')?.addEventListener('load', update)
235
+ })
236
+ </script>
@@ -1,38 +1,196 @@
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 { ChatHeaderView } from './views/ChatHeaderView.js'
23
+ import { cancelActiveEdit } from './views/shared/MessageInteractions.js'
24
+ import { closeEmojiPicker } from './views/shared/EmojiPickerSingleton.js'
10
25
  import { getSettings, syncFromServer, patchSettings } from './settings-sync.js'
11
- import { initSwipeNav } from './swipe-nav.js'
12
- import { initRouter } from './router.js'
26
+ import { initSwipeNav } from './swipe-nav.js'
27
+ import { initRouter } from './router.js'
28
+ import { attachResizeHandle } from './resizable.js'
13
29
 
14
30
  const BASE_PATH = window.__BASE_PATH__ ?? ''
15
31
 
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.
32
+ // ── 1. Pre-mount: apply settings to prevent layout flash ─────────────────────
33
+
18
34
  if (location.pathname.startsWith(`${BASE_PATH}/channels/`)) {
19
35
  const channelId = location.pathname.split('/').pop()
20
36
  if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
21
37
  }
22
38
 
23
- // Apply settings before islands mount (prevent layout flash)
24
39
  const settings = getSettings()
25
40
  if (window.matchMedia('(max-width: 1024px)').matches && settings.mobile_chat_open === false) {
26
41
  document.body.classList.add('sidebar-open')
27
42
  }
28
43
 
29
- await init(window)
44
+ // ── 2. Model ──────────────────────────────────────────────────────────────────
45
+
46
+ const model = new AppModel()
47
+
48
+ // Seed identity from the chat panel's data attributes (written by SSR)
49
+ const chatPanelEl = document.querySelector('.chat-panel')
50
+ if (chatPanelEl) {
51
+ model.setIdentity({
52
+ userId: chatPanelEl.dataset.userId ?? null,
53
+ userHandle: chatPanelEl.dataset.userHandle ?? null,
54
+ })
55
+ // Seed the current channel
56
+ const channelId = chatPanelEl.dataset.id ?? null
57
+ const seedFirstSeq = parseInt(chatPanelEl.dataset.seedFirstSeq ?? '0', 10)
58
+ const seedHasMore = chatPanelEl.dataset.seedHasMore === 'true'
59
+ if (channelId) {
60
+ model.selectChannel(channelId, {
61
+ name: chatPanelEl.dataset.name ?? '',
62
+ topic: chatPanelEl.dataset.topic ?? '',
63
+ kind: chatPanelEl.dataset.kind ?? 'text',
64
+ })
65
+ model.seedMessages(channelId, { oldestSeq: seedFirstSeq, hasMore: seedHasMore })
66
+ }
67
+ }
68
+
69
+ // ── 3. Controllers ────────────────────────────────────────────────────────────
70
+
71
+ const wsController = new WebSocketController(model, `${BASE_PATH}/ws`)
72
+ const ws = wsController.ws
73
+ new ChatController(model, ws)
74
+
75
+ // ── 4. Views ──────────────────────────────────────────────────────────────────
76
+
77
+ // Sidebar
78
+ const sidebarEl = document.querySelector('aside[data-sidebar], aside.sidebar, aside')
79
+ if (sidebarEl) {
80
+ new SidebarView(model, ws, sidebarEl)
81
+ }
82
+
83
+ // Chat panel views (only mount if the chat panel exists on this page)
84
+ if (chatPanelEl) {
85
+ const messagesEl = document.getElementById('messages')
86
+ const sentinelEl = document.getElementById('load-more-sentinel')
87
+ const composerEl = chatPanelEl.querySelector('.composer')
88
+ const threadPanelEl = document.getElementById('thread-panel')
89
+
90
+ const headerEl = chatPanelEl.querySelector('.chat-header')
91
+ if (headerEl) {
92
+ new ChatHeaderView(model, headerEl)
93
+ }
94
+
95
+ if (messagesEl) {
96
+ new MessageListView(model, messagesEl, sentinelEl)
97
+ }
98
+
99
+ if (threadPanelEl) {
100
+ new ThreadPanelView(model, threadPanelEl)
101
+ attachResizeHandle(threadPanelEl, {
102
+ edge: 'left',
103
+ cssVar: '--thread-panel-width',
104
+ min: 260,
105
+ max: 640,
106
+ prefKey: 'thread_panel_width',
107
+ })
108
+ }
109
+
110
+ if (composerEl) {
111
+ new ComposerView(model, composerEl)
112
+ }
113
+
114
+ // CallView (WebRTC) — only if call UI elements are present
115
+ const tilePanelEl = document.getElementById('tile-panel')
116
+ if (tilePanelEl || document.getElementById('btn-start-call')) {
117
+ new CallView(model, ws)
118
+ }
119
+ if (tilePanelEl) {
120
+ attachResizeHandle(tilePanelEl, {
121
+ edge: 'left',
122
+ cssVar: '--tile-panel-width',
123
+ min: 200,
124
+ max: 640,
125
+ prefKey: 'tile_panel_width',
126
+ })
127
+ }
128
+ }
129
+
130
+ // Sidebar resize — desktop only (mobile sidebar is full-screen overlay)
131
+ if (sidebarEl && window.matchMedia('(min-width: 769px)').matches) {
132
+ attachResizeHandle(sidebarEl, {
133
+ edge: 'right',
134
+ cssVar: '--sidebar-width',
135
+ min: 180,
136
+ max: 480,
137
+ prefKey: 'sidebar_width',
138
+ })
139
+ }
140
+
141
+ // ── 5. SPA navigation ─────────────────────────────────────────────────────────
142
+
143
+ // router.js morphs the DOM and dispatches 'chatpanel:navigated'.
144
+ // We update the model here so views react automatically.
145
+ document.addEventListener('chatpanel:navigated', e => {
146
+ const { channelId, name, topic, kind, seedFirstSeq, seedHasMore } = e.detail
147
+
148
+ // Close transient UI on navigation
149
+ cancelActiveEdit()
150
+ closeEmojiPicker()
151
+ if (model.threadParentId) model.closeThread()
152
+
153
+ // Update identity if it changed (shouldn't, but guard anyway)
154
+ const panel = document.querySelector('.chat-panel')
155
+ if (panel?.dataset.userId) {
156
+ model.setIdentity({ userId: panel.dataset.userId, userHandle: panel.dataset.userHandle ?? null })
157
+ }
158
+
159
+ // Seed pagination bookmarks for the new channel
160
+ if (channelId) {
161
+ model.seedMessages(channelId, {
162
+ oldestSeq: parseInt(seedFirstSeq ?? '0', 10),
163
+ hasMore: seedHasMore ?? false,
164
+ })
165
+ }
166
+
167
+ // Navigate — fires 'channel-selected' → all views update
168
+ model.selectChannel(channelId, { name, topic, kind })
169
+
170
+ // Join the new channel on the server (WebSocketController handles the response)
171
+ ws.send({ t: 'channel.join', body: { channel_id: channelId } })
172
+
173
+ // Persist for PWA restore
174
+ if (channelId) patchSettings({ last_channel_id: channelId, mobile_chat_open: true })
175
+ })
176
+
177
+ // ── 6. Router + swipe nav ────────────────────────────────────────────────────
30
178
 
31
179
  initRouter()
32
180
  initSwipeNav()
33
181
 
34
- // Reconcile with server in background after islands mount
182
+ // ── 7. Post-mount: reconcile settings with server ─────────────────────────────
183
+
35
184
  syncFromServer().then(remoteSettings => {
36
185
  if (!remoteSettings || !window.matchMedia('(max-width: 1024px)').matches) return
37
186
  document.body.classList.toggle('sidebar-open', remoteSettings.mobile_chat_open === false)
38
187
  })
188
+
189
+ // ── 8. Global keyboard shortcuts ──────────────────────────────────────────────
190
+
191
+ document.addEventListener('keydown', e => {
192
+ if (e.key === 'Escape') {
193
+ cancelActiveEdit()
194
+ closeEmojiPicker()
195
+ }
196
+ })
@@ -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
+ }