@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 +0 -9
- package/package.json +2 -3
- package/pages/_layout.html +0 -7
- package/pages/admin/_layout.html +0 -7
- package/pages/channels/[channelId].phtml +17 -40
- package/pages/public/client/app.js +136 -13
- package/pages/public/client/controllers/ChatController.js +204 -0
- package/pages/public/client/controllers/WebSocketController.js +191 -0
- package/pages/public/client/model/AppModel.js +351 -0
- package/pages/public/client/model/events.js +41 -0
- package/pages/public/client/shared/messages.js +21 -9
- package/pages/public/client/views/CallView.js +761 -0
- package/pages/public/client/views/ComposerView.js +488 -0
- package/pages/public/client/views/MessageListView.js +461 -0
- package/pages/public/client/views/SidebarView.js +902 -0
- package/pages/public/client/views/ThreadPanelView.js +260 -0
- package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
- package/pages/public/client/views/shared/MentionPicker.js +139 -0
- package/pages/public/client/views/shared/MessageInteractions.js +353 -0
- package/pages/public/themes/base.css +7 -29
- package/pages/public/client/islands/call.js +0 -2282
- package/pages/public/client/islands/sidebar.js +0 -1198
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": "
|
|
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",
|
package/pages/_layout.html
CHANGED
|
@@ -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">
|
package/pages/admin/_layout.html
CHANGED
|
@@ -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
|
-
|
|
2
|
-
<
|
|
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-
|
|
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
|
|
6
|
+
<span>{{name}}</span>
|
|
8
7
|
<button class="btn-hub-gear btn-icon" title="Hub settings" aria-label="Hub settings" type="button">⚙</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
|
|
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">⚙</button>
|
|
22
|
-
</li>
|
|
23
|
-
</template>
|
|
10
|
+
<ul class="channel-list">
|
|
24
11
|
{{#each channels}}
|
|
25
|
-
<li
|
|
26
|
-
<a
|
|
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
|
|
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
|
-
|
|
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">←</button>
|
|
103
89
|
<hgroup>
|
|
104
|
-
<h2 class="chat-title"
|
|
105
|
-
<p class="chat-topic"
|
|
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-
|
|
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
|
|
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">🔔</button>
|
|
159
|
-
<textarea
|
|
160
|
-
rows="1"
|
|
161
|
-
|
|
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">⛶</button>
|
|
164
|
-
<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 —
|
|
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 { 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 }
|
|
12
|
-
import { initRouter }
|
|
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
|
-
//
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|