diff --git a/UI_REFACTOR_TODO.md b/UI_REFACTOR_TODO.md
new file mode 100644
index 0000000..142b797
--- /dev/null
+++ b/UI_REFACTOR_TODO.md
@@ -0,0 +1,319 @@
+# Web UI Refactor Todo List
+
+> 目标:整理当前网页显示界面的重构项,先建立一份可执行的前端重构清单,后续按优先级逐步落地。
+
+## 当前进度
+
+### 已完成(第一轮)
+- [x] 将 `public/style.css` 拆分为多份区域样式文件,并由入口样式统一导入
+- [x] 移除 `public/index.html` 中的内联 `style`
+- [x] 将富内容消息的主要静态样式从 JS 迁移到 CSS class
+- [x] 新增统一连接状态组件,分离“连接状态”和“操作反馈”
+- [x] 统一最近会话 / 好友 / 群组列表容器样式
+- [x] 群组点击行为改为直接进入会话并拉取历史
+- [x] 移动端恢复显式发送按钮
+- [x] 历史消息改为批量渲染,减少重复滚动
+- [x] 为 tab / lightbox / focus-visible 补充一轮基础可访问性支持
+
+### 已完成(第二轮)
+- [x] 从 `public/app.js` 中继续拆出图片管理模块
+- [x] 从 `public/app.js` 中拆出 lightbox 模块
+- [x] 从 `public/app.js` 中拆出消息渲染模块
+- [x] 从 `public/app.js` 中拆出侧栏渲染模块
+- [x] 进一步压缩入口文件体积,降低主文件耦合度
+
+### 已完成(第三轮)
+- [x] 从 `public/app.js` 中拆出 composer 模块
+- [x] 将附件上传、上传队列、URL 图片发送逻辑并入 composer 模块
+- [x] 将表情建议、贴纸/表情选择器逻辑并入 composer 模块
+- [x] 继续压缩入口文件体积,主入口聚焦在页面装配与 WebSocket 流程
+
+### 已完成(第四轮)
+- [x] 从 `public/app.js` 中拆出会话状态模块
+- [x] 从 `public/app.js` 中拆出 WebSocket 协调模块
+- [x] 进一步压缩主入口,仅保留页面装配与少量全局行为
+
+### 已完成(第五轮)
+- [x] 从 `public/app.js` 中拆出通知模块
+- [x] 从 `public/app.js` 中拆出响应式布局/侧栏控制模块
+- [x] 从 `public/app.js` 中拆出本地偏好读取与保存模块
+- [x] 补齐贴纸 URL 辅助方法到前端工具模块,统一消息渲染依赖来源
+- [x] 修复拆分过程中主入口残留引用,恢复语法与测试通过状态
+
+### 已完成(第六轮)
+- [x] 从 `public/app.js` 中拆出 DOM 引用收集模块
+- [x] 从 `public/app.js` 中拆出页面装配 / 事件绑定模块
+- [x] 从主入口中抽出 ws 配置拉取与连接启动逻辑
+- [x] 继续压缩主入口,进一步聚焦控制器组装
+
+### 已完成(第七轮)
+- [x] 从 `messages.js` 中拆出消息气泡渲染模块
+- [x] 将文本消息 / 图片消息 / 贴纸消息的气泡渲染统一收口
+- [x] 让 `messages.js` 聚焦消息列表编排、分隔线与滚动策略
+
+### 本轮后新的文件结构
+- `public/styles/base.css`
+- `public/styles/sidebar.css`
+- `public/styles/messages.css`
+- `public/styles/composer.css`
+- `public/styles/overlays.css`
+- `public/styles/responsive.css`
+- `public/app/utils.js`
+- `public/app/status.js`
+- `public/app/rich-content.js`
+- `public/app/managed-images.js`
+- `public/app/lightbox.js`
+- `public/app/messages.js`
+- `public/app/sidebar.js`
+- `public/app/composer.js`
+- `public/app/session.js`
+- `public/app/websocket.js`
+- `public/app/notifications.js`
+- `public/app/layout.js`
+- `public/app/preferences.js`
+- `public/app/dom.js`
+- `public/app/bootstrap.js`
+- `public/app/message-bubble.js`
+
+## 一、重构目标
+
+- 提升页面结构清晰度,降低 `public/app.js` 与 `public/style.css` 的维护成本
+- 统一桌面端/移动端交互表现
+- 提升消息区、侧栏、发送区的一致性
+- 改善可访问性、状态展示和渲染性能
+
+---
+
+## 二、P0:优先处理
+
+### 1. 拆分前端代码职责
+- [ ] 将 `public/app.js` 继续按功能拆分为独立模块
+ - [x] `sidebar`
+ - [x] `messages`
+ - [x] `message-bubble`
+ - [x] `composer`
+ - [~] `picker`
+ - [x] `lightbox`
+ - [x] `connection-status`
+ - [x] `notifications`
+ - [x] `layout`
+ - [x] `preferences`
+ - [x] `dom`
+ - [x] `bootstrap`
+ - [x] `utils`
+ - [x] `rich-content`
+- [x] 将 `public/style.css` 按页面区域拆分
+ - [x] layout
+ - [x] sidebar
+ - [x] chat
+ - [x] composer
+ - [x] overlay / modal
+- [~] 建立统一命名规范,避免样式和脚本继续堆在单文件里
+ - [x] 已建立一批统一类名
+ - [ ] 仍需继续收敛 `app.js` 中剩余页面装配逻辑
+
+**现状问题**
+- `public/app.js` 已降至约 395 行,主入口已明显收缩
+- `public/style.css` 已降为入口文件
+- 目前主要剩余耦合点集中在跨模块回调编排、消息气泡细分与部分交互细节
+
+---
+
+### 2. 清理内联样式,改为 class 驱动
+- [x] 移除 `public/index.html` 中的内联 `style`
+- [~] 移除 `public/app.js` 中直接写入视觉样式的逻辑
+ - [x] 已清理消息卡片、建议项、OG 卡片等静态视觉样式
+ - [ ] 保留少量运行时样式控制:高度、自适应、transform、进度条宽度
+- [x] 为常见 UI 块补充语义化 class
+- [~] 保证 JS 只控制状态,不直接控制具体视觉细节
+
+**重点位置**
+- [x] `public/index.html:42-72`
+- [~] `public/app.js` 中图片、卡片、建议项、消息项里的 `.style.*`
+
+---
+
+### 3. 重构移动端发送区
+- [x] 保留移动端显式发送按钮,不再完全依赖 Enter 发送
+- [x] 统一附件按钮 / 表情按钮 / 发送按钮布局
+- [x] 优化输入框高度、换行、滚动行为
+- [~] 重新梳理 URL 面板、附件预览、上传队列在移动端的展示顺序
+ - [x] 已完成基础顺序整理
+ - [ ] 仍可继续压缩移动端纵向占用
+
+**现状问题**
+- [x] 移动端 `#sendMessage` 被隐藏
+- [x] 发送行为与桌面端不完全一致
+- [ ] 发送区承载内容仍偏多,仍有进一步整理空间
+
+---
+
+### 4. 统一连接状态展示
+- [x] 合并顶部 `WebSocket` chip 与侧栏 `status` 的职责
+- [x] 建立统一的连接状态组件
+- [x] 明确区分:
+ - [x] 已连接
+ - [x] 重连中
+ - [x] 异常
+ - [x] 请求失败
+- [x] 避免用户同时看到多个状态入口
+
+---
+
+## 三、P1:中优先级
+
+### 5. 抽象统一的侧栏列表组件
+- [x] 为最近会话、好友、群组三类列表建立统一列表容器
+- [x] 提取统一 item 样式和 hover / active 规则
+- [x] 提取统一 empty state
+- [x] 保证三个 tab 的滚动、间距、边界表现一致
+
+**现状问题**
+- [x] `#conversationList` 有专门样式
+- [x] `friendsList` / `groupsList` 缺少同级统一列表容器规则
+
+---
+
+### 6. 优化消息渲染逻辑
+- [x] 将消息列表渲染改为批量插入,减少反复重排
+- [x] 历史消息加载时只在完成后滚动一次
+- [x] 拆出消息气泡渲染器
+ - [x] 文本消息
+ - [x] 图片消息
+ - [x] 贴纸消息
+ - [~] 富文本/链接卡片
+- [x] 为时间分隔线、日期分隔线建立统一渲染入口
+
+**现状问题**
+- [x] `renderHistory()` 中逐条追加
+- [x] `appendEntry()` 每次都触发滚动到底部
+
+---
+
+### 7. 统一富内容消息的样式出口
+- [x] 将表情图、内联图片、Open Graph 卡片从 JS 内联样式改为 CSS class
+- [x] 为卡片消息建立独立样式类
+- [~] 避免消息渲染函数中出现大量 DOM 样式拼接
+ - [x] 已将主要富内容展示迁移到 `rich-content.js`
+ - [ ] 后续仍可继续拆分消息渲染器
+
+**重点位置**
+- [x] `appendEmoticonImage()`
+- [x] `appendInlineImage()`
+- [x] `appendOpenGraphCard()`
+- [x] `renderSuggestionList()`
+
+---
+
+### 8. 统一群组 / 好友 / 会话点击行为
+- [x] 明确三类列表项点击后的统一行为模型
+- [x] 群组点击后应与好友/会话保持一致,避免只填入输入框
+- [ ] 补充“进入会话 / 仅选中 / 自动拉取历史”规则说明
+
+**现状问题**
+- [x] 最近会话、好友:点击后直接进入
+- [x] 群组:点击后仅写入 `targetId`
+
+---
+
+## 四、P2:体验与规范提升
+
+### 9. 补齐可访问性
+- [x] 为 tab 增加完整语义
+ - [x] `role="tablist"`
+ - [x] `role="tab"`
+ - [x] `aria-selected`
+- [x] 为图片预览弹层增加 dialog 语义与焦点管理
+- [x] 增加 `:focus-visible` 样式
+- [~] 优化键盘操作路径
+ - [x] tab 切换
+ - [x] lightbox 关闭
+ - [ ] 建议项导航
+
+---
+
+### 10. 统一视觉 token
+- [ ] 抽取颜色、间距、圆角、阴影为统一变量
+- [ ] 减少样式文件中重复出现的硬编码颜色
+- [ ] 为桌面端/移动端建立更清晰的变量层级
+
+---
+
+### 11. 清理无效或遗留样式
+- [ ] 检查未使用类名和重复规则
+- [ ] 清理历史遗留命名
+- [ ] 合并重复媒体查询样式
+
+**已观察到的潜在项**
+- `send-row`
+- `image-row`
+
+---
+
+## 五、建议的执行顺序
+
+### 第一阶段:结构整理
+- [x] 拆分 JS / CSS 文件
+- [x] 去掉内联样式
+- [~] 建立统一 class 命名
+
+### 第二阶段:交互统一
+- [x] 重构移动端发送区
+- [x] 统一连接状态
+- [x] 统一侧栏三类列表行为
+
+### 第三阶段:渲染优化
+- [~] 重构消息渲染
+- [x] 提取富内容消息组件
+- [x] 优化批量渲染和滚动策略
+
+### 第四阶段:规范与体验
+- [~] 补齐可访问性
+- [ ] 抽取视觉 token
+- [ ] 清理冗余样式
+
+---
+
+## 六、验收标准
+
+- [x] 页面结构拆分后,单文件长度显著下降
+- [x] HTML 与 JS 中不再存在大段视觉内联样式
+- [x] 移动端发送区可稳定发送消息和图片
+- [x] 三类侧栏列表行为一致
+- [x] 消息历史加载更顺畅
+- [~] 主要交互支持键盘和焦点可见性
+
+---
+
+## 七、涉及文件
+
+- `public/index.html`
+- `public/style.css`
+- `public/app.js`
+- `public/styles/*.css`
+- `public/app/*.js`
+
+---
+
+## 八、下一轮建议优先做的事
+
+### 高优先级
+- [ ] 将 `public/app.js` 继续拆成:
+ - [x] `sidebar.js`
+ - [x] `messages.js`
+ - [x] `composer.js`
+ - [~] `picker.js`(当前已并入 `composer.js`,后续可视情况独立)
+ - [x] `lightbox.js`
+- [x] 将会话状态与 WebSocket 通信从主入口中拆分
+- [x] 将通知、布局控制、偏好存储从主入口中拆分
+- [x] 将 DOM 收集、页面装配与连接启动从主入口中拆分
+- [x] 把消息气泡渲染进一步组件化
+- [ ] 清理剩余运行时样式控制中的可静态部分
+
+### 中优先级
+- [ ] 为建议项面板补充完整键盘导航与 aria
+- [ ] 评估是否将 `picker` 从 `composer.js` 中独立成单文件
+- [x] 评估并将页面装配逻辑进一步独立
+- [ ] 继续细化富文本/链接卡片渲染边界
+- [ ] 抽取统一视觉 token(颜色、间距、阴影、圆角)
+- [ ] 清理未使用类名和重复媒体查询
diff --git a/chat.js b/chat.js
index d915cc5..a1b4fb8 100644
--- a/chat.js
+++ b/chat.js
@@ -259,6 +259,18 @@ function normalizeWsRequest(payload) {
action: 'get_emoticons',
requestId: payload.requestId,
};
+ case 'friends':
+ case 'get_friends':
+ return {
+ action: 'get_friends',
+ requestId: payload.requestId,
+ };
+ case 'groups':
+ case 'get_groups':
+ return {
+ action: 'get_groups',
+ requestId: payload.requestId,
+ };
case 'ping':
return {
action: 'ping',
@@ -537,7 +549,7 @@ function buildConversationSummaries(items) {
const current = conversations.get(entry.id) || {
id: entry.id,
- name: entry.name || '',
+ name: entry.echo ? '' : (entry.name || ''),
updatedAt: entry.date || entry.sentAt || '',
preview: buildConversationPreview(entry),
lastType: entry.type || (entry.imageUrl ? 'image' : 'message'),
@@ -545,7 +557,9 @@ function buildConversationSummaries(items) {
messageCount: 0,
};
- current.name = entry.name || current.name;
+ if (!entry.echo) {
+ current.name = entry.name || current.name;
+ }
current.updatedAt = entry.date || entry.sentAt || current.updatedAt;
current.preview = buildConversationPreview(entry) || current.preview;
current.lastType = entry.type || (entry.imageUrl ? 'image' : 'message');
@@ -784,13 +798,13 @@ function createChatService(customDeps = {}) {
}
function appendOutgoingLog(uid, response) {
- client.getUserInfo(steamUser.steamID).then((sender) => {
+ client.getUserInfo(uid).then((friend) => {
appendLogEntry({
type: 'message',
date: dateToString(response.server_timestamp),
echo: true,
id: uid,
- name: sender.player_name,
+ name: friend.player_name,
message: response.modified_message,
ordinal: response.ordinal,
});
@@ -798,13 +812,13 @@ function createChatService(customDeps = {}) {
}
async function appendOutgoingImageLog(uid, imageUrl) {
- const sender = await client.getUserInfo(steamUser.steamID, () => {});
+ const friend = await client.getUserInfo(uid, () => {});
const entry = {
type: 'image',
date: dateToString(new Date()),
echo: true,
id: uid,
- name: sender.player_name,
+ name: friend.player_name,
imageUrl,
ordinal: null,
sentAt: new Date().toISOString(),
@@ -928,6 +942,52 @@ function createChatService(customDeps = {}) {
return { emoticons, stickers };
}
+ async function getFriendsList() {
+ await client.steamLoginPromise;
+ const myFriends = steamUser.myFriends || {};
+ const friendIds = Object.keys(myFriends).filter((id) => myFriends[id] === 3); // EFriendRelationship.Friend
+
+ const friends = [];
+ for (const id of friendIds) {
+ const cached = steamUser.users && steamUser.users[id];
+ friends.push({
+ id,
+ name: (cached && cached.player_name) || id,
+ avatar: (cached && cached.avatar_url_medium) || null,
+ status: cached ? (cached.persona_state || 0) : 0,
+ game: (cached && cached.game_name) || null,
+ });
+ }
+
+ friends.sort((a, b) => {
+ // Online users first (status > 0), then alphabetical
+ if (a.status > 0 && b.status === 0) return -1;
+ if (a.status === 0 && b.status > 0) return 1;
+ return (a.name || '').localeCompare(b.name || '');
+ });
+
+ return friends;
+ }
+
+ async function getGroupsList() {
+ await client.steamLoginPromise;
+ const myGroups = steamUser.myGroups || {};
+ const groupIds = Object.keys(myGroups);
+
+ const groups = [];
+ for (const id of groupIds) {
+ const cached = steamUser.groups && steamUser.groups[id];
+ const nameInfo = cached && cached.name_info;
+ groups.push({
+ id,
+ name: (nameInfo && nameInfo.clan_name) || id,
+ });
+ }
+
+ groups.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ return groups;
+ }
+
function sendFriendMessage(uid, msg) {
return new Promise((resolve, reject) => {
if (!uid || typeof msg !== 'string') {
@@ -1156,7 +1216,20 @@ function createChatService(customDeps = {}) {
async function readConversationSummaries({ limit } = {}) {
const items = await readChatHistory({ limit: sanitizeLimit(limit, 500) });
- return buildConversationSummaries(items);
+ const summaries = buildConversationSummaries(items);
+
+ for (const summary of summaries) {
+ if (!summary.name && summary.id) {
+ try {
+ const friend = await client.getUserInfo(summary.id, () => {});
+ summary.name = friend.player_name || summary.id;
+ } catch (err) {
+ summary.name = summary.id;
+ }
+ }
+ }
+
+ return summaries;
}
async function fetchStickerBuffer(type) {
@@ -1464,6 +1537,28 @@ function createChatService(customDeps = {}) {
return;
}
+ if (req.method === 'GET' && requestUrl.pathname === '/api/friends') {
+ try {
+ const items = await getFriendsList();
+ sendJson(res, 200, { items });
+ } catch (err) {
+ logger.error('failed to get friends list', err);
+ sendJson(res, 500, { error: err.message || 'Internal Server Error' });
+ }
+ return;
+ }
+
+ if (req.method === 'GET' && requestUrl.pathname === '/api/groups') {
+ try {
+ const items = await getGroupsList();
+ sendJson(res, 200, { items });
+ } catch (err) {
+ logger.error('failed to get groups list', err);
+ sendJson(res, 500, { error: err.message || 'Internal Server Error' });
+ }
+ return;
+ }
+
if (req.method === 'GET') {
const ext = path.extname(requestUrl.pathname);
if (ext && STATIC_CONTENT_TYPES[ext]) {
@@ -1557,6 +1652,24 @@ function createChatService(customDeps = {}) {
});
return;
}
+ case 'get_friends': {
+ const items = await getFriendsList();
+ sendWs(ws, {
+ type: 'friends',
+ requestId: request.requestId,
+ data: { items },
+ });
+ return;
+ }
+ case 'get_groups': {
+ const items = await getGroupsList();
+ sendWs(ws, {
+ type: 'groups',
+ requestId: request.requestId,
+ data: { items },
+ });
+ return;
+ }
case 'ping':
sendWs(ws, {
type: 'pong',
@@ -1663,6 +1776,8 @@ function createChatService(customDeps = {}) {
sendFriendMessage,
sendImageToUser,
getEmoticonList,
+ getFriendsList,
+ getGroupsList,
readChatHistory,
readConversationSummaries,
fetchStickerBuffer,
diff --git a/public/app.js b/public/app.js
index bdcd62e..b7b5d73 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,2111 +1,365 @@
+import {
+ buildCachedImageUrl,
+ buildSteamStickerCandidateUrls,
+ buildSteamEmoticonUrl,
+ extractEmoticonNames,
+ extractImageUrls,
+ extractStickerType,
+ formatConversationTime,
+ formatDayLabel,
+ formatTimeLabel,
+ parseBbCodeAttributes,
+ parseDateString,
+ sameDay,
+} from './app/utils.js';
+import { createStatusController } from './app/status.js';
+import { createRichContentRenderer } from './app/rich-content.js';
+import { createLightboxController } from './app/lightbox.js';
+import { createManagedImageController } from './app/managed-images.js';
+import { createMessageBubbleRenderer } from './app/message-bubble.js';
+import { createMessagesController } from './app/messages.js';
+import { createSidebarController } from './app/sidebar.js';
+import { createComposerController } from './app/composer.js';
+import { createSessionController } from './app/session.js';
+import { createWebSocketController } from './app/websocket.js';
+import { createNotificationController } from './app/notifications.js';
+import { createLayoutController } from './app/layout.js';
+import { createPreferencesController } from './app/preferences.js';
+import { getAppDomRefs } from './app/dom.js';
+import { bindAppShellEvents, connectSocketFromConfig } from './app/bootstrap.js';
+
(() => {
- const targetIdInput = document.getElementById('targetId');
- const historyLimitInput = document.getElementById('historyLimit');
- const reloadHistoryButton = document.getElementById('reloadHistory');
- const openConversationButton = document.getElementById('openConversation');
- const reloadConversationsButton = document.getElementById('reloadConversations');
- const conversationListEl = document.getElementById('conversationList');
- const chatTitleEl = document.getElementById('chatTitle');
- const chatSubtitleEl = document.getElementById('chatSubtitle');
- const statusEl = document.getElementById('status');
- const messagesEl = document.getElementById('messages');
- const dropOverlay = document.getElementById('dropOverlay');
- const sidebarEl = document.querySelector('.sidebar');
- const sidebarBackdrop = document.getElementById('sidebarBackdrop');
- const mobileSidebarToggleButton = document.getElementById('mobileSidebarToggle');
- const closeSidebarButton = document.getElementById('closeSidebar');
- const imageLightbox = document.getElementById('imageLightbox');
- const imageLightboxViewport = document.getElementById('imageLightboxViewport');
- const imageLightboxImage = document.getElementById('imageLightboxImage');
- const imageLightboxCaption = document.getElementById('imageLightboxCaption');
- const closeImageLightboxButton = document.getElementById('closeImageLightbox');
- const imageZoomOutButton = document.getElementById('imageZoomOut');
- const imageZoomResetButton = document.getElementById('imageZoomReset');
- const imageZoomInButton = document.getElementById('imageZoomIn');
- const messageInput = document.getElementById('messageInput');
- const emoticonSuggestions = document.getElementById('emoticonSuggestions');
- const emoticonPreview = document.getElementById('emoticonPreview');
- const emoticonPreviewImage = document.getElementById('emoticonPreviewImage');
- const emoticonPreviewLabel = document.getElementById('emoticonPreviewLabel');
- const sendMessageButton = document.getElementById('sendMessage');
- const attachmentButton = document.getElementById('attachmentButton');
- const attachmentMenu = document.getElementById('attachmentMenu');
- const chooseImageButton = document.getElementById('chooseImageButton');
- const chooseUrlButton = document.getElementById('chooseUrlButton');
- const imageFileInput = document.getElementById('imageFile');
- const urlPanel = document.getElementById('urlPanel');
- const imageUrlInput = document.getElementById('imageUrl');
- const confirmImageUrlButton = document.getElementById('confirmImageUrl');
- const cancelImageUrlButton = document.getElementById('cancelImageUrl');
- const attachmentPreview = document.getElementById('attachmentPreview');
- const attachmentPreviewImage = document.getElementById('attachmentPreviewImage');
- const attachmentPreviewTitle = document.getElementById('attachmentPreviewTitle');
- const attachmentPreviewSubtitle = document.getElementById('attachmentPreviewSubtitle');
- const clearAttachmentButton = document.getElementById('clearAttachment');
- const uploadQueue = document.getElementById('uploadQueue');
- const uploadQueueList = document.getElementById('uploadQueueList');
- const pickerButton = document.getElementById('pickerButton');
- const pickerPanel = document.getElementById('pickerPanel');
- const pickerSearch = document.getElementById('pickerSearch');
- const pickerGrid = document.getElementById('pickerGrid');
- const pickerEmpty = document.getElementById('pickerEmpty');
- const pickerTabs = pickerPanel.querySelectorAll('.picker-tab');
+ const {
+ targetIdInput,
+ historyLimitInput,
+ reloadHistoryButton,
+ openConversationButton,
+ reloadConversationsButton,
+ conversationListEl,
+ friendsListEl,
+ groupsListEl,
+ reloadFriendsButton,
+ reloadGroupsButton,
+ sidebarTabs,
+ sidebarTabPanels,
+ chatTitleEl,
+ chatSubtitleEl,
+ feedbackStatusEl,
+ connectionStatusEl,
+ connectionStatusLabelEl,
+ messagesEl,
+ dropOverlay,
+ sidebarEl,
+ sidebarBackdrop,
+ mobileSidebarToggleButton,
+ closeSidebarButton,
+ imageLightbox,
+ imageLightboxViewport,
+ imageLightboxImage,
+ imageLightboxCaption,
+ closeImageLightboxButton,
+ imageZoomOutButton,
+ imageZoomResetButton,
+ imageZoomInButton,
+ messageInput,
+ emoticonSuggestions,
+ emoticonPreviewImage,
+ emoticonPreviewLabel,
+ sendMessageButton,
+ attachmentButton,
+ attachmentMenu,
+ chooseImageButton,
+ chooseUrlButton,
+ imageFileInput,
+ urlPanel,
+ imageUrlInput,
+ confirmImageUrlButton,
+ cancelImageUrlButton,
+ attachmentPreview,
+ attachmentPreviewImage,
+ attachmentPreviewTitle,
+ attachmentPreviewSubtitle,
+ clearAttachmentButton,
+ uploadQueue,
+ uploadQueueList,
+ pickerButton,
+ pickerPanel,
+ pickerSearch,
+ pickerGrid,
+ pickerEmpty,
+ pickerTabs,
+ } = getAppDomRefs(document);
- targetIdInput.value = localStorage.getItem('steam-chat-target-id') || '';
- historyLimitInput.value = localStorage.getItem('steam-chat-history-limit') || '100';
-
- let socket = null;
- let nextRequestId = 1;
- let activeConversationId = '';
- let conversations = [];
- let pendingAttachment = null;
- let dragDepth = 0;
- const knownEmoticons = new Set(['steamhappy', 'steamfacepalm', 'steamthumbsup', 'steamheart', 'steamsad', 'steammocking']);
- let currentSuggestions = [];
- let activeSuggestionIndex = 0;
- let uploadQueueItems = [];
- const uploadRequestMap = new Map();
- const managedImageRequestMap = new WeakMap();
- let activePickerTab = 'emoticons';
- let emoticonInventory = [];
- let stickerInventory = [];
const defaultDocumentTitle = document.title || 'Steam Chat';
- let unreadCount = 0;
- let notificationPermissionRequested = false;
- let nextManagedImageToken = 1;
const mobileLayoutMedia = window.matchMedia('(max-width: 900px)');
- const imageLightboxState = {
- scale: 1,
- minScale: 1,
- maxScale: 6,
- offsetX: 0,
- offsetY: 0,
- dragging: false,
- dragPointerId: null,
- dragStartX: 0,
- dragStartY: 0,
- dragOriginX: 0,
- dragOriginY: 0,
- activePointers: new Map(),
- pinching: false,
- pinchStartDistance: 0,
- pinchStartScale: 1,
- pinchContentX: 0,
- pinchContentY: 0,
- rafPending: false,
- cachedBaseSize: null,
- cachedViewportRect: null,
- };
- function setStatus(text) {
- statusEl.textContent = text;
- }
+ let createRichMessageContent = null;
- function updateViewportHeightVar() {
- const viewport = window.visualViewport;
- const height = viewport && Number.isFinite(viewport.height)
- ? viewport.height
- : window.innerHeight;
- const offsetTop = viewport && Number.isFinite(viewport.offsetTop)
- ? viewport.offsetTop
- : 0;
- document.documentElement.style.setProperty('--app-height', height + 'px');
- document.documentElement.style.setProperty('--viewport-offset-top', offsetTop + 'px');
- }
+ const { setFeedback: setStatus, setConnection: setConnectionStatus } = createStatusController({
+ feedbackEl: feedbackStatusEl,
+ connectionChipEl: connectionStatusEl,
+ connectionLabelEl: connectionStatusLabelEl,
+ });
- function isMobileLayout() {
- return mobileLayoutMedia.matches;
- }
+ setConnectionStatus('connecting');
- function setSidebarOpen(open) {
- const shouldOpen = Boolean(open && isMobileLayout());
- sidebarEl.classList.toggle('open', shouldOpen);
- sidebarBackdrop.classList.toggle('open', shouldOpen);
- sidebarBackdrop.setAttribute('aria-hidden', shouldOpen ? 'false' : 'true');
- mobileSidebarToggleButton.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
- }
+ let session = null;
- function closeSidebar() {
- setSidebarOpen(false);
- }
+ const preferences = createPreferencesController({
+ targetIdInput,
+ historyLimitInput,
+ });
+ preferences.loadPreferences();
+ const {
+ currentHistoryLimit,
+ currentTargetId,
+ } = preferences;
- function toggleSidebar() {
- setSidebarOpen(!sidebarEl.classList.contains('open'));
- }
+ const {
+ createManagedImageHost,
+ cleanupManagedImages,
+ loadManagedImage,
+ resetManagedImage,
+ } = createManagedImageController();
- function syncResponsiveLayout() {
- if (!isMobileLayout()) {
- closeSidebar();
- }
- syncMessageInputPlaceholder();
- }
+ const {
+ handleWindowResize: handleLightboxResize,
+ makeImageZoomable,
+ } = createLightboxController({
+ buildCachedImageUrl,
+ imageLightbox,
+ imageLightboxViewport,
+ imageLightboxImage,
+ imageLightboxCaption,
+ closeImageLightboxButton,
+ imageZoomOutButton,
+ imageZoomResetButton,
+ imageZoomInButton,
+ loadManagedImage,
+ resetManagedImage,
+ });
- function autoResizeMessageInput() {
- const minHeight = isMobileLayout() ? 30 : 80;
- const maxHeight = isMobileLayout() ? 72 : 220;
- messageInput.style.height = 'auto';
- const nextHeight = Math.max(minHeight, Math.min(messageInput.scrollHeight, maxHeight));
- messageInput.style.height = nextHeight + 'px';
- messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? 'auto' : 'hidden';
- }
+ ({ createRichMessageContent } = createRichContentRenderer({
+ buildSteamEmoticonUrl,
+ buildCachedImageUrl,
+ extractImageUrls,
+ parseBbCodeAttributes,
+ createManagedImageHost,
+ makeImageZoomable,
+ loadManagedImage,
+ }));
- function syncMessageInputPlaceholder() {
- const mobilePlaceholder = messageInput.dataset.mobilePlaceholder || '输入消息';
- const desktopPlaceholder = messageInput.dataset.desktopPlaceholder || mobilePlaceholder;
- messageInput.placeholder = isMobileLayout() ? mobilePlaceholder : desktopPlaceholder;
- }
+ const { renderMessageBubble } = createMessageBubbleRenderer({
+ createManagedImageHost,
+ makeImageZoomable,
+ loadManagedImage,
+ buildCachedImageUrl,
+ createRichMessageContent,
+ extractStickerType,
+ buildSteamStickerCandidateUrls,
+ extractImageUrls,
+ });
- function updateDocumentTitle() {
- document.title = unreadCount > 0
- ? '(' + unreadCount + ') ' + defaultDocumentTitle
- : defaultDocumentTitle;
- }
+ const {
+ appendEntry,
+ clearMessages,
+ renderHistory,
+ } = createMessagesController({
+ messagesEl,
+ cleanupManagedImages,
+ parseDateString,
+ sameDay,
+ formatDayLabel,
+ formatTimeLabel,
+ getActiveConversationId: () => (session ? session.getActiveConversationId() : '') || currentTargetId(),
+ renderMessageBubble,
+ });
- function clearUnreadCount() {
- if (!unreadCount) {
- return;
- }
+ const {
+ bindTabKeyboardNavigation,
+ renderConversations,
+ renderFriends,
+ renderGroups,
+ switchSidebarTab,
+ } = createSidebarController({
+ conversationListEl,
+ friendsListEl,
+ groupsListEl,
+ sidebarTabs,
+ sidebarTabPanels,
+ formatConversationTime,
+ getActiveConversationId: () => (session ? session.getActiveConversationId() : ''),
+ onConversationSelect: (conversation) => {
+ session.setActiveConversation(conversation.id, conversation.name);
+ requestHistory();
+ },
+ onFriendSelect: (friend) => {
+ session.setActiveConversation(friend.id, friend.name);
+ switchSidebarTab('conversations');
+ requestHistory();
+ },
+ onGroupSelect: (group) => {
+ session.setActiveConversation(group.id, group.name);
+ switchSidebarTab('conversations');
+ requestHistory();
+ },
+ });
- unreadCount = 0;
- updateDocumentTitle();
- }
+ let composer = null;
- function resolveDisplayImageUrl(url) {
- const value = String(url || '').trim();
- if (!value) {
- return '';
- }
-
- try {
- const parsed = new URL(value, location.origin);
- if (parsed.origin === location.origin) {
- return parsed.toString();
+ const layout = createLayoutController({
+ mediaQuery: mobileLayoutMedia,
+ sidebarEl,
+ sidebarBackdrop,
+ mobileSidebarToggleButton,
+ onResponsiveChange: () => {
+ if (composer) {
+ composer.handleViewportChange();
}
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
- return buildCachedImageUrl(parsed.toString());
+ },
+ });
+ const { isMobileLayout } = layout;
+
+ composer = createComposerController({
+ buildCachedImageUrl,
+ buildSteamEmoticonUrl,
+ extractEmoticonNames,
+ isMobileLayout,
+ setStatus,
+ send: (payload) => socketController.send(payload),
+ createRequestId: (prefix) => socketController.createRequestId(prefix),
+ getConversationId: () => (session ? session.getActiveConversationId() : '') || currentTargetId(),
+ controls: {
+ messageInput,
+ emoticonSuggestions,
+ emoticonPreviewImage,
+ emoticonPreviewLabel,
+ sendMessageButton,
+ attachmentButton,
+ attachmentMenu,
+ chooseImageButton,
+ chooseUrlButton,
+ imageFileInput,
+ urlPanel,
+ imageUrlInput,
+ confirmImageUrlButton,
+ cancelImageUrlButton,
+ attachmentPreview,
+ attachmentPreviewImage,
+ attachmentPreviewTitle,
+ attachmentPreviewSubtitle,
+ clearAttachmentButton,
+ uploadQueue,
+ uploadQueueList,
+ pickerButton,
+ pickerPanel,
+ pickerSearch,
+ pickerGrid,
+ pickerEmpty,
+ pickerTabs,
+ dropOverlay,
+ },
+ });
+
+ session = createSessionController({
+ targetIdInput,
+ chatTitleEl,
+ chatSubtitleEl,
+ renderConversations,
+ renderFriends,
+ renderGroups,
+ savePreferences: preferences.savePreferences,
+ clearUnreadCount: () => notifications.clearUnreadCount(),
+ closeSidebar: () => layout.closeSidebar(),
+ buildConversationPreview: (entry) => (
+ entry.type === 'image' || entry.imageUrl
+ ? '[图片]'
+ : (extractStickerType(entry.message)
+ ? '[贴纸] ' + extractStickerType(entry.message).replace(/^Sticker_/, '')
+ : String(entry.message || '').trim().slice(0, 60))
+ ),
+ });
+
+ const socketController = createWebSocketController({
+ setStatus,
+ setConnectionStatus,
+ savePreferences: preferences.savePreferences,
+ clearPendingUploadRequests: (message) => composer.clearPendingUploadRequests(message),
+ onReady: () => {
+ socketController.requestConversations();
+ socketController.requestFriends();
+ socketController.requestGroups();
+ composer.requestEmoticonInventory();
+ },
+ onEmoticons: (data) => {
+ composer.applyEmoticonInventory(data);
+ },
+ onConversations: (data) => {
+ session.setConversations((data && data.items) || []);
+ const activeConversationId = session.getActiveConversationId();
+ const conversations = session.getConversations();
+ if (activeConversationId) {
+ requestHistory();
+ } else if (preferences.currentTargetId()) {
+ session.setActiveConversation(preferences.currentTargetId());
+ requestHistory();
+ } else if (conversations.length) {
+ session.setActiveConversation(conversations[0].id, conversations[0].name);
+ requestHistory();
}
- return parsed.toString();
- } catch {
- return value;
- }
- }
-
- function computeImageLightboxBaseSize() {
- const viewportWidth = imageLightboxViewport.clientWidth || 1;
- const viewportHeight = imageLightboxViewport.clientHeight || 1;
- const naturalWidth = imageLightboxImage.naturalWidth || viewportWidth;
- const naturalHeight = imageLightboxImage.naturalHeight || viewportHeight;
- const fitScale = Math.min(viewportWidth / naturalWidth, viewportHeight / naturalHeight, 1);
-
- return {
- width: naturalWidth * fitScale,
- height: naturalHeight * fitScale,
- viewportWidth,
- viewportHeight,
- };
- }
-
- function refreshImageLightboxBaseSize() {
- imageLightboxState.cachedBaseSize = computeImageLightboxBaseSize();
- imageLightboxState.cachedViewportRect = imageLightboxViewport.getBoundingClientRect();
- }
-
- function getImageLightboxBaseSize() {
- return imageLightboxState.cachedBaseSize || computeImageLightboxBaseSize();
- }
-
- function clampImageLightboxOffset() {
- if (imageLightboxState.scale <= 1) {
- imageLightboxState.offsetX = 0;
- imageLightboxState.offsetY = 0;
- return;
- }
-
- const { width, height, viewportWidth, viewportHeight } = getImageLightboxBaseSize();
- const scaledWidth = width * imageLightboxState.scale;
- const scaledHeight = height * imageLightboxState.scale;
- const limitX = Math.max(0, (scaledWidth - viewportWidth) / 2);
- const limitY = Math.max(0, (scaledHeight - viewportHeight) / 2);
-
- imageLightboxState.offsetX = Math.min(limitX, Math.max(-limitX, imageLightboxState.offsetX));
- imageLightboxState.offsetY = Math.min(limitY, Math.max(-limitY, imageLightboxState.offsetY));
- }
-
- function getViewportRelativePoint(clientX, clientY) {
- const rect = imageLightboxState.cachedViewportRect || imageLightboxViewport.getBoundingClientRect();
- return {
- x: clientX - rect.left - (rect.width / 2),
- y: clientY - rect.top - (rect.height / 2),
- };
- }
-
- function getTouchPointerList() {
- return [...imageLightboxState.activePointers.values()].filter((pointer) => pointer.pointerType === 'touch');
- }
-
- function getTouchPointerMetrics(pointers) {
- if (!pointers || pointers.length < 2) {
- return null;
- }
-
- const [first, second] = pointers;
- const deltaX = second.clientX - first.clientX;
- const deltaY = second.clientY - first.clientY;
-
- return {
- distance: Math.hypot(deltaX, deltaY),
- centerX: (first.clientX + second.clientX) / 2,
- centerY: (first.clientY + second.clientY) / 2,
- };
- }
-
- function applyImageLightboxTransform() {
- imageLightboxState.rafPending = false;
- clampImageLightboxOffset();
- imageLightboxImage.style.transform = 'translate3d(' + imageLightboxState.offsetX + 'px, ' + imageLightboxState.offsetY + 'px, 0) scale(' + imageLightboxState.scale + ')';
- }
-
- function updateImageLightboxTransform() {
- const isActive = imageLightboxState.dragging || imageLightboxState.pinching;
- imageLightboxImage.classList.toggle('is-dragging', isActive);
- imageLightboxImage.style.cursor = imageLightboxState.scale > 1
- ? (isActive ? 'grabbing' : 'grab')
- : 'zoom-in';
- imageZoomResetButton.textContent = Math.round(imageLightboxState.scale * 100) + '%';
- if (!imageLightboxState.rafPending) {
- imageLightboxState.rafPending = true;
- requestAnimationFrame(applyImageLightboxTransform);
- }
- }
-
- function scheduleTransformOnly() {
- if (!imageLightboxState.rafPending) {
- imageLightboxState.rafPending = true;
- requestAnimationFrame(applyImageLightboxTransform);
- }
- }
-
- function beginImageLightboxDrag(pointerId, clientX, clientY) {
- imageLightboxState.dragging = true;
- imageLightboxState.dragPointerId = pointerId;
- imageLightboxState.dragStartX = clientX;
- imageLightboxState.dragStartY = clientY;
- imageLightboxState.dragOriginX = imageLightboxState.offsetX;
- imageLightboxState.dragOriginY = imageLightboxState.offsetY;
- updateImageLightboxTransform();
- }
-
- function resetImageLightboxTransform() {
- imageLightboxState.scale = 1;
- imageLightboxState.offsetX = 0;
- imageLightboxState.offsetY = 0;
- imageLightboxState.dragging = false;
- imageLightboxState.dragPointerId = null;
- imageLightboxState.activePointers.clear();
- imageLightboxState.pinching = false;
- imageLightboxState.pinchStartDistance = 0;
- imageLightboxState.pinchStartScale = 1;
- imageLightboxState.pinchContentX = 0;
- imageLightboxState.pinchContentY = 0;
- updateImageLightboxTransform();
- }
-
- function setImageLightboxScale(nextScale, clientX, clientY) {
- const clampedScale = Math.min(imageLightboxState.maxScale, Math.max(imageLightboxState.minScale, nextScale));
- const previousScale = imageLightboxState.scale;
-
- if (Math.abs(clampedScale - previousScale) < 0.001) {
- return;
- }
-
- const anchorPoint = (typeof clientX === 'number' && typeof clientY === 'number')
- ? getViewportRelativePoint(clientX, clientY)
- : { x: 0, y: 0 };
- const anchorX = anchorPoint.x;
- const anchorY = anchorPoint.y;
-
- imageLightboxState.offsetX = anchorX - (((anchorX - imageLightboxState.offsetX) / previousScale) * clampedScale);
- imageLightboxState.offsetY = anchorY - (((anchorY - imageLightboxState.offsetY) / previousScale) * clampedScale);
- imageLightboxState.scale = clampedScale;
- updateImageLightboxTransform();
- }
-
- function beginImageLightboxPinch() {
- const metrics = getTouchPointerMetrics(getTouchPointerList());
- if (!metrics) {
- return;
- }
-
- const center = getViewportRelativePoint(metrics.centerX, metrics.centerY);
- imageLightboxState.pinching = true;
- imageLightboxState.dragging = false;
- imageLightboxState.dragPointerId = null;
- imageLightboxState.pinchStartDistance = Math.max(metrics.distance, 1);
- imageLightboxState.pinchStartScale = imageLightboxState.scale;
- imageLightboxState.pinchContentX = (center.x - imageLightboxState.offsetX) / imageLightboxState.scale;
- imageLightboxState.pinchContentY = (center.y - imageLightboxState.offsetY) / imageLightboxState.scale;
- updateImageLightboxTransform();
- }
-
- function updateImageLightboxPinch() {
- const metrics = getTouchPointerMetrics(getTouchPointerList());
- if (!metrics || !imageLightboxState.pinching) {
- return;
- }
-
- const center = getViewportRelativePoint(metrics.centerX, metrics.centerY);
- const nextScale = Math.min(
- imageLightboxState.maxScale,
- Math.max(
- imageLightboxState.minScale,
- imageLightboxState.pinchStartScale * (metrics.distance / Math.max(imageLightboxState.pinchStartDistance, 1)),
- ),
- );
-
- imageLightboxState.scale = nextScale;
- imageLightboxState.offsetX = center.x - (imageLightboxState.pinchContentX * nextScale);
- imageLightboxState.offsetY = center.y - (imageLightboxState.pinchContentY * nextScale);
- scheduleTransformOnly();
- }
-
- function endImageLightboxPinch() {
- if (!imageLightboxState.pinching) {
- return;
- }
-
- imageLightboxState.pinching = false;
- imageLightboxState.pinchStartDistance = 0;
- imageLightboxState.pinchStartScale = imageLightboxState.scale;
- const remainingTouch = getTouchPointerList()[0];
- if (remainingTouch && imageLightboxState.scale > 1) {
- beginImageLightboxDrag(remainingTouch.pointerId, remainingTouch.clientX, remainingTouch.clientY);
- return;
- }
- updateImageLightboxTransform();
- }
-
- function openImageLightbox(url, caption) {
- const displayUrl = resolveDisplayImageUrl(url);
- if (!displayUrl) {
- return;
- }
-
- resetImageLightboxTransform();
- loadManagedImage(imageLightboxViewport, imageLightboxImage, displayUrl, {
- loadingText: '大图加载中',
- errorText: '大图加载失败',
- onLoad() {
- refreshImageLightboxBaseSize();
- updateImageLightboxTransform();
- },
- });
- imageLightboxCaption.textContent = caption || url || '';
- imageLightbox.classList.add('open');
- imageLightbox.setAttribute('aria-hidden', 'false');
- }
-
- function closeImageLightbox() {
- if (!imageLightbox.classList.contains('open')) {
- return;
- }
-
- imageLightbox.classList.remove('open');
- imageLightbox.setAttribute('aria-hidden', 'true');
- resetManagedImage(imageLightboxViewport, imageLightboxImage);
- imageLightboxCaption.textContent = '';
- imageLightboxState.cachedBaseSize = null;
- imageLightboxState.cachedViewportRect = null;
- resetImageLightboxTransform();
- }
-
- function makeImageZoomable(element, url, caption) {
- element.classList.add('zoomable-image');
- element.addEventListener('click', (event) => {
- event.preventDefault();
- event.stopPropagation();
- openImageLightbox(url, caption || element.getAttribute('alt') || '');
- });
- }
-
- function currentTargetId() {
- return targetIdInput.value.trim();
- }
-
- function currentHistoryLimit() {
- const value = Number.parseInt(historyLimitInput.value, 10);
- if (!Number.isFinite(value) || value <= 0) {
- return 100;
- }
- return Math.min(value, 500);
- }
-
- function savePreferences() {
- localStorage.setItem('steam-chat-target-id', currentTargetId());
- localStorage.setItem('steam-chat-history-limit', String(currentHistoryLimit()));
- }
-
- function formatConversationTime(value) {
- if (!value) {
- return '';
- }
- return String(value).slice(5, 16);
- }
-
- function parseDateString(value) {
- if (!value) {
- return null;
- }
- const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/);
- if (!match) {
- return null;
- }
- return new Date(
- Number(match[1]),
- Number(match[2]) - 1,
- Number(match[3]),
- Number(match[4]),
- Number(match[5]),
- Number(match[6]),
- Number(match[7] || 0),
- );
- }
-
- function sameDay(left, right) {
- return left && right &&
- left.getFullYear() === right.getFullYear() &&
- left.getMonth() === right.getMonth() &&
- left.getDate() === right.getDate();
- }
-
- function formatDayLabel(value) {
- const date = parseDateString(value);
- if (!date) {
- return value || '';
- }
- return date.getFullYear() + '-' +
- String(date.getMonth() + 1).padStart(2, '0') + '-' +
- String(date.getDate()).padStart(2, '0');
- }
-
- function formatTimeLabel(value) {
- const date = parseDateString(value);
- if (!date) {
- return value || '';
- }
- return String(date.getHours()).padStart(2, '0') + ':' +
- String(date.getMinutes()).padStart(2, '0');
- }
-
- function extractStickerType(message) {
- const match = String(message || '').match(/\[sticker\s+type="([^"]+)"/i);
- return match ? match[1] : null;
- }
-
- function extractEmoticonNames(message) {
- const content = String(message || '');
- const names = new Set();
-
- for (const match of content.matchAll(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi)) {
- if (match[1]) {
- names.add(match[1]);
- }
- }
-
- for (const match of content.matchAll(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi)) {
- if (match[1]) {
- names.add(match[1].trim());
- }
- }
-
- for (const match of content.matchAll(/(^|\s):([a-z0-9_][a-z0-9_\-]*):(?=\s|$|[!?,.])/gi)) {
- if (match[2]) {
- names.add(match[2]);
- }
- }
-
- return [...names];
- }
-
- function extractImageUrls(message) {
- const content = String(message || '');
- const urls = new Set();
-
- for (const match of content.matchAll(/\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\]/gi)) {
- if (match[1]) {
- urls.add(match[1]);
- }
- }
-
- for (const match of content.matchAll(/
]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi)) {
- if (match[1]) {
- urls.add(match[1]);
- }
- }
-
- for (const match of content.matchAll(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi)) {
- if (match[0]) {
- urls.add(match[0]);
- }
- }
-
- return [...urls];
- }
-
- function parseBbCodeAttributes(rawAttributes) {
- const attrs = {};
- const content = String(rawAttributes || '');
- const attributeRegex = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s"'=<>`]+))/gi;
- let match;
-
- while ((match = attributeRegex.exec(content)) !== null) {
- const key = match[1].toLowerCase();
- const value = match[2] ?? match[3] ?? match[4] ?? '';
- attrs[key] = value.replace(/\\(["'])/g, '$1');
- }
-
- return attrs;
- }
-
- function extractOpenGraphEmbeds(message) {
- const content = String(message || '');
- const embeds = [];
-
- for (const match of content.matchAll(/\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\]/gi)) {
- const attrs = parseBbCodeAttributes(match[1] || '');
- const fallbackUrl = String(match[2] || '').trim();
-
- embeds.push({
- url: attrs.url || fallbackUrl,
- img: attrs.img || null,
- title: attrs.title || '',
- });
- }
-
- return embeds.filter((item) => item.url);
- }
-
- function buildSteamEmoticonUrl(name, large = true) {
- const normalized = String(name || '').trim().replace(/^:+|:+$/g, '');
- if (!normalized) {
- return '';
- }
- return 'https://steamcommunity-a.akamaihd.net/economy/' + (large ? 'emoticonlarge' : 'emoticon') + '/' + encodeURIComponent(normalized);
- }
-
- function buildCachedImageUrl(url) {
- return location.origin + '/proxy/image?url=' + encodeURIComponent(String(url || ''));
- }
-
- function createManagedImageHost(hostClassName, imageClassName) {
- const host = document.createElement('div');
- host.className = 'image-loading-host';
- if (hostClassName) {
- hostClassName.split(/\s+/).filter(Boolean).forEach((className) => host.classList.add(className));
- }
-
- const img = document.createElement('img');
- img.dataset.managedImage = 'true';
- img.classList.add('image-loading-target');
- if (imageClassName) {
- imageClassName.split(/\s+/).filter(Boolean).forEach((className) => img.classList.add(className));
- }
-
- host.appendChild(img);
- return { host, img };
- }
-
- function ensureManagedImageUi(host) {
- if (host.__managedImageUi) {
- return host.__managedImageUi;
- }
-
- const overlay = document.createElement('div');
- overlay.className = 'image-loading-overlay';
-
- const label = document.createElement('div');
- label.className = 'image-loading-label';
- label.textContent = '正在加载中';
-
- const progress = document.createElement('div');
- progress.className = 'image-loading-progress';
-
- const bar = document.createElement('div');
- bar.className = 'image-loading-progress-bar';
-
- progress.appendChild(bar);
- overlay.appendChild(label);
- overlay.appendChild(progress);
- host.appendChild(overlay);
-
- host.__managedImageUi = { overlay, label, progress, bar };
- return host.__managedImageUi;
- }
-
- function setManagedImageState(host, state, labelText, progressValue, indeterminate) {
- const ui = ensureManagedImageUi(host);
- const nextState = state || 'idle';
-
- host.classList.toggle('is-loading', nextState === 'loading');
- host.classList.toggle('is-loaded', nextState === 'loaded');
- host.classList.toggle('is-error', nextState === 'error');
- host.classList.toggle('is-indeterminate', Boolean(indeterminate));
-
- if (labelText) {
- ui.label.textContent = labelText;
- } else if (nextState === 'error') {
- ui.label.textContent = '图片加载失败';
- } else if (nextState === 'loaded') {
- ui.label.textContent = '';
- } else {
- ui.label.textContent = '正在加载中';
- }
-
- const width = Number.isFinite(progressValue)
- ? Math.max(0, Math.min(100, progressValue))
- : 0;
- ui.bar.style.width = width + '%';
- }
-
- function abortManagedImageRequest(img) {
- const request = managedImageRequestMap.get(img);
- if (!request) {
- return;
- }
-
- managedImageRequestMap.delete(img);
-
- try {
- request.abort();
- } catch (error) {
- // ignore
- }
- }
-
- function revokeManagedImageObjectUrl(img) {
- const objectUrl = img && img.dataset ? img.dataset.objectUrl : '';
- if (!objectUrl) {
- return;
- }
-
- try {
- URL.revokeObjectURL(objectUrl);
- } catch (error) {
- // ignore
- }
-
- delete img.dataset.objectUrl;
- }
-
- function resetManagedImage(host, img) {
- abortManagedImageRequest(img);
- revokeManagedImageObjectUrl(img);
- delete img.dataset.managedImageLoadToken;
- img.removeAttribute('src');
-
- if (host) {
- setManagedImageState(host, 'idle', '', 0, false);
- }
- }
-
- function cleanupManagedImages(root) {
- if (!root || typeof root.querySelectorAll !== 'function') {
- return;
- }
-
- root.querySelectorAll('img[data-managed-image="true"]').forEach((img) => {
- resetManagedImage(img.closest('.image-loading-host'), img);
- });
- }
-
- function loadManagedImage(host, img, src, options) {
- const settings = options || {};
- const loadingText = settings.loadingText || '正在加载中';
- const errorText = settings.errorText || '图片加载失败';
- const normalizedSrc = String(src || '').trim();
-
- if (!host || !img) {
- return;
- }
-
- host.classList.add('image-loading-host');
- img.dataset.managedImage = 'true';
- img.classList.add('image-loading-target');
-
- resetManagedImage(host, img);
-
- if (!normalizedSrc) {
- setManagedImageState(host, 'error', errorText, 100, false);
- if (typeof settings.onError === 'function') {
- settings.onError();
- }
- return;
- }
-
- const token = String(nextManagedImageToken++);
- img.dataset.managedImageLoadToken = token;
- setManagedImageState(host, 'loading', loadingText, 8, true);
-
- const request = new XMLHttpRequest();
- managedImageRequestMap.set(img, request);
- request.open('GET', normalizedSrc, true);
- request.responseType = 'blob';
-
- request.onprogress = (event) => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
-
- if (event.lengthComputable && event.total > 0) {
- const percent = Math.max(1, Math.min(99, Math.round((event.loaded / event.total) * 100)));
- setManagedImageState(host, 'loading', loadingText + ' ' + percent + '%', percent, false);
- } else {
- setManagedImageState(host, 'loading', loadingText, 32, true);
- }
- };
-
- request.onerror = () => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
- managedImageRequestMap.delete(img);
- setManagedImageState(host, 'error', errorText, 100, false);
- if (typeof settings.onError === 'function') {
- settings.onError();
- }
- };
-
- request.onabort = () => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
- managedImageRequestMap.delete(img);
- };
-
- request.onload = () => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
-
- managedImageRequestMap.delete(img);
-
- if (request.status < 200 || request.status >= 300 || !(request.response instanceof Blob)) {
- setManagedImageState(host, 'error', errorText, 100, false);
- if (typeof settings.onError === 'function') {
- settings.onError();
- }
- return;
- }
-
- const objectUrl = URL.createObjectURL(request.response);
- img.dataset.objectUrl = objectUrl;
-
- img.addEventListener('load', () => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
- setManagedImageState(host, 'loaded', '', 100, false);
- if (typeof settings.onLoad === 'function') {
- settings.onLoad();
- }
- }, { once: true });
-
- img.addEventListener('error', () => {
- if (img.dataset.managedImageLoadToken !== token) {
- return;
- }
- revokeManagedImageObjectUrl(img);
- setManagedImageState(host, 'error', errorText, 100, false);
- if (typeof settings.onError === 'function') {
- settings.onError();
- }
- }, { once: true });
-
- setManagedImageState(host, 'loading', '即将显示', 100, false);
- img.src = objectUrl;
- };
-
- request.send();
- }
-
- function shouldRequestNotificationPermission() {
- return typeof Notification !== 'undefined'
- && Notification.permission === 'default'
- && !notificationPermissionRequested;
- }
-
- async function ensureNotificationPermission() {
- if (!shouldRequestNotificationPermission()) {
- return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission;
- }
-
- notificationPermissionRequested = true;
-
- try {
- return await Notification.requestPermission();
- } catch (error) {
- return Notification.permission;
- }
- }
-
- function warmupNotifications() {
- ensureNotificationPermission().catch(() => {});
- }
-
- function shouldNotifyForEntry(entry) {
- const activeId = activeConversationId || currentTargetId();
- return document.hidden || !document.hasFocus() || !activeId || entry.id !== activeId;
- }
-
- function buildNotificationBody(entry) {
- if (!entry) {
- return '你有一条新消息';
- }
-
- if (entry.type === 'image' || entry.imageUrl) {
- return '[图片]';
- }
-
- const stickerType = extractStickerType(entry.message);
- if (stickerType) {
- return '[贴纸] ' + stickerType.replace(/^Sticker_/, '');
- }
-
- const text = String(entry.message || '').replace(/\s+/g, ' ').trim();
- return text || '你有一条新消息';
- }
-
- function notifyIncomingEntry(entry) {
- if (!entry || entry.echo || !shouldNotifyForEntry(entry)) {
- return;
- }
-
- unreadCount += 1;
- updateDocumentTitle();
-
- if (typeof Notification === 'undefined' || Notification.permission !== 'granted') {
- return;
- }
-
- const notification = new Notification(entry.name || entry.id || 'Steam Chat', {
- body: buildNotificationBody(entry),
- tag: 'steam-chat-' + (entry.id || 'unknown'),
- });
-
- notification.addEventListener('click', () => {
- window.focus();
+ },
+ onFriends: (data) => {
+ session.setFriends((data && data.items) || []);
+ },
+ onGroups: (data) => {
+ session.setGroups((data && data.items) || []);
+ },
+ onHistory: (data) => {
+ ((data && data.items) || []).forEach((item) => composer.rememberEmoticonsFromMessage(item.message));
+ renderHistory((data && data.items) || []);
+ session.updateConversationList(((data && data.items) || []).slice(-1)[0]);
+ setStatus('历史消息已加载');
+ },
+ onMessage: (entry) => {
+ session.updateConversationList(entry);
+ appendEntry(entry);
+ notifications.notifyIncomingEntry(entry);
+ },
+ onMessageSent: () => {
+ composer.hideSuggestions();
+ },
+ onImageSent: (payload) => {
+ composer.resolveUploadRequest(payload.requestId, true, '已发送');
+ session.updateConversationList(payload.data);
+ appendEntry(payload.data);
+ setStatus('图片已发送');
+ },
+ onError: (payload) => {
+ composer.resolveUploadRequest(payload.requestId, false, payload.message);
+ setStatus(payload.message || '请求失败');
+ },
+ });
+
+ const notifications = createNotificationController({
+ defaultDocumentTitle,
+ getActiveConversationId: () => (session ? session.getActiveConversationId() : '') || preferences.currentTargetId(),
+ onNotificationOpen: (entry) => {
if (entry.id) {
- setActiveConversation(entry.id, entry.name);
+ session.setActiveConversation(entry.id, entry.name);
requestHistory();
}
- clearUnreadCount();
- notification.close();
- });
- }
-
- function appendEmoticonImage(fragment, name) {
- const rawUrl = buildSteamEmoticonUrl(name, true);
- if (!rawUrl) {
- fragment.appendChild(document.createTextNode(':' + name + ':'));
- return;
- }
-
- const link = document.createElement('a');
- link.href = rawUrl;
- link.target = '_blank';
- link.rel = 'noreferrer';
-
- const img = document.createElement('img');
- img.src = buildCachedImageUrl(rawUrl);
- img.alt = ':' + name + ':';
- img.title = ':' + name + ':';
- img.style.display = 'inline-block';
- img.style.width = '28px';
- img.style.height = '28px';
- img.style.verticalAlign = 'middle';
- img.style.margin = '0 2px';
- img.style.objectFit = 'contain';
- link.appendChild(img);
- fragment.appendChild(link);
- }
-
- function appendInlineImage(fragment, url, altText) {
- const link = document.createElement('a');
- link.href = url;
- link.target = '_blank';
- link.rel = 'noreferrer';
- link.style.display = 'inline-block';
- link.style.margin = '4px 4px 4px 0';
-
- const { host, img } = createManagedImageHost('image-loading-host--inline', 'image-preview');
- img.alt = altText || '[图片]';
- img.title = altText || url;
- makeImageZoomable(host, url, altText || url);
- loadManagedImage(host, img, buildCachedImageUrl(url));
-
- link.appendChild(host);
- fragment.appendChild(link);
- }
-
- function appendOpenGraphCard(fragment, embed) {
- const wrapper = document.createElement('a');
- wrapper.href = embed.url;
- wrapper.target = '_blank';
- wrapper.rel = 'noreferrer';
- wrapper.style.display = 'flex';
- wrapper.style.flexDirection = 'column';
- wrapper.style.gap = '8px';
- wrapper.style.margin = '6px 0';
- wrapper.style.padding = '10px';
- wrapper.style.border = '1px solid #475569';
- wrapper.style.borderRadius = '12px';
- wrapper.style.background = 'rgba(15, 23, 42, 0.45)';
- wrapper.style.color = 'inherit';
- wrapper.style.textDecoration = 'none';
-
- if (embed.img) {
- const { host, img } = createManagedImageHost('image-loading-host--card', 'image-preview');
- img.alt = embed.title || embed.url;
- makeImageZoomable(host, embed.img, embed.title || embed.url);
- loadManagedImage(host, img, buildCachedImageUrl(embed.img));
- wrapper.appendChild(host);
- }
-
- const title = document.createElement('div');
- title.style.fontWeight = '600';
- title.style.lineHeight = '1.5';
- title.textContent = embed.title || embed.url;
- wrapper.appendChild(title);
-
- const url = document.createElement('div');
- url.style.fontSize = '12px';
- url.style.opacity = '0.8';
- url.textContent = embed.url;
- wrapper.appendChild(url);
-
- fragment.appendChild(wrapper);
- }
-
- function createRichMessageContent(text) {
- const fragment = document.createDocumentFragment();
- // Normalize [img src=URL ...]...[/img] to [img]URL[/img]
- const content = String(text || '').replace(/\[img\s+src=(https?:\/\/[^\s\]]+)[^\]]*\][\s\S]*?\[\/img\]/gi, '[img]$1[/img]');
- const tokenRegex = /(\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?)|(\[emoticon\]([^\[]+)\[\/emoticon\])|(:([a-z0-9_][a-z0-9_\-]*):)|(\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\])|(
]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>)|(\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\])|(\[url=([^\]]+)\]([\s\S]*?)\[\/url\])|(\[url\]([\s\S]*?)\[\/url\])|(https?:\/\/\S+)/gi;
- let cursor = 0;
- let match;
-
- while ((match = tokenRegex.exec(content)) !== null) {
- if (match.index > cursor) {
- fragment.appendChild(document.createTextNode(content.slice(cursor, match.index)));
- }
-
- if (match[2]) {
- appendEmoticonImage(fragment, match[2]);
- } else if (match[4]) {
- appendEmoticonImage(fragment, match[4].trim());
- } else if (match[6]) {
- appendEmoticonImage(fragment, match[6]);
- } else if (match[8]) {
- appendInlineImage(fragment, match[8], '[img]');
- } else if (match[10]) {
- appendInlineImage(fragment, match[10], '
');
- } else if (match[11]) {
- const attrs = parseBbCodeAttributes(match[12] || '');
- const fallbackUrl = String(match[13] || '').trim();
- const embed = {
- url: attrs.url || fallbackUrl,
- img: attrs.img || null,
- title: attrs.title || '',
- };
- if (embed.url) {
- appendOpenGraphCard(fragment, embed);
- }
- } else if (match[14]) {
- // [url=HREF]LABEL[/url]
- const href = match[15];
- const label = match[16] || href;
- const link = document.createElement('a');
- link.href = href;
- link.target = '_blank';
- link.rel = 'noreferrer';
- link.textContent = label;
- fragment.appendChild(link);
- } else if (match[17]) {
- // [url]HREF[/url]
- const href = match[18];
- const link = document.createElement('a');
- link.href = href;
- link.target = '_blank';
- link.rel = 'noreferrer';
- link.textContent = href;
- fragment.appendChild(link);
- } else if (match[19]) {
- const rawUrl = match[19];
- if (extractImageUrls(rawUrl).length > 0) {
- appendInlineImage(fragment, rawUrl, rawUrl);
- } else {
- const link = document.createElement('a');
- link.href = rawUrl;
- link.target = '_blank';
- link.rel = 'noreferrer';
- link.textContent = rawUrl;
- fragment.appendChild(link);
- }
- }
-
- cursor = match.index + match[0].length;
- }
-
- if (cursor < content.length) {
- fragment.appendChild(document.createTextNode(content.slice(cursor)));
- }
-
- return fragment;
- }
-
- function clearMessages() {
- cleanupManagedImages(messagesEl);
- messagesEl.innerHTML = '';
- }
-
- function categorizeEmoticon(name) {
- if (/^steam/i.test(name)) {
- return 'Steam';
- }
- return '最近使用';
- }
-
- function renderUploadQueue() {
- uploadQueueList.innerHTML = '';
- uploadQueue.classList.toggle('active', uploadQueueItems.length > 0);
-
- uploadQueueItems.forEach((item) => {
- const row = document.createElement('div');
- row.className = 'upload-queue-item';
- if (item.state) {
- row.classList.add('is-' + item.state);
- }
-
- const name = document.createElement('div');
- name.className = 'upload-queue-name';
- name.textContent = item.name;
-
- const status = document.createElement('div');
- status.className = 'upload-queue-status';
- status.textContent = item.statusText;
-
- const progress = document.createElement('div');
- progress.className = 'upload-queue-progress';
-
- const bar = document.createElement('div');
- bar.className = 'upload-queue-progress-bar';
- if (item.state) {
- bar.classList.add('is-' + item.state);
- }
- bar.style.width = item.progress + '%';
-
- progress.appendChild(bar);
- row.appendChild(name);
- row.appendChild(status);
- row.appendChild(progress);
- uploadQueueList.appendChild(row);
- });
- }
-
- function updateQueueItem(id, patch) {
- const item = uploadQueueItems.find((entry) => entry.id === id);
- if (!item) {
- return;
- }
- Object.assign(item, patch);
- renderUploadQueue();
- }
-
- function removeQueueItem(id) {
- const nextItems = uploadQueueItems.filter((entry) => entry.id !== id);
- if (nextItems.length === uploadQueueItems.length) {
- return;
- }
- uploadQueueItems = nextItems;
- renderUploadQueue();
- }
-
- function scheduleQueueItemRemoval(id, delay) {
- window.setTimeout(() => {
- removeQueueItem(id);
- }, delay || 1500);
- }
-
- function createQueueItem(name, statusText, progress) {
- const queueId = 'queue-' + Date.now() + '-' + Math.random().toString(16).slice(2);
- uploadQueueItems.push({
- id: queueId,
- name: name || '图片',
- progress: Number.isFinite(progress) ? progress : 0,
- statusText: statusText || '准备中',
- state: 'pending',
- });
- renderUploadQueue();
- return queueId;
- }
-
- function markQueueItemCompleted(id, statusText) {
- updateQueueItem(id, {
- progress: 100,
- statusText: statusText || '已发送',
- state: 'done',
- });
- scheduleQueueItemRemoval(id, 1200);
- }
-
- function markQueueItemFailed(id, statusText) {
- updateQueueItem(id, {
- progress: 100,
- statusText: statusText || '发送失败',
- state: 'error',
- });
- scheduleQueueItemRemoval(id, 2500);
- }
-
- function resolveUploadRequest(requestId, ok, message) {
- if (!requestId || !uploadRequestMap.has(requestId)) {
- return false;
- }
-
- const queueId = uploadRequestMap.get(requestId);
- uploadRequestMap.delete(requestId);
-
- if (ok) {
- markQueueItemCompleted(queueId, message || '已发送');
- } else {
- markQueueItemFailed(queueId, message || '发送失败');
- }
- return true;
- }
-
- function clearPendingUploadRequests(message) {
- for (const [, queueId] of uploadRequestMap.entries()) {
- markQueueItemFailed(queueId, message || '发送中断');
- }
- uploadRequestMap.clear();
- }
-
- function buildSteamStickerCandidateUrls(type) {
- const normalized = String(type || '').trim();
- if (!normalized) {
- return [];
- }
-
- return [
- 'https://steamcommunity-a.akamaihd.net/economy/sticker/' + encodeURIComponent(normalized),
- 'https://steamcommunity-a.akamaihd.net/economy/stickerlarge/' + encodeURIComponent(normalized),
- 'https://steamcommunity.com/economy/sticker/' + encodeURIComponent(normalized),
- 'https://steamcommunity.com/economy/stickerlarge/' + encodeURIComponent(normalized),
- location.origin + '/proxy/sticker/' + encodeURIComponent(normalized),
- ];
- }
-
- function closeAttachmentMenu() {
- attachmentMenu.classList.remove('open');
- }
-
- function toggleAttachmentMenu() {
- attachmentMenu.classList.toggle('open');
- }
-
- function closeUrlPanel() {
- urlPanel.classList.remove('open');
- }
-
- function togglePickerPanel() {
- const isOpen = pickerPanel.classList.toggle('open');
- if (isOpen) {
- closeAttachmentMenu();
- pickerSearch.value = '';
- renderPickerGrid();
- pickerSearch.focus();
- }
- }
-
- function closePickerPanel() {
- pickerPanel.classList.remove('open');
- }
-
- function setPickerTab(tab) {
- activePickerTab = tab;
- pickerTabs.forEach((btn) => {
- btn.classList.toggle('active', btn.dataset.tab === tab);
- });
- pickerSearch.value = '';
- renderPickerGrid();
- }
-
- function renderPickerGrid() {
- pickerGrid.innerHTML = '';
- const query = (pickerSearch.value || '').trim().toLowerCase();
-
- if (activePickerTab === 'emoticons') {
- pickerGrid.className = 'picker-grid emoticon-grid';
- const items = emoticonInventory
- .filter((e) => !query || e.name.toLowerCase().includes(query))
- .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name));
-
- if (!items.length) {
- pickerEmpty.textContent = emoticonInventory.length ? '无匹配结果' : '加载中…';
- pickerEmpty.classList.add('active');
- return;
- }
- pickerEmpty.classList.remove('active');
-
- items.forEach((e) => {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'picker-item emoticon-item';
- btn.title = ':' + e.name + ':';
-
- const img = document.createElement('img');
- img.src = buildCachedImageUrl(buildSteamEmoticonUrl(e.name, true));
- img.alt = e.name;
- img.loading = 'lazy';
-
- const label = document.createElement('div');
- label.className = 'picker-item-name';
- label.textContent = e.name;
-
- btn.appendChild(img);
- btn.appendChild(label);
- btn.addEventListener('click', () => {
- insertEmoticonAtCursor(e.name);
- closePickerPanel();
- });
- pickerGrid.appendChild(btn);
- });
- } else {
- pickerGrid.className = 'picker-grid sticker-grid';
- const items = stickerInventory
- .filter((s) => !query || s.name.toLowerCase().includes(query))
- .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name));
-
- if (!items.length) {
- pickerEmpty.textContent = stickerInventory.length ? '无匹配结果' : '加载中…';
- pickerEmpty.classList.add('active');
- return;
- }
- pickerEmpty.classList.remove('active');
-
- items.forEach((s) => {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'picker-item sticker-item';
- btn.title = s.name;
-
- const img = document.createElement('img');
- img.src = location.origin + '/proxy/sticker/' + encodeURIComponent(s.name);
- img.alt = s.name;
- img.loading = 'lazy';
-
- const label = document.createElement('div');
- label.className = 'picker-item-name';
- label.textContent = s.name.replace(/^Sticker_/, '');
-
- btn.appendChild(img);
- btn.appendChild(label);
- btn.addEventListener('click', () => {
- sendStickerMessage(s.name);
- closePickerPanel();
- });
- pickerGrid.appendChild(btn);
- });
- }
- }
-
- function insertEmoticonAtCursor(name) {
- const value = messageInput.value;
- const selectionStart = messageInput.selectionStart || 0;
- const selectionEnd = messageInput.selectionEnd || selectionStart;
- let replaceStart = selectionStart;
- let replaceEnd = selectionEnd;
-
- if (selectionStart === selectionEnd) {
- const leftSide = value.slice(0, selectionStart);
- const colonIndex = leftSide.lastIndexOf(':');
-
- if (colonIndex !== -1) {
- const partialName = value.slice(colonIndex + 1, selectionStart);
- if (/^[a-z0-9_\-]*$/i.test(partialName)) {
- let tokenEnd = selectionStart;
-
- while (tokenEnd < value.length && /[a-z0-9_\-]/i.test(value.charAt(tokenEnd))) {
- tokenEnd += 1;
- }
-
- if (value.charAt(tokenEnd) === ':') {
- tokenEnd += 1;
- }
-
- replaceStart = colonIndex;
- replaceEnd = tokenEnd;
- }
- }
- }
-
- const before = value.slice(0, replaceStart);
- const after = value.slice(replaceEnd);
- const insertion = ':' + name + ': ';
- messageInput.value = before + insertion + after;
- const newCaret = replaceStart + insertion.length;
- messageInput.setSelectionRange(newCaret, newCaret);
- autoResizeMessageInput();
- messageInput.focus();
- knownEmoticons.add(name);
- }
-
- function sendStickerMessage(name) {
- const id = activeConversationId || currentTargetId();
- if (!id) {
- setStatus('请先选择会话');
- return;
- }
- const msg = '[sticker type="' + name + '" limit="0"][/sticker]';
- if (send({
- type: 'send_message',
- requestId: 'sticker-' + (nextRequestId++),
- id,
- msg,
- })) {
- setStatus('贴纸已发送');
- }
- }
-
- function fetchEmoticonInventory() {
- send({
- type: 'get_emoticons',
- requestId: 'emoticons-' + (nextRequestId++),
- });
- }
-
- function revokeAttachmentPreviewUrl(attachment) {
- if (attachment && attachment.previewUrl && String(attachment.previewUrl).startsWith('blob:')) {
- URL.revokeObjectURL(attachment.previewUrl);
- }
- }
-
- function revokePendingAttachmentUrl() {
- revokeAttachmentPreviewUrl(pendingAttachment);
- }
-
- function clearPendingAttachment() {
- revokePendingAttachmentUrl();
- pendingAttachment = null;
- attachmentPreview.classList.remove('active');
- attachmentPreviewImage.hidden = true;
- attachmentPreviewImage.removeAttribute('src');
- attachmentPreviewTitle.textContent = '';
- attachmentPreviewSubtitle.textContent = '';
- imageFileInput.value = '';
- }
-
- function setPendingAttachment(attachment) {
- if (!attachment) {
- clearPendingAttachment();
- return;
- }
-
- revokePendingAttachmentUrl();
- pendingAttachment = attachment;
-
- attachmentPreview.classList.add('active');
- attachmentPreviewTitle.textContent = attachment.title || '待发送图片';
- attachmentPreviewSubtitle.textContent = attachment.subtitle || '';
-
- if (attachment.previewUrl) {
- attachmentPreviewImage.hidden = false;
- attachmentPreviewImage.src = attachment.previewUrl;
- } else {
- attachmentPreviewImage.hidden = true;
- attachmentPreviewImage.removeAttribute('src');
- }
- }
-
- function readFileAsDataUrl(file, onProgress) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onprogress = (event) => {
- if (typeof onProgress === 'function') {
- onProgress(event.loaded || 0, event.total || file.size || 0);
- }
- };
- reader.onload = () => resolve(String(reader.result || ''));
- reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
- reader.readAsDataURL(file);
- });
- }
-
- function createFileAttachment(file, sourceLabel) {
- if (!file) {
- throw new Error('请选择图片文件');
- }
-
- return {
- kind: 'file',
- file,
- previewUrl: URL.createObjectURL(file),
- title: file.name || '待发送图片',
- subtitle: sourceLabel || '本地图片',
- };
- }
-
- async function resolveAttachmentPayload(attachment, onProgress) {
- if (!attachment) {
- throw new Error('缺少图片内容');
- }
-
- if (attachment.kind === 'url' || attachment.kind === 'base64') {
- return attachment;
- }
-
- if (attachment.kind !== 'file' || !attachment.file) {
- throw new Error('暂不支持的图片类型');
- }
-
- const dataUrl = await readFileAsDataUrl(attachment.file, onProgress);
-
- const base64 = dataUrl.split(',')[1] || '';
- if (!base64) {
- throw new Error('图片编码失败');
- }
-
- return {
- kind: 'base64',
- payload: base64,
- previewUrl: attachment.previewUrl,
- title: attachment.title,
- subtitle: attachment.subtitle,
- };
- }
-
- function attachFile(file, sourceLabel) {
- if (!file) {
- return;
- }
-
- const attachment = createFileAttachment(file, sourceLabel);
- setPendingAttachment(attachment);
- setStatus((sourceLabel || '图片') + ' 已添加,点击发送即可发送');
- }
-
- async function sendFileDirectly(file, sourceLabel) {
- const id = activeConversationId || currentTargetId();
- if (!id) {
- await attachFile(file, sourceLabel);
- setStatus('请先选择会话,图片已加入待发送');
- return;
- }
-
- const attachment = createFileAttachment(file, sourceLabel);
- if (await sendAttachmentWithQueue(id, attachment, 'drop-')) {
- revokeAttachmentPreviewUrl(attachment);
- setStatus((sourceLabel || '图片') + ' 已发送');
- }
- }
-
- async function sendFilesDirectly(files, sourceLabel) {
- const imageFiles = Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/'));
- if (!imageFiles.length) {
- return;
- }
-
- for (let index = 0; index < imageFiles.length; index += 1) {
- await sendFileDirectly(imageFiles[index], imageFiles.length > 1 ? ((sourceLabel || '拖拽图片') + ' #' + (index + 1)) : sourceLabel);
- }
-
- if (imageFiles.length > 1) {
- setStatus('已连续发送 ' + imageFiles.length + ' 张图片');
- }
- }
-
- function confirmImageUrl() {
- const url = imageUrlInput.value.trim();
- if (!url) {
- setStatus('请输入图片 URL');
- return;
- }
-
- setPendingAttachment({
- kind: 'url',
- payload: url,
- previewUrl: url,
- title: '待发送图片 URL',
- subtitle: url,
- });
- closeUrlPanel();
- closeAttachmentMenu();
- setStatus('图片 URL 已添加,点击发送即可发送');
- }
-
- function sendAttachmentPayload(id, attachment, requestId) {
- const payload = {
- type: 'send_image',
- requestId,
- id,
- };
-
- if (attachment.kind === 'url') {
- payload.url = attachment.payload;
- } else {
- payload.img = attachment.payload;
- }
-
- return send(payload);
- }
-
- async function sendAttachmentWithQueue(id, attachment, requestPrefix) {
- if (!id) {
- throw new Error('请先选择会话');
- }
- if (!attachment) {
- return false;
- }
-
- const name = attachment.title || attachment.subtitle || '图片';
- const isFileAttachment = attachment.kind === 'file' && attachment.file;
- const queueId = createQueueItem(name, isFileAttachment ? '读取中 0%' : '准备发送', isFileAttachment ? 0 : 20);
-
- let resolvedAttachment;
- try {
- resolvedAttachment = await resolveAttachmentPayload(attachment, (loaded, total) => {
- if (!isFileAttachment) {
- return;
- }
-
- const ratio = total > 0 ? loaded / total : 0;
- const progress = Math.max(1, Math.min(90, Math.round(ratio * 90)));
- const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
-
- updateQueueItem(queueId, {
- progress,
- statusText: '读取中 ' + percent + '%',
- state: 'pending',
- });
- });
- } catch (error) {
- markQueueItemFailed(queueId, error.message || '图片读取失败');
- throw error;
- }
-
- const requestId = requestPrefix + (nextRequestId++);
- updateQueueItem(queueId, {
- progress: 95,
- statusText: '等待发送确认',
- state: 'pending',
- });
- uploadRequestMap.set(requestId, queueId);
-
- if (!sendAttachmentPayload(id, resolvedAttachment, requestId)) {
- uploadRequestMap.delete(requestId);
- markQueueItemFailed(queueId, '发送失败');
- return false;
- }
-
- return true;
- }
-
- function hideSuggestions() {
- currentSuggestions = [];
- activeSuggestionIndex = 0;
- emoticonSuggestions.classList.remove('open');
- }
-
- function getAutocompleteContext() {
- const value = messageInput.value;
- const caret = messageInput.selectionStart || 0;
- const textBefore = value.slice(0, caret);
- const match = textBefore.match(/(^|\s):([a-z0-9_][a-z0-9_\-]*)?$/i);
- if (!match) {
- return null;
- }
-
- return {
- start: caret - (match[2] ? match[2].length + 1 : 1),
- end: caret,
- query: (match[2] || '').toLowerCase(),
- };
- }
-
- function refreshSuggestionHighlight() {
- Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node, index) => {
- node.classList.toggle('active', index === activeSuggestionIndex);
- });
- if (currentSuggestions[activeSuggestionIndex]) {
- updateSuggestionPreview(currentSuggestions[activeSuggestionIndex]);
- }
- }
-
- function updateSuggestionPreview(name) {
- emoticonPreviewImage.src = buildSteamEmoticonUrl(name, true);
- emoticonPreviewLabel.textContent = ':' + name + ': · ' + categorizeEmoticon(name);
- }
-
- function applySuggestion(index = activeSuggestionIndex) {
- const suggestion = currentSuggestions[index];
- const context = getAutocompleteContext();
- if (!suggestion || !context) {
- hideSuggestions();
- return;
- }
-
- const value = messageInput.value;
- const replacement = ':' + suggestion + ': ';
- messageInput.value = value.slice(0, context.start) + replacement + value.slice(context.end);
- const caret = context.start + replacement.length;
- messageInput.setSelectionRange(caret, caret);
- knownEmoticons.add(suggestion);
- autoResizeMessageInput();
- hideSuggestions();
- messageInput.focus();
- }
-
- function renderSuggestionList(names) {
- Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node) => node.remove());
- currentSuggestions = names.slice(0, 8);
- activeSuggestionIndex = 0;
-
- if (!currentSuggestions.length) {
- hideSuggestions();
- return;
- }
-
- updateSuggestionPreview(currentSuggestions[0]);
-
- currentSuggestions.forEach((name, index) => {
- const option = document.createElement('button');
- option.type = 'button';
- option.className = 'emoticon-option' + (index === 0 ? ' active' : '');
-
- const img = document.createElement('img');
- img.src = buildSteamEmoticonUrl(name, true);
- img.alt = name;
-
- const label = document.createElement('div');
- label.innerHTML = ':' + name + ':
' + categorizeEmoticon(name) + '
';
-
- option.appendChild(img);
- option.appendChild(label);
- option.addEventListener('click', () => applySuggestion(index));
- option.addEventListener('mouseenter', () => {
- activeSuggestionIndex = index;
- refreshSuggestionHighlight();
- updateSuggestionPreview(name);
- });
- emoticonSuggestions.appendChild(option);
- });
-
- emoticonSuggestions.classList.add('open');
- }
-
- function updateEmoticonSuggestions() {
- const context = getAutocompleteContext();
- if (!context) {
- hideSuggestions();
- return;
- }
-
- const query = context.query;
- const names = [...knownEmoticons]
- .filter((name) => !query || name.toLowerCase().includes(query))
- .sort((a, b) => {
- const aLower = a.toLowerCase();
- const bLower = b.toLowerCase();
- const aCategory = categorizeEmoticon(a);
- const bCategory = categorizeEmoticon(b);
- const aStarts = query ? aLower.startsWith(query) : true;
- const bStarts = query ? bLower.startsWith(query) : true;
-
- if (aStarts !== bStarts) {
- return aStarts ? -1 : 1;
- }
-
- if (aCategory !== bCategory) {
- return aCategory.localeCompare(bCategory);
- }
-
- return a.localeCompare(b);
- });
-
- renderSuggestionList(names);
- }
-
- async function handlePasteImage(event) {
- const items = Array.from((event.clipboardData && event.clipboardData.items) || []);
- const imageItem = items.find((item) => item && item.type && item.type.startsWith('image/'));
- if (!imageItem) {
- return false;
- }
-
- const file = imageItem.getAsFile();
- if (!file) {
- return false;
- }
-
- event.preventDefault();
- const attachment = createFileAttachment(file, '剪切板图片');
- const id = activeConversationId || currentTargetId();
-
- if (id && socket && socket.readyState === WebSocket.OPEN) {
- if (await sendAttachmentWithQueue(id, attachment, 'paste-')) {
- revokeAttachmentPreviewUrl(attachment);
- imageUrlInput.value = '';
- setStatus('剪切板图片发送中');
- return true;
- }
- }
-
- setPendingAttachment(attachment);
- setStatus('剪切板图片已添加,点击发送即可发送');
- return true;
- }
-
- function setActiveConversation(id, name) {
- activeConversationId = id || '';
- targetIdInput.value = activeConversationId;
- savePreferences();
- const conversation = conversations.find((item) => item.id === activeConversationId);
- chatTitleEl.textContent = name || (conversation && conversation.name) || activeConversationId || '未选择会话';
- chatSubtitleEl.textContent = activeConversationId
- ? ('SteamID64: ' + activeConversationId)
- : '请选择左侧会话,或手动输入 SteamID64';
- renderConversations();
-
- if (!document.hidden && document.hasFocus()) {
- clearUnreadCount();
- }
-
- if (activeConversationId) {
- closeSidebar();
- }
- }
-
- function renderConversations() {
- conversationListEl.innerHTML = '';
-
- if (!conversations.length) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.textContent = '暂无历史会话';
- conversationListEl.appendChild(empty);
- return;
- }
-
- conversations.forEach((conversation) => {
- const item = document.createElement('button');
- item.type = 'button';
- item.className = 'conversation-item' + (conversation.id === activeConversationId ? ' active' : '');
-
- const top = document.createElement('div');
- top.className = 'conversation-top';
-
- const name = document.createElement('div');
- name.className = 'conversation-name';
- name.textContent = conversation.name || conversation.id;
-
- const time = document.createElement('div');
- time.className = 'conversation-time';
- time.textContent = formatConversationTime(conversation.updatedAt);
-
- top.appendChild(name);
- top.appendChild(time);
-
- const preview = document.createElement('div');
- preview.className = 'conversation-preview';
- preview.textContent = conversation.preview || '[空会话]';
-
- const idLine = document.createElement('div');
- idLine.className = 'conversation-id';
- idLine.textContent = conversation.id;
- idLine.style.marginTop = '6px';
-
- item.appendChild(top);
- item.appendChild(preview);
- item.appendChild(idLine);
- item.addEventListener('click', () => {
- setActiveConversation(conversation.id, conversation.name);
- requestHistory();
- });
- conversationListEl.appendChild(item);
- });
- }
-
- function insertDivider(text, className) {
- const divider = document.createElement('div');
- divider.className = className;
- divider.textContent = text;
- messagesEl.appendChild(divider);
- }
-
- function appendEntry(entry, previousEntry) {
- if (!entry || !entry.id) {
- return;
- }
-
- const activeId = activeConversationId || currentTargetId();
- if (activeId && entry.id !== activeId) {
- return;
- }
-
- const currentDate = parseDateString(entry.date || entry.sentAt);
- const previousDate = previousEntry ? parseDateString(previousEntry.date || previousEntry.sentAt) : null;
-
- if (currentDate && (!previousDate || !sameDay(currentDate, previousDate))) {
- insertDivider(formatDayLabel(entry.date || entry.sentAt), 'day-divider');
- } else if (currentDate && previousDate && (currentDate.getTime() - previousDate.getTime()) >= 10 * 60 * 1000) {
- insertDivider(formatTimeLabel(entry.date || entry.sentAt), 'time-divider');
- }
-
- const row = document.createElement('div');
- row.className = 'message-row ' + (entry.echo ? 'self' : 'other');
-
- const meta = document.createElement('div');
- meta.className = 'message-meta';
- meta.textContent = (entry.name || (entry.echo ? '我' : '对方')) + ' · ' + formatTimeLabel(entry.date || entry.sentAt);
-
- const bubble = document.createElement('div');
- bubble.className = 'bubble';
-
- if (entry.type === 'image' || entry.imageUrl) {
- bubble.classList.add('image-bubble');
-
- const rawImageUrl = entry.imageUrl;
- const { host, img } = createManagedImageHost('image-loading-host--bubble');
- img.alt = 'image';
- makeImageZoomable(host, rawImageUrl, rawImageUrl);
- loadManagedImage(host, img, buildCachedImageUrl(rawImageUrl));
- bubble.appendChild(host);
- } else {
- const stickerType = extractStickerType(entry.message);
- if (stickerType) {
- bubble.classList.add('sticker-bubble');
-
- const stickerCandidates = buildSteamStickerCandidateUrls(stickerType);
- if (stickerCandidates.length) {
- const stickerImage = document.createElement('img');
- stickerImage.className = 'sticker-image';
- stickerImage.alt = stickerType;
- let stickerIndex = 0;
- stickerImage.src = location.origin + '/proxy/sticker/' + encodeURIComponent(stickerType);
- stickerImage.addEventListener('error', () => {
- stickerIndex += 1;
- if (stickerIndex < stickerCandidates.length) {
- stickerImage.src = stickerCandidates[stickerIndex];
- } else {
- stickerImage.remove();
- }
- });
- bubble.appendChild(stickerImage);
- }
-
- const title = document.createElement('div');
- title.className = 'sticker-title';
- title.textContent = '\u2728';
-
- const name = document.createElement('div');
- name.className = 'sticker-name';
- name.textContent = stickerType.replace(/^Sticker_/, '');
-
- const raw = document.createElement('div');
- raw.style.marginTop = '6px';
- raw.style.fontSize = '12px';
- raw.style.opacity = '0.8';
- raw.textContent = 'Sticker';
-
- bubble.appendChild(title);
- bubble.appendChild(name);
- bubble.appendChild(raw);
- } else {
- bubble.appendChild(createRichMessageContent(entry.message || ''));
- }
- }
-
- row.appendChild(meta);
- row.appendChild(bubble);
- messagesEl.appendChild(row);
- messagesEl.scrollTop = messagesEl.scrollHeight;
- }
-
- function renderHistory(items) {
- clearMessages();
- if (!items.length) {
- const empty = document.createElement('div');
- empty.className = 'empty-state';
- empty.textContent = '暂无历史消息';
- messagesEl.appendChild(empty);
- return;
- }
-
- let previousEntry = null;
- items.forEach((entry) => {
- appendEntry(entry, previousEntry);
- previousEntry = entry;
- });
- }
-
- function updateConversationList(entry) {
- if (!entry || !entry.id) {
- return;
- }
-
- extractEmoticonNames(entry.message).forEach((name) => knownEmoticons.add(name));
-
- const preview = entry.type === 'image' || entry.imageUrl
- ? '[图片]'
- : (extractStickerType(entry.message) ? '[贴纸] ' + extractStickerType(entry.message).replace(/^Sticker_/, '') : String(entry.message || '').trim().slice(0, 60));
- const current = conversations.find((item) => item.id === entry.id);
-
- if (current) {
- current.name = entry.name || current.name;
- current.updatedAt = entry.date || entry.sentAt || current.updatedAt;
- current.preview = preview || current.preview;
- } else {
- conversations.push({
- id: entry.id,
- name: entry.name || entry.id,
- updatedAt: entry.date || entry.sentAt || '',
- preview,
- });
- }
-
- conversations.sort((left, right) => String(right.updatedAt || '').localeCompare(String(left.updatedAt || '')));
- renderConversations();
- }
-
- function send(payload) {
- if (!socket || socket.readyState !== WebSocket.OPEN) {
- setStatus('WebSocket 未连接');
- return false;
- }
-
- savePreferences();
- socket.send(JSON.stringify(payload));
- return true;
- }
-
- function requestConversations() {
- send({
- type: 'get_conversations',
- requestId: 'conv-' + (nextRequestId++),
- limit: 200,
- });
- }
+ },
+ });
function requestHistory() {
- const id = activeConversationId || currentTargetId();
+ const id = (session ? session.getActiveConversationId() : '') || currentTargetId();
if (!id) {
clearMessages();
setStatus('请输入对方 SteamID64 后再加载历史');
return;
}
- send({
- type: 'get_history',
- requestId: 'history-' + (nextRequestId++),
- id,
- limit: currentHistoryLimit(),
- });
- }
-
- function sendImageFile() {
- const file = imageFileInput.files && imageFileInput.files[0];
- if (!file) {
- setStatus('请选择图片文件');
- return;
- }
- attachFile(file, '本地图片');
- }
-
- function connect(wsPath) {
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
- socket = new WebSocket(protocol + '//' + location.host + wsPath);
-
- socket.addEventListener('open', () => {
- setStatus('WebSocket 已连接');
- });
-
- socket.addEventListener('close', () => {
- clearPendingUploadRequests('连接已断开');
- setStatus('WebSocket 已断开,3 秒后重连');
- setTimeout(() => connect(wsPath), 3000);
- });
-
- socket.addEventListener('error', () => {
- setStatus('WebSocket 连接异常');
- });
-
- socket.addEventListener('message', (event) => {
- let payload;
- try {
- payload = JSON.parse(event.data);
- } catch (error) {
- setStatus('收到无法解析的消息');
- return;
- }
-
- switch (payload.type) {
- case 'ready':
- setStatus('WebSocket 已连接');
- requestConversations();
- fetchEmoticonInventory();
- break;
- case 'emoticons':
- emoticonInventory = (payload.data && payload.data.emoticons) || [];
- emoticonInventory.forEach((e) => { e.name = e.name.replace(/^:+|:+$/g, ''); });
- stickerInventory = (payload.data && payload.data.stickers) || [];
- emoticonInventory.forEach((e) => knownEmoticons.add(e.name));
- if (pickerPanel.classList.contains('open')) {
- renderPickerGrid();
- }
- break;
- case 'conversations':
- conversations = (payload.data && payload.data.items) || [];
- renderConversations();
- if (activeConversationId) {
- requestHistory();
- } else if (currentTargetId()) {
- setActiveConversation(currentTargetId());
- requestHistory();
- } else if (conversations.length) {
- setActiveConversation(conversations[0].id, conversations[0].name);
- requestHistory();
- }
- break;
- case 'history':
- ((payload.data && payload.data.items) || []).forEach((item) => extractEmoticonNames(item.message).forEach((name) => knownEmoticons.add(name)));
- renderHistory((payload.data && payload.data.items) || []);
- updateConversationList(((payload.data && payload.data.items) || []).slice(-1)[0]);
- setStatus('历史消息已加载');
- break;
- case 'message':
- case 'image':
- updateConversationList(payload.data);
- appendEntry(payload.data, null);
- notifyIncomingEntry(payload.data);
- break;
- case 'message_sent':
- hideSuggestions();
- break;
- case 'image_sent':
- resolveUploadRequest(payload.requestId, true, '已发送');
- updateConversationList(payload.data);
- appendEntry(payload.data, null);
- setStatus('图片已发送');
- break;
- case 'error':
- resolveUploadRequest(payload.requestId, false, payload.message);
- setStatus(payload.message || '请求失败');
- break;
- case 'pong':
- break;
- default:
- console.log('unknown payload', payload);
- }
- });
+ socketController.requestHistory(id, currentHistoryLimit());
}
function openConversation() {
@@ -2115,409 +369,33 @@
return;
}
- setActiveConversation(id);
+ session.setActiveConversation(id);
requestHistory();
}
- updateViewportHeightVar();
- syncResponsiveLayout();
- syncMessageInputPlaceholder();
- reloadHistoryButton.addEventListener('click', requestHistory);
- reloadConversationsButton.addEventListener('click', requestConversations);
- openConversationButton.addEventListener('click', openConversation);
- targetIdInput.addEventListener('change', openConversation);
- historyLimitInput.addEventListener('change', requestHistory);
- mobileSidebarToggleButton.addEventListener('click', toggleSidebar);
- closeSidebarButton.addEventListener('click', closeSidebar);
- sidebarBackdrop.addEventListener('click', closeSidebar);
- if (typeof mobileLayoutMedia.addEventListener === 'function') {
- mobileLayoutMedia.addEventListener('change', syncResponsiveLayout);
- } else if (typeof mobileLayoutMedia.addListener === 'function') {
- mobileLayoutMedia.addListener(syncResponsiveLayout);
- }
-
- sendMessageButton.addEventListener('click', async () => {
- const id = activeConversationId || currentTargetId();
- const msg = messageInput.value.trim();
- if (!id || (!msg && !pendingAttachment)) {
- setStatus('请输入目标 SteamID,并填写消息或添加图片');
- return;
- }
-
- try {
- if (pendingAttachment) {
- const attachment = pendingAttachment;
- if (await sendAttachmentWithQueue(id, attachment, 'img-')) {
- clearPendingAttachment();
- imageUrlInput.value = '';
- setStatus('图片发送中');
- }
- }
-
- if (msg) {
- if (send({
- type: 'send_message',
- requestId: 'msg-' + (nextRequestId++),
- id,
- msg,
- })) {
- messageInput.value = '';
- autoResizeMessageInput();
- }
- }
- } catch (error) {
- setStatus(error.message || '发送图片失败');
- }
+ bindAppShellEvents({
+ reloadHistoryButton,
+ reloadConversationsButton,
+ reloadFriendsButton,
+ reloadGroupsButton,
+ sidebarTabs,
+ bindTabKeyboardNavigation,
+ switchSidebarTab,
+ openConversationButton,
+ targetIdInput,
+ historyLimitInput,
+ mobileSidebarToggleButton,
+ closeSidebarButton,
+ sidebarBackdrop,
+ mobileLayoutMedia,
+ sidebarEl,
+ requestHistory,
+ openConversation,
+ composer,
+ socketController,
+ layout,
+ notifications,
+ handleLightboxResize,
});
-
- attachmentButton.addEventListener('click', (event) => {
- event.stopPropagation();
- closePickerPanel();
- toggleAttachmentMenu();
- });
-
- pickerButton.addEventListener('click', (event) => {
- event.stopPropagation();
- togglePickerPanel();
- });
-
- pickerTabs.forEach((tab) => {
- tab.addEventListener('click', () => {
- setPickerTab(tab.dataset.tab);
- });
- });
-
- pickerSearch.addEventListener('input', renderPickerGrid);
-
- pickerPanel.addEventListener('click', (event) => {
- event.stopPropagation();
- });
-
- chooseImageButton.addEventListener('click', () => {
- closeAttachmentMenu();
- closeUrlPanel();
- imageFileInput.click();
- });
-
- chooseUrlButton.addEventListener('click', () => {
- closeAttachmentMenu();
- urlPanel.classList.add('open');
- imageUrlInput.focus();
- });
-
- imageFileInput.addEventListener('change', () => {
- try {
- sendImageFile();
- } catch (error) {
- setStatus(error.message || '读取图片失败');
- }
- });
-
- confirmImageUrlButton.addEventListener('click', confirmImageUrl);
- cancelImageUrlButton.addEventListener('click', () => {
- closeUrlPanel();
- imageUrlInput.value = '';
- });
- clearAttachmentButton.addEventListener('click', clearPendingAttachment);
-
- document.addEventListener('click', (event) => {
- if (isMobileLayout()
- && sidebarEl.classList.contains('open')
- && !sidebarEl.contains(event.target)
- && event.target !== mobileSidebarToggleButton) {
- closeSidebar();
- }
- if (!attachmentMenu.contains(event.target) && event.target !== attachmentButton) {
- closeAttachmentMenu();
- }
- if (!pickerPanel.contains(event.target) && event.target !== pickerButton) {
- closePickerPanel();
- }
- });
-
- imageLightbox.addEventListener('click', (event) => {
- if (event.target === imageLightbox) {
- closeImageLightbox();
- }
- });
-
- imageLightboxViewport.addEventListener('wheel', (event) => {
- if (!imageLightbox.classList.contains('open')) {
- return;
- }
-
- event.preventDefault();
- const factor = Math.exp(-event.deltaY * 0.0025);
- setImageLightboxScale(imageLightboxState.scale * factor, event.clientX, event.clientY);
- }, { passive: false });
-
- imageLightboxViewport.addEventListener('pointerdown', (event) => {
- if (!imageLightbox.classList.contains('open')) {
- return;
- }
-
- imageLightboxViewport.setPointerCapture(event.pointerId);
- imageLightboxState.activePointers.set(event.pointerId, {
- pointerId: event.pointerId,
- pointerType: event.pointerType,
- clientX: event.clientX,
- clientY: event.clientY,
- });
-
- if (event.pointerType === 'touch') {
- event.preventDefault();
- if (getTouchPointerList().length >= 2) {
- beginImageLightboxPinch();
- return;
- }
- }
-
- if (event.button !== 0 || imageLightboxState.scale <= 1) {
- return;
- }
-
- event.preventDefault();
- beginImageLightboxDrag(event.pointerId, event.clientX, event.clientY);
- });
-
- imageLightboxViewport.addEventListener('pointermove', (event) => {
- if (imageLightboxState.activePointers.has(event.pointerId)) {
- imageLightboxState.activePointers.set(event.pointerId, {
- pointerId: event.pointerId,
- pointerType: event.pointerType,
- clientX: event.clientX,
- clientY: event.clientY,
- });
- }
-
- if (imageLightboxState.pinching) {
- event.preventDefault();
- updateImageLightboxPinch();
- return;
- }
-
- if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) {
- return;
- }
-
- imageLightboxState.offsetX = imageLightboxState.dragOriginX + (event.clientX - imageLightboxState.dragStartX);
- imageLightboxState.offsetY = imageLightboxState.dragOriginY + (event.clientY - imageLightboxState.dragStartY);
- scheduleTransformOnly();
- });
-
- function stopImageLightboxDrag(event) {
- const hadPointer = imageLightboxState.activePointers.delete(event.pointerId);
-
- if (imageLightboxState.pinching && getTouchPointerList().length < 2) {
- endImageLightboxPinch();
- }
-
- if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) {
- if (hadPointer && imageLightboxViewport.hasPointerCapture(event.pointerId)) {
- imageLightboxViewport.releasePointerCapture(event.pointerId);
- }
- return;
- }
-
- imageLightboxState.dragging = false;
- imageLightboxState.dragPointerId = null;
- if (imageLightboxViewport.hasPointerCapture(event.pointerId)) {
- imageLightboxViewport.releasePointerCapture(event.pointerId);
- }
- updateImageLightboxTransform();
- }
-
- imageLightboxViewport.addEventListener('pointerup', stopImageLightboxDrag);
- imageLightboxViewport.addEventListener('pointercancel', stopImageLightboxDrag);
- imageLightboxViewport.addEventListener('dblclick', (event) => {
- event.preventDefault();
- if (imageLightboxState.scale > 1) {
- resetImageLightboxTransform();
- return;
- }
- setImageLightboxScale(2, event.clientX, event.clientY);
- });
-
- imageLightboxImage.addEventListener('load', () => {
- refreshImageLightboxBaseSize();
- resetImageLightboxTransform();
- });
-
- imageZoomOutButton.addEventListener('click', () => {
- setImageLightboxScale(imageLightboxState.scale / 1.2);
- });
-
- imageZoomResetButton.addEventListener('click', resetImageLightboxTransform);
-
- imageZoomInButton.addEventListener('click', () => {
- setImageLightboxScale(imageLightboxState.scale * 1.2);
- });
-
- closeImageLightboxButton.addEventListener('click', closeImageLightbox);
-
- document.addEventListener('visibilitychange', () => {
- if (!document.hidden && document.hasFocus()) {
- clearUnreadCount();
- }
- });
-
- document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape' && imageLightbox.classList.contains('open')) {
- closeImageLightbox();
- return;
- }
-
- if (!imageLightbox.classList.contains('open')) {
- return;
- }
-
- if (event.key === '+' || event.key === '=') {
- event.preventDefault();
- setImageLightboxScale(imageLightboxState.scale * 1.2);
- } else if (event.key === '-') {
- event.preventDefault();
- setImageLightboxScale(imageLightboxState.scale / 1.2);
- } else if (event.key === '0') {
- event.preventDefault();
- resetImageLightboxTransform();
- }
- });
-
- window.addEventListener('focus', clearUnreadCount);
- window.addEventListener('resize', () => {
- updateViewportHeightVar();
- syncResponsiveLayout();
- autoResizeMessageInput();
- if (imageLightbox.classList.contains('open')) {
- refreshImageLightboxBaseSize();
- }
- });
- if (window.visualViewport) {
- window.visualViewport.addEventListener('resize', () => {
- updateViewportHeightVar();
- autoResizeMessageInput();
- });
- window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
- }
- window.addEventListener('pointerdown', warmupNotifications, { once: true });
- window.addEventListener('keydown', warmupNotifications, { once: true });
-
- messageInput.addEventListener('keydown', (event) => {
- if (emoticonSuggestions.classList.contains('open')) {
- if (event.key === 'ArrowDown') {
- event.preventDefault();
- activeSuggestionIndex = (activeSuggestionIndex + 1) % currentSuggestions.length;
- refreshSuggestionHighlight();
- return;
- }
-
- if (event.key === 'ArrowUp') {
- event.preventDefault();
- activeSuggestionIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
- refreshSuggestionHighlight();
- return;
- }
-
- if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {
- event.preventDefault();
- applySuggestion();
- return;
- }
-
- if (event.key === 'Escape') {
- event.preventDefault();
- hideSuggestions();
- return;
- }
- }
-
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- sendMessageButton.click();
- }
- });
-
- messageInput.addEventListener('input', () => {
- autoResizeMessageInput();
- updateEmoticonSuggestions();
- });
- messageInput.addEventListener('click', updateEmoticonSuggestions);
- messageInput.addEventListener('blur', () => {
- setTimeout(hideSuggestions, 120);
- });
-
- messageInput.addEventListener('paste', (event) => {
- handlePasteImage(event).catch((error) => {
- setStatus(error.message || '处理剪切板图片失败');
- });
- });
-
- document.addEventListener('paste', (event) => {
- if (document.activeElement === messageInput) {
- return;
- }
-
- handlePasteImage(event).catch((error) => {
- setStatus(error.message || '处理剪切板图片失败');
- });
- });
-
- document.addEventListener('dragenter', (event) => {
- const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file');
- if (!hasFile) {
- return;
- }
-
- dragDepth += 1;
- dropOverlay.classList.add('active');
- });
-
- document.addEventListener('dragover', (event) => {
- const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file');
- if (!hasFile) {
- return;
- }
-
- event.preventDefault();
- if (event.dataTransfer) {
- event.dataTransfer.dropEffect = 'copy';
- }
- });
-
- document.addEventListener('dragleave', () => {
- dragDepth = Math.max(0, dragDepth - 1);
- if (dragDepth === 0) {
- dropOverlay.classList.remove('active');
- }
- });
-
- document.addEventListener('drop', (event) => {
- const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []).filter((file) => String(file.type || '').startsWith('image/'));
- if (!files.length) {
- dragDepth = 0;
- dropOverlay.classList.remove('active');
- return;
- }
-
- event.preventDefault();
- dragDepth = 0;
- dropOverlay.classList.remove('active');
-
- sendFilesDirectly(files, '拖拽图片').catch((error) => {
- setStatus(error.message || '发送拖拽图片失败');
- });
- });
-
- autoResizeMessageInput();
-
- // Fetch wsPath from backend config, then connect
- fetch('/api/config')
- .then((res) => res.json())
- .then((config) => {
- connect(config.wsPath || '/ws');
- })
- .catch(() => {
- // Fallback to default wsPath
- connect('/ws');
- });
+ connectSocketFromConfig(socketController);
})();
diff --git a/public/app/bootstrap.js b/public/app/bootstrap.js
new file mode 100644
index 0000000..4d470d7
--- /dev/null
+++ b/public/app/bootstrap.js
@@ -0,0 +1,103 @@
+export function bindAppShellEvents({
+ reloadHistoryButton,
+ reloadConversationsButton,
+ reloadFriendsButton,
+ reloadGroupsButton,
+ sidebarTabs,
+ bindTabKeyboardNavigation,
+ switchSidebarTab,
+ openConversationButton,
+ targetIdInput,
+ historyLimitInput,
+ mobileSidebarToggleButton,
+ closeSidebarButton,
+ sidebarBackdrop,
+ mobileLayoutMedia,
+ sidebarEl,
+ requestHistory,
+ openConversation,
+ composer,
+ socketController,
+ layout,
+ notifications,
+ handleLightboxResize,
+}) {
+ layout.updateViewportHeightVar();
+ layout.syncResponsiveLayout();
+
+ reloadHistoryButton.addEventListener('click', requestHistory);
+ reloadConversationsButton.addEventListener('click', () => socketController.requestConversations());
+ reloadFriendsButton.addEventListener('click', () => socketController.requestFriends());
+ reloadGroupsButton.addEventListener('click', () => socketController.requestGroups());
+
+ sidebarTabs.forEach((tab) => {
+ tab.addEventListener('click', () => {
+ switchSidebarTab(tab.dataset.tab);
+ });
+ });
+ bindTabKeyboardNavigation();
+
+ openConversationButton.addEventListener('click', openConversation);
+ targetIdInput.addEventListener('change', openConversation);
+ historyLimitInput.addEventListener('change', requestHistory);
+ mobileSidebarToggleButton.addEventListener('click', layout.toggleSidebar);
+ closeSidebarButton.addEventListener('click', layout.closeSidebar);
+ sidebarBackdrop.addEventListener('click', layout.closeSidebar);
+
+ if (typeof mobileLayoutMedia.addEventListener === 'function') {
+ mobileLayoutMedia.addEventListener('change', layout.syncResponsiveLayout);
+ } else if (typeof mobileLayoutMedia.addListener === 'function') {
+ mobileLayoutMedia.addListener(layout.syncResponsiveLayout);
+ }
+
+ composer.bindEvents();
+
+ document.addEventListener('click', (event) => {
+ if (layout.isMobileLayout()
+ && sidebarEl.classList.contains('open')
+ && !sidebarEl.contains(event.target)
+ && event.target !== mobileSidebarToggleButton) {
+ layout.closeSidebar();
+ }
+ });
+
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && document.hasFocus()) {
+ notifications.clearUnreadCount();
+ }
+ });
+
+ window.addEventListener('focus', notifications.clearUnreadCount);
+ window.addEventListener('resize', () => {
+ layout.updateViewportHeightVar();
+ layout.syncResponsiveLayout();
+ handleLightboxResize();
+ });
+
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', () => {
+ layout.updateViewportHeightVar();
+ composer.handleViewportChange();
+ });
+ window.visualViewport.addEventListener('scroll', layout.updateViewportHeightVar);
+ }
+
+ window.addEventListener('pointerdown', notifications.warmupNotifications, { once: true });
+ window.addEventListener('keydown', notifications.warmupNotifications, { once: true });
+ composer.handleViewportChange();
+}
+
+export function connectSocketFromConfig(socketController, {
+ configUrl = '/api/config',
+ fallbackWsPath = '/ws',
+ fetchImpl = (...args) => window.fetch(...args),
+} = {}) {
+ return fetchImpl(configUrl)
+ .then((res) => res.json())
+ .then((config) => {
+ socketController.connect((config && config.wsPath) || fallbackWsPath);
+ })
+ .catch(() => {
+ socketController.connect(fallbackWsPath);
+ });
+}
diff --git a/public/app/composer.js b/public/app/composer.js
new file mode 100644
index 0000000..0174e6a
--- /dev/null
+++ b/public/app/composer.js
@@ -0,0 +1,997 @@
+export function createComposerController({
+ buildCachedImageUrl,
+ buildSteamEmoticonUrl,
+ extractEmoticonNames,
+ isMobileLayout,
+ setStatus,
+ send,
+ createRequestId,
+ getConversationId,
+ controls,
+}) {
+ const {
+ messageInput,
+ emoticonSuggestions,
+ emoticonPreviewImage,
+ emoticonPreviewLabel,
+ sendMessageButton,
+ attachmentButton,
+ attachmentMenu,
+ chooseImageButton,
+ chooseUrlButton,
+ imageFileInput,
+ urlPanel,
+ imageUrlInput,
+ confirmImageUrlButton,
+ cancelImageUrlButton,
+ attachmentPreview,
+ attachmentPreviewImage,
+ attachmentPreviewTitle,
+ attachmentPreviewSubtitle,
+ clearAttachmentButton,
+ uploadQueue,
+ uploadQueueList,
+ pickerButton,
+ pickerPanel,
+ pickerSearch,
+ pickerGrid,
+ pickerEmpty,
+ pickerTabs,
+ dropOverlay,
+ } = controls;
+
+ let pendingAttachment = null;
+ let dragDepth = 0;
+ const knownEmoticons = new Set(['steamhappy', 'steamfacepalm', 'steamthumbsup', 'steamheart', 'steamsad', 'steammocking']);
+ let currentSuggestions = [];
+ let activeSuggestionIndex = 0;
+ let uploadQueueItems = [];
+ const uploadRequestMap = new Map();
+ let activePickerTab = 'emoticons';
+ let emoticonInventory = [];
+ let stickerInventory = [];
+
+ function autoResizeMessageInput() {
+ const minHeight = isMobileLayout() ? 30 : 80;
+ const maxHeight = isMobileLayout() ? 72 : 220;
+ messageInput.style.height = 'auto';
+ const nextHeight = Math.max(minHeight, Math.min(messageInput.scrollHeight, maxHeight));
+ messageInput.style.height = nextHeight + 'px';
+ messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? 'auto' : 'hidden';
+ }
+
+ function syncMessageInputPlaceholder() {
+ const mobilePlaceholder = messageInput.dataset.mobilePlaceholder || '输入消息';
+ const desktopPlaceholder = messageInput.dataset.desktopPlaceholder || mobilePlaceholder;
+ messageInput.placeholder = isMobileLayout() ? mobilePlaceholder : desktopPlaceholder;
+ }
+
+ function categorizeEmoticon(name) {
+ if (/^steam/i.test(name)) {
+ return 'Steam';
+ }
+ return '最近使用';
+ }
+
+ function renderUploadQueue() {
+ uploadQueueList.replaceChildren();
+ uploadQueue.classList.toggle('active', uploadQueueItems.length > 0);
+
+ uploadQueueItems.forEach((item) => {
+ const row = document.createElement('div');
+ row.className = 'upload-queue-item';
+ if (item.state) {
+ row.classList.add('is-' + item.state);
+ }
+
+ const name = document.createElement('div');
+ name.className = 'upload-queue-name';
+ name.textContent = item.name;
+
+ const status = document.createElement('div');
+ status.className = 'upload-queue-status';
+ status.textContent = item.statusText;
+
+ const progress = document.createElement('div');
+ progress.className = 'upload-queue-progress';
+
+ const bar = document.createElement('div');
+ bar.className = 'upload-queue-progress-bar';
+ if (item.state) {
+ bar.classList.add('is-' + item.state);
+ }
+ bar.style.width = item.progress + '%';
+
+ progress.appendChild(bar);
+ row.appendChild(name);
+ row.appendChild(status);
+ row.appendChild(progress);
+ uploadQueueList.appendChild(row);
+ });
+ }
+
+ function updateQueueItem(id, patch) {
+ const item = uploadQueueItems.find((entry) => entry.id === id);
+ if (!item) {
+ return;
+ }
+ Object.assign(item, patch);
+ renderUploadQueue();
+ }
+
+ function removeQueueItem(id) {
+ const nextItems = uploadQueueItems.filter((entry) => entry.id !== id);
+ if (nextItems.length === uploadQueueItems.length) {
+ return;
+ }
+ uploadQueueItems = nextItems;
+ renderUploadQueue();
+ }
+
+ function scheduleQueueItemRemoval(id, delay) {
+ window.setTimeout(() => {
+ removeQueueItem(id);
+ }, delay || 1500);
+ }
+
+ function createQueueItem(name, statusText, progress) {
+ const queueId = 'queue-' + Date.now() + '-' + Math.random().toString(16).slice(2);
+ uploadQueueItems.push({
+ id: queueId,
+ name: name || '图片',
+ progress: Number.isFinite(progress) ? progress : 0,
+ statusText: statusText || '准备中',
+ state: 'pending',
+ });
+ renderUploadQueue();
+ return queueId;
+ }
+
+ function markQueueItemCompleted(id, statusText) {
+ updateQueueItem(id, {
+ progress: 100,
+ statusText: statusText || '已发送',
+ state: 'done',
+ });
+ scheduleQueueItemRemoval(id, 1200);
+ }
+
+ function markQueueItemFailed(id, statusText) {
+ updateQueueItem(id, {
+ progress: 100,
+ statusText: statusText || '发送失败',
+ state: 'error',
+ });
+ scheduleQueueItemRemoval(id, 2500);
+ }
+
+ function resolveUploadRequest(requestId, ok, message) {
+ if (!requestId || !uploadRequestMap.has(requestId)) {
+ return false;
+ }
+
+ const queueId = uploadRequestMap.get(requestId);
+ uploadRequestMap.delete(requestId);
+
+ if (ok) {
+ markQueueItemCompleted(queueId, message || '已发送');
+ } else {
+ markQueueItemFailed(queueId, message || '发送失败');
+ }
+ return true;
+ }
+
+ function clearPendingUploadRequests(message) {
+ for (const [, queueId] of uploadRequestMap.entries()) {
+ markQueueItemFailed(queueId, message || '发送中断');
+ }
+ uploadRequestMap.clear();
+ }
+
+ function closeAttachmentMenu() {
+ attachmentMenu.classList.remove('open');
+ }
+
+ function toggleAttachmentMenu() {
+ attachmentMenu.classList.toggle('open');
+ }
+
+ function closeUrlPanel() {
+ urlPanel.classList.remove('open');
+ }
+
+ function togglePickerPanel() {
+ const isOpen = pickerPanel.classList.toggle('open');
+ if (isOpen) {
+ closeAttachmentMenu();
+ pickerSearch.value = '';
+ renderPickerGrid();
+ pickerSearch.focus();
+ }
+ }
+
+ function closePickerPanel() {
+ pickerPanel.classList.remove('open');
+ }
+
+ function setPickerTab(tab) {
+ activePickerTab = tab;
+ pickerTabs.forEach((btn) => {
+ btn.classList.toggle('active', btn.dataset.tab === tab);
+ });
+ pickerSearch.value = '';
+ renderPickerGrid();
+ }
+
+ function insertEmoticonAtCursor(name) {
+ const value = messageInput.value;
+ const selectionStart = messageInput.selectionStart || 0;
+ const selectionEnd = messageInput.selectionEnd || selectionStart;
+ let replaceStart = selectionStart;
+ let replaceEnd = selectionEnd;
+
+ if (selectionStart === selectionEnd) {
+ const leftSide = value.slice(0, selectionStart);
+ const colonIndex = leftSide.lastIndexOf(':');
+
+ if (colonIndex !== -1) {
+ const partialName = value.slice(colonIndex + 1, selectionStart);
+ if (/^[a-z0-9_\-]*$/i.test(partialName)) {
+ let tokenEnd = selectionStart;
+
+ while (tokenEnd < value.length && /[a-z0-9_\-]/i.test(value.charAt(tokenEnd))) {
+ tokenEnd += 1;
+ }
+
+ if (value.charAt(tokenEnd) === ':') {
+ tokenEnd += 1;
+ }
+
+ replaceStart = colonIndex;
+ replaceEnd = tokenEnd;
+ }
+ }
+ }
+
+ const before = value.slice(0, replaceStart);
+ const after = value.slice(replaceEnd);
+ const insertion = ':' + name + ': ';
+ messageInput.value = before + insertion + after;
+ const newCaret = replaceStart + insertion.length;
+ messageInput.setSelectionRange(newCaret, newCaret);
+ autoResizeMessageInput();
+ messageInput.focus();
+ knownEmoticons.add(name);
+ }
+
+ function sendStickerMessage(name) {
+ const id = getConversationId();
+ if (!id) {
+ setStatus('请先选择会话');
+ return;
+ }
+ const msg = '[sticker type="' + name + '" limit="0"][/sticker]';
+ if (send({
+ type: 'send_message',
+ requestId: createRequestId('sticker-'),
+ id,
+ msg,
+ })) {
+ setStatus('贴纸已发送');
+ }
+ }
+
+ function renderPickerGrid() {
+ pickerGrid.replaceChildren();
+ const query = (pickerSearch.value || '').trim().toLowerCase();
+
+ if (activePickerTab === 'emoticons') {
+ pickerGrid.className = 'picker-grid emoticon-grid';
+ const items = emoticonInventory
+ .filter((e) => !query || e.name.toLowerCase().includes(query))
+ .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name));
+
+ if (!items.length) {
+ pickerEmpty.textContent = emoticonInventory.length ? '无匹配结果' : '加载中…';
+ pickerEmpty.classList.add('active');
+ return;
+ }
+ pickerEmpty.classList.remove('active');
+
+ items.forEach((e) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'picker-item emoticon-item';
+ btn.title = ':' + e.name + ':';
+
+ const img = document.createElement('img');
+ img.src = buildCachedImageUrl(buildSteamEmoticonUrl(e.name, true));
+ img.alt = e.name;
+ img.loading = 'lazy';
+
+ const label = document.createElement('div');
+ label.className = 'picker-item-name';
+ label.textContent = e.name;
+
+ btn.appendChild(img);
+ btn.appendChild(label);
+ btn.addEventListener('click', () => {
+ insertEmoticonAtCursor(e.name);
+ closePickerPanel();
+ });
+ pickerGrid.appendChild(btn);
+ });
+ } else {
+ pickerGrid.className = 'picker-grid sticker-grid';
+ const items = stickerInventory
+ .filter((s) => !query || s.name.toLowerCase().includes(query))
+ .sort((a, b) => (b.use_count || 0) - (a.use_count || 0) || a.name.localeCompare(b.name));
+
+ if (!items.length) {
+ pickerEmpty.textContent = stickerInventory.length ? '无匹配结果' : '加载中…';
+ pickerEmpty.classList.add('active');
+ return;
+ }
+ pickerEmpty.classList.remove('active');
+
+ items.forEach((s) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'picker-item sticker-item';
+ btn.title = s.name;
+
+ const img = document.createElement('img');
+ img.src = location.origin + '/proxy/sticker/' + encodeURIComponent(s.name);
+ img.alt = s.name;
+ img.loading = 'lazy';
+
+ const label = document.createElement('div');
+ label.className = 'picker-item-name';
+ label.textContent = s.name.replace(/^Sticker_/, '');
+
+ btn.appendChild(img);
+ btn.appendChild(label);
+ btn.addEventListener('click', () => {
+ sendStickerMessage(s.name);
+ closePickerPanel();
+ });
+ pickerGrid.appendChild(btn);
+ });
+ }
+ }
+
+ function revokeAttachmentPreviewUrl(attachment) {
+ if (attachment && attachment.previewUrl && String(attachment.previewUrl).startsWith('blob:')) {
+ URL.revokeObjectURL(attachment.previewUrl);
+ }
+ }
+
+ function revokePendingAttachmentUrl() {
+ revokeAttachmentPreviewUrl(pendingAttachment);
+ }
+
+ function clearPendingAttachment() {
+ revokePendingAttachmentUrl();
+ pendingAttachment = null;
+ attachmentPreview.classList.remove('active');
+ attachmentPreviewImage.hidden = true;
+ attachmentPreviewImage.removeAttribute('src');
+ attachmentPreviewTitle.textContent = '';
+ attachmentPreviewSubtitle.textContent = '';
+ imageFileInput.value = '';
+ }
+
+ function setPendingAttachment(attachment) {
+ if (!attachment) {
+ clearPendingAttachment();
+ return;
+ }
+
+ revokePendingAttachmentUrl();
+ pendingAttachment = attachment;
+
+ attachmentPreview.classList.add('active');
+ attachmentPreviewTitle.textContent = attachment.title || '待发送图片';
+ attachmentPreviewSubtitle.textContent = attachment.subtitle || '';
+
+ if (attachment.previewUrl) {
+ attachmentPreviewImage.hidden = false;
+ attachmentPreviewImage.src = attachment.previewUrl;
+ } else {
+ attachmentPreviewImage.hidden = true;
+ attachmentPreviewImage.removeAttribute('src');
+ }
+ }
+
+ function readFileAsDataUrl(file, onProgress) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onprogress = (event) => {
+ if (typeof onProgress === 'function') {
+ onProgress(event.loaded || 0, event.total || file.size || 0);
+ }
+ };
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
+ reader.readAsDataURL(file);
+ });
+ }
+
+ function createFileAttachment(file, sourceLabel) {
+ if (!file) {
+ throw new Error('请选择图片文件');
+ }
+
+ return {
+ kind: 'file',
+ file,
+ previewUrl: URL.createObjectURL(file),
+ title: file.name || '待发送图片',
+ subtitle: sourceLabel || '本地图片',
+ };
+ }
+
+ async function resolveAttachmentPayload(attachment, onProgress) {
+ if (!attachment) {
+ throw new Error('缺少图片内容');
+ }
+
+ if (attachment.kind === 'url' || attachment.kind === 'base64') {
+ return attachment;
+ }
+
+ if (attachment.kind !== 'file' || !attachment.file) {
+ throw new Error('暂不支持的图片类型');
+ }
+
+ const dataUrl = await readFileAsDataUrl(attachment.file, onProgress);
+ const base64 = dataUrl.split(',')[1] || '';
+ if (!base64) {
+ throw new Error('图片编码失败');
+ }
+
+ return {
+ kind: 'base64',
+ payload: base64,
+ previewUrl: attachment.previewUrl,
+ title: attachment.title,
+ subtitle: attachment.subtitle,
+ };
+ }
+
+ function attachFile(file, sourceLabel) {
+ if (!file) {
+ return;
+ }
+
+ const attachment = createFileAttachment(file, sourceLabel);
+ setPendingAttachment(attachment);
+ setStatus((sourceLabel || '图片') + ' 已添加,点击发送即可发送');
+ }
+
+ async function sendFileDirectly(file, sourceLabel) {
+ const id = getConversationId();
+ if (!id) {
+ attachFile(file, sourceLabel);
+ setStatus('请先选择会话,图片已加入待发送');
+ return;
+ }
+
+ const attachment = createFileAttachment(file, sourceLabel);
+ if (await sendAttachmentWithQueue(id, attachment, 'drop-')) {
+ revokeAttachmentPreviewUrl(attachment);
+ setStatus((sourceLabel || '图片') + ' 已发送');
+ }
+ }
+
+ async function sendFilesDirectly(files, sourceLabel) {
+ const imageFiles = Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/'));
+ if (!imageFiles.length) {
+ return;
+ }
+
+ for (let index = 0; index < imageFiles.length; index += 1) {
+ await sendFileDirectly(imageFiles[index], imageFiles.length > 1 ? ((sourceLabel || '拖拽图片') + ' #' + (index + 1)) : sourceLabel);
+ }
+
+ if (imageFiles.length > 1) {
+ setStatus('已连续发送 ' + imageFiles.length + ' 张图片');
+ }
+ }
+
+ function confirmImageUrl() {
+ const url = imageUrlInput.value.trim();
+ if (!url) {
+ setStatus('请输入图片 URL');
+ return;
+ }
+
+ setPendingAttachment({
+ kind: 'url',
+ payload: url,
+ previewUrl: url,
+ title: '待发送图片 URL',
+ subtitle: url,
+ });
+ closeUrlPanel();
+ closeAttachmentMenu();
+ setStatus('图片 URL 已添加,点击发送即可发送');
+ }
+
+ function sendAttachmentPayload(id, attachment, requestId) {
+ const payload = {
+ type: 'send_image',
+ requestId,
+ id,
+ };
+
+ if (attachment.kind === 'url') {
+ payload.url = attachment.payload;
+ } else {
+ payload.img = attachment.payload;
+ }
+
+ return send(payload);
+ }
+
+ async function sendAttachmentWithQueue(id, attachment, requestPrefix) {
+ if (!id) {
+ throw new Error('请先选择会话');
+ }
+ if (!attachment) {
+ return false;
+ }
+
+ const name = attachment.title || attachment.subtitle || '图片';
+ const isFileAttachment = attachment.kind === 'file' && attachment.file;
+ const queueId = createQueueItem(name, isFileAttachment ? '读取中 0%' : '准备发送', isFileAttachment ? 0 : 20);
+
+ let resolvedAttachment;
+ try {
+ resolvedAttachment = await resolveAttachmentPayload(attachment, (loaded, total) => {
+ if (!isFileAttachment) {
+ return;
+ }
+
+ const ratio = total > 0 ? loaded / total : 0;
+ const progress = Math.max(1, Math.min(90, Math.round(ratio * 90)));
+ const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
+
+ updateQueueItem(queueId, {
+ progress,
+ statusText: '读取中 ' + percent + '%',
+ state: 'pending',
+ });
+ });
+ } catch (error) {
+ markQueueItemFailed(queueId, error.message || '图片读取失败');
+ throw error;
+ }
+
+ const requestId = createRequestId(requestPrefix);
+ updateQueueItem(queueId, {
+ progress: 95,
+ statusText: '等待发送确认',
+ state: 'pending',
+ });
+ uploadRequestMap.set(requestId, queueId);
+
+ if (!sendAttachmentPayload(id, resolvedAttachment, requestId)) {
+ uploadRequestMap.delete(requestId);
+ markQueueItemFailed(queueId, '发送失败');
+ return false;
+ }
+
+ return true;
+ }
+
+ function hideSuggestions() {
+ currentSuggestions = [];
+ activeSuggestionIndex = 0;
+ emoticonSuggestions.classList.remove('open');
+ }
+
+ function getAutocompleteContext() {
+ const value = messageInput.value;
+ const caret = messageInput.selectionStart || 0;
+ const textBefore = value.slice(0, caret);
+ const match = textBefore.match(/(^|\s):([a-z0-9_][a-z0-9_\-]*)?$/i);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ start: caret - (match[2] ? match[2].length + 1 : 1),
+ end: caret,
+ query: (match[2] || '').toLowerCase(),
+ };
+ }
+
+ function refreshSuggestionHighlight() {
+ Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node, index) => {
+ node.classList.toggle('active', index === activeSuggestionIndex);
+ });
+ if (currentSuggestions[activeSuggestionIndex]) {
+ updateSuggestionPreview(currentSuggestions[activeSuggestionIndex]);
+ }
+ }
+
+ function updateSuggestionPreview(name) {
+ emoticonPreviewImage.src = buildSteamEmoticonUrl(name, true);
+ emoticonPreviewLabel.textContent = ':' + name + ': · ' + categorizeEmoticon(name);
+ }
+
+ function applySuggestion(index = activeSuggestionIndex) {
+ const suggestion = currentSuggestions[index];
+ const context = getAutocompleteContext();
+ if (!suggestion || !context) {
+ hideSuggestions();
+ return;
+ }
+
+ const value = messageInput.value;
+ const replacement = ':' + suggestion + ': ';
+ messageInput.value = value.slice(0, context.start) + replacement + value.slice(context.end);
+ const caret = context.start + replacement.length;
+ messageInput.setSelectionRange(caret, caret);
+ knownEmoticons.add(suggestion);
+ autoResizeMessageInput();
+ hideSuggestions();
+ messageInput.focus();
+ }
+
+ function renderSuggestionList(names) {
+ Array.from(emoticonSuggestions.querySelectorAll('.emoticon-option')).forEach((node) => node.remove());
+ currentSuggestions = names.slice(0, 8);
+ activeSuggestionIndex = 0;
+
+ if (!currentSuggestions.length) {
+ hideSuggestions();
+ return;
+ }
+
+ updateSuggestionPreview(currentSuggestions[0]);
+
+ currentSuggestions.forEach((name, index) => {
+ const option = document.createElement('button');
+ option.type = 'button';
+ option.className = 'emoticon-option' + (index === 0 ? ' active' : '');
+
+ const img = document.createElement('img');
+ img.src = buildSteamEmoticonUrl(name, true);
+ img.alt = name;
+
+ const label = document.createElement('div');
+ label.className = 'emoticon-option-body';
+
+ const code = document.createElement('code');
+ code.textContent = ':' + name + ':';
+
+ const meta = document.createElement('div');
+ meta.className = 'emoticon-option-meta';
+ meta.textContent = categorizeEmoticon(name);
+
+ label.appendChild(code);
+ label.appendChild(meta);
+
+ option.appendChild(img);
+ option.appendChild(label);
+ option.addEventListener('click', () => applySuggestion(index));
+ option.addEventListener('mouseenter', () => {
+ activeSuggestionIndex = index;
+ refreshSuggestionHighlight();
+ updateSuggestionPreview(name);
+ });
+ emoticonSuggestions.appendChild(option);
+ });
+
+ emoticonSuggestions.classList.add('open');
+ }
+
+ function updateEmoticonSuggestions() {
+ const context = getAutocompleteContext();
+ if (!context) {
+ hideSuggestions();
+ return;
+ }
+
+ const query = context.query;
+ const names = [...knownEmoticons]
+ .filter((name) => !query || name.toLowerCase().includes(query))
+ .sort((a, b) => {
+ const aLower = a.toLowerCase();
+ const bLower = b.toLowerCase();
+ const aCategory = categorizeEmoticon(a);
+ const bCategory = categorizeEmoticon(b);
+ const aStarts = query ? aLower.startsWith(query) : true;
+ const bStarts = query ? bLower.startsWith(query) : true;
+
+ if (aStarts !== bStarts) {
+ return aStarts ? -1 : 1;
+ }
+
+ if (aCategory !== bCategory) {
+ return aCategory.localeCompare(bCategory);
+ }
+
+ return a.localeCompare(b);
+ });
+
+ renderSuggestionList(names);
+ }
+
+ async function handlePasteImage(event) {
+ const items = Array.from((event.clipboardData && event.clipboardData.items) || []);
+ const imageItem = items.find((item) => item && item.type && item.type.startsWith('image/'));
+ if (!imageItem) {
+ return false;
+ }
+
+ const file = imageItem.getAsFile();
+ if (!file) {
+ return false;
+ }
+
+ event.preventDefault();
+ const attachment = createFileAttachment(file, '剪切板图片');
+ const id = getConversationId();
+
+ if (id && await sendAttachmentWithQueue(id, attachment, 'paste-')) {
+ revokeAttachmentPreviewUrl(attachment);
+ imageUrlInput.value = '';
+ setStatus('剪切板图片发送中');
+ return true;
+ }
+
+ setPendingAttachment(attachment);
+ setStatus('剪切板图片已添加,点击发送即可发送');
+ return true;
+ }
+
+ async function handleSendMessage() {
+ const id = getConversationId();
+ const msg = messageInput.value.trim();
+ if (!id || (!msg && !pendingAttachment)) {
+ setStatus('请输入目标 SteamID,并填写消息或添加图片');
+ return;
+ }
+
+ try {
+ if (pendingAttachment) {
+ const attachment = pendingAttachment;
+ if (await sendAttachmentWithQueue(id, attachment, 'img-')) {
+ clearPendingAttachment();
+ imageUrlInput.value = '';
+ setStatus('图片发送中');
+ }
+ }
+
+ if (msg) {
+ if (send({
+ type: 'send_message',
+ requestId: createRequestId('msg-'),
+ id,
+ msg,
+ })) {
+ messageInput.value = '';
+ autoResizeMessageInput();
+ }
+ }
+ } catch (error) {
+ setStatus(error.message || '发送图片失败');
+ }
+ }
+
+ function requestEmoticonInventory() {
+ send({
+ type: 'get_emoticons',
+ requestId: createRequestId('emoticons-'),
+ });
+ }
+
+ function applyEmoticonInventory(data) {
+ emoticonInventory = (data && data.emoticons) || [];
+ emoticonInventory.forEach((e) => { e.name = e.name.replace(/^:+|:+$/g, ''); });
+ stickerInventory = (data && data.stickers) || [];
+ emoticonInventory.forEach((e) => knownEmoticons.add(e.name));
+ if (pickerPanel.classList.contains('open')) {
+ renderPickerGrid();
+ }
+ }
+
+ function rememberEmoticonsFromMessage(message) {
+ extractEmoticonNames(message).forEach((name) => knownEmoticons.add(name));
+ }
+
+ function bindEvents() {
+ sendMessageButton.addEventListener('click', () => {
+ handleSendMessage();
+ });
+
+ attachmentButton.addEventListener('click', (event) => {
+ event.stopPropagation();
+ closePickerPanel();
+ toggleAttachmentMenu();
+ });
+
+ pickerButton.addEventListener('click', (event) => {
+ event.stopPropagation();
+ togglePickerPanel();
+ });
+
+ pickerTabs.forEach((tab) => {
+ tab.addEventListener('click', () => {
+ setPickerTab(tab.dataset.tab);
+ });
+ });
+
+ pickerSearch.addEventListener('input', renderPickerGrid);
+
+ pickerPanel.addEventListener('click', (event) => {
+ event.stopPropagation();
+ });
+
+ chooseImageButton.addEventListener('click', () => {
+ closeAttachmentMenu();
+ closeUrlPanel();
+ imageFileInput.click();
+ });
+
+ chooseUrlButton.addEventListener('click', () => {
+ closeAttachmentMenu();
+ urlPanel.classList.add('open');
+ imageUrlInput.focus();
+ });
+
+ imageFileInput.addEventListener('change', () => {
+ try {
+ const file = imageFileInput.files && imageFileInput.files[0];
+ if (!file) {
+ setStatus('请选择图片文件');
+ return;
+ }
+ attachFile(file, '本地图片');
+ } catch (error) {
+ setStatus(error.message || '读取图片失败');
+ }
+ });
+
+ confirmImageUrlButton.addEventListener('click', confirmImageUrl);
+ cancelImageUrlButton.addEventListener('click', () => {
+ closeUrlPanel();
+ imageUrlInput.value = '';
+ });
+ clearAttachmentButton.addEventListener('click', clearPendingAttachment);
+
+ document.addEventListener('click', (event) => {
+ if (!attachmentMenu.contains(event.target) && event.target !== attachmentButton) {
+ closeAttachmentMenu();
+ }
+ if (!pickerPanel.contains(event.target) && event.target !== pickerButton) {
+ closePickerPanel();
+ }
+ });
+
+ messageInput.addEventListener('keydown', (event) => {
+ if (emoticonSuggestions.classList.contains('open')) {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ activeSuggestionIndex = (activeSuggestionIndex + 1) % currentSuggestions.length;
+ refreshSuggestionHighlight();
+ return;
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault();
+ activeSuggestionIndex = (activeSuggestionIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
+ refreshSuggestionHighlight();
+ return;
+ }
+
+ if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {
+ event.preventDefault();
+ applySuggestion();
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ hideSuggestions();
+ return;
+ }
+ }
+
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ sendMessageButton.click();
+ }
+ });
+
+ messageInput.addEventListener('input', () => {
+ autoResizeMessageInput();
+ updateEmoticonSuggestions();
+ });
+ messageInput.addEventListener('click', updateEmoticonSuggestions);
+ messageInput.addEventListener('blur', () => {
+ setTimeout(hideSuggestions, 120);
+ });
+
+ messageInput.addEventListener('paste', (event) => {
+ handlePasteImage(event).catch((error) => {
+ setStatus(error.message || '处理剪切板图片失败');
+ });
+ });
+
+ document.addEventListener('paste', (event) => {
+ if (document.activeElement === messageInput) {
+ return;
+ }
+
+ handlePasteImage(event).catch((error) => {
+ setStatus(error.message || '处理剪切板图片失败');
+ });
+ });
+
+ document.addEventListener('dragenter', (event) => {
+ const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file');
+ if (!hasFile) {
+ return;
+ }
+
+ dragDepth += 1;
+ dropOverlay.classList.add('active');
+ });
+
+ document.addEventListener('dragover', (event) => {
+ const hasFile = Array.from((event.dataTransfer && event.dataTransfer.items) || []).some((item) => item.kind === 'file');
+ if (!hasFile) {
+ return;
+ }
+
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ });
+
+ document.addEventListener('dragleave', () => {
+ dragDepth = Math.max(0, dragDepth - 1);
+ if (dragDepth === 0) {
+ dropOverlay.classList.remove('active');
+ }
+ });
+
+ document.addEventListener('drop', (event) => {
+ const files = Array.from((event.dataTransfer && event.dataTransfer.files) || []).filter((file) => String(file.type || '').startsWith('image/'));
+ if (!files.length) {
+ dragDepth = 0;
+ dropOverlay.classList.remove('active');
+ return;
+ }
+
+ event.preventDefault();
+ dragDepth = 0;
+ dropOverlay.classList.remove('active');
+
+ sendFilesDirectly(files, '拖拽图片').catch((error) => {
+ setStatus(error.message || '发送拖拽图片失败');
+ });
+ });
+ }
+
+ function handleViewportChange() {
+ syncMessageInputPlaceholder();
+ autoResizeMessageInput();
+ }
+
+ return {
+ applyEmoticonInventory,
+ bindEvents,
+ clearPendingUploadRequests,
+ handleViewportChange,
+ hideSuggestions,
+ rememberEmoticonsFromMessage,
+ requestEmoticonInventory,
+ resolveUploadRequest,
+ };
+}
diff --git a/public/app/dom.js b/public/app/dom.js
new file mode 100644
index 0000000..47fea61
--- /dev/null
+++ b/public/app/dom.js
@@ -0,0 +1,68 @@
+export function getAppDomRefs(root = document) {
+ const pickerPanel = root.getElementById('pickerPanel');
+
+ return {
+ targetIdInput: root.getElementById('targetId'),
+ historyLimitInput: root.getElementById('historyLimit'),
+ reloadHistoryButton: root.getElementById('reloadHistory'),
+ openConversationButton: root.getElementById('openConversation'),
+ reloadConversationsButton: root.getElementById('reloadConversations'),
+ conversationListEl: root.getElementById('conversationList'),
+ friendsListEl: root.getElementById('friendsList'),
+ groupsListEl: root.getElementById('groupsList'),
+ reloadFriendsButton: root.getElementById('reloadFriends'),
+ reloadGroupsButton: root.getElementById('reloadGroups'),
+ sidebarTabs: root.querySelectorAll('.sidebar-tab'),
+ sidebarTabPanels: {
+ conversations: root.getElementById('sidebarTabConversations'),
+ friends: root.getElementById('sidebarTabFriends'),
+ groups: root.getElementById('sidebarTabGroups'),
+ },
+ chatTitleEl: root.getElementById('chatTitle'),
+ chatSubtitleEl: root.getElementById('chatSubtitle'),
+ feedbackStatusEl: root.getElementById('feedbackStatus'),
+ connectionStatusEl: root.getElementById('connectionStatus'),
+ connectionStatusLabelEl: root.getElementById('connectionStatusLabel'),
+ messagesEl: root.getElementById('messages'),
+ dropOverlay: root.getElementById('dropOverlay'),
+ sidebarEl: root.querySelector('.sidebar'),
+ sidebarBackdrop: root.getElementById('sidebarBackdrop'),
+ mobileSidebarToggleButton: root.getElementById('mobileSidebarToggle'),
+ closeSidebarButton: root.getElementById('closeSidebar'),
+ imageLightbox: root.getElementById('imageLightbox'),
+ imageLightboxViewport: root.getElementById('imageLightboxViewport'),
+ imageLightboxImage: root.getElementById('imageLightboxImage'),
+ imageLightboxCaption: root.getElementById('imageLightboxCaption'),
+ closeImageLightboxButton: root.getElementById('closeImageLightbox'),
+ imageZoomOutButton: root.getElementById('imageZoomOut'),
+ imageZoomResetButton: root.getElementById('imageZoomReset'),
+ imageZoomInButton: root.getElementById('imageZoomIn'),
+ messageInput: root.getElementById('messageInput'),
+ emoticonSuggestions: root.getElementById('emoticonSuggestions'),
+ emoticonPreviewImage: root.getElementById('emoticonPreviewImage'),
+ emoticonPreviewLabel: root.getElementById('emoticonPreviewLabel'),
+ sendMessageButton: root.getElementById('sendMessage'),
+ attachmentButton: root.getElementById('attachmentButton'),
+ attachmentMenu: root.getElementById('attachmentMenu'),
+ chooseImageButton: root.getElementById('chooseImageButton'),
+ chooseUrlButton: root.getElementById('chooseUrlButton'),
+ imageFileInput: root.getElementById('imageFile'),
+ urlPanel: root.getElementById('urlPanel'),
+ imageUrlInput: root.getElementById('imageUrl'),
+ confirmImageUrlButton: root.getElementById('confirmImageUrl'),
+ cancelImageUrlButton: root.getElementById('cancelImageUrl'),
+ attachmentPreview: root.getElementById('attachmentPreview'),
+ attachmentPreviewImage: root.getElementById('attachmentPreviewImage'),
+ attachmentPreviewTitle: root.getElementById('attachmentPreviewTitle'),
+ attachmentPreviewSubtitle: root.getElementById('attachmentPreviewSubtitle'),
+ clearAttachmentButton: root.getElementById('clearAttachment'),
+ uploadQueue: root.getElementById('uploadQueue'),
+ uploadQueueList: root.getElementById('uploadQueueList'),
+ pickerButton: root.getElementById('pickerButton'),
+ pickerPanel,
+ pickerSearch: root.getElementById('pickerSearch'),
+ pickerGrid: root.getElementById('pickerGrid'),
+ pickerEmpty: root.getElementById('pickerEmpty'),
+ pickerTabs: pickerPanel ? pickerPanel.querySelectorAll('.picker-tab') : [],
+ };
+}
diff --git a/public/app/layout.js b/public/app/layout.js
new file mode 100644
index 0000000..ea5492e
--- /dev/null
+++ b/public/app/layout.js
@@ -0,0 +1,54 @@
+export function createLayoutController({
+ mediaQuery,
+ sidebarEl,
+ sidebarBackdrop,
+ mobileSidebarToggleButton,
+ onResponsiveChange,
+}) {
+ function updateViewportHeightVar() {
+ const viewport = window.visualViewport;
+ const height = viewport && Number.isFinite(viewport.height)
+ ? viewport.height
+ : window.innerHeight;
+ const offsetTop = viewport && Number.isFinite(viewport.offsetTop)
+ ? viewport.offsetTop
+ : 0;
+ document.documentElement.style.setProperty('--app-height', height + 'px');
+ document.documentElement.style.setProperty('--viewport-offset-top', offsetTop + 'px');
+ }
+
+ function isMobileLayout() {
+ return mediaQuery.matches;
+ }
+
+ function setSidebarOpen(open) {
+ const shouldOpen = Boolean(open && isMobileLayout());
+ sidebarEl.classList.toggle('open', shouldOpen);
+ sidebarBackdrop.classList.toggle('open', shouldOpen);
+ sidebarBackdrop.setAttribute('aria-hidden', shouldOpen ? 'false' : 'true');
+ mobileSidebarToggleButton.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
+ }
+
+ function closeSidebar() {
+ setSidebarOpen(false);
+ }
+
+ function toggleSidebar() {
+ setSidebarOpen(!sidebarEl.classList.contains('open'));
+ }
+
+ function syncResponsiveLayout() {
+ if (!isMobileLayout()) {
+ closeSidebar();
+ }
+ onResponsiveChange();
+ }
+
+ return {
+ closeSidebar,
+ isMobileLayout,
+ syncResponsiveLayout,
+ toggleSidebar,
+ updateViewportHeightVar,
+ };
+}
diff --git a/public/app/lightbox.js b/public/app/lightbox.js
new file mode 100644
index 0000000..d822386
--- /dev/null
+++ b/public/app/lightbox.js
@@ -0,0 +1,459 @@
+export function createLightboxController({
+ buildCachedImageUrl,
+ imageLightbox,
+ imageLightboxViewport,
+ imageLightboxImage,
+ imageLightboxCaption,
+ closeImageLightboxButton,
+ imageZoomOutButton,
+ imageZoomResetButton,
+ imageZoomInButton,
+ loadManagedImage,
+ resetManagedImage,
+}) {
+ let lastFocusedElementBeforeLightbox = null;
+ const imageLightboxState = {
+ scale: 1,
+ minScale: 1,
+ maxScale: 6,
+ offsetX: 0,
+ offsetY: 0,
+ dragging: false,
+ dragPointerId: null,
+ dragStartX: 0,
+ dragStartY: 0,
+ dragOriginX: 0,
+ dragOriginY: 0,
+ activePointers: new Map(),
+ pinching: false,
+ pinchStartDistance: 0,
+ pinchStartScale: 1,
+ pinchContentX: 0,
+ pinchContentY: 0,
+ rafPending: false,
+ cachedBaseSize: null,
+ cachedViewportRect: null,
+ };
+
+ function resolveDisplayImageUrl(url) {
+ const value = String(url || '').trim();
+ if (!value) {
+ return '';
+ }
+
+ try {
+ const parsed = new URL(value, location.origin);
+ if (parsed.origin === location.origin) {
+ return parsed.toString();
+ }
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return buildCachedImageUrl(parsed.toString());
+ }
+ return parsed.toString();
+ } catch {
+ return value;
+ }
+ }
+
+ function computeImageLightboxBaseSize() {
+ const viewportWidth = imageLightboxViewport.clientWidth || 1;
+ const viewportHeight = imageLightboxViewport.clientHeight || 1;
+ const naturalWidth = imageLightboxImage.naturalWidth || viewportWidth;
+ const naturalHeight = imageLightboxImage.naturalHeight || viewportHeight;
+ const fitScale = Math.min(viewportWidth / naturalWidth, viewportHeight / naturalHeight, 1);
+
+ return {
+ width: naturalWidth * fitScale,
+ height: naturalHeight * fitScale,
+ viewportWidth,
+ viewportHeight,
+ };
+ }
+
+ function refreshImageLightboxBaseSize() {
+ imageLightboxState.cachedBaseSize = computeImageLightboxBaseSize();
+ imageLightboxState.cachedViewportRect = imageLightboxViewport.getBoundingClientRect();
+ }
+
+ function getImageLightboxBaseSize() {
+ return imageLightboxState.cachedBaseSize || computeImageLightboxBaseSize();
+ }
+
+ function clampImageLightboxOffset() {
+ if (imageLightboxState.scale <= 1) {
+ imageLightboxState.offsetX = 0;
+ imageLightboxState.offsetY = 0;
+ return;
+ }
+
+ const { width, height, viewportWidth, viewportHeight } = getImageLightboxBaseSize();
+ const scaledWidth = width * imageLightboxState.scale;
+ const scaledHeight = height * imageLightboxState.scale;
+ const limitX = Math.max(0, (scaledWidth - viewportWidth) / 2);
+ const limitY = Math.max(0, (scaledHeight - viewportHeight) / 2);
+
+ imageLightboxState.offsetX = Math.min(limitX, Math.max(-limitX, imageLightboxState.offsetX));
+ imageLightboxState.offsetY = Math.min(limitY, Math.max(-limitY, imageLightboxState.offsetY));
+ }
+
+ function getViewportRelativePoint(clientX, clientY) {
+ const rect = imageLightboxState.cachedViewportRect || imageLightboxViewport.getBoundingClientRect();
+ return {
+ x: clientX - rect.left - (rect.width / 2),
+ y: clientY - rect.top - (rect.height / 2),
+ };
+ }
+
+ function getTouchPointerList() {
+ return [...imageLightboxState.activePointers.values()].filter((pointer) => pointer.pointerType === 'touch');
+ }
+
+ function getTouchPointerMetrics(pointers) {
+ if (!pointers || pointers.length < 2) {
+ return null;
+ }
+
+ const [first, second] = pointers;
+ const deltaX = second.clientX - first.clientX;
+ const deltaY = second.clientY - first.clientY;
+
+ return {
+ distance: Math.hypot(deltaX, deltaY),
+ centerX: (first.clientX + second.clientX) / 2,
+ centerY: (first.clientY + second.clientY) / 2,
+ };
+ }
+
+ function applyImageLightboxTransform() {
+ imageLightboxState.rafPending = false;
+ clampImageLightboxOffset();
+ imageLightboxImage.style.transform = 'translate3d(' + imageLightboxState.offsetX + 'px, ' + imageLightboxState.offsetY + 'px, 0) scale(' + imageLightboxState.scale + ')';
+ }
+
+ function updateImageLightboxTransform() {
+ const isActive = imageLightboxState.dragging || imageLightboxState.pinching;
+ imageLightboxImage.classList.toggle('is-dragging', isActive);
+ imageLightboxImage.style.cursor = imageLightboxState.scale > 1
+ ? (isActive ? 'grabbing' : 'grab')
+ : 'zoom-in';
+ imageZoomResetButton.textContent = Math.round(imageLightboxState.scale * 100) + '%';
+ if (!imageLightboxState.rafPending) {
+ imageLightboxState.rafPending = true;
+ requestAnimationFrame(applyImageLightboxTransform);
+ }
+ }
+
+ function scheduleTransformOnly() {
+ if (!imageLightboxState.rafPending) {
+ imageLightboxState.rafPending = true;
+ requestAnimationFrame(applyImageLightboxTransform);
+ }
+ }
+
+ function beginImageLightboxDrag(pointerId, clientX, clientY) {
+ imageLightboxState.dragging = true;
+ imageLightboxState.dragPointerId = pointerId;
+ imageLightboxState.dragStartX = clientX;
+ imageLightboxState.dragStartY = clientY;
+ imageLightboxState.dragOriginX = imageLightboxState.offsetX;
+ imageLightboxState.dragOriginY = imageLightboxState.offsetY;
+ updateImageLightboxTransform();
+ }
+
+ function resetImageLightboxTransform() {
+ imageLightboxState.scale = 1;
+ imageLightboxState.offsetX = 0;
+ imageLightboxState.offsetY = 0;
+ imageLightboxState.dragging = false;
+ imageLightboxState.dragPointerId = null;
+ imageLightboxState.activePointers.clear();
+ imageLightboxState.pinching = false;
+ imageLightboxState.pinchStartDistance = 0;
+ imageLightboxState.pinchStartScale = 1;
+ imageLightboxState.pinchContentX = 0;
+ imageLightboxState.pinchContentY = 0;
+ updateImageLightboxTransform();
+ }
+
+ function setImageLightboxScale(nextScale, clientX, clientY) {
+ const clampedScale = Math.min(imageLightboxState.maxScale, Math.max(imageLightboxState.minScale, nextScale));
+ const previousScale = imageLightboxState.scale;
+
+ if (Math.abs(clampedScale - previousScale) < 0.001) {
+ return;
+ }
+
+ const anchorPoint = (typeof clientX === 'number' && typeof clientY === 'number')
+ ? getViewportRelativePoint(clientX, clientY)
+ : { x: 0, y: 0 };
+
+ imageLightboxState.offsetX = anchorPoint.x - (((anchorPoint.x - imageLightboxState.offsetX) / previousScale) * clampedScale);
+ imageLightboxState.offsetY = anchorPoint.y - (((anchorPoint.y - imageLightboxState.offsetY) / previousScale) * clampedScale);
+ imageLightboxState.scale = clampedScale;
+ updateImageLightboxTransform();
+ }
+
+ function beginImageLightboxPinch() {
+ const metrics = getTouchPointerMetrics(getTouchPointerList());
+ if (!metrics) {
+ return;
+ }
+
+ const center = getViewportRelativePoint(metrics.centerX, metrics.centerY);
+ imageLightboxState.pinching = true;
+ imageLightboxState.dragging = false;
+ imageLightboxState.dragPointerId = null;
+ imageLightboxState.pinchStartDistance = Math.max(metrics.distance, 1);
+ imageLightboxState.pinchStartScale = imageLightboxState.scale;
+ imageLightboxState.pinchContentX = (center.x - imageLightboxState.offsetX) / imageLightboxState.scale;
+ imageLightboxState.pinchContentY = (center.y - imageLightboxState.offsetY) / imageLightboxState.scale;
+ updateImageLightboxTransform();
+ }
+
+ function updateImageLightboxPinch() {
+ const metrics = getTouchPointerMetrics(getTouchPointerList());
+ if (!metrics || !imageLightboxState.pinching) {
+ return;
+ }
+
+ const center = getViewportRelativePoint(metrics.centerX, metrics.centerY);
+ const nextScale = Math.min(
+ imageLightboxState.maxScale,
+ Math.max(
+ imageLightboxState.minScale,
+ imageLightboxState.pinchStartScale * (metrics.distance / Math.max(imageLightboxState.pinchStartDistance, 1)),
+ ),
+ );
+
+ imageLightboxState.scale = nextScale;
+ imageLightboxState.offsetX = center.x - (imageLightboxState.pinchContentX * nextScale);
+ imageLightboxState.offsetY = center.y - (imageLightboxState.pinchContentY * nextScale);
+ scheduleTransformOnly();
+ }
+
+ function endImageLightboxPinch() {
+ if (!imageLightboxState.pinching) {
+ return;
+ }
+
+ imageLightboxState.pinching = false;
+ imageLightboxState.pinchStartDistance = 0;
+ imageLightboxState.pinchStartScale = imageLightboxState.scale;
+ const remainingTouch = getTouchPointerList()[0];
+ if (remainingTouch && imageLightboxState.scale > 1) {
+ beginImageLightboxDrag(remainingTouch.pointerId, remainingTouch.clientX, remainingTouch.clientY);
+ return;
+ }
+ updateImageLightboxTransform();
+ }
+
+ function openImageLightbox(url, caption) {
+ const displayUrl = resolveDisplayImageUrl(url);
+ if (!displayUrl) {
+ return;
+ }
+
+ lastFocusedElementBeforeLightbox = document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ resetImageLightboxTransform();
+ loadManagedImage(imageLightboxViewport, imageLightboxImage, displayUrl, {
+ loadingText: '大图加载中',
+ errorText: '大图加载失败',
+ onLoad() {
+ refreshImageLightboxBaseSize();
+ updateImageLightboxTransform();
+ },
+ });
+ imageLightboxCaption.textContent = caption || url || '';
+ imageLightbox.classList.add('open');
+ imageLightbox.setAttribute('aria-hidden', 'false');
+ closeImageLightboxButton.focus();
+ }
+
+ function closeImageLightbox() {
+ if (!imageLightbox.classList.contains('open')) {
+ return;
+ }
+
+ imageLightbox.classList.remove('open');
+ imageLightbox.setAttribute('aria-hidden', 'true');
+ resetManagedImage(imageLightboxViewport, imageLightboxImage);
+ imageLightboxCaption.textContent = '';
+ imageLightboxState.cachedBaseSize = null;
+ imageLightboxState.cachedViewportRect = null;
+ resetImageLightboxTransform();
+ if (lastFocusedElementBeforeLightbox) {
+ lastFocusedElementBeforeLightbox.focus();
+ lastFocusedElementBeforeLightbox = null;
+ }
+ }
+
+ function makeImageZoomable(element, url, caption) {
+ element.classList.add('zoomable-image');
+ element.addEventListener('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ openImageLightbox(url, caption || element.getAttribute('alt') || '');
+ });
+ }
+
+ function stopImageLightboxDrag(event) {
+ const hadPointer = imageLightboxState.activePointers.delete(event.pointerId);
+
+ if (imageLightboxState.pinching && getTouchPointerList().length < 2) {
+ endImageLightboxPinch();
+ }
+
+ if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) {
+ if (hadPointer && imageLightboxViewport.hasPointerCapture(event.pointerId)) {
+ imageLightboxViewport.releasePointerCapture(event.pointerId);
+ }
+ return;
+ }
+
+ imageLightboxState.dragging = false;
+ imageLightboxState.dragPointerId = null;
+ if (imageLightboxViewport.hasPointerCapture(event.pointerId)) {
+ imageLightboxViewport.releasePointerCapture(event.pointerId);
+ }
+ updateImageLightboxTransform();
+ }
+
+ function bindEvents() {
+ imageLightbox.addEventListener('click', (event) => {
+ if (event.target === imageLightbox) {
+ closeImageLightbox();
+ }
+ });
+
+ imageLightboxViewport.addEventListener('wheel', (event) => {
+ if (!imageLightbox.classList.contains('open')) {
+ return;
+ }
+
+ event.preventDefault();
+ const factor = Math.exp(-event.deltaY * 0.0025);
+ setImageLightboxScale(imageLightboxState.scale * factor, event.clientX, event.clientY);
+ }, { passive: false });
+
+ imageLightboxViewport.addEventListener('pointerdown', (event) => {
+ if (!imageLightbox.classList.contains('open')) {
+ return;
+ }
+
+ imageLightboxViewport.setPointerCapture(event.pointerId);
+ imageLightboxState.activePointers.set(event.pointerId, {
+ pointerId: event.pointerId,
+ pointerType: event.pointerType,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+
+ if (event.pointerType === 'touch') {
+ event.preventDefault();
+ if (getTouchPointerList().length >= 2) {
+ beginImageLightboxPinch();
+ return;
+ }
+ }
+
+ if (event.button !== 0 || imageLightboxState.scale <= 1) {
+ return;
+ }
+
+ event.preventDefault();
+ beginImageLightboxDrag(event.pointerId, event.clientX, event.clientY);
+ });
+
+ imageLightboxViewport.addEventListener('pointermove', (event) => {
+ if (imageLightboxState.activePointers.has(event.pointerId)) {
+ imageLightboxState.activePointers.set(event.pointerId, {
+ pointerId: event.pointerId,
+ pointerType: event.pointerType,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ }
+
+ if (imageLightboxState.pinching) {
+ event.preventDefault();
+ updateImageLightboxPinch();
+ return;
+ }
+
+ if (!imageLightboxState.dragging || imageLightboxState.dragPointerId !== event.pointerId) {
+ return;
+ }
+
+ imageLightboxState.offsetX = imageLightboxState.dragOriginX + (event.clientX - imageLightboxState.dragStartX);
+ imageLightboxState.offsetY = imageLightboxState.dragOriginY + (event.clientY - imageLightboxState.dragStartY);
+ scheduleTransformOnly();
+ });
+
+ imageLightboxViewport.addEventListener('pointerup', stopImageLightboxDrag);
+ imageLightboxViewport.addEventListener('pointercancel', stopImageLightboxDrag);
+ imageLightboxViewport.addEventListener('dblclick', (event) => {
+ event.preventDefault();
+ if (imageLightboxState.scale > 1) {
+ resetImageLightboxTransform();
+ return;
+ }
+ setImageLightboxScale(2, event.clientX, event.clientY);
+ });
+
+ imageLightboxImage.addEventListener('load', () => {
+ refreshImageLightboxBaseSize();
+ resetImageLightboxTransform();
+ });
+
+ imageZoomOutButton.addEventListener('click', () => {
+ setImageLightboxScale(imageLightboxState.scale / 1.2);
+ });
+
+ imageZoomResetButton.addEventListener('click', resetImageLightboxTransform);
+
+ imageZoomInButton.addEventListener('click', () => {
+ setImageLightboxScale(imageLightboxState.scale * 1.2);
+ });
+
+ closeImageLightboxButton.addEventListener('click', closeImageLightbox);
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && imageLightbox.classList.contains('open')) {
+ closeImageLightbox();
+ return;
+ }
+
+ if (!imageLightbox.classList.contains('open')) {
+ return;
+ }
+
+ if (event.key === '+' || event.key === '=') {
+ event.preventDefault();
+ setImageLightboxScale(imageLightboxState.scale * 1.2);
+ } else if (event.key === '-') {
+ event.preventDefault();
+ setImageLightboxScale(imageLightboxState.scale / 1.2);
+ } else if (event.key === '0') {
+ event.preventDefault();
+ resetImageLightboxTransform();
+ }
+ });
+ }
+
+ function handleWindowResize() {
+ if (imageLightbox.classList.contains('open')) {
+ refreshImageLightboxBaseSize();
+ }
+ }
+
+ bindEvents();
+
+ return {
+ closeImageLightbox,
+ handleWindowResize,
+ makeImageZoomable,
+ openImageLightbox,
+ };
+}
diff --git a/public/app/managed-images.js b/public/app/managed-images.js
new file mode 100644
index 0000000..55da590
--- /dev/null
+++ b/public/app/managed-images.js
@@ -0,0 +1,242 @@
+export function createManagedImageController() {
+ const managedImageRequestMap = new WeakMap();
+ let nextManagedImageToken = 1;
+
+ function createManagedImageHost(hostClassName, imageClassName) {
+ const host = document.createElement('div');
+ host.className = 'image-loading-host';
+ if (hostClassName) {
+ hostClassName.split(/\s+/).filter(Boolean).forEach((className) => host.classList.add(className));
+ }
+
+ const img = document.createElement('img');
+ img.dataset.managedImage = 'true';
+ img.classList.add('image-loading-target');
+ if (imageClassName) {
+ imageClassName.split(/\s+/).filter(Boolean).forEach((className) => img.classList.add(className));
+ }
+
+ host.appendChild(img);
+ return { host, img };
+ }
+
+ function ensureManagedImageUi(host) {
+ if (host.__managedImageUi) {
+ return host.__managedImageUi;
+ }
+
+ const overlay = document.createElement('div');
+ overlay.className = 'image-loading-overlay';
+
+ const label = document.createElement('div');
+ label.className = 'image-loading-label';
+ label.textContent = '正在加载中';
+
+ const progress = document.createElement('div');
+ progress.className = 'image-loading-progress';
+
+ const bar = document.createElement('div');
+ bar.className = 'image-loading-progress-bar';
+
+ progress.appendChild(bar);
+ overlay.appendChild(label);
+ overlay.appendChild(progress);
+ host.appendChild(overlay);
+
+ host.__managedImageUi = { overlay, label, progress, bar };
+ return host.__managedImageUi;
+ }
+
+ function setManagedImageState(host, state, labelText, progressValue, indeterminate) {
+ const ui = ensureManagedImageUi(host);
+ const nextState = state || 'idle';
+
+ host.classList.toggle('is-loading', nextState === 'loading');
+ host.classList.toggle('is-loaded', nextState === 'loaded');
+ host.classList.toggle('is-error', nextState === 'error');
+ host.classList.toggle('is-indeterminate', Boolean(indeterminate));
+
+ if (labelText) {
+ ui.label.textContent = labelText;
+ } else if (nextState === 'error') {
+ ui.label.textContent = '图片加载失败';
+ } else if (nextState === 'loaded') {
+ ui.label.textContent = '';
+ } else {
+ ui.label.textContent = '正在加载中';
+ }
+
+ const width = Number.isFinite(progressValue)
+ ? Math.max(0, Math.min(100, progressValue))
+ : 0;
+ ui.bar.style.width = width + '%';
+ }
+
+ function abortManagedImageRequest(img) {
+ const request = managedImageRequestMap.get(img);
+ if (!request) {
+ return;
+ }
+
+ managedImageRequestMap.delete(img);
+
+ try {
+ request.abort();
+ } catch {
+ // ignore
+ }
+ }
+
+ function revokeManagedImageObjectUrl(img) {
+ const objectUrl = img && img.dataset ? img.dataset.objectUrl : '';
+ if (!objectUrl) {
+ return;
+ }
+
+ try {
+ URL.revokeObjectURL(objectUrl);
+ } catch {
+ // ignore
+ }
+
+ delete img.dataset.objectUrl;
+ }
+
+ function resetManagedImage(host, img) {
+ abortManagedImageRequest(img);
+ revokeManagedImageObjectUrl(img);
+ delete img.dataset.managedImageLoadToken;
+ img.removeAttribute('src');
+
+ if (host) {
+ setManagedImageState(host, 'idle', '', 0, false);
+ }
+ }
+
+ function cleanupManagedImages(root) {
+ if (!root || typeof root.querySelectorAll !== 'function') {
+ return;
+ }
+
+ root.querySelectorAll('img[data-managed-image="true"]').forEach((img) => {
+ resetManagedImage(img.closest('.image-loading-host'), img);
+ });
+ }
+
+ function loadManagedImage(host, img, src, options) {
+ const settings = options || {};
+ const loadingText = settings.loadingText || '正在加载中';
+ const errorText = settings.errorText || '图片加载失败';
+ const normalizedSrc = String(src || '').trim();
+
+ if (!host || !img) {
+ return;
+ }
+
+ host.classList.add('image-loading-host');
+ img.dataset.managedImage = 'true';
+ img.classList.add('image-loading-target');
+
+ resetManagedImage(host, img);
+
+ if (!normalizedSrc) {
+ setManagedImageState(host, 'error', errorText, 100, false);
+ if (typeof settings.onError === 'function') {
+ settings.onError();
+ }
+ return;
+ }
+
+ const token = String(nextManagedImageToken++);
+ img.dataset.managedImageLoadToken = token;
+ setManagedImageState(host, 'loading', loadingText, 8, true);
+
+ const request = new XMLHttpRequest();
+ managedImageRequestMap.set(img, request);
+ request.open('GET', normalizedSrc, true);
+ request.responseType = 'blob';
+
+ request.onprogress = (event) => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+
+ if (event.lengthComputable && event.total > 0) {
+ const percent = Math.max(1, Math.min(99, Math.round((event.loaded / event.total) * 100)));
+ setManagedImageState(host, 'loading', loadingText + ' ' + percent + '%', percent, false);
+ } else {
+ setManagedImageState(host, 'loading', loadingText, 32, true);
+ }
+ };
+
+ request.onerror = () => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+ managedImageRequestMap.delete(img);
+ setManagedImageState(host, 'error', errorText, 100, false);
+ if (typeof settings.onError === 'function') {
+ settings.onError();
+ }
+ };
+
+ request.onabort = () => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+ managedImageRequestMap.delete(img);
+ };
+
+ request.onload = () => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+
+ managedImageRequestMap.delete(img);
+
+ if (request.status < 200 || request.status >= 300 || !(request.response instanceof Blob)) {
+ setManagedImageState(host, 'error', errorText, 100, false);
+ if (typeof settings.onError === 'function') {
+ settings.onError();
+ }
+ return;
+ }
+
+ const objectUrl = URL.createObjectURL(request.response);
+ img.dataset.objectUrl = objectUrl;
+
+ img.addEventListener('load', () => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+ setManagedImageState(host, 'loaded', '', 100, false);
+ if (typeof settings.onLoad === 'function') {
+ settings.onLoad();
+ }
+ }, { once: true });
+
+ img.addEventListener('error', () => {
+ if (img.dataset.managedImageLoadToken !== token) {
+ return;
+ }
+ revokeManagedImageObjectUrl(img);
+ setManagedImageState(host, 'error', errorText, 100, false);
+ if (typeof settings.onError === 'function') {
+ settings.onError();
+ }
+ }, { once: true });
+
+ setManagedImageState(host, 'loading', '即将显示', 100, false);
+ img.src = objectUrl;
+ };
+
+ request.send();
+ }
+
+ return {
+ createManagedImageHost,
+ cleanupManagedImages,
+ loadManagedImage,
+ resetManagedImage,
+ };
+}
diff --git a/public/app/message-bubble.js b/public/app/message-bubble.js
new file mode 100644
index 0000000..d991198
--- /dev/null
+++ b/public/app/message-bubble.js
@@ -0,0 +1,119 @@
+export function createMessageBubbleRenderer({
+ createManagedImageHost,
+ makeImageZoomable,
+ loadManagedImage,
+ buildCachedImageUrl,
+ createRichMessageContent,
+ extractStickerType,
+ buildSteamStickerCandidateUrls,
+ extractImageUrls,
+}) {
+ function extractStandaloneImageUrl(message) {
+ const normalizedMessage = String(message || '')
+ .trim()
+ .replace(/\[img\s+src=(https?:\/\/[^\s\]]+)[^\]]*\][\s\S]*?\[\/img\]/gi, '[img]$1[/img]');
+
+ if (!normalizedMessage) {
+ return '';
+ }
+
+ const imageUrls = extractImageUrls(normalizedMessage);
+ if (imageUrls.length !== 1) {
+ return '';
+ }
+
+ const leftoverText = normalizedMessage
+ .replace(/\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\]/gi, '')
+ .replace(/
]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi, '')
+ .replace(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi, '')
+ .trim();
+
+ return leftoverText === '' ? imageUrls[0] : '';
+ }
+
+ function renderImageBubble(bubble, entry) {
+ bubble.classList.add('image-bubble');
+
+ const rawImageUrl = entry.imageUrl;
+ const { host, img } = createManagedImageHost('image-loading-host--bubble');
+ img.alt = 'image';
+ makeImageZoomable(host, rawImageUrl, rawImageUrl);
+ loadManagedImage(host, img, buildCachedImageUrl(rawImageUrl));
+ bubble.appendChild(host);
+ }
+
+ function renderStickerBubble(bubble, stickerType) {
+ bubble.classList.add('sticker-bubble');
+
+ const stickerCandidates = buildSteamStickerCandidateUrls(stickerType);
+ if (stickerCandidates.length) {
+ const stickerImage = document.createElement('img');
+ stickerImage.className = 'sticker-image';
+ stickerImage.alt = stickerType;
+ let stickerIndex = 0;
+ stickerImage.src = location.origin + '/proxy/sticker/' + encodeURIComponent(stickerType);
+ stickerImage.addEventListener('error', () => {
+ stickerIndex += 1;
+ if (stickerIndex < stickerCandidates.length) {
+ stickerImage.src = stickerCandidates[stickerIndex];
+ } else {
+ stickerImage.remove();
+ }
+ });
+ bubble.appendChild(stickerImage);
+ }
+
+ const title = document.createElement('div');
+ title.className = 'sticker-title';
+ title.textContent = '\u2728';
+
+ const name = document.createElement('div');
+ name.className = 'sticker-name';
+ name.textContent = stickerType.replace(/^Sticker_/, '');
+
+ const raw = document.createElement('div');
+ raw.className = 'sticker-raw';
+ raw.textContent = 'Sticker';
+
+ bubble.appendChild(title);
+ bubble.appendChild(name);
+ bubble.appendChild(raw);
+ }
+
+ function renderTextBubble(bubble, entry) {
+ bubble.appendChild(createRichMessageContent(entry.message || ''));
+ }
+
+ function renderMessageBubble(entry) {
+ const bubble = document.createElement('div');
+ bubble.className = 'bubble';
+
+ if (entry.type === 'image' || entry.imageUrl) {
+ renderImageBubble(bubble, entry);
+ return bubble;
+ }
+
+ const stickerType = extractStickerType(entry.message);
+ if (stickerType) {
+ renderStickerBubble(bubble, stickerType);
+ return bubble;
+ }
+
+ // 仅包含单张图片的消息(BBCode / HTML / 纯图片链接)走大图气泡
+ const standaloneImageUrl = extractStandaloneImageUrl(entry.message);
+ if (standaloneImageUrl) {
+ renderImageBubble(bubble, { ...entry, imageUrl: standaloneImageUrl });
+ return bubble;
+ }
+
+ renderTextBubble(bubble, entry);
+ return bubble;
+ }
+
+ return {
+ renderImageBubble,
+ renderMessageBubble,
+ renderStickerBubble,
+ renderTextBubble,
+ };
+}
diff --git a/public/app/messages.js b/public/app/messages.js
new file mode 100644
index 0000000..eef2996
--- /dev/null
+++ b/public/app/messages.js
@@ -0,0 +1,94 @@
+export function createMessagesController({
+ messagesEl,
+ cleanupManagedImages,
+ parseDateString,
+ sameDay,
+ formatDayLabel,
+ formatTimeLabel,
+ getActiveConversationId,
+ renderMessageBubble,
+}) {
+ let lastRenderedEntry = null;
+
+ function clearMessages() {
+ cleanupManagedImages(messagesEl);
+ messagesEl.replaceChildren();
+ lastRenderedEntry = null;
+ }
+
+ function insertDivider(text, className, container = messagesEl) {
+ const divider = document.createElement('div');
+ divider.className = className;
+ divider.textContent = text;
+ container.appendChild(divider);
+ }
+
+ function appendEntry(entry, previousEntry = lastRenderedEntry, options = {}) {
+ if (!entry || !entry.id) {
+ return;
+ }
+
+ const {
+ container = messagesEl,
+ autoScroll = true,
+ } = options;
+
+ const activeId = getActiveConversationId();
+ if (activeId && entry.id !== activeId) {
+ return;
+ }
+
+ const currentDate = parseDateString(entry.date || entry.sentAt);
+ const previousDate = previousEntry ? parseDateString(previousEntry.date || previousEntry.sentAt) : null;
+
+ if (currentDate && (!previousDate || !sameDay(currentDate, previousDate))) {
+ insertDivider(formatDayLabel(entry.date || entry.sentAt), 'day-divider', container);
+ } else if (currentDate && previousDate && (currentDate.getTime() - previousDate.getTime()) >= 10 * 60 * 1000) {
+ insertDivider(formatTimeLabel(entry.date || entry.sentAt), 'time-divider', container);
+ }
+
+ const row = document.createElement('div');
+ row.className = 'message-row ' + (entry.echo ? 'self' : 'other');
+
+ const meta = document.createElement('div');
+ meta.className = 'message-meta';
+ meta.textContent = (entry.name || (entry.echo ? '我' : '对方')) + ' · ' + formatTimeLabel(entry.date || entry.sentAt);
+
+ const bubble = renderMessageBubble(entry);
+
+ row.appendChild(meta);
+ row.appendChild(bubble);
+ container.appendChild(row);
+ if (autoScroll && container === messagesEl) {
+ lastRenderedEntry = entry;
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+ }
+
+ function renderHistory(items) {
+ clearMessages();
+ if (!items.length) {
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.textContent = '暂无历史消息';
+ messagesEl.appendChild(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ let previousEntry = null;
+ items.forEach((entry) => {
+ appendEntry(entry, previousEntry, { container: fragment, autoScroll: false });
+ previousEntry = entry;
+ });
+ messagesEl.appendChild(fragment);
+ lastRenderedEntry = previousEntry;
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+
+ return {
+ appendEntry,
+ clearMessages,
+ renderHistory,
+ };
+}
diff --git a/public/app/notifications.js b/public/app/notifications.js
new file mode 100644
index 0000000..7920b9e
--- /dev/null
+++ b/public/app/notifications.js
@@ -0,0 +1,96 @@
+export function createNotificationController({
+ defaultDocumentTitle,
+ getActiveConversationId,
+ onNotificationOpen,
+}) {
+ let unreadCount = 0;
+ let notificationPermissionRequested = false;
+
+ function updateDocumentTitle() {
+ document.title = unreadCount > 0
+ ? '(' + unreadCount + ') ' + defaultDocumentTitle
+ : defaultDocumentTitle;
+ }
+
+ function clearUnreadCount() {
+ if (!unreadCount) {
+ return;
+ }
+
+ unreadCount = 0;
+ updateDocumentTitle();
+ }
+
+ function shouldRequestNotificationPermission() {
+ return typeof Notification !== 'undefined'
+ && Notification.permission === 'default'
+ && !notificationPermissionRequested;
+ }
+
+ async function ensureNotificationPermission() {
+ if (!shouldRequestNotificationPermission()) {
+ return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission;
+ }
+
+ notificationPermissionRequested = true;
+
+ try {
+ return await Notification.requestPermission();
+ } catch {
+ return Notification.permission;
+ }
+ }
+
+ function warmupNotifications() {
+ ensureNotificationPermission().catch(() => {});
+ }
+
+ function shouldNotifyForEntry(entry) {
+ const activeId = getActiveConversationId();
+ return document.hidden || !document.hasFocus() || !activeId || entry.id !== activeId;
+ }
+
+ function buildNotificationBody(entry) {
+ if (!entry) {
+ return '你有一条新消息';
+ }
+
+ if (entry.type === 'image' || entry.imageUrl) {
+ return '[图片]';
+ }
+
+ const text = String(entry.message || '').replace(/\s+/g, ' ').trim();
+ return text || '你有一条新消息';
+ }
+
+ function notifyIncomingEntry(entry) {
+ if (!entry || entry.echo || !shouldNotifyForEntry(entry)) {
+ return;
+ }
+
+ unreadCount += 1;
+ updateDocumentTitle();
+
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted') {
+ return;
+ }
+
+ const notification = new Notification(entry.name || entry.id || 'Steam Chat', {
+ body: buildNotificationBody(entry),
+ tag: 'steam-chat-' + (entry.id || 'unknown'),
+ });
+
+ notification.addEventListener('click', () => {
+ window.focus();
+ onNotificationOpen(entry);
+ clearUnreadCount();
+ notification.close();
+ });
+ }
+
+ return {
+ clearUnreadCount,
+ notifyIncomingEntry,
+ warmupNotifications,
+ };
+}
diff --git a/public/app/preferences.js b/public/app/preferences.js
new file mode 100644
index 0000000..cbac0bf
--- /dev/null
+++ b/public/app/preferences.js
@@ -0,0 +1,34 @@
+export function createPreferencesController({
+ targetIdInput,
+ historyLimitInput,
+ storage = window.localStorage,
+}) {
+ function loadPreferences() {
+ targetIdInput.value = storage.getItem('steam-chat-target-id') || '';
+ historyLimitInput.value = storage.getItem('steam-chat-history-limit') || '100';
+ }
+
+ function currentTargetId() {
+ return targetIdInput.value.trim();
+ }
+
+ function currentHistoryLimit() {
+ const value = Number.parseInt(historyLimitInput.value, 10);
+ if (!Number.isFinite(value) || value <= 0) {
+ return 100;
+ }
+ return Math.min(value, 500);
+ }
+
+ function savePreferences() {
+ storage.setItem('steam-chat-target-id', currentTargetId());
+ storage.setItem('steam-chat-history-limit', String(currentHistoryLimit()));
+ }
+
+ return {
+ currentHistoryLimit,
+ currentTargetId,
+ loadPreferences,
+ savePreferences,
+ };
+}
diff --git a/public/app/rich-content.js b/public/app/rich-content.js
new file mode 100644
index 0000000..e3f6095
--- /dev/null
+++ b/public/app/rich-content.js
@@ -0,0 +1,153 @@
+export function createRichContentRenderer({
+ buildSteamEmoticonUrl,
+ buildCachedImageUrl,
+ extractImageUrls,
+ parseBbCodeAttributes,
+ createManagedImageHost,
+ makeImageZoomable,
+ loadManagedImage,
+}) {
+ function appendEmoticonImage(fragment, name) {
+ const rawUrl = buildSteamEmoticonUrl(name, true);
+ if (!rawUrl) {
+ fragment.appendChild(document.createTextNode(':' + name + ':'));
+ return;
+ }
+
+ const link = document.createElement('a');
+ link.href = rawUrl;
+ link.target = '_blank';
+ link.rel = 'noreferrer';
+
+ const img = document.createElement('img');
+ img.src = buildCachedImageUrl(rawUrl);
+ img.alt = ':' + name + ':';
+ img.title = ':' + name + ':';
+ img.className = 'inline-emoticon';
+ link.appendChild(img);
+ fragment.appendChild(link);
+ }
+
+ function appendInlineImage(fragment, url, altText) {
+ const link = document.createElement('a');
+ link.href = url;
+ link.target = '_blank';
+ link.rel = 'noreferrer';
+ link.className = 'inline-image-link';
+
+ const { host, img } = createManagedImageHost('image-loading-host--inline', 'image-preview');
+ img.alt = altText || '[图片]';
+ img.title = altText || url;
+ makeImageZoomable(host, url, altText || url);
+ loadManagedImage(host, img, buildCachedImageUrl(url));
+
+ link.appendChild(host);
+ fragment.appendChild(link);
+ }
+
+ function appendOpenGraphCard(fragment, embed) {
+ const wrapper = document.createElement('a');
+ wrapper.href = embed.url;
+ wrapper.target = '_blank';
+ wrapper.rel = 'noreferrer';
+ wrapper.className = 'og-card';
+
+ if (embed.img) {
+ const { host, img } = createManagedImageHost('image-loading-host--card', 'image-preview');
+ img.alt = embed.title || embed.url;
+ makeImageZoomable(host, embed.img, embed.title || embed.url);
+ loadManagedImage(host, img, buildCachedImageUrl(embed.img));
+ wrapper.appendChild(host);
+ }
+
+ const title = document.createElement('div');
+ title.className = 'og-card-title';
+ title.textContent = embed.title || embed.url;
+ wrapper.appendChild(title);
+
+ const url = document.createElement('div');
+ url.className = 'og-card-url';
+ url.textContent = embed.url;
+ wrapper.appendChild(url);
+
+ fragment.appendChild(wrapper);
+ }
+
+ function createRichMessageContent(text) {
+ const fragment = document.createDocumentFragment();
+ const content = String(text || '').replace(/\[img\s+src=(https?:\/\/[^\s\]]+)[^\]]*\][\s\S]*?\[\/img\]/gi, '[img]$1[/img]');
+ const tokenRegex = /(\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?)|(\[emoticon\]([^\[]+)\[\/emoticon\])|(:([a-z0-9_][a-z0-9_\-]*):)|(\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\])|(
]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>)|(\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\])|(\[url=([^\]]+)\]([\s\S]*?)\[\/url\])|(\[url\]([\s\S]*?)\[\/url\])|(https?:\/\/\S+)/gi;
+ let cursor = 0;
+ let match;
+
+ while ((match = tokenRegex.exec(content)) !== null) {
+ if (match.index > cursor) {
+ fragment.appendChild(document.createTextNode(content.slice(cursor, match.index)));
+ }
+
+ if (match[2]) {
+ appendEmoticonImage(fragment, match[2]);
+ } else if (match[4]) {
+ appendEmoticonImage(fragment, match[4].trim());
+ } else if (match[6]) {
+ appendEmoticonImage(fragment, match[6]);
+ } else if (match[8]) {
+ appendInlineImage(fragment, match[8], '[img]');
+ } else if (match[10]) {
+ appendInlineImage(fragment, match[10], '
');
+ } else if (match[11]) {
+ const attrs = parseBbCodeAttributes(match[12] || '');
+ const fallbackUrl = String(match[13] || '').trim();
+ const embed = {
+ url: attrs.url || fallbackUrl,
+ img: attrs.img || null,
+ title: attrs.title || '',
+ };
+ if (embed.url) {
+ appendOpenGraphCard(fragment, embed);
+ }
+ } else if (match[14]) {
+ const href = match[15];
+ const label = match[16] || href;
+ const link = document.createElement('a');
+ link.href = href;
+ link.target = '_blank';
+ link.rel = 'noreferrer';
+ link.textContent = label;
+ fragment.appendChild(link);
+ } else if (match[17]) {
+ const href = match[18];
+ const link = document.createElement('a');
+ link.href = href;
+ link.target = '_blank';
+ link.rel = 'noreferrer';
+ link.textContent = href;
+ fragment.appendChild(link);
+ } else if (match[19]) {
+ const rawUrl = match[19];
+ if (extractImageUrls(rawUrl).length > 0) {
+ appendInlineImage(fragment, rawUrl, rawUrl);
+ } else {
+ const link = document.createElement('a');
+ link.href = rawUrl;
+ link.target = '_blank';
+ link.rel = 'noreferrer';
+ link.textContent = rawUrl;
+ fragment.appendChild(link);
+ }
+ }
+
+ cursor = match.index + match[0].length;
+ }
+
+ if (cursor < content.length) {
+ fragment.appendChild(document.createTextNode(content.slice(cursor)));
+ }
+
+ return fragment;
+ }
+
+ return {
+ createRichMessageContent,
+ };
+}
diff --git a/public/app/session.js b/public/app/session.js
new file mode 100644
index 0000000..a4affd1
--- /dev/null
+++ b/public/app/session.js
@@ -0,0 +1,97 @@
+export function createSessionController({
+ targetIdInput,
+ chatTitleEl,
+ chatSubtitleEl,
+ renderConversations,
+ renderFriends,
+ renderGroups,
+ savePreferences,
+ clearUnreadCount,
+ closeSidebar,
+ buildConversationPreview,
+}) {
+ let activeConversationId = '';
+ let conversations = [];
+ let friendsList = [];
+ let groupsList = [];
+
+ function getActiveConversationId() {
+ return activeConversationId;
+ }
+
+ function getConversations() {
+ return conversations;
+ }
+
+ function setActiveConversation(id, name) {
+ activeConversationId = id || '';
+ targetIdInput.value = activeConversationId;
+ savePreferences();
+ const conversation = conversations.find((item) => item.id === activeConversationId);
+ chatTitleEl.textContent = name || (conversation && conversation.name) || activeConversationId || '未选择会话';
+ chatSubtitleEl.textContent = activeConversationId
+ ? ('SteamID64: ' + activeConversationId)
+ : '请选择左侧会话,或手动输入 SteamID64';
+ renderConversations(conversations);
+
+ if (!document.hidden && document.hasFocus()) {
+ clearUnreadCount();
+ }
+
+ if (activeConversationId) {
+ closeSidebar();
+ }
+ }
+
+ function setConversations(items) {
+ conversations = Array.isArray(items) ? items : [];
+ renderConversations(conversations);
+ }
+
+ function setFriends(items) {
+ friendsList = Array.isArray(items) ? items : [];
+ renderFriends(friendsList);
+ }
+
+ function setGroups(items) {
+ groupsList = Array.isArray(items) ? items : [];
+ renderGroups(groupsList);
+ }
+
+ function updateConversationList(entry) {
+ if (!entry || !entry.id) {
+ return;
+ }
+
+ const preview = buildConversationPreview(entry);
+ const current = conversations.find((item) => item.id === entry.id);
+
+ if (current) {
+ if (!entry.echo) {
+ current.name = entry.name || current.name;
+ }
+ current.updatedAt = entry.date || entry.sentAt || current.updatedAt;
+ current.preview = preview || current.preview;
+ } else {
+ conversations.push({
+ id: entry.id,
+ name: entry.echo ? entry.id : (entry.name || entry.id),
+ updatedAt: entry.date || entry.sentAt || '',
+ preview,
+ });
+ }
+
+ conversations.sort((left, right) => String(right.updatedAt || '').localeCompare(String(left.updatedAt || '')));
+ renderConversations(conversations);
+ }
+
+ return {
+ getActiveConversationId,
+ getConversations,
+ setActiveConversation,
+ setConversations,
+ setFriends,
+ setGroups,
+ updateConversationList,
+ };
+}
diff --git a/public/app/sidebar.js b/public/app/sidebar.js
new file mode 100644
index 0000000..111d2de
--- /dev/null
+++ b/public/app/sidebar.js
@@ -0,0 +1,217 @@
+const PERSONA_STATE_LABELS = {
+ 0: '离线',
+ 1: '在线',
+ 2: '忙碌',
+ 3: '离开',
+ 4: '打盹',
+ 5: '想交易',
+ 6: '想玩游戏',
+};
+
+export function createSidebarController({
+ conversationListEl,
+ friendsListEl,
+ groupsListEl,
+ sidebarTabs,
+ sidebarTabPanels,
+ formatConversationTime,
+ getActiveConversationId,
+ onConversationSelect,
+ onFriendSelect,
+ onGroupSelect,
+}) {
+ function switchSidebarTab(tabName) {
+ sidebarTabs.forEach((tab) => {
+ const isActive = tab.dataset.tab === tabName;
+ tab.classList.toggle('active', isActive);
+ tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
+ tab.tabIndex = isActive ? 0 : -1;
+ });
+ Object.keys(sidebarTabPanels).forEach((key) => {
+ sidebarTabPanels[key].hidden = key !== tabName;
+ });
+ }
+
+ function bindTabKeyboardNavigation() {
+ sidebarTabs.forEach((tab) => {
+ tab.addEventListener('keydown', (event) => {
+ const tabs = Array.from(sidebarTabs);
+ const currentIndex = tabs.indexOf(tab);
+ if (currentIndex === -1) {
+ return;
+ }
+
+ let nextIndex = currentIndex;
+ if (event.key === 'ArrowRight') {
+ nextIndex = (currentIndex + 1) % tabs.length;
+ } else if (event.key === 'ArrowLeft') {
+ nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
+ } else if (event.key === 'Home') {
+ nextIndex = 0;
+ } else if (event.key === 'End') {
+ nextIndex = tabs.length - 1;
+ } else {
+ return;
+ }
+
+ event.preventDefault();
+ const nextTab = tabs[nextIndex];
+ switchSidebarTab(nextTab.dataset.tab);
+ nextTab.focus();
+ });
+ });
+ }
+
+ function renderConversations(conversations) {
+ conversationListEl.replaceChildren();
+
+ if (!conversations.length) {
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.textContent = '暂无历史会话';
+ conversationListEl.appendChild(empty);
+ return;
+ }
+
+ const activeConversationId = getActiveConversationId();
+ const fragment = document.createDocumentFragment();
+ conversations.forEach((conversation) => {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'conversation-item' + (conversation.id === activeConversationId ? ' active' : '');
+
+ const top = document.createElement('div');
+ top.className = 'conversation-top';
+
+ const name = document.createElement('div');
+ name.className = 'conversation-name';
+ name.textContent = conversation.name || conversation.id;
+
+ const time = document.createElement('div');
+ time.className = 'conversation-time';
+ time.textContent = formatConversationTime(conversation.updatedAt);
+
+ top.appendChild(name);
+ top.appendChild(time);
+
+ const preview = document.createElement('div');
+ preview.className = 'conversation-preview';
+ preview.textContent = conversation.preview || '[空会话]';
+
+ const idLine = document.createElement('div');
+ idLine.className = 'conversation-id';
+ idLine.textContent = conversation.id;
+
+ item.appendChild(top);
+ item.appendChild(preview);
+ item.appendChild(idLine);
+ item.addEventListener('click', () => onConversationSelect(conversation));
+ fragment.appendChild(item);
+ });
+ conversationListEl.appendChild(fragment);
+ }
+
+ function renderFriends(friendsList) {
+ friendsListEl.replaceChildren();
+
+ if (!friendsList.length) {
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.textContent = '暂无好友数据';
+ friendsListEl.appendChild(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ friendsList.forEach((friend) => {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'friend-item';
+
+ if (friend.avatar) {
+ const avatar = document.createElement('img');
+ avatar.className = 'friend-avatar';
+ avatar.src = friend.avatar;
+ avatar.alt = friend.name;
+ avatar.loading = 'lazy';
+ item.appendChild(avatar);
+ } else {
+ const placeholder = document.createElement('div');
+ placeholder.className = 'friend-avatar';
+ item.appendChild(placeholder);
+ }
+
+ const info = document.createElement('div');
+ info.className = 'friend-info';
+
+ const name = document.createElement('div');
+ name.className = 'friend-name';
+ name.textContent = friend.name || friend.id;
+ info.appendChild(name);
+
+ const status = document.createElement('div');
+ const isOnline = friend.status > 0;
+ const inGame = friend.game;
+ status.className = 'friend-status' + (inGame ? ' in-game' : isOnline ? ' online' : '');
+ status.textContent = inGame
+ ? ('正在游戏: ' + friend.game)
+ : (PERSONA_STATE_LABELS[friend.status] || '离线');
+ info.appendChild(status);
+
+ const idLine = document.createElement('div');
+ idLine.className = 'friend-id';
+ idLine.textContent = friend.id;
+ info.appendChild(idLine);
+
+ item.appendChild(info);
+ item.addEventListener('click', () => onFriendSelect(friend));
+ fragment.appendChild(item);
+ });
+ friendsListEl.appendChild(fragment);
+ }
+
+ function renderGroups(groupsList) {
+ groupsListEl.replaceChildren();
+
+ if (!groupsList.length) {
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.textContent = '暂无群组数据';
+ groupsListEl.appendChild(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ groupsList.forEach((group) => {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'group-item';
+
+ const info = document.createElement('div');
+ info.className = 'group-info';
+
+ const name = document.createElement('div');
+ name.className = 'group-name';
+ name.textContent = group.name || group.id;
+ info.appendChild(name);
+
+ const idLine = document.createElement('div');
+ idLine.className = 'group-id';
+ idLine.textContent = group.id;
+ info.appendChild(idLine);
+
+ item.appendChild(info);
+ item.addEventListener('click', () => onGroupSelect(group));
+ fragment.appendChild(item);
+ });
+ groupsListEl.appendChild(fragment);
+ }
+
+ return {
+ bindTabKeyboardNavigation,
+ renderConversations,
+ renderFriends,
+ renderGroups,
+ switchSidebarTab,
+ };
+}
diff --git a/public/app/status.js b/public/app/status.js
new file mode 100644
index 0000000..c2859d5
--- /dev/null
+++ b/public/app/status.js
@@ -0,0 +1,24 @@
+const CONNECTION_LABELS = {
+ connecting: '连接中',
+ connected: '已连接',
+ disconnected: '已断开',
+ error: '异常',
+};
+
+export function createStatusController({ feedbackEl, connectionChipEl, connectionLabelEl }) {
+ function setFeedback(text) {
+ feedbackEl.textContent = text || '准备就绪';
+ }
+
+ function setConnection(state, text) {
+ const normalizedState = CONNECTION_LABELS[state] ? state : 'connecting';
+ connectionChipEl.classList.remove('is-connecting', 'is-connected', 'is-disconnected', 'is-error');
+ connectionChipEl.classList.add('is-' + normalizedState);
+ connectionLabelEl.textContent = text || CONNECTION_LABELS[normalizedState];
+ }
+
+ return {
+ setFeedback,
+ setConnection,
+ };
+}
diff --git a/public/app/utils.js b/public/app/utils.js
new file mode 100644
index 0000000..dc14538
--- /dev/null
+++ b/public/app/utils.js
@@ -0,0 +1,169 @@
+export function formatConversationTime(value) {
+ if (!value) {
+ return '';
+ }
+ return String(value).slice(5, 16);
+}
+
+export function parseDateString(value) {
+ if (!value) {
+ return null;
+ }
+ const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/);
+ if (!match) {
+ return null;
+ }
+
+ return new Date(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ Number(match[7] || 0),
+ );
+}
+
+export function sameDay(left, right) {
+ return left && right
+ && left.getFullYear() === right.getFullYear()
+ && left.getMonth() === right.getMonth()
+ && left.getDate() === right.getDate();
+}
+
+export function formatDayLabel(value) {
+ const date = parseDateString(value);
+ if (!date) {
+ return value || '';
+ }
+
+ return date.getFullYear() + '-'
+ + String(date.getMonth() + 1).padStart(2, '0') + '-'
+ + String(date.getDate()).padStart(2, '0');
+}
+
+export function formatTimeLabel(value) {
+ const date = parseDateString(value);
+ if (!date) {
+ return value || '';
+ }
+
+ return String(date.getHours()).padStart(2, '0') + ':'
+ + String(date.getMinutes()).padStart(2, '0');
+}
+
+export function extractStickerType(message) {
+ const match = String(message || '').match(/\[sticker\s+type="([^"]+)"/i);
+ return match ? match[1] : null;
+}
+
+export function extractEmoticonNames(message) {
+ const content = String(message || '');
+ const names = new Set();
+
+ for (const match of content.matchAll(/\[emoticon\s+name="([^"]+)"\](?:\[\/emoticon\])?/gi)) {
+ if (match[1]) {
+ names.add(match[1]);
+ }
+ }
+
+ for (const match of content.matchAll(/\[emoticon\]([^\[]+)\[\/emoticon\]/gi)) {
+ if (match[1]) {
+ names.add(match[1].trim());
+ }
+ }
+
+ for (const match of content.matchAll(/(^|\s):([a-z0-9_][a-z0-9_\-]*):(?=\s|$|[!?,.])/gi)) {
+ if (match[2]) {
+ names.add(match[2]);
+ }
+ }
+
+ return [...names];
+}
+
+export function extractImageUrls(message) {
+ const content = String(message || '');
+ const urls = new Set();
+
+ for (const match of content.matchAll(/\[img\](https?:\/\/[^\s\[\]]+?)\[\/img\]/gi)) {
+ if (match[1]) {
+ urls.add(match[1]);
+ }
+ }
+
+ for (const match of content.matchAll(/
]*?\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi)) {
+ if (match[1]) {
+ urls.add(match[1]);
+ }
+ }
+
+ for (const match of content.matchAll(/https?:\/\/\S+?(?:png|jpe?g|gif|webp|bmp)(?:\?\S*)?/gi)) {
+ if (match[0]) {
+ urls.add(match[0]);
+ }
+ }
+
+ return [...urls];
+}
+
+export function parseBbCodeAttributes(rawAttributes) {
+ const attrs = {};
+ const content = String(rawAttributes || '');
+ const attributeRegex = /([a-z][a-z0-9_-]*)=(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^\s"'=<>`]+))/gi;
+ let match;
+
+ while ((match = attributeRegex.exec(content)) !== null) {
+ const key = match[1].toLowerCase();
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
+ attrs[key] = value.replace(/\\(["'])/g, '$1');
+ }
+
+ return attrs;
+}
+
+export function extractOpenGraphEmbeds(message) {
+ const content = String(message || '');
+ const embeds = [];
+
+ for (const match of content.matchAll(/\[og\s+([^\]]+)\]([\s\S]*?)\[\/og\]/gi)) {
+ const attrs = parseBbCodeAttributes(match[1] || '');
+ const fallbackUrl = String(match[2] || '').trim();
+
+ embeds.push({
+ url: attrs.url || fallbackUrl,
+ img: attrs.img || null,
+ title: attrs.title || '',
+ });
+ }
+
+ return embeds.filter((item) => item.url);
+}
+
+export function buildSteamEmoticonUrl(name, large = true) {
+ const normalized = String(name || '').trim().replace(/^:+|:+$/g, '');
+ if (!normalized) {
+ return '';
+ }
+
+ return 'https://steamcommunity-a.akamaihd.net/economy/' + (large ? 'emoticonlarge' : 'emoticon') + '/' + encodeURIComponent(normalized);
+}
+
+export function buildCachedImageUrl(url) {
+ return location.origin + '/proxy/image?url=' + encodeURIComponent(String(url || ''));
+}
+
+export function buildSteamStickerCandidateUrls(type) {
+ const normalized = String(type || '').trim();
+ if (!normalized) {
+ return [];
+ }
+
+ return [
+ 'https://steamcommunity-a.akamaihd.net/economy/sticker/' + encodeURIComponent(normalized),
+ 'https://steamcommunity-a.akamaihd.net/economy/stickerlarge/' + encodeURIComponent(normalized),
+ 'https://steamcommunity.com/economy/sticker/' + encodeURIComponent(normalized),
+ 'https://steamcommunity.com/economy/stickerlarge/' + encodeURIComponent(normalized),
+ ];
+}
diff --git a/public/app/websocket.js b/public/app/websocket.js
new file mode 100644
index 0000000..c10d163
--- /dev/null
+++ b/public/app/websocket.js
@@ -0,0 +1,156 @@
+export function createWebSocketController({
+ setStatus,
+ setConnectionStatus,
+ savePreferences,
+ clearPendingUploadRequests,
+ onReady,
+ onEmoticons,
+ onConversations,
+ onFriends,
+ onGroups,
+ onHistory,
+ onMessage,
+ onMessageSent,
+ onImageSent,
+ onError,
+}) {
+ let socket = null;
+ let nextRequestId = 1;
+
+ function createRequestId(prefix) {
+ return String(prefix || 'req-') + (nextRequestId++);
+ }
+
+ function send(payload) {
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
+ setStatus('WebSocket 未连接');
+ return false;
+ }
+
+ savePreferences();
+ socket.send(JSON.stringify(payload));
+ return true;
+ }
+
+ function requestConversations(limit = 200) {
+ send({
+ type: 'get_conversations',
+ requestId: createRequestId('conv-'),
+ limit,
+ });
+ }
+
+ function requestFriends() {
+ send({
+ type: 'get_friends',
+ requestId: createRequestId('friends-'),
+ });
+ }
+
+ function requestGroups() {
+ send({
+ type: 'get_groups',
+ requestId: createRequestId('groups-'),
+ });
+ }
+
+ function requestHistory(id, limit) {
+ send({
+ type: 'get_history',
+ requestId: createRequestId('history-'),
+ id,
+ limit,
+ });
+ }
+
+ function requestEmoticons() {
+ send({
+ type: 'get_emoticons',
+ requestId: createRequestId('emoticons-'),
+ });
+ }
+
+ function connect(wsPath) {
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ setConnectionStatus('connecting', '连接中');
+ socket = new WebSocket(protocol + '//' + location.host + wsPath);
+
+ socket.addEventListener('open', () => {
+ setConnectionStatus('connected', '已连接');
+ setStatus('WebSocket 已连接');
+ });
+
+ socket.addEventListener('close', () => {
+ clearPendingUploadRequests('连接已断开');
+ setConnectionStatus('connecting', '3 秒后重连');
+ setStatus('WebSocket 已断开,3 秒后重连');
+ setTimeout(() => connect(wsPath), 3000);
+ });
+
+ socket.addEventListener('error', () => {
+ setConnectionStatus('error', '连接异常');
+ setStatus('WebSocket 连接异常');
+ });
+
+ socket.addEventListener('message', (event) => {
+ let payload;
+ try {
+ payload = JSON.parse(event.data);
+ } catch {
+ setStatus('收到无法解析的消息');
+ return;
+ }
+
+ switch (payload.type) {
+ case 'ready':
+ setConnectionStatus('connected', '已同步');
+ setStatus('WebSocket 已连接');
+ onReady();
+ break;
+ case 'emoticons':
+ onEmoticons(payload.data);
+ break;
+ case 'conversations':
+ onConversations(payload.data);
+ break;
+ case 'friends':
+ onFriends(payload.data);
+ break;
+ case 'groups':
+ onGroups(payload.data);
+ break;
+ case 'history':
+ onHistory(payload.data);
+ break;
+ case 'message':
+ case 'image':
+ onMessage(payload.data);
+ break;
+ case 'message_sent':
+ onMessageSent(payload);
+ break;
+ case 'image_sent':
+ onImageSent(payload);
+ break;
+ case 'error':
+ onError(payload);
+ break;
+ case 'pong':
+ break;
+ default:
+ console.log('unknown payload', payload);
+ }
+ });
+ }
+
+ return {
+ connect,
+ createRequestId,
+ requestConversations,
+ requestEmoticons,
+ requestFriends,
+ requestGroups,
+ requestHistory,
+ send,
+ };
+}
diff --git a/public/index.html b/public/index.html
index 8d621c4..9b199ed 100644
--- a/public/index.html
+++ b/public/index.html
@@ -12,7 +12,7 @@
-
+
![放大预览]()
@@ -39,21 +39,42 @@
-
-
-
@@ -139,6 +163,6 @@
-
+