@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,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 & 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 —
|
|
2
|
+
* app.js — MVC bootstrap.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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 }
|
|
12
|
-
import { initRouter }
|
|
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
|
-
//
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|