@yiarce/dsh-chat 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -3
- package/appearance.js +158 -0
- package/chat.html +46 -0
- package/client.js +130 -0
- package/context-menu.js +152 -0
- package/cordis.patch.yml +4 -0
- package/history-client.js +109 -0
- package/history-host.js +86 -0
- package/history-model.js +20 -0
- package/history-store.js +238 -0
- package/host.js +67 -0
- package/login.js +250 -0
- package/media-client.js +46 -0
- package/media.js +197 -0
- package/package.json +52 -6
- package/page.css +1463 -0
- package/page.js +1819 -0
- package/standalone.js +11 -0
- package/window-client.js +32 -0
package/page.js
ADDED
|
@@ -0,0 +1,1819 @@
|
|
|
1
|
+
import { ChatHistoryClient } from "./history-client.js";
|
|
2
|
+
import { entryId } from "./history-model.js";
|
|
3
|
+
import { createMediaClient } from "./media-client.js";
|
|
4
|
+
import { mountChatContextMenu } from "./context-menu.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {HTMLElement} root
|
|
8
|
+
* @param {{fetcher?: (url: string, init?: RequestInit) => Promise<{ok: boolean, status: number, json: () => Promise<any>}>, baseURL?: string, onMessage?: () => void, uploadImage?: (file: File, signal: AbortSignal, onProgress: (loaded: number, total: number) => void) => Promise<{url: string}>, loadStickerImage?: (url: string, signal: AbortSignal) => Promise<string>, searchStickers?: (keyword: string, signal: AbortSignal, page?: number) => Promise<{images: Array<{url: string, alt: string}>, hasMore: boolean}>}} options
|
|
9
|
+
* @returns {() => void}
|
|
10
|
+
*/
|
|
11
|
+
export function mountChat(root, { fetcher = window.fetch.bind(window), baseURL = new URL('api/yiarce-chat/page', document.baseURI).href, onMessage = () => {}, searchStickers = null, loadStickerImage = null, uploadImage = null } = {}) {
|
|
12
|
+
const transport = (url, init) => fetcher(new URL(url, baseURL).href, init);
|
|
13
|
+
const media = createMediaClient({ baseURL, fetcher });
|
|
14
|
+
searchStickers ??= media.searchStickers;
|
|
15
|
+
loadStickerImage ??= media.loadStickerImage;
|
|
16
|
+
uploadImage ??= media.uploadImage;
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// 极简 protobuf 编解码(只覆盖 proto/chat.proto 里用到的字段类型),
|
|
20
|
+
// 不依赖任何 CDN,页面离线也能用。
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const DEFS = {
|
|
23
|
+
clientFrame: { fields: [
|
|
24
|
+
{ n: 1, name: 'joinRoom', t: 'msg:joinRoom' },
|
|
25
|
+
{ n: 2, name: 'leaveRoom', t: 'msg:leaveRoom' },
|
|
26
|
+
{ n: 3, name: 'roomChat', t: 'msg:roomChat' },
|
|
27
|
+
{ n: 4, name: 'privateChat', t: 'msg:privateChat' },
|
|
28
|
+
{ n: 5, name: 'ping', t: 'msg:ping' },
|
|
29
|
+
]},
|
|
30
|
+
joinRoom: { fields: [{ n: 1, name: 'roomId', t: 'uint32' }] },
|
|
31
|
+
leaveRoom: { fields: [{ n: 1, name: 'roomId', t: 'uint32' }] },
|
|
32
|
+
roomChat: { fields: [
|
|
33
|
+
{ n: 1, name: 'roomId', t: 'uint32' },
|
|
34
|
+
{ n: 2, name: 'content', t: 'string' },
|
|
35
|
+
{ n: 3, name: 'sticker', t: 'msg:sticker' },
|
|
36
|
+
{ n: 4, name: 'clientMessageId', t: 'string' },
|
|
37
|
+
]},
|
|
38
|
+
privateChat: { fields: [
|
|
39
|
+
{ n: 1, name: 'toUserId', t: 'string' },
|
|
40
|
+
{ n: 2, name: 'content', t: 'string' },
|
|
41
|
+
{ n: 3, name: 'sticker', t: 'msg:sticker' },
|
|
42
|
+
{ n: 4, name: 'clientMessageId', t: 'string' },
|
|
43
|
+
]},
|
|
44
|
+
sticker: { fields: [
|
|
45
|
+
{ n: 1, name: 'url', t: 'string' },
|
|
46
|
+
{ n: 2, name: 'alt', t: 'string' },
|
|
47
|
+
]},
|
|
48
|
+
ping: { fields: [] },
|
|
49
|
+
|
|
50
|
+
serverFrame: { fields: [
|
|
51
|
+
{ n: 1, name: 'roomStat', t: 'msg:roomStat' },
|
|
52
|
+
{ n: 2, name: 'message', t: 'msg:chatMessage' },
|
|
53
|
+
{ n: 3, name: 'notice', t: 'msg:systemNotice' },
|
|
54
|
+
{ n: 4, name: 'leaveResult', t: 'msg:leaveResult' },
|
|
55
|
+
{ n: 5, name: 'pong', t: 'msg:pong' },
|
|
56
|
+
{ n: 6, name: 'error', t: 'msg:chatError' },
|
|
57
|
+
{ n: 7, name: 'self', t: 'msg:selfProfile' },
|
|
58
|
+
]},
|
|
59
|
+
roomStat: { fields: [
|
|
60
|
+
{ n: 1, name: 'roomId', t: 'uint32' },
|
|
61
|
+
{ n: 2, name: 'messageCount', t: 'uint64' },
|
|
62
|
+
{ n: 3, name: 'memberCount', t: 'uint32' },
|
|
63
|
+
]},
|
|
64
|
+
chatMessage: { fields: [
|
|
65
|
+
{ n: 1, name: 'seq', t: 'uint64' },
|
|
66
|
+
{ n: 2, name: 'roomId', t: 'uint32' },
|
|
67
|
+
{ n: 3, name: 'fromUserId', t: 'string' },
|
|
68
|
+
{ n: 4, name: 'fromNickname', t: 'string' },
|
|
69
|
+
{ n: 5, name: 'fromAvatar', t: 'string' },
|
|
70
|
+
{ n: 6, name: 'toUserId', t: 'string' },
|
|
71
|
+
{ n: 7, name: 'content', t: 'string' },
|
|
72
|
+
{ n: 8, name: 'sentAtMs', t: 'int64' },
|
|
73
|
+
{ n: 9, name: 'privateMsg', t: 'bool' },
|
|
74
|
+
{ n: 10, name: 'sticker', t: 'msg:sticker' },
|
|
75
|
+
{ n: 11, name: 'clientMessageId', t: 'string' },
|
|
76
|
+
]},
|
|
77
|
+
systemNotice: { fields: [
|
|
78
|
+
{ n: 1, name: 'roomId', t: 'uint32' },
|
|
79
|
+
{ n: 2, name: 'text', t: 'string' },
|
|
80
|
+
{ n: 3, name: 'sentAtMs', t: 'int64' },
|
|
81
|
+
]},
|
|
82
|
+
leaveResult: { fields: [{ n: 1, name: 'roomId', t: 'uint32' }] },
|
|
83
|
+
pong: { fields: [] },
|
|
84
|
+
chatError: { fields: [
|
|
85
|
+
{ n: 1, name: 'code', t: 'enum' },
|
|
86
|
+
{ n: 2, name: 'message', t: 'string' },
|
|
87
|
+
{ n: 3, name: 'clientMessageId', t: 'string' },
|
|
88
|
+
]},
|
|
89
|
+
selfProfile: { fields: [
|
|
90
|
+
{ n: 1, name: 'userId', t: 'string' },
|
|
91
|
+
{ n: 2, name: 'nickname', t: 'string' },
|
|
92
|
+
{ n: 3, name: 'avatar', t: 'string' },
|
|
93
|
+
]},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const ENC = new TextEncoder();
|
|
97
|
+
const DEC = new TextDecoder();
|
|
98
|
+
|
|
99
|
+
function writeVarint(out, n) {
|
|
100
|
+
n = Math.floor(n);
|
|
101
|
+
if (n < 0) throw new Error('不支持负数 varint');
|
|
102
|
+
while (n > 127) {
|
|
103
|
+
out.push((n & 127) | 128);
|
|
104
|
+
n = Math.floor(n / 128);
|
|
105
|
+
}
|
|
106
|
+
out.push(n & 127);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readVarint(b, p) {
|
|
110
|
+
let r = 0, m = 1;
|
|
111
|
+
for (;;) {
|
|
112
|
+
if (p >= b.length) throw new Error('varint 越界');
|
|
113
|
+
const x = b[p++];
|
|
114
|
+
r += (x & 127) * m;
|
|
115
|
+
if (x < 128) return [r, p];
|
|
116
|
+
m *= 128;
|
|
117
|
+
if (m > Number.MAX_SAFE_INTEGER) throw new Error('varint 超出安全整数范围');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function encodeMsg(name, obj) {
|
|
122
|
+
const def = DEFS[name];
|
|
123
|
+
if (!def) throw new Error('未知消息类型: ' + name);
|
|
124
|
+
if (def.oneof?.filter(key => obj[key] !== undefined && obj[key] !== null).length > 1) throw new Error('消息只能包含一种正文');
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const f of def.fields) {
|
|
127
|
+
const v = obj[f.name];
|
|
128
|
+
if (v === undefined || v === null) continue;
|
|
129
|
+
if (f.t.startsWith('msg:')) {
|
|
130
|
+
const sub = encodeMsg(f.t.slice(4), v);
|
|
131
|
+
writeVarint(out, (f.n << 3) | 2);
|
|
132
|
+
writeVarint(out, sub.length);
|
|
133
|
+
for (const byte of sub) out.push(byte);
|
|
134
|
+
} else if (f.t === 'string') {
|
|
135
|
+
if (v === '') continue;
|
|
136
|
+
const bytes = ENC.encode(v);
|
|
137
|
+
writeVarint(out, (f.n << 3) | 2);
|
|
138
|
+
writeVarint(out, bytes.length);
|
|
139
|
+
for (const byte of bytes) out.push(byte);
|
|
140
|
+
} else if (f.t === 'bool') {
|
|
141
|
+
if (!v) continue;
|
|
142
|
+
writeVarint(out, (f.n << 3) | 0);
|
|
143
|
+
out.push(1);
|
|
144
|
+
} else {
|
|
145
|
+
if (v === 0) continue;
|
|
146
|
+
writeVarint(out, (f.n << 3) | 0);
|
|
147
|
+
writeVarint(out, v);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return new Uint8Array(out);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeMsg(name, bytes) {
|
|
154
|
+
const def = DEFS[name];
|
|
155
|
+
if (!def) throw new Error('未知消息类型: ' + name);
|
|
156
|
+
const obj = {};
|
|
157
|
+
let p = 0;
|
|
158
|
+
while (p < bytes.length) {
|
|
159
|
+
let key;
|
|
160
|
+
[key, p] = readVarint(bytes, p);
|
|
161
|
+
const num = Math.floor(key / 8);
|
|
162
|
+
const wire = key % 8;
|
|
163
|
+
const f = def.fields.find((x) => x.n === num);
|
|
164
|
+
if (wire === 0) {
|
|
165
|
+
let v;
|
|
166
|
+
[v, p] = readVarint(bytes, p);
|
|
167
|
+
if (f) obj[f.name] = f.t === 'bool' ? v !== 0 : v;
|
|
168
|
+
} else if (wire === 2) {
|
|
169
|
+
let len;
|
|
170
|
+
[len, p] = readVarint(bytes, p);
|
|
171
|
+
const chunk = bytes.subarray(p, p + len);
|
|
172
|
+
p += len;
|
|
173
|
+
if (!f) continue;
|
|
174
|
+
if (def.oneof?.includes(f.name)) for (const key of def.oneof) delete obj[key];
|
|
175
|
+
if (f.t === 'string') obj[f.name] = DEC.decode(chunk);
|
|
176
|
+
else if (f.t.startsWith('msg:')) obj[f.name] = decodeMsg(f.t.slice(4), chunk);
|
|
177
|
+
} else if (wire === 5) {
|
|
178
|
+
p += 4;
|
|
179
|
+
} else if (wire === 1) {
|
|
180
|
+
p += 8;
|
|
181
|
+
} else {
|
|
182
|
+
throw new Error('不支持的 wire type: ' + wire);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return obj;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
const $ = id => root.querySelector('[id="' + id + '"]');
|
|
190
|
+
const contextEntries = new WeakMap();
|
|
191
|
+
const hiddenLocalMessages = new Set();
|
|
192
|
+
const deletedEntryKeys = new Set();
|
|
193
|
+
const CONTROL_PREFIX = '\u2063yiarce-chat:';
|
|
194
|
+
const contacts = new Map();
|
|
195
|
+
const friendRequests = new Map();
|
|
196
|
+
const localImageURLs = new Set();
|
|
197
|
+
const state = { ws: null, me: null, rooms: new Set(), opened: false, sessions: new Map(), active: 'room:1', pendingRoom: null };
|
|
198
|
+
const ERROR_NAMES = ['未知错误', '请求无效', '尚未加入房间', '对方不在线', '服务器繁忙'];
|
|
199
|
+
let connected = false;
|
|
200
|
+
let lastUserID = null;
|
|
201
|
+
let lastProfile = null;
|
|
202
|
+
let feedbackTimer;
|
|
203
|
+
let connectionTimer;
|
|
204
|
+
let historyEpoch = 0;
|
|
205
|
+
const sessionRevisions = new Map();
|
|
206
|
+
const recordStore = new ChatHistoryClient({ baseURL, onEvent: applyHistoryEvent, onError: historyError });
|
|
207
|
+
const make = (tag, cls, text) => { const el = document.createElement(tag); if (cls) el.className = cls; if (text !== undefined) el.textContent = text; return el; };
|
|
208
|
+
const timeStr = ms => ms ? new Date(Number(ms)).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) : '';
|
|
209
|
+
function session(kind, id, name, avatar = '') {
|
|
210
|
+
const key = kind + ':' + id;
|
|
211
|
+
if (kind === 'room' && Number(id) === 1) name = '广场';
|
|
212
|
+
if (!state.sessions.has(key)) state.sessions.set(key, { key, kind, id, name: name || (kind === 'room' ? id + ' 号房' : id), avatar, entries: [], loaded: false, loading: null, loadVersion: 0, displayFloor: 0, clearedRevision: -1, detachedEntries: new Map(), messageCount: 0, localNew: true, unread: 0, draft: '', draftSticker: null, draftVersion: 0, preview: '', updated: 0, count: null, members: null });
|
|
213
|
+
const item = state.sessions.get(key);
|
|
214
|
+
if (name) item.name = name;
|
|
215
|
+
if (avatar) item.avatar = avatar;
|
|
216
|
+
return item;
|
|
217
|
+
}
|
|
218
|
+
function current() { return state.sessions.get(state.active); }
|
|
219
|
+
function contactOf(id) { return contacts.get(String(id))?.relation || null; }
|
|
220
|
+
function isFriend(id) { return contactOf(id) === 'friend'; }
|
|
221
|
+
function isBlocked(id) { return contactOf(id) === 'blocked'; }
|
|
222
|
+
function requestOf(id, direction, status) { return [...friendRequests.values()].find(value => value.peerId === String(id) && (!direction || value.direction === direction) && (!status || value.status === status)); }
|
|
223
|
+
function controlMessage(type, fields = {}) { return CONTROL_PREFIX + JSON.stringify({ plugin: 'yiarce-chat', type, ...fields }); }
|
|
224
|
+
function parseControl(message) {
|
|
225
|
+
if (typeof message?.content !== 'string' || !message.content.startsWith(CONTROL_PREFIX)) return null;
|
|
226
|
+
try { const value = JSON.parse(message.content.slice(CONTROL_PREFIX.length)); return value?.plugin === 'yiarce-chat' && typeof value.type === 'string' ? value : null; } catch { return null; }
|
|
227
|
+
}
|
|
228
|
+
function setContactLocal(userId, relation, profile = {}) {
|
|
229
|
+
const id = String(userId); if (!id || id === String(state.me?.id || lastUserID)) return;
|
|
230
|
+
if (relation === 'none') contacts.delete(id); else contacts.set(id, { userId: id, nickname: profile.nickname || contacts.get(id)?.nickname || '', avatar: profile.avatar || contacts.get(id)?.avatar || '', relation });
|
|
231
|
+
renderSessions(); renderHeader();
|
|
232
|
+
}
|
|
233
|
+
function setRequestLocal(value) { if (value?.requestId) friendRequests.set(value.requestId, { ...value, peerId: String(value.peerId) }); }
|
|
234
|
+
async function saveContact(id, relation, profile = {}) { setContactLocal(id, relation, profile); await recordStore.contact(id, relation, profile); }
|
|
235
|
+
async function saveRequest(value) { setRequestLocal(value); await recordStore.requestFriend(value); }
|
|
236
|
+
function sendControl(peerId, type, fields = {}) {
|
|
237
|
+
if (!connected || !state.ws || isBlocked(peerId)) return false;
|
|
238
|
+
return sendClientFrame({ privateChat: { toUserId: String(peerId), clientMessageId: crypto.randomUUID(), content: controlMessage(type, { ...fields, toUserId: String(peerId) }) } });
|
|
239
|
+
}
|
|
240
|
+
function addRequestCard(request) {
|
|
241
|
+
const item = session('private', request.peerId, request.nickname, request.avatar);
|
|
242
|
+
const existing = item.entries.find(entry => entry.friendRequest?.requestId === request.requestId);
|
|
243
|
+
if (existing) existing.friendRequest = request;
|
|
244
|
+
else addEntry(item, { id: 'request:' + request.requestId, friendRequest: request, message: { fromUserId: request.peerId, fromNickname: request.nickname, fromAvatar: request.avatar, sentAtMs: request.created }, sendStatus: 'sent' });
|
|
245
|
+
if (current() === item) renderMessages(); renderSessions();
|
|
246
|
+
}
|
|
247
|
+
async function handleControlMessage(message, control) {
|
|
248
|
+
const mine = String(message.fromUserId) === String(state.me?.id || lastUserID);
|
|
249
|
+
const peerId = mine ? String(message.toUserId) : String(message.fromUserId);
|
|
250
|
+
if (!peerId || isBlocked(peerId)) return;
|
|
251
|
+
const profile = { nickname: message.fromNickname || peerId, avatar: message.fromAvatar || '' };
|
|
252
|
+
const base = { requestId: String(control.requestId || ''), peerId, nickname: profile.nickname, avatar: profile.avatar, created: Number(control.createdAt) || Date.now() };
|
|
253
|
+
if (control.type === 'friend-request' && !mine) {
|
|
254
|
+
if (!base.requestId || isFriend(peerId)) return;
|
|
255
|
+
const request = { ...base, direction: 'incoming', status: 'pending' }; await saveRequest(request); addRequestCard(request); return;
|
|
256
|
+
}
|
|
257
|
+
if (control.type === 'friend-accept' && !mine) { const request = requestOf(peerId, 'outgoing'); if (request) { request.status = 'accepted'; await saveRequest(request); await saveContact(peerId, 'friend', profile); } return; }
|
|
258
|
+
if (control.type === 'friend-reject' && !mine) { const request = requestOf(peerId, 'outgoing'); if (request) { request.status = 'rejected'; await saveRequest(request); } return; }
|
|
259
|
+
if (control.type === 'friend-release' && !mine) { const request = requestOf(peerId, 'outgoing', 'rejected'); if (request) { request.status = 'released'; await saveRequest(request); } }
|
|
260
|
+
}
|
|
261
|
+
async function handleRequestAction(request, action) {
|
|
262
|
+
if (action === 'accept') { await saveRequest({ ...request, status: 'accepted' }); await saveContact(request.peerId, 'friend', request); sendControl(request.peerId, 'friend-accept', { requestId: request.requestId }); }
|
|
263
|
+
else if (action === 'reject') { await saveRequest({ ...request, status: 'rejected' }); sendControl(request.peerId, 'friend-reject', { requestId: request.requestId }); }
|
|
264
|
+
else if (action === 'release') { await saveRequest({ ...request, status: 'released' }); sendControl(request.peerId, 'friend-release', { requestId: request.requestId }); }
|
|
265
|
+
addRequestCard({ ...request, status: action === 'accept' ? 'accepted' : action === 'reject' ? 'rejected' : 'released' });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function avatarFor(item, mine = false) {
|
|
269
|
+
const el = make('span', 'yc-avatar' + (item.kind === 'room' ? ' yc-room-avatar' : ''));
|
|
270
|
+
if (item.kind === 'room') {
|
|
271
|
+
el.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6M21 20v-2a6 6 0 0 0-4-5.65"/></svg>';
|
|
272
|
+
} else {
|
|
273
|
+
el.textContent = Array.from(item.name || '?')[0];
|
|
274
|
+
}
|
|
275
|
+
if (item.kind !== 'room' && item.avatar) {
|
|
276
|
+
try {
|
|
277
|
+
const url = new URL(item.avatar);
|
|
278
|
+
if (url.protocol === 'https:') {
|
|
279
|
+
const image = make('img'); image.src = url.href; image.alt = ''; image.referrerPolicy = 'no-referrer';
|
|
280
|
+
image.onerror = () => { image.remove(); el.textContent = Array.from(item.name || '?')[0]; };
|
|
281
|
+
el.replaceChildren(image);
|
|
282
|
+
}
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
if (mine) el.classList.add('yc-my-avatar');
|
|
286
|
+
return el;
|
|
287
|
+
}
|
|
288
|
+
function renderProfile() {
|
|
289
|
+
const profile = state.me || lastProfile;
|
|
290
|
+
$('myName').textContent = profile?.nickname || '未登录';
|
|
291
|
+
$('myName').title = profile ? '用户 ID: ' + profile.id : '';
|
|
292
|
+
$('myAvatar').replaceChildren(avatarFor({ name: profile?.nickname || '我', avatar: profile?.avatar }, true));
|
|
293
|
+
}
|
|
294
|
+
function renderSessions() {
|
|
295
|
+
const filter = $('search').value.trim().toLowerCase();
|
|
296
|
+
const items = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
297
|
+
$('sessionCount').textContent = String(items.length);
|
|
298
|
+
const fragment = document.createDocumentFragment();
|
|
299
|
+
for (const item of items) {
|
|
300
|
+
if (filter && !(item.name + ' ' + item.id).toLowerCase().includes(filter)) continue;
|
|
301
|
+
const button = make('button', 'yc-session' + (item.key === state.active ? ' is-active' : ''));
|
|
302
|
+
button.type = 'button'; button.setAttribute('aria-current', item.key === state.active ? 'true' : 'false');
|
|
303
|
+
button.dataset.sessionKey = item.key;
|
|
304
|
+
const info = make('span', 'yc-session-info');
|
|
305
|
+
const top = make('span', 'yc-session-top'); top.append(make('strong', '', item.name), make('time', '', timeStr(item.updated)));
|
|
306
|
+
const bottom = make('span', 'yc-session-bottom');
|
|
307
|
+
bottom.append(make('span', 'yc-preview', item.preview || (item.kind === 'room' ? state.rooms.has(Number(item.id)) ? '已加入房间' : '连接后加入聊天' : '发起一段新的对话')));
|
|
308
|
+
if (item.unread) bottom.append(make('b', 'yc-unread', item.unread > 99 ? '99+' : String(item.unread)));
|
|
309
|
+
info.append(top, bottom); button.append(avatarFor(item), info);
|
|
310
|
+
button.onclick = () => selectSession(item.key); fragment.append(button);
|
|
311
|
+
}
|
|
312
|
+
if (!fragment.childNodes.length) fragment.append(make('p', 'yc-list-empty', '没有找到会话'));
|
|
313
|
+
$('sessions').replaceChildren(fragment);
|
|
314
|
+
}
|
|
315
|
+
// Overlay scrollbar: its visibility never changes the message viewport width.
|
|
316
|
+
const messageViewport = $('messageViewport');
|
|
317
|
+
const messageScrollArea = make('div', 'yc-message-scroll-area');
|
|
318
|
+
const messageScroll = make('div', 'yc-message-scroll');
|
|
319
|
+
const messageScrollThumb = make('div', 'yc-message-scroll-thumb');
|
|
320
|
+
messageScroll.hidden = true; messageScroll.tabIndex = 0;
|
|
321
|
+
messageScroll.setAttribute('role', 'scrollbar');
|
|
322
|
+
messageScroll.setAttribute('aria-label', '滚动聊天记录');
|
|
323
|
+
messageScroll.setAttribute('aria-orientation', 'vertical');
|
|
324
|
+
messageScroll.setAttribute('aria-controls', 'messageViewport');
|
|
325
|
+
messageScroll.setAttribute('aria-valuemin', '0');
|
|
326
|
+
messageScroll.append(messageScrollThumb);
|
|
327
|
+
messageViewport.before(messageScrollArea); messageScrollArea.append(messageViewport, messageScroll);
|
|
328
|
+
const messageScrollLife = new AbortController();
|
|
329
|
+
let messageScrollDrag = null;
|
|
330
|
+
let messageScrollFrame = null;
|
|
331
|
+
function endMessageScrollDrag() {
|
|
332
|
+
const drag = messageScrollDrag; messageScrollDrag = null;
|
|
333
|
+
if (drag && messageScroll.hasPointerCapture(drag.id)) messageScroll.releasePointerCapture(drag.id);
|
|
334
|
+
messageScroll.classList.remove('is-dragging');
|
|
335
|
+
}
|
|
336
|
+
function syncMessageScroll() {
|
|
337
|
+
messageScrollFrame = null;
|
|
338
|
+
if (!alive) return;
|
|
339
|
+
const max = Math.max(0, messageViewport.scrollHeight - messageViewport.clientHeight);
|
|
340
|
+
messageScroll.hidden = max < 1 || messageViewport.clientHeight < 1;
|
|
341
|
+
if (messageScroll.hidden) { endMessageScrollDrag(); return; }
|
|
342
|
+
const height = messageScroll.clientHeight;
|
|
343
|
+
const thumbHeight = Math.min(height, Math.max(28, height * messageViewport.clientHeight / messageViewport.scrollHeight));
|
|
344
|
+
const progress = Math.max(0, Math.min(1, messageViewport.scrollTop / max));
|
|
345
|
+
messageScrollThumb.style.height = thumbHeight + 'px';
|
|
346
|
+
messageScrollThumb.style.transform = 'translateY(' + progress * (height - thumbHeight) + 'px)';
|
|
347
|
+
messageScroll.setAttribute('aria-valuemax', String(Math.round(max)));
|
|
348
|
+
messageScroll.setAttribute('aria-valuenow', String(Math.round(progress * max)));
|
|
349
|
+
}
|
|
350
|
+
function scheduleMessageScroll() {
|
|
351
|
+
if (messageScrollFrame === null) messageScrollFrame = requestAnimationFrame(syncMessageScroll);
|
|
352
|
+
}
|
|
353
|
+
messageScroll.addEventListener('pointerdown', event => {
|
|
354
|
+
if (event.button !== 0) return;
|
|
355
|
+
const max = messageViewport.scrollHeight - messageViewport.clientHeight;
|
|
356
|
+
const thumb = messageScrollThumb.getBoundingClientRect();
|
|
357
|
+
const travel = messageScroll.clientHeight - thumb.height;
|
|
358
|
+
if (max <= 0 || travel <= 0) return;
|
|
359
|
+
event.preventDefault(); messageScroll.focus({ preventScroll: true });
|
|
360
|
+
if (event.clientY < thumb.top || event.clientY > thumb.bottom) {
|
|
361
|
+
const y = event.clientY - messageScroll.getBoundingClientRect().top - thumb.height / 2;
|
|
362
|
+
messageViewport.scrollTop = Math.max(0, Math.min(1, y / travel)) * max;
|
|
363
|
+
scheduleMessageScroll();
|
|
364
|
+
}
|
|
365
|
+
messageScrollDrag = { id: event.pointerId, y: event.clientY, scroll: messageViewport.scrollTop };
|
|
366
|
+
messageScroll.setPointerCapture(event.pointerId); messageScroll.classList.add('is-dragging');
|
|
367
|
+
}, { signal: messageScrollLife.signal });
|
|
368
|
+
messageScroll.addEventListener('pointermove', event => {
|
|
369
|
+
if (!messageScrollDrag || messageScrollDrag.id !== event.pointerId) return;
|
|
370
|
+
const max = messageViewport.scrollHeight - messageViewport.clientHeight;
|
|
371
|
+
const travel = messageScroll.clientHeight - messageScrollThumb.offsetHeight;
|
|
372
|
+
if (travel > 0) messageViewport.scrollTop = messageScrollDrag.scroll + (event.clientY - messageScrollDrag.y) / travel * max;
|
|
373
|
+
scheduleMessageScroll();
|
|
374
|
+
}, { signal: messageScrollLife.signal });
|
|
375
|
+
for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) {
|
|
376
|
+
messageScroll.addEventListener(type, endMessageScrollDrag, { signal: messageScrollLife.signal });
|
|
377
|
+
}
|
|
378
|
+
messageScroll.addEventListener('keydown', event => {
|
|
379
|
+
if (event.ctrlKey || event.altKey || event.metaKey) return;
|
|
380
|
+
const max = messageViewport.scrollHeight - messageViewport.clientHeight;
|
|
381
|
+
const steps = { ArrowUp: -40, ArrowDown: 40, PageUp: -messageViewport.clientHeight * 0.85, PageDown: messageViewport.clientHeight * 0.85 };
|
|
382
|
+
if (event.key in steps) messageViewport.scrollTop += steps[event.key];
|
|
383
|
+
else if (event.key === 'Home') messageViewport.scrollTop = 0;
|
|
384
|
+
else if (event.key === 'End') messageViewport.scrollTop = max;
|
|
385
|
+
else return;
|
|
386
|
+
event.preventDefault(); event.stopPropagation(); scheduleMessageScroll();
|
|
387
|
+
}, { signal: messageScrollLife.signal });
|
|
388
|
+
messageScroll.addEventListener('wheel', event => {
|
|
389
|
+
if (event.ctrlKey || !event.deltaY) return;
|
|
390
|
+
event.preventDefault();
|
|
391
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? messageViewport.clientHeight : 1;
|
|
392
|
+
messageViewport.scrollTop += event.deltaY * unit; scheduleMessageScroll();
|
|
393
|
+
}, { passive: false, signal: messageScrollLife.signal });
|
|
394
|
+
messageViewport.addEventListener('scroll', scheduleMessageScroll, { passive: true, signal: messageScrollLife.signal });
|
|
395
|
+
const messageScrollResize = new ResizeObserver(scheduleMessageScroll);
|
|
396
|
+
for (const element of [messageViewport, $('messages')]) messageScrollResize.observe(element);
|
|
397
|
+
|
|
398
|
+
// Shared bounded image cache/queue for candidates and message bubbles.
|
|
399
|
+
function messageImageURL(value) {
|
|
400
|
+
if (typeof value !== 'string') return null;
|
|
401
|
+
try {
|
|
402
|
+
const url = new URL(value);
|
|
403
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.href.length > 2048) return null;
|
|
404
|
+
return url.href;
|
|
405
|
+
} catch {}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function createStickerMedia() {
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
const cache = new Map();
|
|
412
|
+
const jobs = new Map();
|
|
413
|
+
const queue = [];
|
|
414
|
+
let active = 0;
|
|
415
|
+
let bytes = 0;
|
|
416
|
+
const pump = () => {
|
|
417
|
+
while (!controller.signal.aborted && active < 3 && queue.length) {
|
|
418
|
+
const job = queue.shift();
|
|
419
|
+
++active;
|
|
420
|
+
Promise.resolve().then(() => loadStickerImage(job.url, controller.signal)).then(data => {
|
|
421
|
+
if (controller.signal.aborted) throw new Error('cancelled');
|
|
422
|
+
if (typeof data !== 'string' || !/^data:image\/(jpeg|png|gif|webp);base64,/.test(data)) throw new Error('图片数据无效');
|
|
423
|
+
cache.set(job.url, data); bytes += data.length;
|
|
424
|
+
while (cache.size > 24 || bytes > 16 * 1024 * 1024) {
|
|
425
|
+
const key = cache.keys().next().value;
|
|
426
|
+
bytes -= cache.get(key).length; cache.delete(key);
|
|
427
|
+
}
|
|
428
|
+
job.resolve(data);
|
|
429
|
+
}).catch(job.reject).finally(() => { jobs.delete(job.url); --active; pump(); });
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
return {
|
|
433
|
+
get(url, signal) {
|
|
434
|
+
if (!loadStickerImage || !messageImageURL(url)) return Promise.reject(new Error('图片地址或加载通道无效'));
|
|
435
|
+
if (signal.aborted || controller.signal.aborted) return Promise.reject(new Error('cancelled'));
|
|
436
|
+
let promise;
|
|
437
|
+
if (cache.has(url)) {
|
|
438
|
+
const data = cache.get(url); cache.delete(url); cache.set(url, data);
|
|
439
|
+
promise = Promise.resolve(data);
|
|
440
|
+
} else {
|
|
441
|
+
promise = jobs.get(url);
|
|
442
|
+
if (!promise) {
|
|
443
|
+
promise = new Promise((resolve, reject) => queue.push({ url, resolve, reject }));
|
|
444
|
+
jobs.set(url, promise); pump();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return new Promise((resolve, reject) => {
|
|
448
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(new Error('cancelled')); };
|
|
449
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
450
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
evict(url) {
|
|
454
|
+
const data = cache.get(url);
|
|
455
|
+
if (data) { bytes -= data.length; cache.delete(url); }
|
|
456
|
+
},
|
|
457
|
+
dispose() {
|
|
458
|
+
controller.abort();
|
|
459
|
+
for (const job of queue.splice(0)) job.reject(new Error('cancelled'));
|
|
460
|
+
cache.clear(); jobs.clear(); bytes = 0;
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
let stickerMedia = createStickerMedia();
|
|
465
|
+
let messageImages = new WeakMap();
|
|
466
|
+
const messageImageObserver = new IntersectionObserver(entries => {
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
if (!entry.isIntersecting) continue;
|
|
469
|
+
messageImageObserver.unobserve(entry.target);
|
|
470
|
+
messageImages.get(entry.target)?.();
|
|
471
|
+
}
|
|
472
|
+
}, { root: $('messageViewport'), rootMargin: '150px 0px' });
|
|
473
|
+
const messageMediaController = new AbortController();
|
|
474
|
+
function stickerMessageNode(sticker, localURL = null) {
|
|
475
|
+
const box = make('div', 'yc-message-sticker');
|
|
476
|
+
const url = messageImageURL(sticker.url);
|
|
477
|
+
box.setAttribute('role', 'group');
|
|
478
|
+
box.setAttribute('aria-label', '聊天图片');
|
|
479
|
+
const image = make('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer';
|
|
480
|
+
const retry = make('button', 'yc-sticker-retry', '图片加载失败 · 点击重试');
|
|
481
|
+
retry.type = 'button'; retry.hidden = true;
|
|
482
|
+
box.append(image, retry);
|
|
483
|
+
let loading = false;
|
|
484
|
+
const failed = () => {
|
|
485
|
+
loading = false; box.classList.remove('is-loaded'); box.setAttribute('aria-busy', 'false');
|
|
486
|
+
image.removeAttribute('src'); retry.hidden = false;
|
|
487
|
+
};
|
|
488
|
+
image.onload = () => {
|
|
489
|
+
const ratio = image.naturalWidth / image.naturalHeight;
|
|
490
|
+
if (Number.isFinite(ratio) && ratio > 0) {
|
|
491
|
+
box.style.aspectRatio = String(ratio);
|
|
492
|
+
box.style.width = Math.min(220, 220 * ratio) + 'px';
|
|
493
|
+
}
|
|
494
|
+
loading = false; box.classList.add('is-loaded'); box.setAttribute('aria-busy', 'false');
|
|
495
|
+
};
|
|
496
|
+
image.onerror = () => { stickerMedia.evict(url); failed(); };
|
|
497
|
+
const load = async () => {
|
|
498
|
+
if (loading || !alive || messageMediaController.signal.aborted) return;
|
|
499
|
+
loading = true; retry.hidden = true; box.setAttribute('aria-busy', 'true');
|
|
500
|
+
try {
|
|
501
|
+
if (!localURL && !url) throw new Error('图片地址无效');
|
|
502
|
+
const data = localURL && localImageURLs.has(localURL) ? localURL : loadStickerImage ? await stickerMedia.get(url, messageMediaController.signal) : url;
|
|
503
|
+
if (!alive || !box.isConnected) { loading = false; return; }
|
|
504
|
+
image.src = data;
|
|
505
|
+
} catch {
|
|
506
|
+
if (alive && box.isConnected) failed();
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
retry.onclick = load;
|
|
510
|
+
messageImages.set(box, load); messageImageObserver.observe(box);
|
|
511
|
+
return box;
|
|
512
|
+
}
|
|
513
|
+
function resetMessageImages() {
|
|
514
|
+
messageImageObserver.disconnect(); messageImages = new WeakMap();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Local files stay in drafts until the user sends the message.
|
|
518
|
+
const composerToolbar = make('div', 'yc-composer-toolbar');
|
|
519
|
+
composerToolbar.setAttribute('role', 'group');
|
|
520
|
+
composerToolbar.setAttribute('aria-label', '聊天工具');
|
|
521
|
+
const emojiButton = make('button', 'yc-composer-tool');
|
|
522
|
+
emojiButton.type = 'button';
|
|
523
|
+
emojiButton.setAttribute('aria-label', '选择 Emoji');
|
|
524
|
+
emojiButton.setAttribute('aria-expanded', 'false');
|
|
525
|
+
emojiButton.title = '表情';
|
|
526
|
+
emojiButton.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 14a4.5 4.5 0 0 0 8 0"/><circle cx="9" cy="9" r=".8" fill="currentColor" stroke="none"/><circle cx="15" cy="9" r=".8" fill="currentColor" stroke="none"/></svg>';
|
|
527
|
+
const imageUploadButton = make('button', 'yc-composer-tool yc-upload-image');
|
|
528
|
+
imageUploadButton.type = 'button'; imageUploadButton.hidden = !uploadImage;
|
|
529
|
+
imageUploadButton.setAttribute('aria-label', '上传本地图片');
|
|
530
|
+
imageUploadButton.title = '上传 JPG、PNG、GIF、WebP 图片,单张最大 7MB';
|
|
531
|
+
imageUploadButton.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="8" r="1.5"/><path d="m3 17 5-5 4 4 4-6 5 7"/></svg>';
|
|
532
|
+
const imageUploadInput = make('input');
|
|
533
|
+
imageUploadInput.type = 'file'; imageUploadInput.accept = 'image/jpeg,image/png,image/gif,image/webp'; imageUploadInput.hidden = true;
|
|
534
|
+
composerToolbar.append(emojiButton, imageUploadButton);
|
|
535
|
+
$('messageInput').before(composerToolbar);
|
|
536
|
+
$('composer').append(imageUploadInput);
|
|
537
|
+
imageUploadButton.onclick = () => { if (!imageUploadButton.disabled) imageUploadInput.click(); };
|
|
538
|
+
imageUploadInput.onchange = () => {
|
|
539
|
+
const file = imageUploadInput.files?.[0]; imageUploadInput.value = '';
|
|
540
|
+
stageDraftImage(file);
|
|
541
|
+
};
|
|
542
|
+
function releaseLocalURL(url) {
|
|
543
|
+
if (localImageURLs.delete(url)) URL.revokeObjectURL(url);
|
|
544
|
+
}
|
|
545
|
+
function releaseEntryImage(entry) {
|
|
546
|
+
if (entry.localImage) {
|
|
547
|
+
releaseLocalURL(entry.localImage.url); entry.localImage = null;
|
|
548
|
+
}
|
|
549
|
+
entry.uploadNode = null; entry.statusNode = null;
|
|
550
|
+
}
|
|
551
|
+
function releaseAllLocalImages() {
|
|
552
|
+
for (const url of localImageURLs) URL.revokeObjectURL(url);
|
|
553
|
+
localImageURLs.clear();
|
|
554
|
+
}
|
|
555
|
+
function stageDraftImage(file) {
|
|
556
|
+
const item = current();
|
|
557
|
+
if (!file || !uploadImage || !item || composerInput.disabled) return;
|
|
558
|
+
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
559
|
+
if (!allowed.includes(file.type) && (file.type || !/\.(jpe?g|png|gif|webp)$/i.test(file.name))) { log('仅支持 JPG、PNG、GIF、WebP 图片', 'err'); return; }
|
|
560
|
+
if (!file.size || file.size > 7 * 1024 * 1024) { log('请选择不超过 7MB 的图片', 'err'); return; }
|
|
561
|
+
const url = URL.createObjectURL(file); localImageURLs.add(url);
|
|
562
|
+
releaseLocalURL(item.draftSticker?.url);
|
|
563
|
+
item.draftSticker = { url, file }; ++item.draftVersion;
|
|
564
|
+
closeEmoji(); closeStickers(); syncComposer(); composerInput.focus();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The attachment and textarea share the same composer editing area.
|
|
568
|
+
const composerEditor = make('div', 'yc-composer-editor');
|
|
569
|
+
const draftStickerPreview = make('div', 'yc-draft-sticker');
|
|
570
|
+
draftStickerPreview.hidden = true;
|
|
571
|
+
draftStickerPreview.setAttribute('role', 'group');
|
|
572
|
+
draftStickerPreview.setAttribute('aria-label', '待发送图片');
|
|
573
|
+
const draftStickerImage = make('img');
|
|
574
|
+
draftStickerImage.alt = ''; draftStickerImage.decoding = 'async'; draftStickerImage.referrerPolicy = 'no-referrer';
|
|
575
|
+
const removeDraftSticker = make('button', 'yc-draft-sticker-remove', '×');
|
|
576
|
+
removeDraftSticker.type = 'button'; removeDraftSticker.title = '移除图片'; removeDraftSticker.setAttribute('aria-label', '移除待发送图片');
|
|
577
|
+
const retryDraftSticker = make('button', 'yc-draft-sticker-retry', '重试');
|
|
578
|
+
retryDraftSticker.type = 'button'; retryDraftSticker.hidden = true; retryDraftSticker.setAttribute('aria-label', '重新加载待发送图片');
|
|
579
|
+
draftStickerPreview.append(draftStickerImage, retryDraftSticker, removeDraftSticker);
|
|
580
|
+
const composerInput = $('messageInput');
|
|
581
|
+
composerInput.before(composerEditor); composerEditor.append(draftStickerPreview, composerInput);
|
|
582
|
+
// Basic Unicode emoji: no network requests or image stickers.
|
|
583
|
+
const emojiGroups = [
|
|
584
|
+
{ name: '笑脸', items: '😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥳 😏 😒 😞 😔 😟 😕 🙁 ☹️ 😣 😖 😫 😩 🥺 😢 😭 😤 😠 😡 🤬 🤯 😳 🥵 🥶 😱 😨 😰 😥 😓 🤗 🤔 🤭 🤫 🤥 😶 😐 😑 😬 🙄 😯 😦 😧 😮 😲 🥱 😴 🤤 😪 😵 🤐 🥴 🤢 🤮 🤧 😷 🤒 🤕 🤑 🤠 😈 👿 👻 💀 👽 🤖 💩' },
|
|
585
|
+
{ name: '手势与爱心', items: '👋 🤚 🖐️ ✋ 🖖 👌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲 🤝 🙏 💪 🫶 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💯 💢 💥 💫 💦 💤' },
|
|
586
|
+
{ name: '动物与自然', items: '🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🐔 🐧 🐦 🐤 🦆 🦉 🦋 🐝 🐞 🐢 🐍 🐙 🦑 🦀 🐬 🐳 🦈 🐊 🐘 🦒 🦌 🐕 🐈 🐇 🌵 🎄 🌲 🌳 🌴 🌱 🌿 🍀 🍁 🍂 🌸 🌼 🌻 🌹 🌷 🌺 🌈 ☀️ 🌤️ ☁️ 🌧️ ⛈️ ❄️ 🌙 ⭐ 🌟 🔥 💧' },
|
|
587
|
+
{ name: '食物', items: '🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥝 🍅 🥑 🥦 🥕 🌽 🥔 🍠 🥐 🍞 🥖 🥨 🧀 🥚 🍳 🥓 🥩 🍗 🍔 🍟 🍕 🌭 🥪 🌮 🌯 🍜 🍲 🍛 🍣 🍱 🥟 🍙 🍚 🍢 🍡 🍦 🍨 🍩 🍪 🎂 🍰 🧁 🍫 🍬 🍭 🍮 🍯 🥛 ☕ 🍵 🧋 🧃 🍺 🍻 🥂 🍷' },
|
|
588
|
+
{ name: '活动与物品', items: '⚽ 🏀 🏈 ⚾ 🎾 🏐 🏓 🏸 🥊 🎯 🎮 🎲 🧩 🎨 🎤 🎧 🎸 🎹 🎺 🏆 🥇 🎉 🎊 🎈 🎁 🎀 🧸 🚗 🚕 🚌 🚓 🚑 🚒 🚲 ✈️ 🚀 🚁 🚢 🚂 🏠 🏢 🏥 🏫 🏖️ 🏕️ 🗻 📱 💻 ⌨️ 🖥️ 📷 💡 🔦 📚 📖 ✏️ 📝 💌 📦 🔑 🔒 🔔 ✅ ❌ ❓ ❗ 💬' },
|
|
589
|
+
].map(group => ({ ...group, items: group.items.split(' ') }));
|
|
590
|
+
const knownEmoji = new Set(emojiGroups.flatMap(group => group.items));
|
|
591
|
+
let recentEmoji = [];
|
|
592
|
+
try {
|
|
593
|
+
const saved = JSON.parse(localStorage.getItem('yc-recent-emoji') || '[]');
|
|
594
|
+
if (Array.isArray(saved)) recentEmoji = [...new Set(saved.filter(value => knownEmoji.has(value)))].slice(0, 22);
|
|
595
|
+
} catch { /* Storage may be unavailable in embedded views. */ }
|
|
596
|
+
const emojiLife = new AbortController();
|
|
597
|
+
const emojiPanel = make('section', 'yc-emoji-panel');
|
|
598
|
+
emojiPanel.hidden = true; emojiPanel.setAttribute('aria-label', 'Emoji 表情');
|
|
599
|
+
const emojiBody = make('div', 'yc-emoji-body');
|
|
600
|
+
const emojiTabs = make('div', 'yc-emoji-tabs');
|
|
601
|
+
emojiTabs.setAttribute('role', 'group'); emojiTabs.setAttribute('aria-label', '表情分类');
|
|
602
|
+
emojiPanel.append(emojiBody, emojiTabs); composerToolbar.append(emojiPanel);
|
|
603
|
+
function closeEmoji() {
|
|
604
|
+
emojiPanel.hidden = true; emojiButton.setAttribute('aria-expanded', 'false');
|
|
605
|
+
}
|
|
606
|
+
function insertEmoji(value) {
|
|
607
|
+
if (composerInput.disabled || composing) return;
|
|
608
|
+
const start = composerInput.selectionStart, end = composerInput.selectionEnd;
|
|
609
|
+
const next = composerInput.value.slice(0, start) + value + composerInput.value.slice(end);
|
|
610
|
+
if (next.length > composerInput.maxLength || Array.from(next.trim()).length > 1000) {
|
|
611
|
+
log('消息最多 1000 字', 'err'); return;
|
|
612
|
+
}
|
|
613
|
+
composerInput.setRangeText(value, start, end, 'end');
|
|
614
|
+
composerInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
615
|
+
composerInput.focus();
|
|
616
|
+
recentEmoji = [value, ...recentEmoji.filter(item => item !== value)].slice(0, 22);
|
|
617
|
+
try { localStorage.setItem('yc-recent-emoji', JSON.stringify(recentEmoji)); } catch { /* Optional history. */ }
|
|
618
|
+
}
|
|
619
|
+
function renderEmoji() {
|
|
620
|
+
emojiBody.replaceChildren();
|
|
621
|
+
const addGroup = (name, items) => {
|
|
622
|
+
if (!items.length) return;
|
|
623
|
+
emojiBody.append(make('div', 'yc-emoji-heading', name));
|
|
624
|
+
const grid = make('div', 'yc-emoji-grid');
|
|
625
|
+
for (const value of items) {
|
|
626
|
+
const button = make('button', 'yc-emoji-item', value);
|
|
627
|
+
button.type = 'button'; button.setAttribute('aria-label', value);
|
|
628
|
+
button.onclick = () => insertEmoji(value); grid.append(button);
|
|
629
|
+
}
|
|
630
|
+
emojiBody.append(grid);
|
|
631
|
+
};
|
|
632
|
+
addGroup('最近表情', recentEmoji);
|
|
633
|
+
addGroup('全部表情', [...knownEmoji]);
|
|
634
|
+
emojiBody.scrollTop = 0;
|
|
635
|
+
}
|
|
636
|
+
const emojiTab = make('button', 'yc-emoji-tab');
|
|
637
|
+
emojiTab.type = 'button'; emojiTab.title = 'Emoji';
|
|
638
|
+
emojiTab.setAttribute('aria-label', 'Emoji');
|
|
639
|
+
emojiTab.setAttribute('aria-pressed', 'true');
|
|
640
|
+
emojiTab.innerHTML = emojiButton.innerHTML;
|
|
641
|
+
emojiTab.onclick = renderEmoji;
|
|
642
|
+
emojiTabs.append(emojiTab);
|
|
643
|
+
// Keep the textarea selection while choosing with the mouse.
|
|
644
|
+
emojiPanel.addEventListener('mousedown', event => {
|
|
645
|
+
if (event.target.closest('button')) event.preventDefault();
|
|
646
|
+
}, { signal: emojiLife.signal });
|
|
647
|
+
emojiButton.onclick = () => {
|
|
648
|
+
if (!emojiPanel.hidden) { closeEmoji(); return; }
|
|
649
|
+
closeStickers(); renderEmoji(); emojiPanel.hidden = false;
|
|
650
|
+
emojiButton.setAttribute('aria-expanded', 'true');
|
|
651
|
+
};
|
|
652
|
+
document.addEventListener('pointerdown', event => {
|
|
653
|
+
if (!emojiPanel.contains(event.target) && !emojiButton.contains(event.target)) closeEmoji();
|
|
654
|
+
}, { signal: emojiLife.signal });
|
|
655
|
+
emojiPanel.addEventListener('keydown', event => {
|
|
656
|
+
if (event.key === 'Escape') { event.stopPropagation(); closeEmoji(); emojiButton.focus(); }
|
|
657
|
+
}, { signal: emojiLife.signal });
|
|
658
|
+
|
|
659
|
+
let draftStickerKey = '';
|
|
660
|
+
let draftStickerController = null;
|
|
661
|
+
function syncDraftSticker() {
|
|
662
|
+
const item = current();
|
|
663
|
+
const url = item?.draftSticker?.url;
|
|
664
|
+
const key = url ? item.key + ':' + url : '';
|
|
665
|
+
composerEditor.classList.toggle('has-sticker', Boolean(url));
|
|
666
|
+
if (key === draftStickerKey) return;
|
|
667
|
+
draftStickerKey = key;
|
|
668
|
+
draftStickerController?.abort(); draftStickerController = null;
|
|
669
|
+
draftStickerImage.onload = draftStickerImage.onerror = null;
|
|
670
|
+
draftStickerImage.removeAttribute('src');
|
|
671
|
+
draftStickerPreview.classList.remove('is-loaded'); retryDraftSticker.hidden = true;
|
|
672
|
+
draftStickerPreview.hidden = !url;
|
|
673
|
+
draftStickerPreview.setAttribute('aria-busy', 'false');
|
|
674
|
+
if (!url) return;
|
|
675
|
+
const load = async () => {
|
|
676
|
+
draftStickerController?.abort();
|
|
677
|
+
const controller = draftStickerController = new AbortController();
|
|
678
|
+
const valid = () => alive && !controller.signal.aborted && draftStickerKey === key;
|
|
679
|
+
retryDraftSticker.hidden = true; draftStickerPreview.setAttribute('aria-busy', 'true');
|
|
680
|
+
const failed = () => {
|
|
681
|
+
if (!valid()) return;
|
|
682
|
+
stickerMedia.evict(url); draftStickerPreview.classList.remove('is-loaded');
|
|
683
|
+
draftStickerPreview.setAttribute('aria-busy', 'false'); retryDraftSticker.hidden = false;
|
|
684
|
+
};
|
|
685
|
+
draftStickerImage.onload = () => {
|
|
686
|
+
if (!valid()) return;
|
|
687
|
+
draftStickerPreview.classList.add('is-loaded'); draftStickerPreview.setAttribute('aria-busy', 'false');
|
|
688
|
+
};
|
|
689
|
+
draftStickerImage.onerror = failed;
|
|
690
|
+
try {
|
|
691
|
+
const data = localImageURLs.has(url) ? url : loadStickerImage ? await stickerMedia.get(url, controller.signal) : url;
|
|
692
|
+
if (valid()) draftStickerImage.src = data;
|
|
693
|
+
} catch { failed(); }
|
|
694
|
+
};
|
|
695
|
+
retryDraftSticker.onclick = load;
|
|
696
|
+
void load();
|
|
697
|
+
}
|
|
698
|
+
removeDraftSticker.onclick = () => {
|
|
699
|
+
const item = current();
|
|
700
|
+
if (!item?.draftSticker) return;
|
|
701
|
+
releaseLocalURL(item.draftSticker.url);
|
|
702
|
+
item.draftSticker = null; ++item.draftVersion;
|
|
703
|
+
syncComposer(); scheduleStickers();
|
|
704
|
+
if (!composerInput.disabled) composerInput.focus();
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Search candidates
|
|
708
|
+
const stickerPanel = make('section', 'yc-sticker-panel');
|
|
709
|
+
stickerPanel.hidden = true;
|
|
710
|
+
stickerPanel.setAttribute('aria-label', '表情搜索候选');
|
|
711
|
+
const stickerStatus = make('div', 'yc-sticker-status');
|
|
712
|
+
stickerStatus.setAttribute('role', 'status');
|
|
713
|
+
const stickerList = make('div', 'yc-sticker-list');
|
|
714
|
+
stickerList.setAttribute('role', 'listbox');
|
|
715
|
+
stickerList.setAttribute('aria-label', '表情候选,左右键选择');
|
|
716
|
+
stickerList.tabIndex = 0;
|
|
717
|
+
const stickerId = 'yc-stickers-' + crypto.randomUUID();
|
|
718
|
+
stickerList.id = stickerId;
|
|
719
|
+
const stickerScroll = make('div', 'yc-sticker-scroll');
|
|
720
|
+
const stickerThumb = make('div', 'yc-sticker-thumb');
|
|
721
|
+
stickerScroll.hidden = true;
|
|
722
|
+
stickerScroll.tabIndex = 0;
|
|
723
|
+
stickerScroll.setAttribute('role', 'scrollbar');
|
|
724
|
+
stickerScroll.setAttribute('aria-label', '横向浏览表情');
|
|
725
|
+
stickerScroll.setAttribute('aria-orientation', 'horizontal');
|
|
726
|
+
stickerScroll.setAttribute('aria-controls', stickerId);
|
|
727
|
+
stickerScroll.setAttribute('aria-valuemin', '0');
|
|
728
|
+
stickerScroll.append(stickerThumb);
|
|
729
|
+
let stickerSelected = -1;
|
|
730
|
+
let stickerKeyword = '';
|
|
731
|
+
stickerPanel.append(stickerStatus, stickerList, stickerScroll);
|
|
732
|
+
if (searchStickers) $('composer').prepend(stickerPanel);
|
|
733
|
+
let stickerTimer;
|
|
734
|
+
let stickerRequest;
|
|
735
|
+
let stickerObserver;
|
|
736
|
+
let stickerObserveImages;
|
|
737
|
+
let stickerNextPage = 2;
|
|
738
|
+
let stickerMoreLoading = false;
|
|
739
|
+
let stickerHasMore = false;
|
|
740
|
+
let stickerFailedIndex = -1;
|
|
741
|
+
const stickerSeen = new Set();
|
|
742
|
+
let stickerGeneration = 0;
|
|
743
|
+
let composing = false;
|
|
744
|
+
let stickerDrag = null;
|
|
745
|
+
function closeStickers() {
|
|
746
|
+
clearTimeout(stickerTimer);
|
|
747
|
+
stickerObserver?.disconnect(); stickerObserver = null;
|
|
748
|
+
stickerObserveImages = null;
|
|
749
|
+
stickerNextPage = 2;
|
|
750
|
+
stickerMoreLoading = false;
|
|
751
|
+
stickerHasMore = false;
|
|
752
|
+
stickerFailedIndex = -1;
|
|
753
|
+
stickerSeen.clear();
|
|
754
|
+
stickerRequest?.abort(); stickerRequest = null;
|
|
755
|
+
++stickerGeneration;
|
|
756
|
+
endStickerDrag();
|
|
757
|
+
stickerScroll.hidden = true;
|
|
758
|
+
stickerPanel.hidden = true;
|
|
759
|
+
stickerList.replaceChildren();
|
|
760
|
+
stickerSelected = -1;
|
|
761
|
+
stickerKeyword = '';
|
|
762
|
+
stickerList.removeAttribute('aria-activedescendant');
|
|
763
|
+
}
|
|
764
|
+
function syncStickerScroll() {
|
|
765
|
+
const max = Math.max(0, stickerList.scrollWidth - stickerList.clientWidth);
|
|
766
|
+
stickerScroll.hidden = stickerPanel.hidden || max < 1;
|
|
767
|
+
if (stickerScroll.hidden) return;
|
|
768
|
+
const width = stickerScroll.clientWidth;
|
|
769
|
+
const thumb = Math.min(width, Math.max(30, width * stickerList.clientWidth / stickerList.scrollWidth));
|
|
770
|
+
const progress = Math.max(0, Math.min(1, stickerList.scrollLeft / max));
|
|
771
|
+
stickerThumb.style.width = thumb + 'px';
|
|
772
|
+
stickerThumb.style.transform = 'translateX(' + progress * (width - thumb) + 'px)';
|
|
773
|
+
stickerScroll.setAttribute('aria-valuemax', String(Math.round(max)));
|
|
774
|
+
stickerScroll.setAttribute('aria-valuenow', String(Math.round(progress * max)));
|
|
775
|
+
}
|
|
776
|
+
function endStickerDrag() {
|
|
777
|
+
const drag = stickerDrag;
|
|
778
|
+
stickerDrag = null;
|
|
779
|
+
if (drag && stickerScroll.hasPointerCapture(drag.id)) stickerScroll.releasePointerCapture(drag.id);
|
|
780
|
+
stickerScroll.classList.remove('is-dragging');
|
|
781
|
+
}
|
|
782
|
+
stickerScroll.addEventListener('pointerdown', event => {
|
|
783
|
+
if (event.button !== 0) return;
|
|
784
|
+
event.preventDefault();
|
|
785
|
+
stickerScroll.focus({ preventScroll: true });
|
|
786
|
+
const thumb = stickerThumb.getBoundingClientRect();
|
|
787
|
+
const max = stickerList.scrollWidth - stickerList.clientWidth;
|
|
788
|
+
const travel = stickerScroll.clientWidth - thumb.width;
|
|
789
|
+
if (max <= 0 || travel <= 0) return;
|
|
790
|
+
if (event.clientX < thumb.left || event.clientX > thumb.right) {
|
|
791
|
+
const x = event.clientX - stickerScroll.getBoundingClientRect().left - thumb.width / 2;
|
|
792
|
+
stickerList.scrollLeft = Math.max(0, Math.min(1, x / travel)) * max;
|
|
793
|
+
syncStickerScroll();
|
|
794
|
+
}
|
|
795
|
+
stickerDrag = { id: event.pointerId, x: event.clientX, scroll: stickerList.scrollLeft };
|
|
796
|
+
stickerScroll.setPointerCapture(event.pointerId);
|
|
797
|
+
stickerScroll.classList.add('is-dragging');
|
|
798
|
+
});
|
|
799
|
+
stickerScroll.addEventListener('pointermove', event => {
|
|
800
|
+
if (!stickerDrag || stickerDrag.id !== event.pointerId) return;
|
|
801
|
+
const travel = stickerScroll.clientWidth - stickerThumb.offsetWidth;
|
|
802
|
+
const max = stickerList.scrollWidth - stickerList.clientWidth;
|
|
803
|
+
if (travel > 0) stickerList.scrollLeft = stickerDrag.scroll + (event.clientX - stickerDrag.x) / travel * max;
|
|
804
|
+
syncStickerScroll();
|
|
805
|
+
});
|
|
806
|
+
for (const type of ['pointerup', 'pointercancel', 'lostpointercapture']) stickerScroll.addEventListener(type, endStickerDrag);
|
|
807
|
+
stickerScroll.addEventListener('keydown', event => {
|
|
808
|
+
const max = stickerList.scrollWidth - stickerList.clientWidth;
|
|
809
|
+
const steps = { ArrowLeft: -104, ArrowRight: 104, PageUp: -stickerList.clientWidth, PageDown: stickerList.clientWidth };
|
|
810
|
+
if (event.key in steps) stickerList.scrollLeft += steps[event.key];
|
|
811
|
+
else if (event.key === 'Home') stickerList.scrollLeft = 0;
|
|
812
|
+
else if (event.key === 'End') stickerList.scrollLeft = max;
|
|
813
|
+
else return;
|
|
814
|
+
event.preventDefault(); event.stopPropagation(); syncStickerScroll();
|
|
815
|
+
});
|
|
816
|
+
stickerList.addEventListener('scroll', syncStickerScroll, { passive: true });
|
|
817
|
+
const stickerResize = new ResizeObserver(syncStickerScroll);
|
|
818
|
+
stickerResize.observe(stickerList);
|
|
819
|
+
|
|
820
|
+
function selectSticker(index, reveal = false) {
|
|
821
|
+
const cards = stickerList.children;
|
|
822
|
+
if (!cards.length) return;
|
|
823
|
+
stickerSelected = Math.max(0, Math.min(index, cards.length - 1));
|
|
824
|
+
for (let i = 0; i < cards.length; ++i) {
|
|
825
|
+
const selected = i === stickerSelected;
|
|
826
|
+
cards[i].classList.toggle('is-selected', selected);
|
|
827
|
+
cards[i].setAttribute('aria-selected', String(selected));
|
|
828
|
+
}
|
|
829
|
+
const card = cards[stickerSelected];
|
|
830
|
+
stickerList.setAttribute('aria-activedescendant', card.id);
|
|
831
|
+
prefetchStickerPage();
|
|
832
|
+
stickerStatus.textContent = stickerKeyword + ' · ' + (stickerSelected + 1) + ' / ' + cards.length + ' · ← → 选择 · Esc 关闭';
|
|
833
|
+
if (reveal) {
|
|
834
|
+
const bounds = stickerList.getBoundingClientRect();
|
|
835
|
+
const target = card.getBoundingClientRect();
|
|
836
|
+
if (target.left < bounds.left) stickerList.scrollLeft += target.left - bounds.left;
|
|
837
|
+
else if (target.right > bounds.right) stickerList.scrollLeft += target.right - bounds.right;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function stickerKey(event) {
|
|
841
|
+
if (composing || event.isComposing || event.keyCode === 229 || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey || stickerPanel.hidden) return false;
|
|
842
|
+
if (event.key === 'Escape') {
|
|
843
|
+
event.preventDefault(); event.stopPropagation(); closeStickers();
|
|
844
|
+
$('messageInput').focus(); return true;
|
|
845
|
+
}
|
|
846
|
+
if (!stickerList.children.length) return false;
|
|
847
|
+
if (event.key === 'Enter') {
|
|
848
|
+
event.preventDefault(); event.stopPropagation(); insertSelectedSticker(); return true;
|
|
849
|
+
}
|
|
850
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return false;
|
|
851
|
+
event.preventDefault(); event.stopPropagation();
|
|
852
|
+
selectSticker(stickerSelected + (event.key === 'ArrowRight' ? 1 : -1), true);
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
stickerList.addEventListener('keydown', stickerKey);
|
|
856
|
+
|
|
857
|
+
function loadVisibleStickerImages(controller, generation) {
|
|
858
|
+
const queue = [];
|
|
859
|
+
let active = 0;
|
|
860
|
+
const current = () => alive && !controller.signal.aborted && generation === stickerGeneration;
|
|
861
|
+
const pump = () => {
|
|
862
|
+
while (current() && active < 3 && queue.length) {
|
|
863
|
+
const image = queue.shift();
|
|
864
|
+
++active;
|
|
865
|
+
Promise.resolve().then(() => stickerMedia.get(image.dataset.remoteUrl, controller.signal)).then(dataURL => {
|
|
866
|
+
if (!current()) return;
|
|
867
|
+
if (typeof dataURL !== 'string' || !/^data:image\/(jpeg|png|gif|webp);base64,/.test(dataURL)) throw new Error('图片数据无效');
|
|
868
|
+
image.src = dataURL;
|
|
869
|
+
delete image.dataset.remoteUrl;
|
|
870
|
+
}).catch(() => {
|
|
871
|
+
if (!current()) return;
|
|
872
|
+
image.classList.remove('is-loaded');
|
|
873
|
+
}).finally(() => { --active; pump(); });
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
stickerObserver = new IntersectionObserver(entries => {
|
|
877
|
+
for (const entry of entries) {
|
|
878
|
+
if (!entry.isIntersecting) continue;
|
|
879
|
+
stickerObserver?.unobserve(entry.target);
|
|
880
|
+
queue.push(entry.target);
|
|
881
|
+
}
|
|
882
|
+
pump();
|
|
883
|
+
}, { root: stickerList, rootMargin: '0px 100px' });
|
|
884
|
+
stickerObserveImages = () => {
|
|
885
|
+
for (const image of stickerList.querySelectorAll('img[data-remote-url]')) {
|
|
886
|
+
if (image.dataset.observed) continue;
|
|
887
|
+
image.dataset.observed = 'true';
|
|
888
|
+
stickerObserver.observe(image);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
stickerObserveImages();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function appendStickerImages(images) {
|
|
895
|
+
const fragment = document.createDocumentFragment();
|
|
896
|
+
for (const item of images) {
|
|
897
|
+
const normalized = messageImageURL(item.url);
|
|
898
|
+
if (!normalized) continue;
|
|
899
|
+
const url = new URL(normalized);
|
|
900
|
+
if (stickerSeen.has(url.href)) continue;
|
|
901
|
+
stickerSeen.add(url.href);
|
|
902
|
+
const card = make('div', 'yc-sticker-card');
|
|
903
|
+
const index = stickerList.children.length + fragment.childNodes.length;
|
|
904
|
+
card.id = stickerId + '-' + index;
|
|
905
|
+
card.setAttribute('role', 'option');
|
|
906
|
+
card.setAttribute('aria-selected', 'false');
|
|
907
|
+
card.setAttribute('aria-label', typeof item.alt === 'string' && item.alt ? item.alt : '表情 ' + (index + 1));
|
|
908
|
+
card.onmouseenter = () => selectSticker(index);
|
|
909
|
+
card.onmousedown = event => event.preventDefault();
|
|
910
|
+
card.dataset.stickerUrl = url.href;
|
|
911
|
+
card.onclick = () => { selectSticker(index, true); insertSelectedSticker(); };
|
|
912
|
+
const image = make('img');
|
|
913
|
+
image.alt = '';
|
|
914
|
+
image.onload = () => { image.classList.add('is-loaded'); };
|
|
915
|
+
|
|
916
|
+
image.referrerPolicy = 'no-referrer';
|
|
917
|
+
image.loading = 'lazy'; image.decoding = 'async';
|
|
918
|
+
image.onerror = () => { stickerMedia.evict(url.href); image.classList.remove('is-loaded'); };
|
|
919
|
+
if (loadStickerImage) image.dataset.remoteUrl = url.href;
|
|
920
|
+
else image.src = url.href;
|
|
921
|
+
card.append(image); fragment.append(card);
|
|
922
|
+
}
|
|
923
|
+
const count = fragment.childNodes.length;
|
|
924
|
+
stickerList.append(fragment);
|
|
925
|
+
stickerObserveImages?.();
|
|
926
|
+
return count;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async function prefetchStickerPage() {
|
|
930
|
+
if (!stickerHasMore || stickerMoreLoading || !stickerRequest || stickerRequest.signal.aborted || stickerSelected < 0 || stickerFailedIndex === stickerSelected) return;
|
|
931
|
+
if (stickerList.children.length - stickerSelected - 1 > 10) return;
|
|
932
|
+
const generation = stickerGeneration;
|
|
933
|
+
const controller = stickerRequest;
|
|
934
|
+
const page = stickerNextPage;
|
|
935
|
+
stickerMoreLoading = true;
|
|
936
|
+
try {
|
|
937
|
+
const result = await searchStickers(stickerKeyword, controller.signal, page);
|
|
938
|
+
if (!alive || controller.signal.aborted || generation !== stickerGeneration) return;
|
|
939
|
+
if (!result || !Array.isArray(result.images) || typeof result.hasMore !== 'boolean') throw new Error('无法读取下一页表情');
|
|
940
|
+
const added = appendStickerImages(result.images);
|
|
941
|
+
stickerNextPage = page + 1;
|
|
942
|
+
stickerHasMore = result.hasMore && added > 0;
|
|
943
|
+
stickerFailedIndex = -1;
|
|
944
|
+
syncStickerScroll();
|
|
945
|
+
stickerStatus.textContent = '已加载 ' + stickerList.children.length + ' 张表情';
|
|
946
|
+
} catch (error) {
|
|
947
|
+
if (!alive || controller.signal.aborted || generation !== stickerGeneration) return;
|
|
948
|
+
stickerFailedIndex = stickerSelected;
|
|
949
|
+
log('下一页表情加载失败,切换选中图片可重试', 'err');
|
|
950
|
+
} finally {
|
|
951
|
+
if (generation === stickerGeneration) stickerMoreLoading = false;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function scheduleStickers() {
|
|
956
|
+
closeStickers();
|
|
957
|
+
const keyword = $('messageInput').value.trim();
|
|
958
|
+
const keywordLength = Array.from(keyword).length;
|
|
959
|
+
if (!emojiPanel.hidden || !searchStickers || composing || current()?.draftSticker || $('messageInput').disabled || keywordLength < 2 || keywordLength > 4) return;
|
|
960
|
+
const generation = stickerGeneration;
|
|
961
|
+
const sessionKey = state.active;
|
|
962
|
+
stickerTimer = setTimeout(async () => {
|
|
963
|
+
const controller = stickerRequest = new AbortController();
|
|
964
|
+
stickerStatus.textContent = '正在搜索表情…';
|
|
965
|
+
try {
|
|
966
|
+
const result = await searchStickers(keyword, controller.signal);
|
|
967
|
+
if (!alive || generation !== stickerGeneration || sessionKey !== state.active) return;
|
|
968
|
+
if (!result || !Array.isArray(result.images) || typeof result.hasMore !== 'boolean') throw new Error('无法读取表情搜索结果');
|
|
969
|
+
const count = appendStickerImages(result.images);
|
|
970
|
+
// Reveal only after valid candidate cards exist, so the panel opens at full height.
|
|
971
|
+
stickerPanel.hidden = count === 0;
|
|
972
|
+
stickerList.scrollLeft = 0;
|
|
973
|
+
syncStickerScroll();
|
|
974
|
+
if (loadStickerImage) loadVisibleStickerImages(controller, generation);
|
|
975
|
+
stickerKeyword = keyword;
|
|
976
|
+
stickerHasMore = result.hasMore && count > 0;
|
|
977
|
+
if (count) selectSticker(0);
|
|
978
|
+
else stickerStatus.textContent = '没有找到相关表情';
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (!alive || generation !== stickerGeneration || controller.signal.aborted) return;
|
|
981
|
+
stickerStatus.textContent = error.message || '表情搜索暂不可用';
|
|
982
|
+
}
|
|
983
|
+
}, 350);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Preserve existing text nodes when composer state changes without new text.
|
|
987
|
+
function syncComposerText(id, text) {
|
|
988
|
+
const node = $(id);
|
|
989
|
+
if (node.textContent !== text) node.textContent = text;
|
|
990
|
+
}
|
|
991
|
+
function syncComposer() {
|
|
992
|
+
const item = current();
|
|
993
|
+
const available = connected && state.me && recordStore.ready && item?.loaded && (item.kind === 'private' || state.rooms.has(Number(item.id)));
|
|
994
|
+
$('messageInput').disabled = !available;
|
|
995
|
+
emojiButton.disabled = !available;
|
|
996
|
+
if (!available) closeEmoji();
|
|
997
|
+
$('btnSend').disabled = !available || (!$('messageInput').value.trim() && !item?.draftSticker);
|
|
998
|
+
syncComposerText('charCount', Array.from($('messageInput').value).length + ' / 1000');
|
|
999
|
+
imageUploadButton.disabled = !available;
|
|
1000
|
+
syncDraftSticker();
|
|
1001
|
+
}
|
|
1002
|
+
function renderHeader() {
|
|
1003
|
+
const item = current();
|
|
1004
|
+
const members = item?.kind === 'room' && connected && state.rooms.has(Number(item.id)) ? item.members : null;
|
|
1005
|
+
$('conversationTitle').textContent = item?.kind === 'room' ? item.name + '(' + (members ?? '—') + ')' : item?.name || '选择一个会话';
|
|
1006
|
+
$('conversationMeta').textContent = item?.kind === 'private' ? 'ID: ' + item.id : '';
|
|
1007
|
+
$('conversationMeta').hidden = item?.kind !== 'private';
|
|
1008
|
+
syncComposer();
|
|
1009
|
+
}
|
|
1010
|
+
function scrollLatest() {
|
|
1011
|
+
const box = $('messageViewport'); box.scrollTop = box.scrollHeight; $('btnLatest').hidden = true;
|
|
1012
|
+
}
|
|
1013
|
+
function nearBottom() { const box = $('messageViewport'); return box.scrollHeight - box.scrollTop - box.clientHeight < 80; }
|
|
1014
|
+
function messageNode(entry) {
|
|
1015
|
+
if (entry.friendRequest) {
|
|
1016
|
+
const card = make('article', 'yc-friend-request-card');
|
|
1017
|
+
card.append(make('strong', '', entry.friendRequest.direction === 'incoming' ? '好友申请' : entry.friendRequest.status === 'accepted' ? '已成为好友' : entry.friendRequest.status === 'rejected' ? '申请已拒绝' : '好友申请中'));
|
|
1018
|
+
card.append(make('p', '', entry.friendRequest.nickname + ' 请求添加你为好友'));
|
|
1019
|
+
if (entry.friendRequest.direction === 'incoming' && entry.friendRequest.status === 'pending') {
|
|
1020
|
+
const actions = make('div', 'yc-friend-request-actions');
|
|
1021
|
+
for (const [label, action] of [['同意','accept'],['拒绝','reject'],['稍后处理',null]]) { const button = make('button', '', label); button.type = 'button'; if (action) button.onclick = () => void handleRequestAction(entry.friendRequest, action).catch(error => log(error.message || '操作失败', 'err')); actions.append(button); }
|
|
1022
|
+
card.append(actions);
|
|
1023
|
+
}
|
|
1024
|
+
return card;
|
|
1025
|
+
}
|
|
1026
|
+
if (entry.system) return make('p', 'yc-system-message', entry.text);
|
|
1027
|
+
const m = entry.message;
|
|
1028
|
+
const attachment = m.sticker || (entry.localImage ? { url: entry.localImage.url } : null);
|
|
1029
|
+
const mine = m.fromUserId === (state.me?.id || lastUserID);
|
|
1030
|
+
const row = make('article', 'yc-message' + (mine ? ' is-mine' : ''));
|
|
1031
|
+
const identity = { name: m.fromNickname || m.fromUserId || '用户', avatar: m.fromAvatar };
|
|
1032
|
+
const person = make('button', 'yc-message-avatar'); person.type = 'button'; person.title = mine ? '我' : '私聊 ' + identity.name;
|
|
1033
|
+
person.setAttribute('aria-label', person.title); person.append(avatarFor(identity));
|
|
1034
|
+
person.onclick = () => { if (!mine) { const peer = session('private', m.fromUserId, identity.name, m.fromAvatar); selectSession(peer.key); } };
|
|
1035
|
+
const content = make('div', 'yc-message-content');
|
|
1036
|
+
const meta = make('div', 'yc-message-meta'); meta.append(make('span', '', mine ? '我' : identity.name), make('time', '', timeStr(m.sentAtMs)));
|
|
1037
|
+
const bubble = make('div', 'yc-bubble' + (attachment ? ' yc-sticker-bubble' : ''), attachment ? undefined : entry.imagePending ? (entry.sendStatus === 'uploading' ? '图片上传中…' : '图片上传未完成') + (m.content ? '\n' + m.content : '') : (m.content || ''));
|
|
1038
|
+
if (attachment) {
|
|
1039
|
+
const picture = stickerMessageNode(attachment, entry.localImage?.url);
|
|
1040
|
+
if (entry.localImage) {
|
|
1041
|
+
const overlay = make('div', 'yc-image-upload');
|
|
1042
|
+
overlay.setAttribute('role', 'progressbar');
|
|
1043
|
+
overlay.setAttribute('aria-label', '图片上传进度');
|
|
1044
|
+
overlay.setAttribute('aria-valuemin', '0'); overlay.setAttribute('aria-valuemax', '100');
|
|
1045
|
+
overlay.innerHTML = '<span class="yc-image-upload-ring" aria-hidden="true"><svg viewBox="0 0 40 40"><circle class="yc-image-upload-track" cx="20" cy="20" r="17"/><circle class="yc-image-upload-fill" cx="20" cy="20" r="17" pathLength="100"/></svg></span><span class="yc-image-upload-text"></span>';
|
|
1046
|
+
entry.uploadNode = overlay; picture.append(overlay);
|
|
1047
|
+
}
|
|
1048
|
+
bubble.append(picture);
|
|
1049
|
+
if (m.content) bubble.append(make('div', 'yc-sticker-caption', m.content));
|
|
1050
|
+
}
|
|
1051
|
+
if (mine) {
|
|
1052
|
+
const status = make('span', 'yc-message-send-status');
|
|
1053
|
+
status.setAttribute('role', 'status');
|
|
1054
|
+
entry.statusNode = status;
|
|
1055
|
+
syncMessageStatus(entry);
|
|
1056
|
+
bubble.append(status);
|
|
1057
|
+
}
|
|
1058
|
+
content.append(meta, bubble);
|
|
1059
|
+
entry.rowNode = row;
|
|
1060
|
+
contextEntries.set(row, entry);
|
|
1061
|
+
row.append(person, content); return row;
|
|
1062
|
+
}
|
|
1063
|
+
function renderMessages(force = false) {
|
|
1064
|
+
const box = $('messageViewport'); const top = box.scrollTop;
|
|
1065
|
+
const bottom = force || nearBottom();
|
|
1066
|
+
const item = current();
|
|
1067
|
+
const entries = item?.entries || [];
|
|
1068
|
+
resetMessageImages();
|
|
1069
|
+
for (const row of $('messages').children) { const entry = contextEntries.get(row); if (entry) detachMessageRow(entry); }
|
|
1070
|
+
$('messages').replaceChildren(...entries.map(messageNode));
|
|
1071
|
+
if (bottom) scrollLatest(); else box.scrollTop = top;
|
|
1072
|
+
}
|
|
1073
|
+
function selectSession(key) {
|
|
1074
|
+
contextActions.close(); closeEmoji(); closeStickers();
|
|
1075
|
+
const previous = current();
|
|
1076
|
+
if (previous) { previous.draft = $('messageInput').value; recordStore.draft(previous.key, previous.draft); void recordStore.flushDraft(previous.key).catch(historyError); }
|
|
1077
|
+
state.active = key;
|
|
1078
|
+
const item = current(); if (!item) return;
|
|
1079
|
+
item.unread = 0; $('messageInput').value = item.draft;
|
|
1080
|
+
renderSessions(); renderHeader(); renderMessages(true);
|
|
1081
|
+
if (recordStore.ready) {
|
|
1082
|
+
if (item.localNew) void recordStore.session(item).catch(historyError);
|
|
1083
|
+
void recordStore.read(key).catch(historyError);
|
|
1084
|
+
void loadSessionMessages(item);
|
|
1085
|
+
}
|
|
1086
|
+
if (!$('messageInput').disabled) $('messageInput').focus();
|
|
1087
|
+
}
|
|
1088
|
+
function detachMessageRow(entry) {
|
|
1089
|
+
for (const image of entry.rowNode?.querySelectorAll('.yc-message-sticker') || []) messageImageObserver.unobserve(image);
|
|
1090
|
+
entry.rowNode = null; entry.statusNode = null; entry.uploadNode = null;
|
|
1091
|
+
}
|
|
1092
|
+
function addEntry(item, entry) {
|
|
1093
|
+
entry.id ||= entryId(entry.message);
|
|
1094
|
+
if (item.entries.some(value => value.id === entry.id)) return;
|
|
1095
|
+
const bottom = nearBottom(), active = item.key === state.active;
|
|
1096
|
+
item.entries.push(entry); item.messageCount = Math.max(item.messageCount, item.entries.length);
|
|
1097
|
+
const excess = Math.max(0, item.entries.length - 1000);
|
|
1098
|
+
const anchor = active && excess ? $('messages').children[excess] : null;
|
|
1099
|
+
const anchorTop = anchor?.getBoundingClientRect().top;
|
|
1100
|
+
for (let i = 0; i < excess; i++) {
|
|
1101
|
+
const oldest = item.entries.shift(); oldest.evicted = true;
|
|
1102
|
+
item.displayFloor = Math.max(item.displayFloor, oldest.order || 0);
|
|
1103
|
+
const row = oldest.rowNode; detachMessageRow(oldest); row?.remove();
|
|
1104
|
+
const job = pendingSends.get(oldest.message?.clientMessageId);
|
|
1105
|
+
if (job?.entry === oldest) item.detachedEntries.set(oldest.id, oldest);
|
|
1106
|
+
// A display eviction must not cancel an upload or a sent message's acknowledgement.
|
|
1107
|
+
if (job?.entry !== oldest) releaseEntryImage(oldest);
|
|
1108
|
+
}
|
|
1109
|
+
if (active) {
|
|
1110
|
+
$('messages').append(messageNode(entry));
|
|
1111
|
+
if (bottom) scrollLatest();
|
|
1112
|
+
else if (anchor?.isConnected) messageViewport.scrollTop += anchor.getBoundingClientRect().top - anchorTop;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
function log(text, cls) {
|
|
1116
|
+
if (!alive) return;
|
|
1117
|
+
$('feedback').textContent = text; $('feedback').hidden = false;
|
|
1118
|
+
$('feedback').classList.toggle('is-error', cls === 'err');
|
|
1119
|
+
clearTimeout(feedbackTimer); feedbackTimer = setTimeout(() => { if (alive) $('feedback').hidden = true; }, 6000);
|
|
1120
|
+
}
|
|
1121
|
+
function setStatus(text, ok) {
|
|
1122
|
+
$('status').textContent = text; $('status').title = text;
|
|
1123
|
+
$('presenceDot').classList.toggle('is-online', Boolean(ok));
|
|
1124
|
+
}
|
|
1125
|
+
function refreshRooms() { renderSessions(); renderHeader(); }
|
|
1126
|
+
function setConnected(on) {
|
|
1127
|
+
connected = on;
|
|
1128
|
+
if (!on) {
|
|
1129
|
+
if (clearPendingSends()) log('连接已断开,消息未确认发送,请重试', 'err');
|
|
1130
|
+
closeStickers();
|
|
1131
|
+
}
|
|
1132
|
+
$('btnConnect').disabled = on;
|
|
1133
|
+
$('btnQuit').disabled = !on;
|
|
1134
|
+
$('btnRelogin').disabled = on || !loginReady;
|
|
1135
|
+
$('loginBox').hidden = true;
|
|
1136
|
+
renderProfile(); refreshRooms();
|
|
1137
|
+
}
|
|
1138
|
+
function sendClientFrame(obj) {
|
|
1139
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { log('尚未连接,请先连接聊天服务', 'err'); return false; }
|
|
1140
|
+
try { state.ws.send(encodeMsg('clientFrame', obj)); return true; }
|
|
1141
|
+
catch { log('消息发送失败,请重试', 'err'); return false; }
|
|
1142
|
+
}
|
|
1143
|
+
async function renderMessage(m) {
|
|
1144
|
+
const control = m.privateMsg ? parseControl(m) : null;
|
|
1145
|
+
if (control) { await handleControlMessage(m, control); return; }
|
|
1146
|
+
if (m.clientMessageId && hiddenLocalMessages.has(m.fromUserId + ":" + m.clientMessageId)) return;
|
|
1147
|
+
const mine = m.fromUserId === (state.me?.id || lastUserID);
|
|
1148
|
+
if (m.privateMsg && !mine && !isFriend(m.fromUserId)) return;
|
|
1149
|
+
if (!m.privateMsg && !mine && isBlocked(m.fromUserId)) return;
|
|
1150
|
+
const item = m.privateMsg ? session('private', mine ? m.toUserId : m.fromUserId, mine ? undefined : m.fromNickname, mine ? '' : m.fromAvatar) : session('room', m.roomId);
|
|
1151
|
+
const epoch = historyEpoch, atBottom = nearBottom();
|
|
1152
|
+
const read = item.key === state.active && !document.hidden && document.hasFocus();
|
|
1153
|
+
try {
|
|
1154
|
+
const result = await recordStore.put(item, { id: entryId(m), message: m, sendStatus: 'sent' }, { read });
|
|
1155
|
+
if (!alive || epoch !== historyEpoch || result.ignored) return;
|
|
1156
|
+
const confirmed = mine && confirmPendingSend(m, item);
|
|
1157
|
+
if (!confirmed && result.entry) mergeStoredEntry(item, result.entry);
|
|
1158
|
+
if (result.session) applyStoredSession(result.session, result.revision);
|
|
1159
|
+
if (!mine) {
|
|
1160
|
+
onMessage();
|
|
1161
|
+
if (item.key === state.active && !atBottom) $('btnLatest').hidden = false;
|
|
1162
|
+
if (read) void recordStore.read(item.key).catch(historyError);
|
|
1163
|
+
}
|
|
1164
|
+
renderSessions();
|
|
1165
|
+
if (mine && item.key === state.active) scrollLatest();
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
historyError(error);
|
|
1168
|
+
if (alive && epoch === historyEpoch && !mine && item.loaded) addEntry(item, { id: entryId(m), message: m, sendStatus: 'sent' });
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
async function handleFrame(f) {
|
|
1172
|
+
if (f.self) {
|
|
1173
|
+
const nextAccount = JSON.stringify([state.serverKey, String(f.self.userId)]);
|
|
1174
|
+
if (recordStore.account && recordStore.account !== nextAccount || lastUserID && lastUserID !== f.self.userId) {
|
|
1175
|
+
clearPendingSends(); releaseAllLocalImages(); stickerMedia.dispose(); stickerMedia = createStickerMedia();
|
|
1176
|
+
recordStore.close(); sessionRevisions.clear(); hiddenLocalMessages.clear(); deletedEntryKeys.clear(); contacts.clear(); friendRequests.clear(); ++historyEpoch;
|
|
1177
|
+
state.sessions.clear(); state.active = 'room:1'; $('messageInput').value = ''; session('room', 1);
|
|
1178
|
+
}
|
|
1179
|
+
lastUserID = f.self.userId;
|
|
1180
|
+
state.me = { id: f.self.userId, nickname: f.self.nickname, avatar: f.self.avatar };
|
|
1181
|
+
lastProfile = state.me;
|
|
1182
|
+
try {
|
|
1183
|
+
await recordStore.open(f.self.userId, state.serverKey);
|
|
1184
|
+
if (!alive || state.me?.id !== f.self.userId) return;
|
|
1185
|
+
await loadSessionMessages(current());
|
|
1186
|
+
} catch (error) { historyError(error); }
|
|
1187
|
+
renderProfile(); renderSessions(); renderHeader(); renderMessages(true); setStatus('已连接', true); return;
|
|
1188
|
+
}
|
|
1189
|
+
if (f.roomStat) {
|
|
1190
|
+
const info = f.roomStat; state.rooms.add(info.roomId);
|
|
1191
|
+
const item = session('room', info.roomId); item.count = info.messageCount || 0; item.members = info.memberCount || 0;
|
|
1192
|
+
if (state.pendingRoom === info.roomId) { state.pendingRoom = null; selectSession(item.key); }
|
|
1193
|
+
if (recordStore.ready && item.localNew) void recordStore.session(item).catch(historyError);
|
|
1194
|
+
refreshRooms(); return;
|
|
1195
|
+
}
|
|
1196
|
+
if (f.message) { await renderMessage(f.message); return; }
|
|
1197
|
+
// Room notices (joins/leaves) are temporarily hidden to avoid noisy timelines.
|
|
1198
|
+
if (f.notice) return;
|
|
1199
|
+
if (f.leaveResult) {
|
|
1200
|
+
state.rooms.delete(f.leaveResult.roomId);
|
|
1201
|
+
for (const job of pendingSends.values()) {
|
|
1202
|
+
if (job.item.key === 'room:' + f.leaveResult.roomId) failPendingSend(job.id, '已退出房间,消息未发送');
|
|
1203
|
+
}
|
|
1204
|
+
refreshRooms(); return;
|
|
1205
|
+
}
|
|
1206
|
+
if (f.error) {
|
|
1207
|
+
failPendingSend(f.error.clientMessageId, (ERROR_NAMES[f.error.code] || '发送失败') + ': ' + f.error.message);
|
|
1208
|
+
state.pendingRoom = null; log((ERROR_NAMES[f.error.code] || '发送失败') + ': ' + f.error.message, 'err');
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const pendingSends = new Map();
|
|
1212
|
+
function syncMessageStatus(entry) {
|
|
1213
|
+
const overlay = entry.uploadNode;
|
|
1214
|
+
if (overlay) {
|
|
1215
|
+
const uploading = entry.sendStatus === 'uploading';
|
|
1216
|
+
overlay.hidden = !uploading;
|
|
1217
|
+
const percent = Math.max(0, Math.min(100, entry.uploadPercent || 0));
|
|
1218
|
+
overlay.classList.toggle('is-processing', percent >= 100);
|
|
1219
|
+
overlay.style.setProperty('--upload-progress', String(percent));
|
|
1220
|
+
overlay.setAttribute('aria-valuenow', String(percent));
|
|
1221
|
+
const text = percent >= 100 ? '处理中…' : '上传中 ' + percent + '%';
|
|
1222
|
+
overlay.setAttribute('aria-valuetext', text);
|
|
1223
|
+
const label = overlay.querySelector('.yc-image-upload-text');
|
|
1224
|
+
if (label.textContent !== text) label.textContent = text;
|
|
1225
|
+
}
|
|
1226
|
+
const status = entry.statusNode;
|
|
1227
|
+
if (!status) return;
|
|
1228
|
+
const sending = entry.sendStatus === 'sending';
|
|
1229
|
+
const failed = entry.sendStatus === 'failed';
|
|
1230
|
+
status.hidden = !sending && !failed;
|
|
1231
|
+
status.classList.toggle('is-sending', sending);
|
|
1232
|
+
status.classList.toggle('is-failed', failed);
|
|
1233
|
+
status.textContent = failed ? '!' : '';
|
|
1234
|
+
status.title = failed ? entry.sendError || '发送失败' : sending ? '发送中' : '';
|
|
1235
|
+
status.setAttribute('aria-label', status.title);
|
|
1236
|
+
}
|
|
1237
|
+
function failPendingSend(id, reason = '发送失败') {
|
|
1238
|
+
const job = pendingSends.get(id);
|
|
1239
|
+
if (!job) return false;
|
|
1240
|
+
pendingSends.delete(id); clearTimeout(job.timer); job.controller?.abort();
|
|
1241
|
+
job.entry.sendStatus = 'failed'; job.entry.sendError = reason;
|
|
1242
|
+
if (job.entry.localImage) job.entry.localImage.file = null;
|
|
1243
|
+
syncMessageStatus(job.entry);
|
|
1244
|
+
void persistEntry(job.item, job.entry).finally(() => {
|
|
1245
|
+
if (job.entry.evicted) {
|
|
1246
|
+
job.item.displayFloor = Math.max(job.item.displayFloor, job.entry.order || 0);
|
|
1247
|
+
job.item.detachedEntries.delete(job.entry.id);
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
if (job.entry.evicted) releaseEntryImage(job.entry);
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
function clearPendingSends() {
|
|
1254
|
+
const hadPending = pendingSends.size > 0;
|
|
1255
|
+
for (const id of pendingSends.keys()) failPendingSend(id, '连接已断开,消息发送未完成');
|
|
1256
|
+
return hadPending;
|
|
1257
|
+
}
|
|
1258
|
+
function confirmPendingSend(message, item) {
|
|
1259
|
+
if (!message.clientMessageId) return false;
|
|
1260
|
+
// Also reconcile a late confirmation after a timeout or disconnect.
|
|
1261
|
+
const job = pendingSends.get(message.clientMessageId);
|
|
1262
|
+
const entry = job?.item === item ? job.entry : item.detachedEntries.get(entryId(message)) || item.entries.find(entry => entry.message?.clientMessageId === message.clientMessageId);
|
|
1263
|
+
if (!entry) return false;
|
|
1264
|
+
if (job?.item === item) {
|
|
1265
|
+
pendingSends.delete(message.clientMessageId); clearTimeout(job.timer);
|
|
1266
|
+
}
|
|
1267
|
+
entry.message = message; entry.sendStatus = 'sent'; entry.imagePending = false;
|
|
1268
|
+
syncMessageStatus(entry);
|
|
1269
|
+
if (entry.statusNode) {
|
|
1270
|
+
const time = entry.statusNode.closest('.yc-message-content')?.querySelector('time');
|
|
1271
|
+
if (time) time.textContent = timeStr(message.sentAtMs);
|
|
1272
|
+
}
|
|
1273
|
+
if (entry.evicted) { releaseEntryImage(entry); item.detachedEntries.delete(entry.id); }
|
|
1274
|
+
return true;
|
|
1275
|
+
}
|
|
1276
|
+
function insertSelectedSticker() {
|
|
1277
|
+
const card = stickerList.children[stickerSelected];
|
|
1278
|
+
const item = current();
|
|
1279
|
+
if (!card || stickerPanel.hidden || !item || composing || composerInput.disabled) return;
|
|
1280
|
+
const url = messageImageURL(card.dataset.stickerUrl);
|
|
1281
|
+
if (!url) { log('图片地址无效', 'err'); return; }
|
|
1282
|
+
releaseLocalURL(item.draftSticker?.url);
|
|
1283
|
+
item.draftSticker = { url };
|
|
1284
|
+
// The current 2–4 character input is the search query, not the caption.
|
|
1285
|
+
item.draft = ''; ++item.draftVersion; composerInput.value = '';
|
|
1286
|
+
recordStore.draft(item.key, item.draft);
|
|
1287
|
+
closeStickers(); syncComposer(); composerInput.focus();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function canFinishSend(job) {
|
|
1291
|
+
return alive && connected && state.ws === job.ws && (state.me?.id || lastUserID) === job.owner &&
|
|
1292
|
+
pendingSends.get(job.id) === job && state.sessions.get(job.item.key) === job.item && !job.entry.removed &&
|
|
1293
|
+
(job.item.kind === 'private' || state.rooms.has(Number(job.item.id)));
|
|
1294
|
+
}
|
|
1295
|
+
async function sendPreparedMessage(job) {
|
|
1296
|
+
if (!canFinishSend(job)) { failPendingSend(job.id, '会话或连接已变化,消息未发送'); return; }
|
|
1297
|
+
const { item, entry, id } = job;
|
|
1298
|
+
const message = entry.message;
|
|
1299
|
+
if (message.sticker && !messageImageURL(message.sticker.url)) { failPendingSend(id, "图片尚未上传完成"); return; }
|
|
1300
|
+
const body = { clientMessageId: id, content: message.content, ...(message.sticker ? { sticker: message.sticker } : {}) };
|
|
1301
|
+
entry.sendStatus = 'sending'; entry.imagePending = false; syncMessageStatus(entry);
|
|
1302
|
+
if (!await persistEntry(item, entry)) { failPendingSend(id, '记录保存失败,消息未发送'); return; }
|
|
1303
|
+
if (!canFinishSend(job)) return;
|
|
1304
|
+
const frame = item.kind === 'room' ? { roomChat: { roomId: Number(item.id), ...body } } : { privateChat: { toUserId: String(item.id), ...body } };
|
|
1305
|
+
if (!sendClientFrame(frame)) { failPendingSend(id); return; }
|
|
1306
|
+
// The acknowledgement deadline begins after the upload has finished.
|
|
1307
|
+
job.timer = setTimeout(() => failPendingSend(id, '未收到发送确认,请检查连接'), 15000);
|
|
1308
|
+
}
|
|
1309
|
+
async function uploadMessageImage(job) {
|
|
1310
|
+
const file = job.entry.localImage.file;
|
|
1311
|
+
try {
|
|
1312
|
+
const result = await uploadImage(file, job.controller.signal, (loaded, total) => {
|
|
1313
|
+
if (pendingSends.get(job.id) !== job || job.controller.signal.aborted || total <= 0) return;
|
|
1314
|
+
job.entry.uploadPercent = Math.max(job.entry.uploadPercent || 0, Math.floor(Math.min(1, loaded / total) * 100));
|
|
1315
|
+
syncMessageStatus(job.entry);
|
|
1316
|
+
});
|
|
1317
|
+
if (job.controller.signal.aborted || pendingSends.get(job.id) !== job) return;
|
|
1318
|
+
const url = messageImageURL(result?.url);
|
|
1319
|
+
if (!url) throw new Error('图床返回的图片地址无效');
|
|
1320
|
+
job.entry.message.sticker = { url };
|
|
1321
|
+
job.entry.localImage.file = null;
|
|
1322
|
+
await sendPreparedMessage(job);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
if (pendingSends.get(job.id) !== job) return;
|
|
1325
|
+
failPendingSend(job.id, error.message === 'transport' ? '图片上传连接中断或超时' : error.message || '图片上传失败');
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function sendMessage() {
|
|
1329
|
+
if (!connected || composing || !recordStore.ready || !current()?.loaded) return;
|
|
1330
|
+
const item = current(); const content = $('messageInput').value.trim();
|
|
1331
|
+
const sticker = item?.draftSticker;
|
|
1332
|
+
if (!item || (!sticker && !content)) return;
|
|
1333
|
+
if (Array.from(content).length > 1000) { log('消息最多 1000 字', 'err'); return; }
|
|
1334
|
+
const local = Boolean(sticker?.file && localImageURLs.has(sticker.url));
|
|
1335
|
+
const attachmentURL = sticker ? local ? sticker.url : messageImageURL(sticker.url) : null;
|
|
1336
|
+
if (sticker && !attachmentURL) { log('图片地址无效', 'err'); return; }
|
|
1337
|
+
if (item.kind === 'room' && !state.rooms.has(Number(item.id))) { log('请先加入当前房间', 'err'); return; }
|
|
1338
|
+
const id = crypto.randomUUID();
|
|
1339
|
+
const entry = { message: {
|
|
1340
|
+
clientMessageId: id, content, ...(attachmentURL ? { sticker: { url: attachmentURL } } : {}),
|
|
1341
|
+
fromUserId: state.me?.id || lastUserID,
|
|
1342
|
+
fromNickname: state.me?.nickname || lastProfile?.nickname,
|
|
1343
|
+
fromAvatar: state.me?.avatar || lastProfile?.avatar,
|
|
1344
|
+
sentAtMs: Date.now(), privateMsg: item.kind === 'private',
|
|
1345
|
+
...(item.kind === 'room' ? { roomId: Number(item.id) } : { toUserId: String(item.id) }),
|
|
1346
|
+
}, sendStatus: local ? 'uploading' : 'sending', uploadPercent: 0,
|
|
1347
|
+
imagePending: local, localImage: local ? { url: sticker.url, file: sticker.file } : null };
|
|
1348
|
+
entry.id = entryId(entry.message);
|
|
1349
|
+
if (item.kind === 'private' && !isFriend(item.id)) {
|
|
1350
|
+
entry.sendStatus = 'failed'; entry.sendError = isBlocked(item.id) ? '已屏蔽该用户' : '对方不是好友,消息未发送';
|
|
1351
|
+
item.draft = ''; item.draftSticker = null; $('messageInput').value = ''; syncComposer(); addEntry(item, entry); void persistEntry(item, entry); renderMessages(); return;
|
|
1352
|
+
}
|
|
1353
|
+
const job = { id, item, entry, timer: null, controller: local ? new AbortController() : null, ws: state.ws, owner: state.me?.id || lastUserID };
|
|
1354
|
+
pendingSends.set(id, job);
|
|
1355
|
+
// Transfer ownership of the local preview from the draft into this message.
|
|
1356
|
+
item.draft = ''; item.draftSticker = null; item.draftBarrier = id; ++item.draftVersion;
|
|
1357
|
+
$('messageInput').value = ''; closeEmoji(); closeStickers(); syncComposer();
|
|
1358
|
+
item.preview = '我: ' + (attachmentURL ? '[图片]' + (content ? ' ' + content : '') : content);
|
|
1359
|
+
item.updated = entry.message.sentAtMs;
|
|
1360
|
+
addEntry(item, entry); renderSessions(); scrollLatest();
|
|
1361
|
+
recordStore.cancelDraft(item.key);
|
|
1362
|
+
void beginStoredSend(job, local);
|
|
1363
|
+
if (!$('messageInput').disabled) $('messageInput').focus();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
let alive = true;
|
|
1367
|
+
let busy = false;
|
|
1368
|
+
let loginEndpoint = './login';
|
|
1369
|
+
let credentialEndpoint = './login';
|
|
1370
|
+
let usingAccount = false;
|
|
1371
|
+
let loginReady = false;
|
|
1372
|
+
let pollTimer;
|
|
1373
|
+
let ownedAttempt;
|
|
1374
|
+
let authGeneration = 0;
|
|
1375
|
+
const life = new AbortController();
|
|
1376
|
+
const AUTH_ERRORS = {
|
|
1377
|
+
'auth-expired': '登录已过期,请重新扫码',
|
|
1378
|
+
storage: '读取或保存登录信息失败,请重试',
|
|
1379
|
+
network: '账号服务连接失败,请重试',
|
|
1380
|
+
business: '账号服务暂时不可用',
|
|
1381
|
+
'invalid-response': '账号服务响应异常',
|
|
1382
|
+
transport: '无法连接 DSH,请稍后重试',
|
|
1383
|
+
};
|
|
1384
|
+
async function loginRequest(endpoint, action, data = {}) {
|
|
1385
|
+
const response = await transport(endpoint, {
|
|
1386
|
+
method: action ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store',
|
|
1387
|
+
...(action ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...data }) } : {}),
|
|
1388
|
+
signal: AbortSignal.any([life.signal, AbortSignal.timeout(20000)]),
|
|
1389
|
+
});
|
|
1390
|
+
if (!response.ok) {
|
|
1391
|
+
let data;
|
|
1392
|
+
try { data = await response.json(); } catch {}
|
|
1393
|
+
const error = new Error(data?.error || 'transport');
|
|
1394
|
+
error.status = response.status;
|
|
1395
|
+
throw error;
|
|
1396
|
+
}
|
|
1397
|
+
return response.json();
|
|
1398
|
+
}
|
|
1399
|
+
function showLoginError(error) {
|
|
1400
|
+
if (!alive) return;
|
|
1401
|
+
const text = AUTH_ERRORS[error.message] || '操作失败,请稍后重试';
|
|
1402
|
+
setStatus(text, false); log(text, 'err');
|
|
1403
|
+
if (!$('loginBox').hidden) $('loginHint').textContent = text;
|
|
1404
|
+
}
|
|
1405
|
+
function showLogin(view) {
|
|
1406
|
+
$('loginBox').hidden = false;
|
|
1407
|
+
const attempt = view?.attempt;
|
|
1408
|
+
const qr = typeof attempt?.qrcode === 'string' && /^data:image\/png;base64,[A-Za-z0-9+/=\r\n]+$/.test(attempt.qrcode) ? attempt.qrcode : '';
|
|
1409
|
+
$('qr').hidden = !qr;
|
|
1410
|
+
if (qr) $('qr').src = qr; else $('qr').removeAttribute('src');
|
|
1411
|
+
$('loginHint').textContent = attempt?.phase === 'scanned' ? '扫码成功,请在微信中确认' : attempt?.phase === 'expired' ? '二维码已过期,请重新获取' : attempt?.phase === 'loading' ? '正在获取二维码…' : '请使用微信扫码登录';
|
|
1412
|
+
$('btnLogin').textContent = attempt ? '刷新二维码' : '扫码登录';
|
|
1413
|
+
if (attempt?.error || view?.error) showLoginError(new Error(attempt?.error || view.error));
|
|
1414
|
+
}
|
|
1415
|
+
async function getCredential() { return loginRequest(credentialEndpoint, 'credential'); }
|
|
1416
|
+
async function cancelOwnedAttempt() {
|
|
1417
|
+
clearTimeout(pollTimer);
|
|
1418
|
+
const id = ownedAttempt; ownedAttempt = null;
|
|
1419
|
+
if (id) { try { await loginRequest(loginEndpoint, 'cancel', { id }); } catch {} }
|
|
1420
|
+
}
|
|
1421
|
+
async function discover() {
|
|
1422
|
+
if (busy || state.ws) return;
|
|
1423
|
+
busy = true; $('btnConnect').disabled = true;
|
|
1424
|
+
const generation = ++authGeneration;
|
|
1425
|
+
try {
|
|
1426
|
+
await cancelOwnedAttempt();
|
|
1427
|
+
const endpoint = new URL('../yiarce-account/chat-token', baseURL).href;
|
|
1428
|
+
let credential;
|
|
1429
|
+
const response = await transport(endpoint, { method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: AbortSignal.any([life.signal, AbortSignal.timeout(20000)]) });
|
|
1430
|
+
if (response.status === 404) {
|
|
1431
|
+
usingAccount = false; loginEndpoint = credentialEndpoint = './login'; loginReady = true;
|
|
1432
|
+
} else {
|
|
1433
|
+
usingAccount = true; credentialEndpoint = endpoint; loginEndpoint = new URL('../yiarce-account', baseURL).href;
|
|
1434
|
+
if (response.status !== 401) {
|
|
1435
|
+
if (!response.ok) throw new Error('transport');
|
|
1436
|
+
credential = await response.json();
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
if (!alive || generation !== authGeneration) return;
|
|
1440
|
+
loginReady = true;
|
|
1441
|
+
if (credential) { connect(credential.wsUrl); return; }
|
|
1442
|
+
if (!usingAccount) {
|
|
1443
|
+
try {
|
|
1444
|
+
const credential = await getCredential();
|
|
1445
|
+
if (alive && generation === authGeneration) connect(credential.wsUrl);
|
|
1446
|
+
return;
|
|
1447
|
+
} catch (error) { if (error.status !== 401) throw error; }
|
|
1448
|
+
}
|
|
1449
|
+
const view = await loginRequest(loginEndpoint);
|
|
1450
|
+
if (alive && generation === authGeneration) { showLogin(view); setStatus('请先登录后进入聊天', false); }
|
|
1451
|
+
} catch (error) { showLoginError(error); }
|
|
1452
|
+
finally {
|
|
1453
|
+
busy = false;
|
|
1454
|
+
if (alive && !state.ws) { $('btnConnect').disabled = false; $('btnRelogin').disabled = !loginReady; }
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
async function beginLogin() {
|
|
1458
|
+
if (!loginReady || busy || state.ws) return;
|
|
1459
|
+
busy = true;
|
|
1460
|
+
const generation = ++authGeneration;
|
|
1461
|
+
$('btnLogin').disabled = true;
|
|
1462
|
+
try {
|
|
1463
|
+
await cancelOwnedAttempt();
|
|
1464
|
+
const view = await loginRequest(loginEndpoint, 'begin');
|
|
1465
|
+
if (!alive || generation !== authGeneration) return;
|
|
1466
|
+
ownedAttempt = view.attempt?.id; showLogin(view);
|
|
1467
|
+
pollTimer = setTimeout(() => pollLogin(generation), 1500);
|
|
1468
|
+
} catch (error) { showLoginError(error); }
|
|
1469
|
+
finally { busy = false; if (alive) $('btnLogin').disabled = false; }
|
|
1470
|
+
}
|
|
1471
|
+
async function pollLogin(generation) {
|
|
1472
|
+
if (!alive || generation !== authGeneration) return;
|
|
1473
|
+
try {
|
|
1474
|
+
const view = await loginRequest(loginEndpoint);
|
|
1475
|
+
if (!alive || generation !== authGeneration) return;
|
|
1476
|
+
if (view.phase === 'signed-in' && view.profile) {
|
|
1477
|
+
ownedAttempt = null;
|
|
1478
|
+
const credential = await getCredential();
|
|
1479
|
+
if (alive && generation === authGeneration) connect(credential.wsUrl);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
showLogin(view);
|
|
1483
|
+
if (!view.attempt || ['expired', 'error'].includes(view.attempt.phase)) return;
|
|
1484
|
+
pollTimer = setTimeout(() => pollLogin(generation), 1500);
|
|
1485
|
+
} catch (error) { showLoginError(error); }
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function connect(wsUrl) {
|
|
1489
|
+
if (state.ws || !alive) return;
|
|
1490
|
+
const url = new URL(wsUrl);
|
|
1491
|
+
if (!['ws:', 'wss:'].includes(url.protocol) || !url.searchParams.get('token') || url.username || url.password) throw new Error('invalid-response');
|
|
1492
|
+
if (location.protocol === 'https:' && url.protocol === 'ws:') {
|
|
1493
|
+
setStatus('当前使用 HTTPS,请将 CHAT_SERVER 配置为 HTTPS/WSS 地址', false);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const ws = new WebSocket(url.href);
|
|
1497
|
+
ws.binaryType = 'arraybuffer';
|
|
1498
|
+
state.serverKey = url.origin + url.pathname;
|
|
1499
|
+
state.ws = ws;
|
|
1500
|
+
state.opened = false;
|
|
1501
|
+
$('btnConnect').disabled = true;
|
|
1502
|
+
$('btnQuit').disabled = false;
|
|
1503
|
+
$('btnRelogin').disabled = true;
|
|
1504
|
+
$('loginBox').hidden = true;
|
|
1505
|
+
setStatus('正在连接聊天服务…', false);
|
|
1506
|
+
const timeout = connectionTimer = setTimeout(() => {
|
|
1507
|
+
if (ws.readyState === WebSocket.CONNECTING) ws.close();
|
|
1508
|
+
}, 20000);
|
|
1509
|
+
ws.onopen = () => {
|
|
1510
|
+
clearTimeout(timeout);
|
|
1511
|
+
if (!alive) { ws.close(); return; }
|
|
1512
|
+
state.opened = true;
|
|
1513
|
+
setConnected(true);
|
|
1514
|
+
setStatus('已连接', true);
|
|
1515
|
+
};
|
|
1516
|
+
let frameQueue = Promise.resolve();
|
|
1517
|
+
ws.onmessage = event => {
|
|
1518
|
+
if (!alive || state.ws !== ws || !(event.data instanceof ArrayBuffer)) return;
|
|
1519
|
+
let frame;
|
|
1520
|
+
try { frame = decodeMsg('serverFrame', new Uint8Array(event.data)); }
|
|
1521
|
+
catch { log('收到无法识别的消息', 'err'); return; }
|
|
1522
|
+
frameQueue = frameQueue.then(async () => {
|
|
1523
|
+
if (alive && state.ws === ws) await handleFrame(frame);
|
|
1524
|
+
}).catch(historyError);
|
|
1525
|
+
};
|
|
1526
|
+
ws.onerror = () => { if (alive) setStatus('连接失败,请检查聊天服务地址或重新登录', false); };
|
|
1527
|
+
ws.onclose = () => {
|
|
1528
|
+
clearTimeout(timeout);
|
|
1529
|
+
if (!alive || state.ws !== ws) return;
|
|
1530
|
+
state.ws = null;
|
|
1531
|
+
state.rooms.clear(); state.pendingRoom = null;
|
|
1532
|
+
state.me = null;
|
|
1533
|
+
refreshRooms();
|
|
1534
|
+
setConnected(false);
|
|
1535
|
+
setStatus(state.opened ? '连接已断开,点击连接 / 重试可重新进入' : '连接失败:登录已过期或聊天服务不可用', false);
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
$('btnConnect').addEventListener('click', () => { $('accountMenu').open = false; discover(); });
|
|
1540
|
+
$('btnLogin').addEventListener('click', beginLogin);
|
|
1541
|
+
$('btnRelogin').addEventListener('click', () => { if (!busy && !state.ws) { $('accountMenu').open = false; showLogin(null); beginLogin(); } });
|
|
1542
|
+
$('btnQuit').addEventListener('click', () => { $('accountMenu').open = false; state.ws?.close(); });
|
|
1543
|
+
$('composer').addEventListener('submit', event => { event.preventDefault(); sendMessage(); });
|
|
1544
|
+
$('messageInput').addEventListener('input', () => { if (current()) { current().draft = $('messageInput').value; ++current().draftVersion; recordStore.draft(current().key, current().draft); } syncComposer(); scheduleStickers(); });
|
|
1545
|
+
$('messageInput').addEventListener('compositionstart', () => { composing = true; closeStickers(); });
|
|
1546
|
+
$('messageInput').addEventListener('compositionend', () => { composing = false; scheduleStickers(); });
|
|
1547
|
+
$('messageInput').addEventListener('keydown', event => {
|
|
1548
|
+
if (event.key === 'Enter' && event.repeat) { event.preventDefault(); return; }
|
|
1549
|
+
if (stickerKey(event)) return;
|
|
1550
|
+
if (event.key !== 'Enter' || event.isComposing || event.keyCode === 229) return;
|
|
1551
|
+
event.preventDefault();
|
|
1552
|
+
if (event.ctrlKey) {
|
|
1553
|
+
const input = $('messageInput');
|
|
1554
|
+
if (input.disabled || input.value.length - (input.selectionEnd - input.selectionStart) >= input.maxLength) return;
|
|
1555
|
+
input.setRangeText('\n', input.selectionStart, input.selectionEnd, 'end');
|
|
1556
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1557
|
+
} else {
|
|
1558
|
+
sendMessage();
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
$('search').addEventListener('input', renderSessions);
|
|
1562
|
+
$('btnLatest').addEventListener('click', () => { scrollLatest(); if (current()) { current().unread = 0; renderSessions(); if (recordStore.ready) void recordStore.read(current().key).catch(historyError); } });
|
|
1563
|
+
$('messageViewport').addEventListener('scroll', () => { if (nearBottom()) $('btnLatest').hidden = true; });
|
|
1564
|
+
function historyError(error) {
|
|
1565
|
+
if (alive) log(error?.message || '聊天记录保存或读取失败', 'err');
|
|
1566
|
+
}
|
|
1567
|
+
function applyStoredSession(meta, revision = -1, allowDraft = false) {
|
|
1568
|
+
if (!meta) return null;
|
|
1569
|
+
const known = sessionRevisions.get(meta.key) ?? -1;
|
|
1570
|
+
if (revision >= 0 && revision < known) return state.sessions.get(meta.key);
|
|
1571
|
+
if (revision >= 0) sessionRevisions.set(meta.key, revision);
|
|
1572
|
+
const item = session(meta.kind, meta.id, meta.name, meta.avatar);
|
|
1573
|
+
item.localNew = false;
|
|
1574
|
+
item.preview = meta.preview; item.updated = meta.updated; item.unread = meta.unread; item.messageCount = meta.messageCount;
|
|
1575
|
+
const hydrateDraft = !item.draftHydrated; item.draftHydrated = true;
|
|
1576
|
+
if (!recordStore.dirty(meta.key) && !item.draftBarrier && (hydrateDraft || allowDraft)) {
|
|
1577
|
+
item.draft = meta.draft || '';
|
|
1578
|
+
if (current() === item) $('messageInput').value = item.draft;
|
|
1579
|
+
}
|
|
1580
|
+
return item;
|
|
1581
|
+
}
|
|
1582
|
+
function mergeStoredEntry(item, saved) {
|
|
1583
|
+
if (saved.revision <= item.clearedRevision || deletedEntryKeys.has(JSON.stringify([item.key, saved.id]))) return null;
|
|
1584
|
+
let entry = item.detachedEntries.get(saved.id) || item.entries.find(value => value.id === saved.id);
|
|
1585
|
+
const job = pendingSends.get(saved.message.clientMessageId);
|
|
1586
|
+
if (!entry && job?.item === item && job.entry.id === saved.id) entry = job.entry;
|
|
1587
|
+
if (entry) {
|
|
1588
|
+
if ((entry.revision || 0) > saved.revision) return entry;
|
|
1589
|
+
const priorImage = entry.message.sticker?.url, priorMissing = entry.imagePending;
|
|
1590
|
+
Object.assign(entry, saved); entry.storageSaved = true;
|
|
1591
|
+
if (job?.entry === entry && ['sent', 'failed'].includes(saved.sendStatus)) {
|
|
1592
|
+
pendingSends.delete(job.id); clearTimeout(job.timer); job.controller?.abort();
|
|
1593
|
+
if (entry.localImage) entry.localImage.file = null;
|
|
1594
|
+
}
|
|
1595
|
+
syncMessageStatus(entry);
|
|
1596
|
+
if (entry.evicted) {
|
|
1597
|
+
item.displayFloor = Math.max(item.displayFloor, saved.order || 0);
|
|
1598
|
+
if (!pendingSends.has(saved.message.clientMessageId)) { releaseEntryImage(entry); item.detachedEntries.delete(entry.id); }
|
|
1599
|
+
}
|
|
1600
|
+
else if (current() === item && !entry.localImage && (priorImage !== entry.message.sticker?.url || priorMissing !== entry.imagePending)) renderMessages();
|
|
1601
|
+
return entry;
|
|
1602
|
+
}
|
|
1603
|
+
if (item.loaded && saved.order && saved.order <= item.displayFloor) return null;
|
|
1604
|
+
if (!item.loaded) {
|
|
1605
|
+
if (item.loading) { item.loadingBuffer.set(saved.id, saved); if (item.loadingBuffer.size > 1000) item.loadingBuffer.delete(item.loadingBuffer.keys().next().value); }
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
entry = { ...saved, storageSaved: true }; addEntry(item, entry); return entry;
|
|
1609
|
+
}
|
|
1610
|
+
async function loadSessionMessages(item) {
|
|
1611
|
+
if (!item || item.loaded || !recordStore.ready) return;
|
|
1612
|
+
if (item.loading) return item.loading;
|
|
1613
|
+
const epoch = historyEpoch, version = item.loadVersion;
|
|
1614
|
+
item.loadingBuffer = new Map();
|
|
1615
|
+
item.loading = (async () => {
|
|
1616
|
+
try {
|
|
1617
|
+
const result = await recordStore.recent(item.key);
|
|
1618
|
+
if (!alive || epoch !== historyEpoch || version !== item.loadVersion || state.sessions.get(item.key) !== item) return;
|
|
1619
|
+
const entries = new Map(result.entries.filter(entry => entry.revision > item.clearedRevision && !deletedEntryKeys.has(JSON.stringify([item.key, entry.id]))).map(entry => [entry.id, entry]));
|
|
1620
|
+
for (const entry of item.loadingBuffer.values()) if (entry.revision > result.revision && !deletedEntryKeys.has(JSON.stringify([item.key, entry.id]))) entries.set(entry.id, entry);
|
|
1621
|
+
item.entries = [...entries.values()].slice(-1000).map(entry => ({ ...entry, storageSaved: true }));
|
|
1622
|
+
item.loaded = true; item.displayFloor = item.entries.length ? (item.entries[0].order || 1) - 1 : 0;
|
|
1623
|
+
if (current() === item) renderMessages(true);
|
|
1624
|
+
} catch (error) { historyError(error); }
|
|
1625
|
+
finally {
|
|
1626
|
+
if (version === item.loadVersion) { item.loading = null; item.loadingBuffer.clear(); }
|
|
1627
|
+
if (alive) syncComposer();
|
|
1628
|
+
}
|
|
1629
|
+
})();
|
|
1630
|
+
return item.loading;
|
|
1631
|
+
}
|
|
1632
|
+
async function reconcileLoadedMessages(item) {
|
|
1633
|
+
const epoch = historyEpoch;
|
|
1634
|
+
try {
|
|
1635
|
+
const existing = item.entries.filter(entry => entry.storageSaved).map(entry => entry.id);
|
|
1636
|
+
for await (const result of recordStore.sync(item.key, existing)) {
|
|
1637
|
+
if (!alive || epoch !== historyEpoch || state.sessions.get(item.key) !== item) return;
|
|
1638
|
+
const returned = new Set(result.entries.map(entry => entry.id));
|
|
1639
|
+
for (const id of result.requested) {
|
|
1640
|
+
const entry = item.entries.find(value => value.id === id);
|
|
1641
|
+
if (entry && !returned.has(id) && (entry.revision || 0) <= result.revision) discardLocalEntry(item, entry);
|
|
1642
|
+
}
|
|
1643
|
+
for (const saved of [...result.entries, ...(result.recent || [])]) mergeStoredEntry(item, saved);
|
|
1644
|
+
if (current() === item) renderMessages();
|
|
1645
|
+
}
|
|
1646
|
+
} catch (error) { historyError(error); }
|
|
1647
|
+
}
|
|
1648
|
+
function forgetSessionMessages(item, cutoff = Infinity, pendingIds = []) {
|
|
1649
|
+
if (Number.isFinite(cutoff)) item.clearedRevision = Math.max(item.clearedRevision, cutoff);
|
|
1650
|
+
++item.loadVersion; item.loading = null; item.loadingBuffer?.clear();
|
|
1651
|
+
const erased = entry => cutoff === Infinity || pendingIds.includes(entry.id) || entry.storageSaved && (entry.revision || 0) <= cutoff;
|
|
1652
|
+
for (const job of [...pendingSends.values()]) if (job.item === item && erased(job.entry)) discardLocalEntry(item, job.entry);
|
|
1653
|
+
for (const entry of [...item.detachedEntries.values()]) if (erased(entry)) discardLocalEntry(item, entry);
|
|
1654
|
+
for (const entry of [...item.entries]) if (erased(entry)) discardLocalEntry(item, entry);
|
|
1655
|
+
item.loaded = true;
|
|
1656
|
+
if (!item.entries.length) { item.messageCount = 0; item.unread = 0; item.preview = ''; item.updated = 0; }
|
|
1657
|
+
if (current() === item) { renderMessages(); $('btnLatest').hidden = true; }
|
|
1658
|
+
}
|
|
1659
|
+
function removeStoredSession(item) {
|
|
1660
|
+
if (!item || item.key === 'room:1') return;
|
|
1661
|
+
forgetSessionMessages(item); releaseLocalURL(item.draftSticker?.url); recordStore.cancelDraft(item.key);
|
|
1662
|
+
const active = current() === item;
|
|
1663
|
+
state.sessions.delete(item.key);
|
|
1664
|
+
if (active) selectSession('room:1');
|
|
1665
|
+
}
|
|
1666
|
+
function applyHistoryEvent(event) {
|
|
1667
|
+
if (!alive) return;
|
|
1668
|
+
if (event.type === 'snapshot') {
|
|
1669
|
+
contacts.clear(); for (const contact of event.contacts || []) contacts.set(String(contact.userId), contact);
|
|
1670
|
+
friendRequests.clear(); for (const request of event.requests || []) { setRequestLocal(request); if (request.status === 'pending' || request.status === 'accepted' || request.status === 'rejected') addRequestCard(request); }
|
|
1671
|
+
const known = new Set(event.sessions.map(item => item.key));
|
|
1672
|
+
for (const item of [...state.sessions.values()]) if (!known.has(item.key) && !item.localNew) removeStoredSession(item);
|
|
1673
|
+
for (const meta of event.sessions) {
|
|
1674
|
+
const item = applyStoredSession(meta, event.revision, true);
|
|
1675
|
+
if (item.loaded) void reconcileLoadedMessages(item);
|
|
1676
|
+
}
|
|
1677
|
+
void loadSessionMessages(current());
|
|
1678
|
+
if (current() && !document.hidden && document.hasFocus()) void recordStore.read(current().key).catch(historyError);
|
|
1679
|
+
} else if (event.type === 'contact' || event.type === 'request') {
|
|
1680
|
+
contacts.clear(); for (const contact of event.contacts || []) contacts.set(String(contact.userId), contact);
|
|
1681
|
+
friendRequests.clear(); for (const request of event.requests || []) { setRequestLocal(request); if (request.status === 'pending' || request.status === 'accepted' || request.status === 'rejected') addRequestCard(request); }
|
|
1682
|
+
} else if (event.type === 'remove') {
|
|
1683
|
+
const item = state.sessions.get(event.key);
|
|
1684
|
+
if (event.revision < (sessionRevisions.get(event.key) ?? -1)) { if (item) forgetSessionMessages(item, event.revision, event.pendingIds); }
|
|
1685
|
+
else { sessionRevisions.set(event.key, event.revision); removeStoredSession(item); }
|
|
1686
|
+
} else {
|
|
1687
|
+
if (!state.sessions.has(event.key) && event.revision < (sessionRevisions.get(event.key) ?? -1)) return;
|
|
1688
|
+
// Metadata is versioned separately from messages to tolerate HTTP/SSE reordering.
|
|
1689
|
+
const item = applyStoredSession(event.session, event.revision, event.type === 'draft' && event.client !== recordStore.client);
|
|
1690
|
+
if (!item) return;
|
|
1691
|
+
if (event.type === 'clear') forgetSessionMessages(item, event.revision, event.pendingIds);
|
|
1692
|
+
else if (event.type === 'delete') {
|
|
1693
|
+
deletedEntryKeys.add(JSON.stringify([event.key, event.id])); item.loadingBuffer?.delete(event.id);
|
|
1694
|
+
const entry = item.entries.find(value => value.id === event.id) || [...pendingSends.values()].find(job => job.item === item && job.entry.id === event.id)?.entry;
|
|
1695
|
+
if (entry) discardLocalEntry(item, entry);
|
|
1696
|
+
if (current() === item) renderMessages();
|
|
1697
|
+
} else if (event.type === 'message') mergeStoredEntry(item, event.entry);
|
|
1698
|
+
}
|
|
1699
|
+
renderSessions(); renderHeader();
|
|
1700
|
+
}
|
|
1701
|
+
async function persistEntry(item, entry, options = {}) {
|
|
1702
|
+
if (entry.removed) return false;
|
|
1703
|
+
try {
|
|
1704
|
+
const result = await recordStore.put(item, entry, options);
|
|
1705
|
+
if (result.ignored) { discardLocalEntry(item, entry); if (current() === item) renderMessages(); return false; }
|
|
1706
|
+
entry.storageSaved = true;
|
|
1707
|
+
if (result.entry) entry.revision = Math.max(entry.revision || 0, result.entry.revision || 0);
|
|
1708
|
+
return true;
|
|
1709
|
+
} catch (error) { historyError(error); if (entry.evicted && !pendingSends.has(entry.message.clientMessageId)) item.detachedEntries.delete(entry.id); return false; }
|
|
1710
|
+
}
|
|
1711
|
+
async function beginStoredSend(job, local) {
|
|
1712
|
+
const saved = await persistEntry(job.item, job.entry, { clearDraft: true });
|
|
1713
|
+
if (job.item.draftBarrier === job.id) job.item.draftBarrier = null;
|
|
1714
|
+
if (!saved) { failPendingSend(job.id, '记录保存失败,消息未发送'); return; }
|
|
1715
|
+
if (!canFinishSend(job)) return;
|
|
1716
|
+
if (local) await uploadMessageImage(job); else await sendPreparedMessage(job);
|
|
1717
|
+
}
|
|
1718
|
+
function discardLocalEntry(item, entry) {
|
|
1719
|
+
entry.removed = true; item.detachedEntries.delete(entry.id);
|
|
1720
|
+
deletedEntryKeys.add(JSON.stringify([item.key, entry.id]));
|
|
1721
|
+
if (entry.message?.clientMessageId) {
|
|
1722
|
+
hiddenLocalMessages.add(entry.message.fromUserId + ':' + entry.message.clientMessageId);
|
|
1723
|
+
const job = pendingSends.get(entry.message.clientMessageId);
|
|
1724
|
+
if (job?.entry === entry) { job.controller?.abort(); clearTimeout(job.timer); pendingSends.delete(job.id); }
|
|
1725
|
+
}
|
|
1726
|
+
detachMessageRow(entry); releaseEntryImage(entry);
|
|
1727
|
+
const index = item.entries.indexOf(entry);
|
|
1728
|
+
if (index >= 0) item.entries.splice(index, 1);
|
|
1729
|
+
}
|
|
1730
|
+
async function addFriend(message) {
|
|
1731
|
+
const id = String(message.fromUserId); if (!connected || isFriend(id) || isBlocked(id)) return;
|
|
1732
|
+
if (requestOf(id, 'outgoing', 'pending')) { log('好友申请已发送,正在等待对方处理'); return; }
|
|
1733
|
+
const request = { requestId: crypto.randomUUID(), peerId: id, direction: 'outgoing', status: 'pending', nickname: message.fromNickname || id, avatar: message.fromAvatar || '', created: Date.now() };
|
|
1734
|
+
if (!sendControl(id, 'friend-request', { requestId: request.requestId, createdAt: request.created })) { log('好友申请发送失败', 'err'); return; }
|
|
1735
|
+
await saveRequest(request); addRequestCard(request); log('好友申请已发送');
|
|
1736
|
+
}
|
|
1737
|
+
async function deleteFriend(message) { await saveContact(message.fromUserId, 'none', message); log('已删除好友'); }
|
|
1738
|
+
async function toggleBlock(message) {
|
|
1739
|
+
const id = String(message.fromUserId); if (isFriend(id)) return;
|
|
1740
|
+
const relation = isBlocked(id) ? 'none' : 'blocked'; await saveContact(id, relation, message); log(relation === 'blocked' ? '已屏蔽该用户' : '已解除屏蔽');
|
|
1741
|
+
}
|
|
1742
|
+
function clearLocalMessages(item) {
|
|
1743
|
+
if (state.sessions.get(item.key) !== item) return;
|
|
1744
|
+
return recordStore.change('clear', item.key);
|
|
1745
|
+
}
|
|
1746
|
+
const contextActions = mountChatContextMenu(root, {
|
|
1747
|
+
input: composerInput, current, latest: scrollLatest, feedback: log,
|
|
1748
|
+
sessionFor: button => state.sessions.get(button.dataset.sessionKey),
|
|
1749
|
+
entryFor: row => {
|
|
1750
|
+
const entry = contextEntries.get(row), item = current();
|
|
1751
|
+
return entry && item?.entries.includes(entry) ? { item, entry } : null;
|
|
1752
|
+
},
|
|
1753
|
+
isSelf: id => id === (state.me?.id || lastUserID),
|
|
1754
|
+
relation: id => contactOf(id),
|
|
1755
|
+
pendingFriend: id => Boolean(requestOf(id, 'outgoing', 'pending')),
|
|
1756
|
+
addFriend,
|
|
1757
|
+
deleteFriend,
|
|
1758
|
+
toggleBlock,
|
|
1759
|
+
privateChat: message => {
|
|
1760
|
+
if (!message.fromUserId || message.fromUserId === (state.me?.id || lastUserID)) return;
|
|
1761
|
+
const peer = session("private", message.fromUserId, message.fromNickname || message.fromUserId, message.fromAvatar);
|
|
1762
|
+
selectSession(peer.key);
|
|
1763
|
+
},
|
|
1764
|
+
clear: clearLocalMessages,
|
|
1765
|
+
delete: (item, entry) => {
|
|
1766
|
+
if (state.sessions.get(item.key) !== item || !item.entries.includes(entry)) return;
|
|
1767
|
+
return recordStore.change('delete', item.key, entry.id);
|
|
1768
|
+
},
|
|
1769
|
+
remove: item => {
|
|
1770
|
+
if (item.key === 'room:1' || state.sessions.get(item.key) !== item) return;
|
|
1771
|
+
return recordStore.change('remove', item.key);
|
|
1772
|
+
},
|
|
1773
|
+
});
|
|
1774
|
+
// Clipboard files are staged locally, just like files chosen with the image button.
|
|
1775
|
+
composerInput.addEventListener("paste", event => {
|
|
1776
|
+
if (composerInput.disabled || composerInput.readOnly || !uploadImage) return;
|
|
1777
|
+
const clipboard = event.clipboardData;
|
|
1778
|
+
if (!clipboard) return;
|
|
1779
|
+
const imageItem = [...clipboard.items].find(item => item.kind === "file" && item.type.startsWith("image/"));
|
|
1780
|
+
const image = imageItem?.getAsFile() || [...clipboard.files].find(file => file.type.startsWith("image/"));
|
|
1781
|
+
if (!image) return;
|
|
1782
|
+
event.preventDefault();
|
|
1783
|
+
stageDraftImage(image);
|
|
1784
|
+
}, { signal: life.signal });
|
|
1785
|
+
const onFocus = () => {
|
|
1786
|
+
if (!document.hidden && document.hasFocus() && root.getClientRects().length && current()) { current().unread = 0; renderSessions(); if (recordStore.ready) void recordStore.read(current().key).catch(historyError); }
|
|
1787
|
+
};
|
|
1788
|
+
const onKey = event => { if (event.key === 'Escape') { $('accountMenu').open = false; closeEmoji(); } };
|
|
1789
|
+
root.addEventListener('keydown', onKey);
|
|
1790
|
+
window.addEventListener('focus', onFocus);
|
|
1791
|
+
document.addEventListener('visibilitychange', onFocus);
|
|
1792
|
+
const dispose = () => {
|
|
1793
|
+
if (!alive) return;
|
|
1794
|
+
alive = false; ++authGeneration; clearPendingSends(); releaseAllLocalImages();
|
|
1795
|
+
if (current()) recordStore.draft(current().key, $('messageInput').value);
|
|
1796
|
+
recordStore.close();
|
|
1797
|
+
contextActions.dispose();
|
|
1798
|
+
emojiLife.abort(); emojiPanel.remove();
|
|
1799
|
+
imageUploadInput.onchange = null; imageUploadInput.remove(); composerToolbar.remove();
|
|
1800
|
+
endMessageScrollDrag(); messageScrollLife.abort(); messageScrollResize.disconnect();
|
|
1801
|
+
if (messageScrollFrame !== null) cancelAnimationFrame(messageScrollFrame);
|
|
1802
|
+
messageScrollArea.before(messageViewport); messageScrollArea.remove();
|
|
1803
|
+
draftStickerController?.abort(); draftStickerImage.onload = draftStickerImage.onerror = null;
|
|
1804
|
+
composerEditor.before(composerInput); composerEditor.remove();
|
|
1805
|
+
messageMediaController.abort(); messageImageObserver.disconnect(); stickerMedia.dispose();
|
|
1806
|
+
closeStickers(); stickerResize.disconnect(); stickerPanel.remove();
|
|
1807
|
+
clearTimeout(pollTimer); clearTimeout(feedbackTimer); clearTimeout(connectionTimer);
|
|
1808
|
+
life.abort(); state.ws?.close();
|
|
1809
|
+
if (ownedAttempt) transport(loginEndpoint, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cancel', id: ownedAttempt }) }).catch(() => {});
|
|
1810
|
+
root.removeEventListener('keydown', onKey);
|
|
1811
|
+
window.removeEventListener('focus', onFocus);
|
|
1812
|
+
document.removeEventListener('visibilitychange', onFocus);
|
|
1813
|
+
window.removeEventListener('pagehide', dispose);
|
|
1814
|
+
};
|
|
1815
|
+
window.addEventListener('pagehide', dispose, { once: true });
|
|
1816
|
+
session('room', 1); renderProfile(); renderSessions(); renderHeader();
|
|
1817
|
+
discover();
|
|
1818
|
+
return dispose;
|
|
1819
|
+
}
|