@yiarce/dsh-chat 0.1.0 → 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/chat.html +5 -1
- package/context-menu.js +14 -4
- package/history-client.js +3 -0
- package/history-host.js +2 -2
- package/history-store.js +50 -0
- package/package.json +1 -1
- package/page.css +40 -0
- package/page.js +94 -1
- package/window-client.js +1 -0
package/chat.html
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
</aside>
|
|
24
24
|
<section class="yc-workspace" aria-label="当前会话">
|
|
25
25
|
<div id="window-bar" class="yc-window-bar">
|
|
26
|
-
<div id="window-controls" class="yc-window-controls" hidden
|
|
26
|
+
<div id="window-controls" class="yc-window-controls" hidden aria-label="窗口操作">
|
|
27
|
+
<button id="window-minimize" type="button" aria-label="最小化" title="最小化"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
|
|
28
|
+
<button id="window-maximize" type="button" aria-label="最大化或还原" title="最大化或还原"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="1"/></svg></button>
|
|
29
|
+
<button id="window-close" type="button" aria-label="关闭" title="关闭"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
|
30
|
+
</div>
|
|
27
31
|
</div>
|
|
28
32
|
<header class="yc-conversation-header">
|
|
29
33
|
<div class="yc-conversation-heading"><h2 id="conversationTitle">广场</h2><p id="conversationMeta" hidden></p></div>
|
package/context-menu.js
CHANGED
|
@@ -78,10 +78,20 @@ export function mountChatContextMenu(root, api) {
|
|
|
78
78
|
const { item, entry } = found;
|
|
79
79
|
if (target.closest(".yc-message-avatar")) {
|
|
80
80
|
const id = entry.message.fromUserId;
|
|
81
|
-
return [
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
if (item.kind !== "room") return [];
|
|
82
|
+
const relation = api.relation(id);
|
|
83
|
+
const entries = [];
|
|
84
|
+
if (relation === "friend") {
|
|
85
|
+
entries.push({ label: "私聊", disabled: !id || api.isSelf(id), run: () => api.privateChat(entry.message) });
|
|
86
|
+
entries.push({ label: "删除好友", danger: true, run: () => api.deleteFriend(entry.message) });
|
|
87
|
+
} else if (relation === "blocked") {
|
|
88
|
+
entries.push({ label: "解除屏蔽", run: () => api.toggleBlock(entry.message) });
|
|
89
|
+
} else {
|
|
90
|
+
entries.push({ label: api.pendingFriend(id) ? "好友申请中" : "加为好友", disabled: !id || api.isSelf(id) || api.pendingFriend(id), run: () => api.addFriend(entry.message) });
|
|
91
|
+
entries.push({ label: "屏蔽用户", danger: true, disabled: !id || api.isSelf(id), run: () => api.toggleBlock(entry.message) });
|
|
92
|
+
}
|
|
93
|
+
entries.push({ label: "复制 ID", disabled: !id, run: () => copy(id) });
|
|
94
|
+
return entries;
|
|
85
95
|
}
|
|
86
96
|
if (!target.closest(".yc-bubble") || target.closest(".yc-message-send-status")) return [];
|
|
87
97
|
return [
|
package/history-client.js
CHANGED
|
@@ -78,6 +78,9 @@ export class ChatHistoryClient {
|
|
|
78
78
|
}
|
|
79
79
|
read(key) { return this.request('read', { key }) }
|
|
80
80
|
change(action, key, id) { return this.request(action, { key, id }) }
|
|
81
|
+
contacts() { return this.request('contacts') }
|
|
82
|
+
contact(userId, relation, profile = {}) { return this.request('contact', { userId, relation, nickname: profile.nickname, avatar: profile.avatar }) }
|
|
83
|
+
requestFriend(fields) { return this.request('request', fields) }
|
|
81
84
|
dirty(key) { return this.drafts.get(key)?.binding === this.binding }
|
|
82
85
|
draft(key, text) {
|
|
83
86
|
if (!this.binding) return
|
package/history-host.js
CHANGED
|
@@ -71,9 +71,9 @@ export function registerHistory(ctx) {
|
|
|
71
71
|
const push = value => { if (!closed) try { output.enqueue(encoder.encode('data: ' + JSON.stringify(value) + '\n\n')) } catch { close() } }
|
|
72
72
|
request.signal.addEventListener('abort', close, { once: true }); life.signal.addEventListener('abort', close, { once: true })
|
|
73
73
|
if (request.signal.aborted || life.signal.aborted) { close(); return }
|
|
74
|
-
const sessions = db.list(account)
|
|
74
|
+
const sessions = db.list(account), contacts = db.contacts(account), requests = db.requests(account)
|
|
75
75
|
unsubscribe = db.subscribe(event => { if (event.account === account) push(event) })
|
|
76
|
-
push({ type: 'snapshot', account, sessions, revision: db.revision() })
|
|
76
|
+
push({ type: 'snapshot', account, sessions, contacts, requests, revision: db.revision() })
|
|
77
77
|
heartbeat = setInterval(() => { if (!closed) try { output.enqueue(encoder.encode(': heartbeat\n\n')) } catch { close() } }, 15000)
|
|
78
78
|
heartbeat.unref?.()
|
|
79
79
|
},
|
package/history-store.js
CHANGED
|
@@ -72,6 +72,18 @@ export class ChatHistoryStore {
|
|
|
72
72
|
account TEXT NOT NULL, session_key TEXT NOT NULL, message_id TEXT NOT NULL,
|
|
73
73
|
PRIMARY KEY(account, session_key, message_id)
|
|
74
74
|
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
76
|
+
account TEXT NOT NULL, user_id TEXT NOT NULL, nickname TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '',
|
|
77
|
+
relation TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
78
|
+
PRIMARY KEY(account, user_id)
|
|
79
|
+
);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS friend_requests (
|
|
81
|
+
account TEXT NOT NULL, request_id TEXT NOT NULL, peer_id TEXT NOT NULL, direction TEXT NOT NULL,
|
|
82
|
+
status TEXT NOT NULL, nickname TEXT NOT NULL DEFAULT '', avatar TEXT NOT NULL DEFAULT '',
|
|
83
|
+
created INTEGER NOT NULL, updated INTEGER NOT NULL,
|
|
84
|
+
PRIMARY KEY(account, request_id)
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS friend_requests_peer ON friend_requests(account, peer_id, updated DESC);
|
|
75
87
|
UPDATE messages SET error=CASE WHEN status='uploading' THEN '图片上传未完成' ELSE '发送未完成,未自动重发' END,
|
|
76
88
|
status='failed' WHERE status IN ('uploading','sending');
|
|
77
89
|
`)
|
|
@@ -110,6 +122,14 @@ export class ChatHistoryStore {
|
|
|
110
122
|
entry(row) {
|
|
111
123
|
return { id: row.message_id, order: row.ordinal, message: JSON.parse(row.payload), sendStatus: row.status, sendError: row.error, imagePending: Boolean(row.image_pending), revision: row.revision }
|
|
112
124
|
}
|
|
125
|
+
contacts(account) {
|
|
126
|
+
accountId(account)
|
|
127
|
+
return this.db.prepare('SELECT user_id AS userId,nickname,avatar,relation,created,updated FROM contacts WHERE account=? ORDER BY updated DESC').all(account)
|
|
128
|
+
}
|
|
129
|
+
requests(account) {
|
|
130
|
+
accountId(account)
|
|
131
|
+
return this.db.prepare('SELECT request_id AS requestId,peer_id AS peerId,direction,status,nickname,avatar,created,updated FROM friend_requests WHERE account=? ORDER BY updated DESC').all(account)
|
|
132
|
+
}
|
|
113
133
|
sync(account, key, ids, includeRecent) {
|
|
114
134
|
accountId(account); roomKey(key)
|
|
115
135
|
if (!Array.isArray(ids) || ids.length > 100 || ids.some(id => typeof id !== 'string' || id.length > 8192)) throw new Error('同步消息标识无效')
|
|
@@ -149,6 +169,8 @@ export class ChatHistoryStore {
|
|
|
149
169
|
return { event: this.emit(event, client), entry: event.entry, session: event.session, revision: event.revision }
|
|
150
170
|
}
|
|
151
171
|
change(account, client, input) {
|
|
172
|
+
if (input.action === 'contacts') return { contacts: this.contacts(account), requests: this.requests(account), revision: this.revision() }
|
|
173
|
+
if (input.action === 'contact' || input.action === 'request') return this.changeRelation(account, client, input)
|
|
152
174
|
accountId(account)
|
|
153
175
|
const key = roomKey(input.key)
|
|
154
176
|
if (input.action === 'remove' && key === 'room:1') throw new Error('广场不能移除')
|
|
@@ -175,6 +197,34 @@ export class ChatHistoryStore {
|
|
|
175
197
|
})
|
|
176
198
|
return { event: this.emit(event, client) }
|
|
177
199
|
}
|
|
200
|
+
changeRelation(account, client, input) {
|
|
201
|
+
const now = Date.now(), id = text(input.userId || input.peerId, 512), action = input.action
|
|
202
|
+
if (!id) throw new Error('用户标识无效')
|
|
203
|
+
const event = this.transaction(() => {
|
|
204
|
+
const revision = this.nextRevision()
|
|
205
|
+
if (action === 'contact') {
|
|
206
|
+
const relation = ['friend', 'blocked', 'none'].includes(input.relation) ? input.relation : ''
|
|
207
|
+
if (!relation) throw new Error('联系人关系无效')
|
|
208
|
+
if (relation === 'none') this.db.prepare('DELETE FROM contacts WHERE account=? AND user_id=?').run(account, id)
|
|
209
|
+
else this.db.prepare(`INSERT INTO contacts(account,user_id,nickname,avatar,relation,created,updated) VALUES (?,?,?,?,?,?,?)
|
|
210
|
+
ON CONFLICT(account,user_id) DO UPDATE SET nickname=CASE WHEN excluded.nickname<>'' THEN excluded.nickname ELSE contacts.nickname END,
|
|
211
|
+
avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE contacts.avatar END,relation=excluded.relation,updated=excluded.updated`).run(account,id,text(input.nickname),text(input.avatar,2048),relation,now,now)
|
|
212
|
+
} else {
|
|
213
|
+
const requestId = text(input.requestId, 200); const peerId = text(input.peerId || id, 512)
|
|
214
|
+
if (!requestId) throw new Error('申请标识无效')
|
|
215
|
+
const status = ['pending','accepted','rejected','released'].includes(input.status) ? input.status : ''
|
|
216
|
+
const direction = input.direction === 'incoming' ? 'incoming' : 'outgoing'
|
|
217
|
+
if (!status) throw new Error('申请状态无效')
|
|
218
|
+
this.db.prepare(`INSERT INTO friend_requests(account,request_id,peer_id,direction,status,nickname,avatar,created,updated) VALUES (?,?,?,?,?,?,?,?,?)
|
|
219
|
+
ON CONFLICT(account,request_id) DO UPDATE SET status=excluded.status,nickname=CASE WHEN excluded.nickname<>'' THEN excluded.nickname ELSE friend_requests.nickname END,
|
|
220
|
+
avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE friend_requests.avatar END,updated=excluded.updated`).run(account,requestId,peerId,direction,status,text(input.nickname),text(input.avatar,2048),Number(input.created)||now,now)
|
|
221
|
+
if (status === 'accepted') this.db.prepare(`INSERT INTO contacts(account,user_id,nickname,avatar,relation,created,updated) VALUES (?,?,?,?,?,?,?)
|
|
222
|
+
ON CONFLICT(account,user_id) DO UPDATE SET relation='friend',updated=excluded.updated`).run(account,peerId,text(input.nickname),text(input.avatar,2048),'friend',now,now)
|
|
223
|
+
}
|
|
224
|
+
return { type: action, account, userId: id, requestId: input.requestId, relation: input.relation, status: input.status, contacts: this.contacts(account), requests: this.requests(account), revision }
|
|
225
|
+
})
|
|
226
|
+
return { event: this.emit(event, client), contacts: event.contacts, requests: event.requests }
|
|
227
|
+
}
|
|
178
228
|
endClient(account, client) {
|
|
179
229
|
const rows = this.db.prepare("SELECT * FROM messages WHERE account=? AND owner=? AND status IN ('uploading','sending')").all(account, client)
|
|
180
230
|
for (const row of rows) {
|
package/package.json
CHANGED
package/page.css
CHANGED
|
@@ -876,6 +876,46 @@
|
|
|
876
876
|
color: white;
|
|
877
877
|
}
|
|
878
878
|
|
|
879
|
+
.yc-chat-surface .yc-friend-request-card {
|
|
880
|
+
width: fit-content;
|
|
881
|
+
max-width: min(360px, 80%);
|
|
882
|
+
margin: 10px auto;
|
|
883
|
+
padding: 12px 14px;
|
|
884
|
+
border: 1px solid var(--yc-line);
|
|
885
|
+
border-radius: 12px;
|
|
886
|
+
background: var(--yc-card);
|
|
887
|
+
color: var(--yc-text);
|
|
888
|
+
box-shadow: 0 4px 16px #00000012;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.yc-chat-surface .yc-friend-request-card p { margin: 6px 0 10px; color: var(--yc-muted); font-size: 12px; }
|
|
892
|
+
.yc-chat-surface .yc-friend-request-actions { display: flex; gap: 8px; }
|
|
893
|
+
.yc-chat-surface .yc-friend-request-actions button { padding: 5px 10px; border-radius: 6px; background: var(--yc-hover); color: var(--yc-text); }
|
|
894
|
+
.yc-chat-surface .yc-friend-request-actions button:hover { background: var(--yc-accent); color: white; }
|
|
895
|
+
|
|
896
|
+
.yc-chat-surface .yc-window-controls button svg {
|
|
897
|
+
display: block;
|
|
898
|
+
width: 14px;
|
|
899
|
+
height: 14px;
|
|
900
|
+
margin: auto;
|
|
901
|
+
fill: none;
|
|
902
|
+
stroke: currentColor;
|
|
903
|
+
stroke-width: 1.7;
|
|
904
|
+
stroke-linecap: round;
|
|
905
|
+
stroke-linejoin: round;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.yc-chat-surface #window-close svg {
|
|
909
|
+
width: 15px;
|
|
910
|
+
height: 15px;
|
|
911
|
+
stroke-width: 2.2;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.yc-chat-surface .yc-window-controls button:focus-visible {
|
|
915
|
+
outline: 1px solid currentColor;
|
|
916
|
+
outline-offset: -4px;
|
|
917
|
+
}
|
|
918
|
+
|
|
879
919
|
@media(max-width:720px) {
|
|
880
920
|
.yc-chat-surface {
|
|
881
921
|
grid-template-columns: 190px minmax(0,1fr);
|
package/page.js
CHANGED
|
@@ -190,6 +190,9 @@ const $ = id => root.querySelector('[id="' + id + '"]');
|
|
|
190
190
|
const contextEntries = new WeakMap();
|
|
191
191
|
const hiddenLocalMessages = new Set();
|
|
192
192
|
const deletedEntryKeys = new Set();
|
|
193
|
+
const CONTROL_PREFIX = '\u2063yiarce-chat:';
|
|
194
|
+
const contacts = new Map();
|
|
195
|
+
const friendRequests = new Map();
|
|
193
196
|
const localImageURLs = new Set();
|
|
194
197
|
const state = { ws: null, me: null, rooms: new Set(), opened: false, sessions: new Map(), active: 'room:1', pendingRoom: null };
|
|
195
198
|
const ERROR_NAMES = ['未知错误', '请求无效', '尚未加入房间', '对方不在线', '服务器繁忙'];
|
|
@@ -213,6 +216,55 @@ function session(kind, id, name, avatar = '') {
|
|
|
213
216
|
return item;
|
|
214
217
|
}
|
|
215
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
|
+
|
|
216
268
|
function avatarFor(item, mine = false) {
|
|
217
269
|
const el = make('span', 'yc-avatar' + (item.kind === 'room' ? ' yc-room-avatar' : ''));
|
|
218
270
|
if (item.kind === 'room') {
|
|
@@ -960,6 +1012,17 @@ function scrollLatest() {
|
|
|
960
1012
|
}
|
|
961
1013
|
function nearBottom() { const box = $('messageViewport'); return box.scrollHeight - box.scrollTop - box.clientHeight < 80; }
|
|
962
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
|
+
}
|
|
963
1026
|
if (entry.system) return make('p', 'yc-system-message', entry.text);
|
|
964
1027
|
const m = entry.message;
|
|
965
1028
|
const attachment = m.sticker || (entry.localImage ? { url: entry.localImage.url } : null);
|
|
@@ -1078,8 +1141,12 @@ function sendClientFrame(obj) {
|
|
|
1078
1141
|
catch { log('消息发送失败,请重试', 'err'); return false; }
|
|
1079
1142
|
}
|
|
1080
1143
|
async function renderMessage(m) {
|
|
1144
|
+
const control = m.privateMsg ? parseControl(m) : null;
|
|
1145
|
+
if (control) { await handleControlMessage(m, control); return; }
|
|
1081
1146
|
if (m.clientMessageId && hiddenLocalMessages.has(m.fromUserId + ":" + m.clientMessageId)) return;
|
|
1082
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;
|
|
1083
1150
|
const item = m.privateMsg ? session('private', mine ? m.toUserId : m.fromUserId, mine ? undefined : m.fromNickname, mine ? '' : m.fromAvatar) : session('room', m.roomId);
|
|
1084
1151
|
const epoch = historyEpoch, atBottom = nearBottom();
|
|
1085
1152
|
const read = item.key === state.active && !document.hidden && document.hasFocus();
|
|
@@ -1106,7 +1173,7 @@ async function handleFrame(f) {
|
|
|
1106
1173
|
const nextAccount = JSON.stringify([state.serverKey, String(f.self.userId)]);
|
|
1107
1174
|
if (recordStore.account && recordStore.account !== nextAccount || lastUserID && lastUserID !== f.self.userId) {
|
|
1108
1175
|
clearPendingSends(); releaseAllLocalImages(); stickerMedia.dispose(); stickerMedia = createStickerMedia();
|
|
1109
|
-
recordStore.close(); sessionRevisions.clear(); hiddenLocalMessages.clear(); deletedEntryKeys.clear(); ++historyEpoch;
|
|
1176
|
+
recordStore.close(); sessionRevisions.clear(); hiddenLocalMessages.clear(); deletedEntryKeys.clear(); contacts.clear(); friendRequests.clear(); ++historyEpoch;
|
|
1110
1177
|
state.sessions.clear(); state.active = 'room:1'; $('messageInput').value = ''; session('room', 1);
|
|
1111
1178
|
}
|
|
1112
1179
|
lastUserID = f.self.userId;
|
|
@@ -1279,6 +1346,10 @@ function sendMessage() {
|
|
|
1279
1346
|
}, sendStatus: local ? 'uploading' : 'sending', uploadPercent: 0,
|
|
1280
1347
|
imagePending: local, localImage: local ? { url: sticker.url, file: sticker.file } : null };
|
|
1281
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
|
+
}
|
|
1282
1353
|
const job = { id, item, entry, timer: null, controller: local ? new AbortController() : null, ws: state.ws, owner: state.me?.id || lastUserID };
|
|
1283
1354
|
pendingSends.set(id, job);
|
|
1284
1355
|
// Transfer ownership of the local preview from the draft into this message.
|
|
@@ -1595,6 +1666,8 @@ function removeStoredSession(item) {
|
|
|
1595
1666
|
function applyHistoryEvent(event) {
|
|
1596
1667
|
if (!alive) return;
|
|
1597
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); }
|
|
1598
1671
|
const known = new Set(event.sessions.map(item => item.key));
|
|
1599
1672
|
for (const item of [...state.sessions.values()]) if (!known.has(item.key) && !item.localNew) removeStoredSession(item);
|
|
1600
1673
|
for (const meta of event.sessions) {
|
|
@@ -1603,6 +1676,9 @@ function applyHistoryEvent(event) {
|
|
|
1603
1676
|
}
|
|
1604
1677
|
void loadSessionMessages(current());
|
|
1605
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); }
|
|
1606
1682
|
} else if (event.type === 'remove') {
|
|
1607
1683
|
const item = state.sessions.get(event.key);
|
|
1608
1684
|
if (event.revision < (sessionRevisions.get(event.key) ?? -1)) { if (item) forgetSessionMessages(item, event.revision, event.pendingIds); }
|
|
@@ -1651,6 +1727,18 @@ function discardLocalEntry(item, entry) {
|
|
|
1651
1727
|
const index = item.entries.indexOf(entry);
|
|
1652
1728
|
if (index >= 0) item.entries.splice(index, 1);
|
|
1653
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
|
+
}
|
|
1654
1742
|
function clearLocalMessages(item) {
|
|
1655
1743
|
if (state.sessions.get(item.key) !== item) return;
|
|
1656
1744
|
return recordStore.change('clear', item.key);
|
|
@@ -1663,6 +1751,11 @@ const contextActions = mountChatContextMenu(root, {
|
|
|
1663
1751
|
return entry && item?.entries.includes(entry) ? { item, entry } : null;
|
|
1664
1752
|
},
|
|
1665
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,
|
|
1666
1759
|
privateChat: message => {
|
|
1667
1760
|
if (!message.fromUserId || message.fromUserId === (state.me?.id || lastUserID)) return;
|
|
1668
1761
|
const peer = session("private", message.fromUserId, message.fromNickname || message.fromUserId, message.fromAvatar);
|
package/window-client.js
CHANGED
|
@@ -24,6 +24,7 @@ export function bindNativeWindow(root) {
|
|
|
24
24
|
}
|
|
25
25
|
root.querySelector("#window-minimize").addEventListener("click", () => emit("minimize"), { signal: life.signal })
|
|
26
26
|
root.querySelector("#window-close").addEventListener("click", () => emit("close"), { signal: life.signal })
|
|
27
|
+
root.querySelector("#window-maximize").addEventListener("click", () => emit("maximize"), { signal: life.signal })
|
|
27
28
|
root.querySelector("#window-bar").addEventListener("dblclick", event => { if (!event.target.closest("button")) emit("maximize") }, { signal: life.signal })
|
|
28
29
|
window.addEventListener("yiarce-chat-native-ready", initialize, { signal: life.signal })
|
|
29
30
|
initialize()
|