重构聊天前端模块并优化图片消息渲染
This commit is contained in:
319
UI_REFACTOR_TODO.md
Normal file
319
UI_REFACTOR_TODO.md
Normal 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(颜色、间距、阴影、圆角)
|
||||
- [ ] 清理未使用类名和重复媒体查询
|
||||
129
chat.js
129
chat.js
@@ -259,6 +259,18 @@ function normalizeWsRequest(payload) {
|
||||
action: 'get_emoticons',
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
case 'friends':
|
||||
case 'get_friends':
|
||||
return {
|
||||
action: 'get_friends',
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
case 'groups':
|
||||
case 'get_groups':
|
||||
return {
|
||||
action: 'get_groups',
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
case 'ping':
|
||||
return {
|
||||
action: 'ping',
|
||||
@@ -537,7 +549,7 @@ function buildConversationSummaries(items) {
|
||||
|
||||
const current = conversations.get(entry.id) || {
|
||||
id: entry.id,
|
||||
name: entry.name || '',
|
||||
name: entry.echo ? '' : (entry.name || ''),
|
||||
updatedAt: entry.date || entry.sentAt || '',
|
||||
preview: buildConversationPreview(entry),
|
||||
lastType: entry.type || (entry.imageUrl ? 'image' : 'message'),
|
||||
@@ -545,7 +557,9 @@ function buildConversationSummaries(items) {
|
||||
messageCount: 0,
|
||||
};
|
||||
|
||||
current.name = entry.name || current.name;
|
||||
if (!entry.echo) {
|
||||
current.name = entry.name || current.name;
|
||||
}
|
||||
current.updatedAt = entry.date || entry.sentAt || current.updatedAt;
|
||||
current.preview = buildConversationPreview(entry) || current.preview;
|
||||
current.lastType = entry.type || (entry.imageUrl ? 'image' : 'message');
|
||||
@@ -784,13 +798,13 @@ function createChatService(customDeps = {}) {
|
||||
}
|
||||
|
||||
function appendOutgoingLog(uid, response) {
|
||||
client.getUserInfo(steamUser.steamID).then((sender) => {
|
||||
client.getUserInfo(uid).then((friend) => {
|
||||
appendLogEntry({
|
||||
type: 'message',
|
||||
date: dateToString(response.server_timestamp),
|
||||
echo: true,
|
||||
id: uid,
|
||||
name: sender.player_name,
|
||||
name: friend.player_name,
|
||||
message: response.modified_message,
|
||||
ordinal: response.ordinal,
|
||||
});
|
||||
@@ -798,13 +812,13 @@ function createChatService(customDeps = {}) {
|
||||
}
|
||||
|
||||
async function appendOutgoingImageLog(uid, imageUrl) {
|
||||
const sender = await client.getUserInfo(steamUser.steamID, () => {});
|
||||
const friend = await client.getUserInfo(uid, () => {});
|
||||
const entry = {
|
||||
type: 'image',
|
||||
date: dateToString(new Date()),
|
||||
echo: true,
|
||||
id: uid,
|
||||
name: sender.player_name,
|
||||
name: friend.player_name,
|
||||
imageUrl,
|
||||
ordinal: null,
|
||||
sentAt: new Date().toISOString(),
|
||||
@@ -928,6 +942,52 @@ function createChatService(customDeps = {}) {
|
||||
return { emoticons, stickers };
|
||||
}
|
||||
|
||||
async function getFriendsList() {
|
||||
await client.steamLoginPromise;
|
||||
const myFriends = steamUser.myFriends || {};
|
||||
const friendIds = Object.keys(myFriends).filter((id) => myFriends[id] === 3); // EFriendRelationship.Friend
|
||||
|
||||
const friends = [];
|
||||
for (const id of friendIds) {
|
||||
const cached = steamUser.users && steamUser.users[id];
|
||||
friends.push({
|
||||
id,
|
||||
name: (cached && cached.player_name) || id,
|
||||
avatar: (cached && cached.avatar_url_medium) || null,
|
||||
status: cached ? (cached.persona_state || 0) : 0,
|
||||
game: (cached && cached.game_name) || null,
|
||||
});
|
||||
}
|
||||
|
||||
friends.sort((a, b) => {
|
||||
// Online users first (status > 0), then alphabetical
|
||||
if (a.status > 0 && b.status === 0) return -1;
|
||||
if (a.status === 0 && b.status > 0) return 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
return friends;
|
||||
}
|
||||
|
||||
async function getGroupsList() {
|
||||
await client.steamLoginPromise;
|
||||
const myGroups = steamUser.myGroups || {};
|
||||
const groupIds = Object.keys(myGroups);
|
||||
|
||||
const groups = [];
|
||||
for (const id of groupIds) {
|
||||
const cached = steamUser.groups && steamUser.groups[id];
|
||||
const nameInfo = cached && cached.name_info;
|
||||
groups.push({
|
||||
id,
|
||||
name: (nameInfo && nameInfo.clan_name) || id,
|
||||
});
|
||||
}
|
||||
|
||||
groups.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
return groups;
|
||||
}
|
||||
|
||||
function sendFriendMessage(uid, msg) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!uid || typeof msg !== 'string') {
|
||||
@@ -1156,7 +1216,20 @@ function createChatService(customDeps = {}) {
|
||||
|
||||
async function readConversationSummaries({ limit } = {}) {
|
||||
const items = await readChatHistory({ limit: sanitizeLimit(limit, 500) });
|
||||
return buildConversationSummaries(items);
|
||||
const summaries = buildConversationSummaries(items);
|
||||
|
||||
for (const summary of summaries) {
|
||||
if (!summary.name && summary.id) {
|
||||
try {
|
||||
const friend = await client.getUserInfo(summary.id, () => {});
|
||||
summary.name = friend.player_name || summary.id;
|
||||
} catch (err) {
|
||||
summary.name = summary.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function fetchStickerBuffer(type) {
|
||||
@@ -1464,6 +1537,28 @@ function createChatService(customDeps = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/friends') {
|
||||
try {
|
||||
const items = await getFriendsList();
|
||||
sendJson(res, 200, { items });
|
||||
} catch (err) {
|
||||
logger.error('failed to get friends list', err);
|
||||
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/groups') {
|
||||
try {
|
||||
const items = await getGroupsList();
|
||||
sendJson(res, 200, { items });
|
||||
} catch (err) {
|
||||
logger.error('failed to get groups list', err);
|
||||
sendJson(res, 500, { error: err.message || 'Internal Server Error' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const ext = path.extname(requestUrl.pathname);
|
||||
if (ext && STATIC_CONTENT_TYPES[ext]) {
|
||||
@@ -1557,6 +1652,24 @@ function createChatService(customDeps = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'get_friends': {
|
||||
const items = await getFriendsList();
|
||||
sendWs(ws, {
|
||||
type: 'friends',
|
||||
requestId: request.requestId,
|
||||
data: { items },
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'get_groups': {
|
||||
const items = await getGroupsList();
|
||||
sendWs(ws, {
|
||||
type: 'groups',
|
||||
requestId: request.requestId,
|
||||
data: { items },
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'ping':
|
||||
sendWs(ws, {
|
||||
type: 'pong',
|
||||
@@ -1663,6 +1776,8 @@ function createChatService(customDeps = {}) {
|
||||
sendFriendMessage,
|
||||
sendImageToUser,
|
||||
getEmoticonList,
|
||||
getFriendsList,
|
||||
getGroupsList,
|
||||
readChatHistory,
|
||||
readConversationSummaries,
|
||||
fetchStickerBuffer,
|
||||
|
||||
2836
public/app.js
2836
public/app.js
File diff suppressed because it is too large
Load Diff
103
public/app/bootstrap.js
vendored
Normal file
103
public/app/bootstrap.js
vendored
Normal 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
997
public/app/composer.js
Normal 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
68
public/app/dom.js
Normal 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
54
public/app/layout.js
Normal 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
459
public/app/lightbox.js
Normal 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,
|
||||
};
|
||||
}
|
||||
242
public/app/managed-images.js
Normal file
242
public/app/managed-images.js
Normal 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,
|
||||
};
|
||||
}
|
||||
119
public/app/message-bubble.js
Normal file
119
public/app/message-bubble.js
Normal 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
94
public/app/messages.js
Normal 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,
|
||||
};
|
||||
}
|
||||
96
public/app/notifications.js
Normal file
96
public/app/notifications.js
Normal 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
34
public/app/preferences.js
Normal 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
153
public/app/rich-content.js
Normal 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
97
public/app/session.js
Normal 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
217
public/app/sidebar.js
Normal 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
24
public/app/status.js
Normal 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
169
public/app/utils.js
Normal 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
156
public/app/websocket.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;">
|
||||
<strong>最近会话</strong>
|
||||
<button id="reloadConversations" class="secondary" type="button">刷新列表</button>
|
||||
<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" 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 id="conversationList"></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>
|
||||
|
||||
1341
public/style.css
1341
public/style.css
File diff suppressed because it is too large
Load Diff
215
public/styles/base.css
Normal file
215
public/styles/base.css
Normal 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
366
public/styles/composer.css
Normal 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
264
public/styles/messages.css
Normal 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
125
public/styles/overlays.css
Normal 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;
|
||||
}
|
||||
446
public/styles/responsive.css
Normal file
446
public/styles/responsive.css
Normal 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
154
public/styles/sidebar.css
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user