@yiarce/dsh-chat 0.1.0 → 0.1.2
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 +116 -3
- 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: "加为好友", disabled: !id || api.isSelf(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 = ['未知错误', '请求无效', '尚未加入房间', '对方不在线', '服务器繁忙'];
|
|
@@ -206,13 +209,67 @@ const timeStr = ms => ms ? new Date(Number(ms)).toLocaleTimeString('zh-CN', { ho
|
|
|
206
209
|
function session(kind, id, name, avatar = '') {
|
|
207
210
|
const key = kind + ':' + id;
|
|
208
211
|
if (kind === 'room' && Number(id) === 1) name = '广场';
|
|
209
|
-
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 });
|
|
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(), cards: new Map(), messageCount: 0, localNew: true, unread: 0, draft: '', draftSticker: null, draftVersion: 0, preview: '', updated: 0, count: null, members: null });
|
|
210
213
|
const item = state.sessions.get(key);
|
|
211
214
|
if (name) item.name = name;
|
|
212
215
|
if (avatar) item.avatar = 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
|
+
if (!request?.requestId) return;
|
|
242
|
+
const item = session('private', request.peerId, request.nickname, request.avatar);
|
|
243
|
+
item.cards.set(request.requestId, { ...request });
|
|
244
|
+
if (current() === item) renderMessages();
|
|
245
|
+
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
|
+
// The sender may retry freely; the receiver dedupes. Already a friend,
|
|
255
|
+
// an untouched pending request and an unreleased rejection all drop it.
|
|
256
|
+
if (!base.requestId || isFriend(peerId)) return;
|
|
257
|
+
const existing = requestOf(peerId, 'incoming');
|
|
258
|
+
if (existing && (existing.status === 'pending' || existing.status === 'rejected')) return;
|
|
259
|
+
const request = { ...base, direction: 'incoming', status: 'pending' }; await saveRequest(request); addRequestCard(request); return;
|
|
260
|
+
}
|
|
261
|
+
// Senders keep no local request record, so acceptance is applied directly.
|
|
262
|
+
if (control.type === 'friend-accept' && !mine) { await saveContact(peerId, 'friend', profile); return; }
|
|
263
|
+
if (control.type === 'friend-reject' && !mine) { log('对方拒绝了你的好友申请', 'err'); return; }
|
|
264
|
+
if (control.type === 'friend-release' && !mine) return;
|
|
265
|
+
}
|
|
266
|
+
async function handleRequestAction(request, action) {
|
|
267
|
+
if (action === 'accept') { await saveRequest({ ...request, status: 'accepted' }); await saveContact(request.peerId, 'friend', request); sendControl(request.peerId, 'friend-accept', { requestId: request.requestId }); }
|
|
268
|
+
else if (action === 'reject') { await saveRequest({ ...request, status: 'rejected' }); sendControl(request.peerId, 'friend-reject', { requestId: request.requestId }); }
|
|
269
|
+
else if (action === 'release') { await saveRequest({ ...request, status: 'released' }); sendControl(request.peerId, 'friend-release', { requestId: request.requestId }); }
|
|
270
|
+
addRequestCard({ ...request, status: action === 'accept' ? 'accepted' : action === 'reject' ? 'rejected' : 'released' });
|
|
271
|
+
}
|
|
272
|
+
|
|
216
273
|
function avatarFor(item, mine = false) {
|
|
217
274
|
const el = make('span', 'yc-avatar' + (item.kind === 'room' ? ' yc-room-avatar' : ''));
|
|
218
275
|
if (item.kind === 'room') {
|
|
@@ -959,6 +1016,26 @@ function scrollLatest() {
|
|
|
959
1016
|
const box = $('messageViewport'); box.scrollTop = box.scrollHeight; $('btnLatest').hidden = true;
|
|
960
1017
|
}
|
|
961
1018
|
function nearBottom() { const box = $('messageViewport'); return box.scrollHeight - box.scrollTop - box.clientHeight < 80; }
|
|
1019
|
+
function requestCardNode(request) {
|
|
1020
|
+
const status = request.status;
|
|
1021
|
+
const title = status === 'accepted' ? '已成为好友'
|
|
1022
|
+
: status === 'rejected' ? '申请已拒绝'
|
|
1023
|
+
: status === 'released' ? '已解除拒绝'
|
|
1024
|
+
: request.direction === 'incoming' ? '好友申请' : '好友申请中';
|
|
1025
|
+
const card = make('article', 'yc-friend-request-card');
|
|
1026
|
+
card.append(make('strong', '', title));
|
|
1027
|
+
card.append(make('p', '', request.nickname + (request.direction === 'incoming' ? ' 请求添加你为好友' : ' 的好友申请')));
|
|
1028
|
+
const actions = make('div', 'yc-friend-request-actions');
|
|
1029
|
+
const add = (label, action) => {
|
|
1030
|
+
const button = make('button', '', label); button.type = 'button';
|
|
1031
|
+
button.onclick = () => void handleRequestAction(request, action).catch(error => log(error.message || '操作失败', 'err'));
|
|
1032
|
+
actions.append(button);
|
|
1033
|
+
};
|
|
1034
|
+
if (request.direction === 'incoming' && status === 'pending') { add('同意', 'accept'); add('拒绝', 'reject'); }
|
|
1035
|
+
else if (request.direction === 'incoming' && status === 'rejected') add('解除拒绝', 'release');
|
|
1036
|
+
if (actions.childNodes.length) card.append(actions);
|
|
1037
|
+
return card;
|
|
1038
|
+
}
|
|
962
1039
|
function messageNode(entry) {
|
|
963
1040
|
if (entry.system) return make('p', 'yc-system-message', entry.text);
|
|
964
1041
|
const m = entry.message;
|
|
@@ -1004,7 +1081,9 @@ function renderMessages(force = false) {
|
|
|
1004
1081
|
const entries = item?.entries || [];
|
|
1005
1082
|
resetMessageImages();
|
|
1006
1083
|
for (const row of $('messages').children) { const entry = contextEntries.get(row); if (entry) detachMessageRow(entry); }
|
|
1007
|
-
|
|
1084
|
+
const nodes = entries.map(messageNode);
|
|
1085
|
+
for (const request of item?.cards?.values() || []) nodes.push(requestCardNode(request));
|
|
1086
|
+
$('messages').replaceChildren(...nodes);
|
|
1008
1087
|
if (bottom) scrollLatest(); else box.scrollTop = top;
|
|
1009
1088
|
}
|
|
1010
1089
|
function selectSession(key) {
|
|
@@ -1078,8 +1157,12 @@ function sendClientFrame(obj) {
|
|
|
1078
1157
|
catch { log('消息发送失败,请重试', 'err'); return false; }
|
|
1079
1158
|
}
|
|
1080
1159
|
async function renderMessage(m) {
|
|
1160
|
+
const control = m.privateMsg ? parseControl(m) : null;
|
|
1161
|
+
if (control) { await handleControlMessage(m, control); return; }
|
|
1081
1162
|
if (m.clientMessageId && hiddenLocalMessages.has(m.fromUserId + ":" + m.clientMessageId)) return;
|
|
1082
1163
|
const mine = m.fromUserId === (state.me?.id || lastUserID);
|
|
1164
|
+
if (m.privateMsg && !mine && !isFriend(m.fromUserId)) return;
|
|
1165
|
+
if (!m.privateMsg && !mine && isBlocked(m.fromUserId)) return;
|
|
1083
1166
|
const item = m.privateMsg ? session('private', mine ? m.toUserId : m.fromUserId, mine ? undefined : m.fromNickname, mine ? '' : m.fromAvatar) : session('room', m.roomId);
|
|
1084
1167
|
const epoch = historyEpoch, atBottom = nearBottom();
|
|
1085
1168
|
const read = item.key === state.active && !document.hidden && document.hasFocus();
|
|
@@ -1106,7 +1189,7 @@ async function handleFrame(f) {
|
|
|
1106
1189
|
const nextAccount = JSON.stringify([state.serverKey, String(f.self.userId)]);
|
|
1107
1190
|
if (recordStore.account && recordStore.account !== nextAccount || lastUserID && lastUserID !== f.self.userId) {
|
|
1108
1191
|
clearPendingSends(); releaseAllLocalImages(); stickerMedia.dispose(); stickerMedia = createStickerMedia();
|
|
1109
|
-
recordStore.close(); sessionRevisions.clear(); hiddenLocalMessages.clear(); deletedEntryKeys.clear(); ++historyEpoch;
|
|
1192
|
+
recordStore.close(); sessionRevisions.clear(); hiddenLocalMessages.clear(); deletedEntryKeys.clear(); contacts.clear(); friendRequests.clear(); ++historyEpoch;
|
|
1110
1193
|
state.sessions.clear(); state.active = 'room:1'; $('messageInput').value = ''; session('room', 1);
|
|
1111
1194
|
}
|
|
1112
1195
|
lastUserID = f.self.userId;
|
|
@@ -1279,6 +1362,10 @@ function sendMessage() {
|
|
|
1279
1362
|
}, sendStatus: local ? 'uploading' : 'sending', uploadPercent: 0,
|
|
1280
1363
|
imagePending: local, localImage: local ? { url: sticker.url, file: sticker.file } : null };
|
|
1281
1364
|
entry.id = entryId(entry.message);
|
|
1365
|
+
if (item.kind === 'private' && !isFriend(item.id)) {
|
|
1366
|
+
entry.sendStatus = 'failed'; entry.sendError = isBlocked(item.id) ? '已屏蔽该用户' : '对方不是好友,消息未发送';
|
|
1367
|
+
item.draft = ''; item.draftSticker = null; $('messageInput').value = ''; syncComposer(); addEntry(item, entry); void persistEntry(item, entry); renderMessages(); return;
|
|
1368
|
+
}
|
|
1282
1369
|
const job = { id, item, entry, timer: null, controller: local ? new AbortController() : null, ws: state.ws, owner: state.me?.id || lastUserID };
|
|
1283
1370
|
pendingSends.set(id, job);
|
|
1284
1371
|
// Transfer ownership of the local preview from the draft into this message.
|
|
@@ -1595,6 +1682,10 @@ function removeStoredSession(item) {
|
|
|
1595
1682
|
function applyHistoryEvent(event) {
|
|
1596
1683
|
if (!alive) return;
|
|
1597
1684
|
if (event.type === 'snapshot') {
|
|
1685
|
+
contacts.clear(); for (const contact of event.contacts || []) contacts.set(String(contact.userId), contact);
|
|
1686
|
+
friendRequests.clear(); for (const request of event.requests || []) setRequestLocal(request);
|
|
1687
|
+
for (const item of state.sessions.values()) item.cards?.clear();
|
|
1688
|
+
for (const request of event.requests || []) if (request.status === 'pending' || request.status === 'accepted' || request.status === 'rejected') addRequestCard(request);
|
|
1598
1689
|
const known = new Set(event.sessions.map(item => item.key));
|
|
1599
1690
|
for (const item of [...state.sessions.values()]) if (!known.has(item.key) && !item.localNew) removeStoredSession(item);
|
|
1600
1691
|
for (const meta of event.sessions) {
|
|
@@ -1603,6 +1694,11 @@ function applyHistoryEvent(event) {
|
|
|
1603
1694
|
}
|
|
1604
1695
|
void loadSessionMessages(current());
|
|
1605
1696
|
if (current() && !document.hidden && document.hasFocus()) void recordStore.read(current().key).catch(historyError);
|
|
1697
|
+
} else if (event.type === 'contact' || event.type === 'request') {
|
|
1698
|
+
contacts.clear(); for (const contact of event.contacts || []) contacts.set(String(contact.userId), contact);
|
|
1699
|
+
friendRequests.clear(); for (const request of event.requests || []) setRequestLocal(request);
|
|
1700
|
+
for (const item of state.sessions.values()) item.cards?.clear();
|
|
1701
|
+
for (const request of event.requests || []) if (request.status === 'pending' || request.status === 'accepted' || request.status === 'rejected') addRequestCard(request);
|
|
1606
1702
|
} else if (event.type === 'remove') {
|
|
1607
1703
|
const item = state.sessions.get(event.key);
|
|
1608
1704
|
if (event.revision < (sessionRevisions.get(event.key) ?? -1)) { if (item) forgetSessionMessages(item, event.revision, event.pendingIds); }
|
|
@@ -1651,6 +1747,19 @@ function discardLocalEntry(item, entry) {
|
|
|
1651
1747
|
const index = item.entries.indexOf(entry);
|
|
1652
1748
|
if (index >= 0) item.entries.splice(index, 1);
|
|
1653
1749
|
}
|
|
1750
|
+
async function addFriend(message) {
|
|
1751
|
+
const id = String(message.fromUserId); if (!connected || isFriend(id) || isBlocked(id)) return;
|
|
1752
|
+
// No local pending record: a retry is always allowed so a lost request or a
|
|
1753
|
+
// removed conversation can be recovered. The receiver dedupes.
|
|
1754
|
+
const requestId = crypto.randomUUID(), createdAt = Date.now();
|
|
1755
|
+
if (!sendControl(id, 'friend-request', { requestId, createdAt })) { log('好友申请发送失败,请重试', 'err'); return; }
|
|
1756
|
+
log('好友申请已发送');
|
|
1757
|
+
}
|
|
1758
|
+
async function deleteFriend(message) { await saveContact(message.fromUserId, 'none', message); log('已删除好友'); }
|
|
1759
|
+
async function toggleBlock(message) {
|
|
1760
|
+
const id = String(message.fromUserId); if (isFriend(id)) return;
|
|
1761
|
+
const relation = isBlocked(id) ? 'none' : 'blocked'; await saveContact(id, relation, message); log(relation === 'blocked' ? '已屏蔽该用户' : '已解除屏蔽');
|
|
1762
|
+
}
|
|
1654
1763
|
function clearLocalMessages(item) {
|
|
1655
1764
|
if (state.sessions.get(item.key) !== item) return;
|
|
1656
1765
|
return recordStore.change('clear', item.key);
|
|
@@ -1663,6 +1772,10 @@ const contextActions = mountChatContextMenu(root, {
|
|
|
1663
1772
|
return entry && item?.entries.includes(entry) ? { item, entry } : null;
|
|
1664
1773
|
},
|
|
1665
1774
|
isSelf: id => id === (state.me?.id || lastUserID),
|
|
1775
|
+
relation: id => contactOf(id),
|
|
1776
|
+
addFriend,
|
|
1777
|
+
deleteFriend,
|
|
1778
|
+
toggleBlock,
|
|
1666
1779
|
privateChat: message => {
|
|
1667
1780
|
if (!message.fromUserId || message.fromUserId === (state.me?.id || lastUserID)) return;
|
|
1668
1781
|
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()
|