@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 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><button id="window-minimize" aria-label="最小化">−</button><button id="window-close" aria-label="关闭">×</button></div>
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
- { label: "私聊", disabled: !id || api.isSelf(id), run: () => api.privateChat(entry.message) },
83
- { label: "复制 ID", disabled: !id, run: () => copy(id) },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yiarce/dsh-chat",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "独立聊天插件:共享聊天页面、图片上传、表情搜索;可选桌面独立窗口。",
5
5
  "type": "module",
6
6
  "main": "./host.js",
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()