重构聊天前端模块并优化图片消息渲染

This commit is contained in:
2026-03-22 20:09:50 +08:00
parent f370f28da6
commit 91c4e561e4
28 changed files with 5495 additions and 3836 deletions

319
UI_REFACTOR_TODO.md Normal file
View File

@@ -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颜色、间距、阴影、圆角
- [ ] 清理未使用类名和重复媒体查询

127
chat.js
View File

@@ -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,
};
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,

File diff suppressed because it is too large Load Diff

103
public/app/bootstrap.js vendored Normal file
View File

@@ -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);
});
}

997
public/app/composer.js Normal file
View File

@@ -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,
};
}

68
public/app/dom.js Normal file
View File

@@ -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') : [],
};
}

54
public/app/layout.js Normal file
View File

@@ -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,
};
}

459
public/app/lightbox.js Normal file
View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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(/<img\b[^>]*?\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,
};
}

94
public/app/messages.js Normal file
View File

@@ -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,
};
}

View File

@@ -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,
};
}

34
public/app/preferences.js Normal file
View File

@@ -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,
};
}

153
public/app/rich-content.js Normal file
View File

@@ -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\])|(<img\b[^>]*?\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], '<img>');
} 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,
};
}

97
public/app/session.js Normal file
View File

@@ -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,
};
}

217
public/app/sidebar.js Normal file
View File

@@ -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,
};
}

24
public/app/status.js Normal file
View File

@@ -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,
};
}

169
public/app/utils.js Normal file
View File

@@ -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(/<img\b[^>]*?\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),
];
}

156
public/app/websocket.js Normal file
View File

@@ -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,
};
}

View File

@@ -12,7 +12,7 @@
</div>
<div id="sidebarBackdrop" class="sidebar-backdrop" aria-hidden="true"></div>
<div id="imageLightbox" class="image-lightbox" aria-hidden="true">
<div class="image-lightbox-dialog">
<div class="image-lightbox-dialog" role="dialog" aria-modal="true" aria-label="图片预览" tabindex="-1">
<button id="closeImageLightbox" class="secondary image-lightbox-close" type="button" aria-label="关闭">×</button>
<div id="imageLightboxViewport" class="image-lightbox-viewport">
<img id="imageLightboxImage" alt="放大预览" />
@@ -39,21 +39,42 @@
</label>
<button id="openConversation" type="button">打开</button>
</div>
<div class="toolbar" style="margin-top: 12px;">
<label style="max-width: 160px;">
<div class="toolbar toolbar-spaced">
<label class="field-compact">
<span class="field-label">历史条数</span>
<input id="historyLimit" type="number" min="1" max="500" value="100" />
</label>
<button id="reloadHistory" class="secondary" type="button">刷新历史</button>
</div>
<div id="status" style="margin-top: 12px;">正在连接 WebSocket…</div>
<div id="feedbackStatus" class="feedback-status" aria-live="polite">准备就绪</div>
</div>
<div class="card conversation-card" style="flex: 1; min-height: 0; display: flex; flex-direction: column;">
<div class="chat-header" style="margin-bottom: 12px;">
<div class="card conversation-card card-fill">
<div class="sidebar-tabs" role="tablist" aria-label="侧边栏列表">
<button type="button" id="sidebarTabButtonConversations" class="sidebar-tab active" data-tab="conversations" role="tab" aria-selected="true" aria-controls="sidebarTabConversations">最近会话</button>
<button type="button" id="sidebarTabButtonFriends" class="sidebar-tab" data-tab="friends" role="tab" aria-selected="false" aria-controls="sidebarTabFriends" tabindex="-1">好友</button>
<button type="button" id="sidebarTabButtonGroups" class="sidebar-tab" data-tab="groups" role="tab" aria-selected="false" aria-controls="sidebarTabGroups" tabindex="-1">群组</button>
</div>
<div id="sidebarTabConversations" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonConversations">
<div class="chat-header sidebar-section-header">
<strong>最近会话</strong>
<button id="reloadConversations" class="secondary" type="button">刷新列表</button>
</div>
<div id="conversationList"></div>
<div id="conversationList" class="sidebar-list"></div>
</div>
<div id="sidebarTabFriends" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonFriends" hidden>
<div class="chat-header sidebar-section-header">
<strong>好友列表</strong>
<button id="reloadFriends" class="secondary" type="button">刷新列表</button>
</div>
<div id="friendsList" class="sidebar-list"></div>
</div>
<div id="sidebarTabGroups" class="sidebar-tab-panel" role="tabpanel" aria-labelledby="sidebarTabButtonGroups" hidden>
<div class="chat-header sidebar-section-header">
<strong>群组列表</strong>
<button id="reloadGroups" class="secondary" type="button">刷新列表</button>
</div>
<div id="groupsList" class="sidebar-list"></div>
</div>
</div>
</aside>
@@ -66,7 +87,10 @@
</div>
<div class="chat-header-actions">
<button id="mobileSidebarToggle" class="secondary mobile-nav-button" type="button" aria-expanded="false">会话列表</button>
<span class="chip">WebSocket</span>
<div id="connectionStatus" class="connection-chip is-connecting" role="status" aria-live="polite">
<span class="connection-chip-dot" aria-hidden="true"></span>
<span id="connectionStatusLabel">连接中</span>
</div>
</div>
</div>
</div>
@@ -139,6 +163,6 @@
</main>
</div>
<script src="/app.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

215
public/styles/base.css Normal file
View File

@@ -0,0 +1,215 @@
:root {
color-scheme: dark;
--page-padding: 16px;
--app-height: 100dvh;
--viewport-offset-top: 0px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
min-height: 100%;
overflow: hidden;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111827;
color: #e5e7eb;
}
body {
min-height: var(--app-height);
}
.app {
height: var(--app-height);
max-height: var(--app-height);
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
padding: var(--page-padding);
overflow: hidden;
}
.card {
background: #1f2937;
border: 1px solid #374151;
border-radius: 12px;
padding: 16px;
}
.sidebar, .chat-panel {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.toolbar, .send-row, .image-row, .chat-header {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.chat-header {
justify-content: space-between;
}
.chat-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.chat-heading {
min-width: 0;
}
.chat-title {
font-size: 18px;
font-weight: 700;
line-height: 1.35;
}
.chat-subtitle {
margin-top: 2px;
font-size: 13px;
color: #9ca3af;
line-height: 1.4;
word-break: break-word;
}
.chat-panel > .card {
flex-shrink: 0;
}
.chat-panel > #messages {
flex-shrink: 1;
}
.card-fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.toolbar-spaced {
margin-top: 12px;
}
.field-compact {
max-width: 160px;
}
.sidebar-section-header {
margin-bottom: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
color: #d1d5db;
flex: 1;
min-width: 180px;
}
.field-label {
display: inline-flex;
align-items: center;
min-height: 18px;
}
input, textarea, button {
border-radius: 8px;
border: 1px solid #4b5563;
background: #111827;
color: #f9fafb;
padding: 10px 12px;
font: inherit;
}
textarea {
min-height: 80px;
resize: vertical;
width: 100%;
}
button {
cursor: pointer;
background: #2563eb;
border-color: #2563eb;
}
button.secondary {
background: #374151;
border-color: #4b5563;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button,
input,
textarea {
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
.sidebar-tab:focus-visible,
.conversation-item:focus-visible,
.friend-item:focus-visible,
.group-item:focus-visible,
.picker-item:focus-visible,
.emoticon-option:focus-visible {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
.sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.7);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 35;
}
.sidebar-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.sidebar-mobile-header,
.mobile-nav-button {
display: none;
}
.feedback-status {
font-size: 14px;
color: #93c5fd;
margin-top: 12px;
min-height: 20px;
}
.connection-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid #334155;
background: #111827;
color: #cbd5e1;
font-size: 12px;
line-height: 1;
}
.connection-chip-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.connection-chip.is-connected .connection-chip-dot {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12);
}
.connection-chip.is-error .connection-chip-dot,
.connection-chip.is-disconnected .connection-chip-dot {
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
}
.connection-chip.is-connecting .connection-chip-dot {
background: #f59e0b;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.empty-state {
color: #9ca3af;
text-align: center;
margin: auto 0;
}

366
public/styles/composer.css Normal file
View File

@@ -0,0 +1,366 @@
.composer {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
}
.composer-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: end;
}
.composer-actions {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 100%;
}
#sendMessage {
min-width: 72px;
min-height: 44px;
}
.icon-button {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
padding: 0;
}
.attachment-menu {
position: absolute;
left: 0;
bottom: 52px;
width: 200px;
display: none;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #0f172a;
border: 1px solid #374151;
border-radius: 10px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
z-index: 20;
}
.attachment-menu.open {
display: flex;
}
.attachment-menu button {
width: 100%;
text-align: left;
background: #1f2937;
border-color: #374151;
}
.attachment-hint {
font-size: 12px;
color: #9ca3af;
line-height: 1.4;
padding: 4px 2px 0;
}
.attachment-preview {
display: none;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #374151;
border-radius: 12px;
background: #111827;
}
.attachment-preview.active {
display: flex;
}
.attachment-preview img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 10px;
background: #0b1220;
border: 1px solid #374151;
}
.attachment-preview-body {
min-width: 0;
flex: 1;
}
.attachment-preview-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.attachment-preview-subtitle {
font-size: 12px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-queue {
display: none;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid #374151;
border-radius: 12px;
background: #111827;
}
.upload-queue.active {
display: flex;
}
.upload-queue-title {
font-size: 13px;
color: #cbd5e1;
font-weight: 600;
}
.upload-queue-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.upload-queue-item.is-done .upload-queue-status {
color: #86efac;
}
.upload-queue-item.is-error .upload-queue-status {
color: #fca5a5;
}
.upload-queue-name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-queue-status {
font-size: 12px;
color: #93c5fd;
}
.upload-queue-progress {
grid-column: 1 / -1;
height: 6px;
border-radius: 999px;
background: #1f2937;
overflow: hidden;
}
.upload-queue-progress-bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #2563eb, #60a5fa);
transition: width 0.2s ease;
}
.upload-queue-progress-bar.is-done {
background: linear-gradient(90deg, #16a34a, #4ade80);
}
.upload-queue-progress-bar.is-error {
background: linear-gradient(90deg, #dc2626, #f87171);
}
.url-panel {
display: none;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: end;
}
.url-panel.open {
display: grid;
}
.composer-field {
position: relative;
min-width: 0;
}
.emoticon-suggestions {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 10px);
display: none;
flex-direction: column;
gap: 4px;
padding: 8px;
background: #0f172a;
border: 1px solid #374151;
border-radius: 12px;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
max-height: 220px;
overflow: auto;
z-index: 25;
}
.emoticon-suggestions.open {
display: flex;
}
.emoticon-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 10px;
border-bottom: 1px solid #334155;
margin-bottom: 4px;
}
.emoticon-preview img {
width: 40px;
height: 40px;
object-fit: contain;
flex: none;
}
.emoticon-preview-label {
font-size: 13px;
color: #cbd5e1;
}
.emoticon-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: #111827;
border: 1px solid transparent;
border-radius: 10px;
padding: 8px 10px;
color: #e5e7eb;
text-align: left;
}
.emoticon-option.active {
border-color: #2563eb;
background: #172554;
}
.emoticon-option img {
width: 24px;
height: 24px;
object-fit: contain;
flex: none;
}
.emoticon-option code {
color: #bfdbfe;
background: transparent;
padding: 0;
}
.emoticon-option-body {
min-width: 0;
}
.emoticon-option-meta {
margin-top: 2px;
font-size: 12px;
color: #94a3b8;
}
.picker-panel {
position: absolute;
left: 0;
bottom: 52px;
width: 360px;
display: none;
flex-direction: column;
background: #0f172a;
border: 1px solid #374151;
border-radius: 12px;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32);
z-index: 30;
max-height: 400px;
}
.picker-panel.open {
display: flex;
}
.picker-tabs {
display: flex;
border-bottom: 1px solid #374151;
flex-shrink: 0;
}
.picker-tab {
flex: 1;
padding: 10px 0;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
border-radius: 0;
}
.picker-tab.active {
color: #e5e7eb;
border-bottom-color: #2563eb;
background: transparent;
}
.picker-search {
padding: 8px 10px;
flex-shrink: 0;
}
.picker-search input {
width: 100%;
padding: 6px 10px;
font-size: 13px;
border-radius: 6px;
}
.picker-grid {
display: grid;
gap: 4px;
padding: 6px 10px 10px;
overflow: auto;
min-height: 0;
flex: 1;
}
.picker-grid.emoticon-grid {
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
}
.picker-grid.sticker-grid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
.picker-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: #e5e7eb;
}
.picker-item:hover {
background: #1e293b;
border-color: #334155;
}
.picker-item img {
object-fit: contain;
}
.picker-item.emoticon-item img {
width: 32px;
height: 32px;
}
.picker-item.sticker-item img {
width: 72px;
height: 72px;
}
.picker-item-name {
font-size: 10px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
text-align: center;
}
.picker-empty {
display: none;
color: #9ca3af;
text-align: center;
padding: 24px 10px;
font-size: 13px;
}
.picker-empty.active {
display: block;
}
@media (max-width: 900px) {
.picker-panel {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(88px + var(--safe-bottom));
width: auto;
max-width: none;
max-height: min(52vh, 420px);
}
}
@media (max-width: 600px) {
.picker-grid.sticker-grid {
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
}
}

264
public/styles/messages.css Normal file
View File

@@ -0,0 +1,264 @@
#messages {
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
flex: 1;
padding: 20px;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 25%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.08), transparent 30%),
#0f172a;
}
.day-divider, .time-divider {
align-self: center;
font-size: 12px;
color: #9ca3af;
background: rgba(17, 24, 39, 0.9);
border: 1px solid #374151;
padding: 4px 10px;
border-radius: 999px;
margin: 8px 0;
}
.message-row {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 78%;
}
.message-row.self {
align-self: flex-end;
align-items: flex-end;
}
.message-row.other {
align-self: flex-start;
align-items: flex-start;
}
.message-meta {
font-size: 12px;
color: #9ca3af;
padding: 0 4px;
}
.bubble {
padding: 10px 14px;
border-radius: 12px;
background: #1f2937;
border: 1px solid #374151;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-row.self .bubble {
background: #1d4ed8;
border-color: #2563eb;
color: #eff6ff;
border-bottom-right-radius: 4px;
}
.message-row.other .bubble {
border-bottom-left-radius: 4px;
}
.sticker-bubble {
min-width: 180px;
padding: 16px;
background: linear-gradient(135deg, #1e3a8a, #312e81);
border-color: #2563eb;
}
.sticker-title {
font-size: 26px;
margin-bottom: 6px;
}
.sticker-name {
font-size: 14px;
font-weight: 600;
}
.sticker-image {
max-width: min(240px, 100%);
max-height: 240px;
display: block;
margin-bottom: 10px;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.28));
}
.image-bubble {
padding: 0;
display: block;
width: min(320px, 100%);
overflow: hidden;
border: none !important;
background: transparent !important;
line-height: 0;
}
.image-bubble .image-loading-host {
display: block;
width: 100%;
border-radius: inherit;
}
.image-bubble .image-loading-host:not(.is-loaded) {
min-height: 140px;
}
.image-bubble .image-loading-target {
width: 100%;
height: auto;
max-height: 400px;
display: block;
border-radius: inherit;
object-fit: contain;
}
.bubble a {
color: inherit;
text-decoration: underline;
}
.inline-emoticon {
display: inline-block;
width: 28px;
height: 28px;
margin: 0 2px;
vertical-align: middle;
object-fit: contain;
}
.inline-image-link {
display: inline-block;
margin: 4px 4px 4px 0;
}
.og-card {
display: flex;
flex-direction: column;
gap: 8px;
margin: 6px 0;
padding: 10px;
border: 1px solid #475569;
border-radius: 12px;
background: rgba(15, 23, 42, 0.45);
color: inherit;
text-decoration: none;
}
.og-card-title {
font-weight: 600;
line-height: 1.5;
}
.og-card-url {
font-size: 12px;
opacity: 0.8;
}
.sticker-raw {
margin-top: 6px;
font-size: 12px;
opacity: 0.8;
}
.zoomable-image {
cursor: zoom-in;
}
.image-loading-host {
position: relative;
overflow: hidden;
background: #0b1220;
}
.image-loading-target {
display: block;
max-width: 100%;
max-height: 100%;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-loading-host.is-loaded .image-loading-target {
opacity: 1;
}
.image-loading-host.is-error .image-loading-target {
opacity: 0.2;
}
.image-loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.92));
color: #dbeafe;
text-align: center;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-loading-host.is-loading .image-loading-overlay,
.image-loading-host.is-error .image-loading-overlay {
opacity: 1;
}
.image-loading-label {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
}
.image-loading-progress {
width: min(220px, 80%);
height: 6px;
border-radius: 999px;
background: rgba(51, 65, 85, 0.95);
overflow: hidden;
}
.image-loading-progress-bar {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2563eb, #60a5fa);
transition: width 0.2s ease;
}
.image-loading-host.is-indeterminate .image-loading-progress-bar {
width: 35%;
animation: image-loading-indeterminate 1.1s ease-in-out infinite;
}
.image-loading-host.is-error .image-loading-progress-bar {
width: 100%;
background: linear-gradient(90deg, #dc2626, #f87171);
animation: none;
}
.image-loading-host--inline {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: min(180px, 100%);
min-height: 120px;
max-width: 280px;
max-height: 280px;
border-radius: 8px;
}
.image-loading-host--inline .image-loading-target {
max-width: 280px;
max-height: 280px;
object-fit: contain;
}
.image-loading-host--card {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 180px;
max-height: 220px;
border-radius: 10px;
}
.image-loading-host--card .image-loading-target {
width: 100%;
max-height: 220px;
object-fit: cover;
}
@keyframes image-loading-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
.content img, .image-preview {
border-radius: 8px;
border: 1px solid #4b5563;
}
.image-loading-target.image-preview {
border: 0;
}

125
public/styles/overlays.css Normal file
View File

@@ -0,0 +1,125 @@
.drop-overlay {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.76);
backdrop-filter: blur(2px);
z-index: 50;
}
.drop-overlay.active {
display: flex;
}
.drop-overlay-card {
padding: 28px 34px;
border: 2px dashed #60a5fa;
border-radius: 18px;
background: rgba(30, 41, 59, 0.92);
color: #dbeafe;
font-size: 18px;
font-weight: 600;
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
}
.image-lightbox {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(2, 6, 23, 0.88);
z-index: 60;
}
.image-lightbox.open {
display: flex;
}
.image-lightbox-dialog {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: calc(100vw - 16px);
height: calc(100vh - 16px);
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
padding: 8px;
border-radius: 14px;
border: 1px solid #334155;
background: rgba(15, 23, 42, 0.96);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.image-lightbox-viewport {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
user-select: none;
}
.image-lightbox-viewport.image-loading-host {
background: transparent;
}
.image-lightbox-close {
position: absolute;
top: 12px;
right: 12px;
width: 40px;
height: 40px;
padding: 0;
font-size: 24px;
line-height: 1;
border-radius: 999px;
}
.image-lightbox img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 12px;
background: #020617;
transform-origin: center center;
transition: transform 0.12s ease;
will-change: transform;
user-select: none;
-webkit-user-drag: none;
}
.image-lightbox img.is-dragging {
transition: none;
}
.image-lightbox-toolbar {
position: absolute;
left: 12px;
top: 12px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(2px);
}
.image-lightbox-toolbar button {
min-width: 52px;
padding: 8px 10px;
}
.image-lightbox-caption {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
padding: 8px 12px;
font-size: 12px;
color: #cbd5e1;
text-align: center;
word-break: break-all;
border-radius: 10px;
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(2px);
}
.image-lightbox-caption:empty {
display: none;
}

View File

@@ -0,0 +1,446 @@
@media (max-width: 900px) {
.app {
display: flex;
flex-direction: column;
gap: 0;
height: var(--app-height);
max-height: var(--app-height);
padding: 0;
padding-top: max(var(--safe-top), var(--viewport-offset-top));
padding-bottom: var(--safe-bottom);
background: #111827;
overflow: hidden;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(88vw, 360px);
max-width: 360px;
padding: calc(12px + var(--safe-top)) 12px calc(12px + var(--safe-bottom));
background: #111827;
border-right: 1px solid #374151;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.42);
transform: translateX(-105%);
transition: transform 0.24s ease;
z-index: 40;
overflow: auto;
height: auto;
max-height: none;
color: #e5e7eb;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar strong,
.sidebar label {
color: #d1d5db;
}
.sidebar input {
background: #111827;
border-color: #4b5563;
color: #f9fafb;
}
.sidebar button.secondary {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
}
.sidebar #openConversation {
background: #2563eb;
border-color: #2563eb;
color: #ffffff;
}
.sidebar-mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
}
.mobile-nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 10px;
border-radius: 16px;
background: #374151;
border-color: #4b5563;
color: #f9fafb;
}
.chat-panel,
.sidebar {
gap: 0;
}
.chat-panel {
flex: 1 1 auto;
height: auto;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0;
overflow: hidden;
}
.card {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.chat-topbar {
grid-row: 1;
padding: 10px 12px;
background: #1f2937;
border-bottom: 1px solid #374151;
}
.chat-header {
flex-wrap: nowrap;
align-items: center;
}
.chat-heading {
flex: 1;
min-width: 0;
}
.chat-header-actions {
flex-wrap: nowrap;
justify-content: flex-end;
margin-left: auto;
}
.connection-chip {
min-height: 30px;
padding: 4px 8px;
}
.chat-title {
font-size: 16px;
color: #f9fafb;
}
.chat-subtitle {
display: none;
}
#messages {
grid-row: 2;
min-height: 0;
padding: 12px 10px;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 25%),
radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.08), transparent 30%),
#0f172a;
}
.message-meta {
font-size: 11px;
color: #9ca3af;
}
.message-row {
max-width: 88%;
}
.bubble {
max-width: 100%;
padding: 9px 12px;
line-height: 1.5;
border: 1px solid #374151;
box-shadow: none;
}
.day-divider,
.time-divider {
color: #9ca3af;
background: rgba(17, 24, 39, 0.9);
border: 1px solid #374151;
}
.message-row.self .bubble {
background: #1d4ed8;
border-color: #2563eb;
color: #eff6ff;
border-bottom-right-radius: 6px;
}
.message-row.other .bubble {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
border-bottom-left-radius: 6px;
}
.bubble a {
color: inherit;
}
.image-bubble {
width: min(100%, 280px);
}
.sticker-image {
max-width: min(100%, 280px);
}
.conversation-card,
.sidebar-config-card {
background: transparent;
}
.conversation-item {
border-color: #374151;
background: #111827;
}
.conversation-item.active {
border-color: #2563eb;
background: #172554;
}
.conversation-name {
color: #f9fafb;
}
.conversation-preview {
color: #d1d5db;
}
.conversation-time,
.conversation-id,
.feedback-status {
color: #93c5fd;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar label,
.toolbar button,
#reloadConversations,
#clearAttachment {
width: 100%;
min-width: 0;
}
.url-panel {
grid-template-columns: 1fr;
gap: 10px;
}
.attachment-preview {
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
padding: 10px;
background: #111827;
border: 1px solid #374151;
border-radius: 12px;
}
.attachment-preview img {
width: 56px;
height: 56px;
}
.attachment-preview-body {
width: calc(100% - 84px);
}
.upload-queue {
padding: 10px;
background: #111827;
border: 1px solid #374151;
border-radius: 12px;
}
.composer {
grid-row: 3;
gap: 8px;
padding: 5px 8px calc(6px + var(--safe-bottom));
background: #1f2937;
border-top: 1px solid #374151;
box-shadow: 0 -10px 26px rgba(2, 6, 23, 0.22);
max-height: min(34vh, 260px);
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.composer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas: "field actions send";
gap: 6px 8px;
align-items: center;
}
.composer-actions {
grid-area: actions;
justify-content: flex-end;
gap: 6px;
height: auto;
}
#sendMessage {
grid-area: send;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
min-height: 36px;
padding: 0 12px;
border-radius: 999px;
}
.composer-field {
grid-area: field;
display: flex;
align-items: center;
height: 36px;
min-height: 36px;
padding: 0 10px;
border: 1px solid #334155;
border-radius: 18px;
background: #0f172a;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.composer-field:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.16);
}
.composer-field .field-label {
display: none;
}
#messageInput {
min-height: 0;
height: 34px;
max-height: 72px;
border: none;
background: transparent;
padding: 0;
line-height: 34px;
resize: none;
overflow-y: hidden;
box-shadow: none;
color: #f9fafb;
}
#messageInput::placeholder {
color: #9ca3af;
}
#messageInput:focus {
outline: none;
}
textarea,
input,
button {
font-size: 16px;
}
.icon-button {
width: 36px;
height: 36px;
font-size: 17px;
border-radius: 999px;
background: #0f172a;
border-color: #334155;
color: #dbeafe;
}
.sidebar-config-card .field-label,
.url-panel .field-label {
font-size: 12px;
}
.attachment-menu {
position: fixed;
left: 12px;
right: 12px;
bottom: calc(88px + var(--safe-bottom));
width: auto;
max-width: none;
background: #0f172a;
border-color: #374151;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
.attachment-menu button {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.attachment-hint {
color: #9ca3af;
}
.picker-panel {
background: #0f172a;
border-color: #374151;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32);
}
.picker-tab {
color: #9ca3af;
}
.picker-tab.active {
color: #e5e7eb;
border-bottom-color: #2563eb;
}
.picker-item:hover {
background: #1e293b;
border-color: #334155;
}
.picker-item {
color: #e5e7eb;
}
.picker-item-name,
.picker-empty {
color: #9ca3af;
}
.image-lightbox-dialog {
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
padding: 0;
border-radius: 0;
}
.image-lightbox-toolbar {
left: 8px;
top: 8px;
gap: 6px;
padding: 6px;
}
.image-lightbox-toolbar button {
min-width: 46px;
}
.image-lightbox-close {
top: 8px;
right: 8px;
}
.image-lightbox-caption {
left: 8px;
right: 8px;
bottom: calc(8px + var(--safe-bottom));
}
}
@media (max-width: 600px) {
:root {
--page-padding: 12px;
}
.app {
gap: 0;
}
.chat-topbar {
padding: 8px 10px;
}
.chat-header {
align-items: center;
gap: 6px;
}
.chat-header-actions {
width: auto;
justify-content: flex-end;
gap: 6px;
}
.conversation-top {
align-items: flex-start;
}
.conversation-time {
white-space: nowrap;
}
#messages {
padding: 10px 8px;
}
.attachment-preview-body {
width: 100%;
}
.composer {
padding: 4px 6px calc(4px + var(--safe-bottom));
max-height: min(36vh, 260px);
}
.composer-row {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-areas: "field actions send";
gap: 6px;
}
.composer-actions {
gap: 6px;
}
.composer-field {
height: 36px;
min-height: 36px;
padding: 0 8px;
}
#messageInput {
max-height: 68px;
}
.sidebar {
width: 92vw;
}
}

154
public/styles/sidebar.css Normal file
View File

@@ -0,0 +1,154 @@
.conversation-card {
min-height: 0;
}
.sidebar-list {
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.sidebar-tabs {
display: flex;
gap: 0;
margin-bottom: 12px;
border-bottom: 1px solid #374151;
}
.sidebar-tab {
flex: 1;
padding: 8px 4px;
font-size: 13px;
font-weight: 500;
color: #9ca3af;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.sidebar-tab:hover {
color: #d1d5db;
}
.sidebar-tab.active {
color: #60a5fa;
border-bottom-color: #2563eb;
}
.sidebar-tab[aria-selected="true"] {
color: #60a5fa;
border-bottom-color: #2563eb;
}
.sidebar-tab-panel {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
.sidebar-tab-panel[hidden] {
display: none;
}
.conversation-item,
.friend-item,
.group-item {
border: 1px solid #374151;
border-radius: 10px;
padding: 10px 12px;
background: #111827;
cursor: pointer;
display: flex;
width: 100%;
text-align: left;
gap: 10px;
}
.conversation-item:hover,
.friend-item:hover,
.group-item:hover {
border-color: #4b5563;
}
.conversation-item {
flex-direction: column;
align-items: stretch;
}
.friend-item,
.group-item {
align-items: center;
}
.friend-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
background: #1f2937;
}
.friend-info, .group-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.friend-name, .group-name {
font-weight: 600;
font-size: 14px;
color: #f9fafb;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-status {
font-size: 12px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-status.online {
color: #57cbde;
}
.friend-status.in-game {
color: #a3d977;
}
.friend-id, .group-id {
font-size: 11px;
color: #6b7280;
margin-top: 2px;
}
.conversation-item {
border: 1px solid #374151;
border-radius: 10px;
padding: 12px;
background: #111827;
cursor: pointer;
}
.conversation-item.active {
border-color: #2563eb;
background: #172554;
}
.conversation-top {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 14px;
margin-bottom: 6px;
}
.conversation-name {
font-weight: 600;
color: #f9fafb;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time, .conversation-id {
color: #9ca3af;
font-size: 12px;
}
.conversation-id {
margin-top: 6px;
}
.conversation-preview {
font-size: 13px;
color: #d1d5db;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -560,7 +560,7 @@ test('handleSendMessageRequest broadcasts messages and deduplicates echoed messa
});
assert.equal(data.echo, true);
assert.equal(data.name, 'Self User');
assert.equal(data.name, 'Friend User');
assert.equal(wsClient.messages.length, 1);
assert.deepEqual(wsClient.messages[0], {
type: 'message',
@@ -686,7 +686,7 @@ test('handleHttp returns JSON response for message endpoint', async () => {
date: 'formatted-date',
echo: true,
id: 'friend-id',
name: 'Self User',
name: 'Friend User',
message: 'hello via http',
ordinal: 42,
imageUrl: null,
@@ -1008,7 +1008,7 @@ test('readConversationSummaries groups recent conversations', async () => {
},
{
id: 'b',
name: 'Self User',
name: 'Friend User',
updatedAt: '2026-03-20 09:10:00.000',
preview: '[图片]',
lastType: 'message',
@@ -1041,7 +1041,7 @@ test('handleWsCommand returns conversation summaries', async () => {
items: [
{
id: 'friend-2',
name: 'Self User',
name: 'Friend User',
updatedAt: '2026-03-20 11:05:00.000',
preview: '[图片]',
lastType: 'image',