diff --git a/src/main/chat-client.ts b/src/main/chat-client.ts index acf4459..4de7d5f 100644 --- a/src/main/chat-client.ts +++ b/src/main/chat-client.ts @@ -14,6 +14,7 @@ interface Entry { peer: DeviceInfo ws: WebSocket | null retryTimer: NodeJS.Timeout | null + pingTimer: NodeJS.Timeout | null backoff: number alive: boolean } @@ -51,7 +52,7 @@ export class ChatClient extends EventEmitter { } this.scheduleConnect(e, e.backoff) } else { - e = { peer, ws: null, retryTimer: null, backoff: 1000, alive: true } + e = { peer, ws: null, retryTimer: null, pingTimer: null, backoff: 1000, alive: true } this.entries.set(peer.deviceId, e) this.scheduleConnect(e, 0) } @@ -73,6 +74,7 @@ export class ChatClient extends EventEmitter { if (!e) return e.alive = false if (e.retryTimer) clearTimeout(e.retryTimer) + this.stopPing(e) try { e.ws?.close() } catch {} this.entries.delete(deviceId) } @@ -90,13 +92,33 @@ export class ChatClient extends EventEmitter { const ws = new WebSocket(url, { handshakeTimeout: 5000 }) e.ws = ws ws.on('open', () => { - ws.send(JSON.stringify({ type: 'hello', from: this.self })) + try { + ws.send(JSON.stringify({ type: 'hello', from: this.self })) + } catch {} e.backoff = 2000 this.emit('open', e.peer) + // NAT keepalive: 每 25s 发一次 WS 控制层 ping frame (比应用层 ping 更省, NAT 路由/防火墙 + // 能识别 ping 控制帧, 不会因为"空闲太久"砍掉连接 — 这是 WS 抖动最常见的隐形原因) + // 顺便: 应用层 ping (JSON) 也发一份, 让对方知道我们活着 (双保险) + this.stopPing(e) + e.pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + try { ws.ping() } catch {} + try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) } catch {} + } + }, 25_000) + }) + ws.on('pong', () => { + // 对端回了 pong, 标记连接健康 (留个 hook, 后续可加 ws-down 检测) }) ws.on('message', (raw) => { try { const frame = JSON.parse(raw.toString('utf8')) as WsFrame + if (frame.type === 'ping') { + // 应用层 ping: 回 pong + try { ws.send(JSON.stringify({ type: 'pong', ts: frame.ts })) } catch {} + return + } // 更新对端地址 (可能 ip 变了) if (frame.type === 'hello' && frame.from) { e.peer = { ...peer, ...frame.from, address: peer.address } @@ -105,10 +127,11 @@ export class ChatClient extends EventEmitter { } catch {} }) ws.on('close', () => { + this.stopPing(e) e.ws = null this.emit('close', e.peer) if (e.alive) this.scheduleConnect(e, e.backoff) - e.backoff = Math.min(e.backoff * 2, 30_000) + e.backoff = Math.min(e.backoff * 2, 5_000) }) ws.on('error', (err: Error) => { this.emit('error', e.peer, err) @@ -116,12 +139,27 @@ export class ChatClient extends EventEmitter { }) } - send(frame: WsFrame, deviceId?: string) { + private stopPing(e: Entry) { + if (e.pingTimer) { + clearInterval(e.pingTimer) + e.pingTimer = null + } + } + + send(frame: WsFrame, deviceId?: string): boolean { if (deviceId) { const e = this.entries.get(deviceId) if (e?.ws && e.ws.readyState === WebSocket.OPEN) { - e.ws.send(JSON.stringify(frame)) - return true + try { + e.ws.send(JSON.stringify(frame)) + return true + } catch { + // send 抛错 (例如连接刚挂但 readyState 还没更新) — 关掉这个 entry 让下一次 flush 重连 + try { e.ws.terminate() } catch {} + e.ws = null + if (e.alive) this.scheduleConnect(e, 0) + return false + } } return false } @@ -129,8 +167,7 @@ export class ChatClient extends EventEmitter { let any = false for (const e of this.entries.values()) { if (e.ws && e.ws.readyState === WebSocket.OPEN) { - e.ws.send(JSON.stringify(frame)) - any = true + try { e.ws.send(JSON.stringify(frame)); any = true } catch {} } } return any diff --git a/src/main/chat-server.ts b/src/main/chat-server.ts index 1458495..445b778 100644 --- a/src/main/chat-server.ts +++ b/src/main/chat-server.ts @@ -10,6 +10,7 @@ export interface ChatServerEvents { message: (from: DeviceInfo, msg: MessageEnvelope) => void recall: (from: DeviceInfo, messageId: string) => void typing: (from: DeviceInfo) => void + read: (from: DeviceInfo, upToMessageId: string) => void } export class ChatServer extends EventEmitter { @@ -77,6 +78,10 @@ export class ChatServer extends EventEmitter { case 'recall': if (peer) this.emit('recall', peer, frame.messageId) break + case 'read': + // 已读回执: 接收方已看到 upTo (含), 把 ≤ upTo 的发送方消息视为已读 + if (peer) this.emit('read', peer, frame.upTo) + break case 'typing': if (peer) this.emit('typing', peer) break diff --git a/src/main/db.ts b/src/main/db.ts index 06a2404..f834970 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -80,8 +80,11 @@ export function upsertDevice(d: DeviceRow) { upsertDeviceStmt.run(d) } -export function listDevices(): DeviceRow[] { - return db.prepare(`SELECT * FROM devices ORDER BY last_seen DESC`).all() as DeviceRow[] +export function listDevices(includeIgnored = false): DeviceRow[] { + const sql = includeIgnored + ? `SELECT * FROM devices ORDER BY last_seen DESC` + : `SELECT * FROM devices WHERE ignored = 0 ORDER BY last_seen DESC` + return db.prepare(sql).all() as DeviceRow[] } export function getDevice(id: string): DeviceRow | undefined { @@ -178,6 +181,19 @@ export function listPendingMessages(peerId: string, selfId: string): MessageRow[ ).all(key) as MessageRow[] } +// 已上传完但 WS update 帧 (带 savedPath) 没送达: metadata 帧 status='sent', +// body.savedPath 已填, 重发完整 envelope 让收件方出 "打开/位置" +export function listSentFileUpdates(selfId: string, peerId: string): MessageRow[] { + return db.prepare( + `SELECT * FROM messages + WHERE from_id = ? AND to_id = ? + AND status = 'sent' + AND type IN ('file', 'image') + AND json_extract(body_json, '$.savedPath') IS NOT NULL + ORDER BY ts ASC` + ).all(selfId, peerId) as MessageRow[] +} + export function listAllPending(selfId: string): MessageRow[] { return db.prepare( `SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC` diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b1806d4..009a950 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -9,8 +9,8 @@ import { getSettings, updateSettings, ensureDownloadDir } from './settings' import { db, upsertDevice, listDevices, listMessages, insertMessage, getMessage, updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread, - getUnread, totalUnread, listPendingMessages, listAllPending, - setDeviceIgnored, listIgnoredDevices, deleteConversation + getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates, + setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation } from './db' import { Discovery, listNetInterfaces, type NetInterface } from './discovery' import { ChatServer } from './chat-server' @@ -19,7 +19,7 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server' import { broadcastToRenderer, focusMainWindow } from './window' import { rebuildMenu } from './tray' import { notify, setBadgeCount } from './notify' -import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage } from './protocol' +import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef } from './protocol' interface NetCtx { self: DeviceInfo @@ -32,7 +32,8 @@ interface NetCtx { let ctx: NetCtx | null = null // 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer -// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "receiving" +// 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息 +// 气泡留在 receiving 状态, 让用户右键手动删 const PENDING_FILE_TIMEOUT_MS = 60_000 const pendingMetaTimers = new Map() @@ -40,10 +41,8 @@ function armPendingMetaTimer(fileId: string, messageId: string, fromDeviceId: st disarmPendingMetaTimer(fileId) const timer = setTimeout(() => { pendingMetaTimers.delete(fileId) - console.warn(`[ipc] file ${fileId} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`) - try { deleteMessage(messageId) } catch {} - broadcastToRenderer('message:recalled', { messageId, conversationKey: '' }) - // 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark failed) + console.warn(`[ipc] file ${fileId.slice(0, 8)} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s; bubble stays for user to delete`) + // 通知 sender 标记失败 (sender 在线时能把 status='sent' 升级到 'failed') ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId) }, PENDING_FILE_TIMEOUT_MS) pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId }) @@ -61,6 +60,8 @@ export function bindNetworkContext(c: NetCtx) { ctx = c // 网络事件 -> 渲染进程 c.discovery.on('found', (d) => { + // 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连 + if (getDevice(d.deviceId)?.ignored) return upsertDevice({ device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0 @@ -69,6 +70,7 @@ export function bindNetworkContext(c: NetCtx) { c.chatClient.connectTo(d) }) c.discovery.on('updated', (d: DeviceInfo) => { + if (getDevice(d.deviceId)?.ignored) return upsertDevice({ device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform, app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0 @@ -77,6 +79,7 @@ export function bindNetworkContext(c: NetCtx) { c.chatClient.connectTo(d) }) c.discovery.on('lost', (id) => { + if (getDevice(id)?.ignored) return broadcastToRenderer('device:lost', { deviceId: id }) }) @@ -172,6 +175,17 @@ export function bindNetworkContext(c: NetCtx) { console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`) }) + // 我们这端 outgoing WS 接通 / 断开 — 都要广播给 renderer, 让 UI 能区分 + // "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路, + // 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推) + c.chatClient.on('open', (peer) => { + broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: true }) + flushPendingFor(peer.deviceId) + }) + c.chatClient.on('close', (peer) => { + broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: false }) + }) + c.chatServer.on('disconnect', (id) => { broadcastToRenderer('device:offline', { deviceId: id }) }) @@ -189,6 +203,37 @@ export function bindNetworkContext(c: NetCtx) { c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => { disarmPendingMetaTimer(info.fileId) }) + + // 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示 + c.chatServer.on('recall', (from, messageId) => { + const row = getMessage(messageId) + if (!row) return + let savedPath: string | undefined + try { savedPath = (JSON.parse(row.body_json) as any)?.savedPath } catch {} + deleteMessage(messageId) + if (savedPath) { + fsp.unlink(savedPath).catch((e) => { + if (e?.code !== 'ENOENT') console.warn(`[recall] unlink ${savedPath} failed: ${e.message}`) + }) + } + broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) + }) + + // 已读回执: 对端说"我看到 upTo 了", 把我方所有 from=self, to=peer, ts<=read.ts, status='delivered' 的消息升 'read' + c.chatServer.on('read', (from, upToMessageId) => { + if (!ctx) return + const row = getMessage(upToMessageId) + if (!row) return + const upToTs = row.ts + // 只升级 delivered -> read, sent 留给 ack 处理 + const stmt = db.prepare(`UPDATE messages SET status = 'read' WHERE from_id = ? AND to_id = ? AND status = 'delivered' AND ts <= ?`) + stmt.run(ctx.self.deviceId, from.deviceId, upToTs) + // 把这次升级告诉 renderer (逐条 statusChanged, 复用现有渲染层逻辑) + const updated = db.prepare(`SELECT message_id FROM messages WHERE from_id = ? AND to_id = ? AND ts <= ? AND status = 'read'`).all(ctx.self.deviceId, from.deviceId, upToTs) as Array<{ message_id: string }> + for (const r of updated) { + broadcastToRenderer('message:statusChanged', { messageId: r.message_id, status: 'read' }) + } + }) } function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) { @@ -229,8 +274,10 @@ async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; rea // 2. 通过 ws 发送 const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId) if (!sent) { - // 对方不在线, 保持 pending, 等 flush - return { ok: false, reason: 'peer offline', status: 'pending' } + // WS 没开 — 消息已在 DB 里 status='pending', flushPendingFor 在对端 outgoing WS 接通时会自动重发. + // 不当作 "失败" 报给前端, 避免误导 (因为对方 UDP 可能还在, 只是 WS 抖了). + // 副作用: 渲染端的 "待对方上线" 气泡/重试按钮照常显示; 不再弹 "发送失败" toast. + return { ok: true, status: 'pending' } } // 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck) updateMessageStatus(env.messageId, 'sent') @@ -267,24 +314,23 @@ async function flushPendingFor(peerId: string) { } // 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent') // 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置" - for (const row of rows) { + // 注意: rows 上面已经被 status='pending' 过滤, 这里必须重新查 status='sent' + savedPath 已填的文件 + const updateRows = listSentFileUpdates(ctx.self.deviceId, peerId) + for (const row of updateRows) { const body = JSON.parse(row.body_json) - const fileBody = body as any - if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) { - const env: MessageEnvelope = { - messageId: row.message_id, - type: row.type as any, - fromDeviceId: row.from_id, - toDeviceId: row.to_id, - ts: row.ts, - body, - } - const sentUpdate = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId) - if (sentUpdate) { - // 触发 renderer 同步一下 body (保险 — 让对端的 uploaded bubble 也再被 forced update) - broadcastToRenderer('message:statusChanged', { messageId: row.message_id, status: 'sent' }) - count++ - } + const env: MessageEnvelope = { + messageId: row.message_id, + type: row.type as any, + fromDeviceId: row.from_id, + toDeviceId: row.to_id, + ts: row.ts, + body, + } + const sentUpdate = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId) + if (sentUpdate) { + // 触发 renderer 同步一下 body (保险 — 让对端的 uploaded bubble 也再被 forced update) + broadcastToRenderer('message:statusChanged', { messageId: row.message_id, status: 'sent' }) + count++ } } if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`) @@ -326,6 +372,56 @@ export function registerIpc() { return true }) + // 删除设备: 标记 ignored=1, 停掉后台重连, 从 sidebar 消失 + // UDP 后续再发现也不会重新弹 (discovery 上游已过滤 ignored) + ipcMain.handle('device:delete', (_e, deviceId: string) => { + if (!ctx) return { ok: false, reason: 'no ctx' } + if (deviceId === ctx.self.deviceId) return { ok: false, reason: '不能删除本机' } + setDeviceIgnored(deviceId, true) + ctx.chatClient.remove(deviceId) // 停掉 backoff 重连 + ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除 + broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉 + // 清掉对应未读 + clearUnread(deviceId) + return { ok: true } + }) + + // 删除单条消息 (本地) — 不广播, 不撤回对端 + // opts.deleteFile: 同时删掉磁盘上的文件 (按 msg.body.savedPath) + // 返回 { ok, deletedFile? } 让 UI 在弹框里展示真实路径 + ipcMain.handle('message:delete', async (_e, messageId: string, opts?: { deleteFile?: boolean }) => { + const row = getMessage(messageId) + if (!row) return { ok: false, reason: '消息不存在' } + let deletedFile: string | undefined + if (opts?.deleteFile) { + const body = (() => { try { return JSON.parse(row.body_json) } catch { return null } })() + const filePath: string | undefined = body?.savedPath + if (filePath) { + try { + await fsp.unlink(filePath) + deletedFile = filePath + } catch (e: any) { + // 文件不在磁盘上 (用户手动删了/换盘) 不视为失败 — 把消息也删了 + if (e?.code !== 'ENOENT') return { ok: false, reason: `删除文件失败: ${e?.message || e}` } + } + } + } + deleteMessage(messageId) + broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) + return { ok: true, deletedFile } + }) + + // 已读回执: 本机在 active 对话里看到了新消息, 告诉对端把 upTo (含) 之前的消息标 'read' + ipcMain.handle('message:markRead', (_e, args: { peerId: string; upToMessageId: string }) => { + if (!ctx) return { ok: false, reason: 'no ctx' } + if (!args?.peerId || !args?.upToMessageId) return { ok: false, reason: '参数缺失' } + const sent = ctx.chatClient.send( + { type: 'read', upTo: args.upToMessageId, ts: Date.now() }, + args.peerId + ) + return { ok: sent, reason: sent ? undefined : '对方离线' } + }) + // ---- 消息 ---- ipcMain.handle('message:list', (_e, peerId: string) => { if (!ctx) return [] @@ -333,7 +429,7 @@ export function registerIpc() { return rows.map(rowToEnvelope) }) - ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string }) => { + ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string; replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const env: MessageEnvelope = { messageId: randomUUID(), @@ -345,6 +441,7 @@ export function registerIpc() { type: 'text', messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId, content: args.content, + ...(args.replyTo ? { replyTo: args.replyTo } : {}), } as TextMessage, } ;(env.body as TextMessage).messageId = env.messageId @@ -365,6 +462,7 @@ export function registerIpc() { mime?: string asImage?: boolean onProgress?: boolean + replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) @@ -387,6 +485,7 @@ export function registerIpc() { savedPath: null as string | null, hash: null as string | null, localPath: args.localPath, // retry 时复用 + ...(args.replyTo ? { replyTo: args.replyTo } : {}), } const env: MessageEnvelope = { messageId, @@ -453,8 +552,12 @@ export function registerIpc() { } broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) // 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开") - if (ctx!.chatClient.isOpen(args.toDeviceId)) { - ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) + // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发 + const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId) + ? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) + : false + if (!sentUpdate) { + console.warn(`[sendFile] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`) } // 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered void fileId // suppress unused warning @@ -473,6 +576,7 @@ export function registerIpc() { dataBase64: string name: string mime: string + replyTo?: ReplyRef }) => { if (!ctx) return { ok: false, reason: 'no ctx' } const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId) @@ -490,6 +594,7 @@ export function registerIpc() { fileId, name: args.name, size: buf.length, mime: args.mime, thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined, dataBase64: args.dataBase64, + ...(args.replyTo ? { replyTo: args.replyTo } : {}), } const env: MessageEnvelope = { messageId, type: 'image', @@ -540,8 +645,12 @@ export function registerIpc() { body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any }, } broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' }) - if (ctx!.chatClient.isOpen(args.toDeviceId)) { - ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) + // 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发 + const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId) + ? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId) + : false + if (!sentUpdate) { + console.warn(`[sendBuffer] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`) } void fileId })().catch((e: any) => { @@ -556,11 +665,17 @@ export function registerIpc() { ipcMain.handle('message:recall', async (_e, messageId: string) => { if (!ctx) return { ok: false } const row = getMessage(messageId) - if (!row) return { ok: false, reason: 'no message' } - if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' } + if (!row) return { ok: false, reason: '消息不存在' } + if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: '只能撤回自己发的消息' } + // 微信式 2 分钟时限 + const RECALL_LIMIT_MS = 2 * 60_000 + if (Date.now() - row.ts > RECALL_LIMIT_MS) { + const ageMin = Math.round((Date.now() - row.ts) / 60_000) + return { ok: false, reason: `超过 2 分钟无法撤回 (已 ${ageMin} 分钟)` } + } // 删除本地 deleteMessage(messageId) - // 通知对端 + // 通知对端 (不传 savedPath 让对端从 DB 自行取 — 这里的 row 还在; 但保险起见让对端查自己的 DB) ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id) broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key }) return { ok: true } @@ -599,7 +714,7 @@ export function registerIpc() { const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id) if (!peer) { setStatus('pending') - return { ok: false, reason: 'peer offline', status: 'pending' } + return { ok: false, reason: '对方设备不在线', status: 'pending', wsDown: true } } if (!ctx.chatClient.isOpen(row.to_id)) { setStatus('pending') @@ -701,7 +816,13 @@ export function registerIpc() { ensureDownloadDir() if (patch.deviceName) { ctx!.self.name = patch.deviceName - // 通知前端并重启 broadcast 频次 + } + if ('autoStart' in patch && app.isPackaged) { + try { + app.setLoginItemSettings({ openAtLogin: !!patch.autoStart, path: process.execPath }) + } catch (e: any) { + console.warn('[settings:set] setLoginItemSettings failed:', e?.message) + } } rebuildMenu() broadcastToRenderer('settings:changed', ns) diff --git a/src/main/protocol.ts b/src/main/protocol.ts index 9793175..3ebd9fb 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -35,17 +35,28 @@ export type WsFrame = | { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' } | { type: 'recall'; messageId: string; ts: number } | { type: 'typing'; from: string; ts: number } + | { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读 // ============ 消息 ============ export type MessageType = 'text' | 'image' | 'file' | 'system' +// 引用回复的快照: 只携带"足够渲染"作者 + 内容预览, 不依赖原消息存在/可见 +// (原消息被撤回/删除也不影响这条引用) +export interface ReplyRef { + messageId: string + authorName: string + type: MessageType // 只能是 text/file/image, 不允许 system/system + preview: string // text: 截断的正文; file/image: 文件名; 多行用空格折 +} + interface BaseMessage { messageId: string type: MessageType fromDeviceId: string toDeviceId: string ts: number + replyTo?: ReplyRef // 可选引用 } export interface TextMessage extends BaseMessage { diff --git a/src/preload/index.ts b/src/preload/index.ts index 1be073f..c66c8ef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,15 +9,19 @@ contextBridge.exposeInMainWorld('api', { self: () => ipcRenderer.invoke('self:info'), listDevices: () => ipcRenderer.invoke('device:list'), triggerScan: () => ipcRenderer.invoke('device:triggerScan'), + deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId), // 消息 listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId), - sendText: (toDeviceId: string, content: string) => ipcRenderer.invoke('message:sendText', { toDeviceId, content }), - sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => + sendText: (toDeviceId: string, content: string, opts?: { replyTo?: any }) => + ipcRenderer.invoke('message:sendText', { toDeviceId, content, replyTo: opts?.replyTo }), + sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: any }) => ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }), - sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => - ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime }), + sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: any }) => + ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime, replyTo: opts?.replyTo }), recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId), + deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => ipcRenderer.invoke('message:delete', messageId, opts), + markRead: (peerId: string, upToMessageId: string) => ipcRenderer.invoke('message:markRead', { peerId, upToMessageId }), retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId), typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId), diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 42b07b4..fdd2de4 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -6,6 +6,7 @@ import { useSessionStore } from '@/stores/session' import Sidebar from '@/components/Sidebar.vue' import ChatView from '@/components/ChatView.vue' import SettingsView from '@/components/SettingsView.vue' +import ContextMenu from '@/components/ContextMenu.vue' import { formatTime } from '@/utils/format' import type { DeviceView, MessageView, Settings } from '@/api' @@ -59,12 +60,20 @@ onMounted(async () => { const d = device.devices.find(x => x.deviceId === deviceId) if (d) d.online = false }) + // 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线" + window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => { + device.setWsOpen(deviceId, wsOpen) + }) window.api.on('message:received', (env: MessageView) => { const fromId = env.fromDeviceId // 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次 // 第二次带 savedPath, 气泡自动有"打开/位置" const fid = (env.body as any)?.fileId - if (fid && !(fid in message.progressByFileId)) { + const savedPath = (env.body as any)?.savedPath + if (fid && savedPath) { + // 完整帧: 进度条直接清, 防止进度 entry 在 setProgress(0) 时被复活成 sent=0 幽灵 + message.clearProgress(fid) + } else if (fid && !(fid in message.progressByFileId)) { message.setProgress(fid, 0, (env.body as any)?.size || 0) } message.upsert(env) @@ -83,6 +92,10 @@ onMounted(async () => { device.unread = next } window.api.clearUnread(fromId) + // 已读回执: 当前在 active 对话里看到了新消息, 告诉对端 + if (env.type !== 'system') { + window.api.markRead(fromId, env.messageId).catch(() => {}) + } return } device.refresh().then(() => { @@ -141,6 +154,14 @@ watch(() => session.activePeerId, async (id) => { } await message.ensureLoaded(id) await window.api.clearUnread(id) + // 已读回执: 切到对话时把看到的最后一条发给对端; 对端会把 ≤ upTo.ts 的发送方消息标 'read' + const msgs = message.byPeer[id] || [] + if (msgs.length > 0) { + const latest = msgs[msgs.length - 1] + if (latest.type !== 'system') { + window.api.markRead(id, latest.messageId).catch(() => {}) + } + } }) document.addEventListener('click', (e) => { @@ -168,5 +189,6 @@ document.addEventListener('click', (e) => {
+ diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index 6f77182..b86bc4c 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -6,12 +6,15 @@ export type LnmApi = { self: () => Promise listDevices: () => Promise triggerScan: () => Promise + deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }> listMessages: (peerId: string) => Promise - sendText: (toDeviceId: string, content: string) => Promise<{ ok: boolean; reason?: string }> - sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => Promise<{ ok: boolean; reason?: string }> - sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => Promise<{ ok: boolean; reason?: string }> + sendText: (toDeviceId: string, content: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }> + sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }> + sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }> recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }> + deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => Promise<{ ok: boolean; reason?: string; deletedFile?: string }> + markRead: (peerId: string, upToMessageId: string) => Promise<{ ok: boolean; reason?: string }> retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }> typing: (toDeviceId: string) => Promise @@ -104,3 +107,12 @@ export interface ProgressEvent { sent: number total: number } + +// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致) +// 仅携带足够渲染作者 + 内容预览的字段 — 原消息被撤回/删除也不影响这条引用 +export interface ReplyRef { + messageId: string + authorName: string + type: MessageType + preview: string +} diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index fb93039..74ab97c 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -6,7 +6,7 @@ import { useSessionStore } from '@/stores/session' import MessageItem from './MessageItem.vue' import MessageInput from './MessageInput.vue' import type { DeviceView } from '@/api' -import { initialsOf, colorFor } from '@/utils/format' +import { initialsOf, colorFor, formatTime } from '@/utils/format' import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus' import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue' @@ -20,6 +20,13 @@ const session = useSessionStore() const self = computed(() => device.self) const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : []) const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false) +// 我方 outgoing WS 是否连通 — 严格默认 false (从没收到过 device:wsState 时, +// 显示 "等待重连" 而不是骗用户 "在线"; 看到首次 open 事件后才会变成 true) +const wsOpen = computed(() => { + if (!props.peer) return false + const v = device.wsOpen[props.peer.deviceId] + return v === undefined ? false : v +}) const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null) function scrollToBottom() { @@ -28,6 +35,24 @@ function scrollToBottom() { }) } +// 点击回复卡片 → 滚到原消息 (并短闪高亮) +function onScrollToMessage(messageId: string) { + const inner = document.querySelector('.chat-body-inner') + if (!inner) return + const target = inner.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`) as HTMLElement | null + if (!target) { + ElMessage.info('原消息已撤回或不在当前会话') + return + } + // 滚到目标附近 (让浏览器负责处理 scrollable 容器) + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + // 短闪高亮 (重启动画 — 强制 reflow 去掉旧 class) + target.classList.remove('flash-target') + void (target as HTMLElement).offsetWidth + target.classList.add('flash-target') + setTimeout(() => target.classList.remove('flash-target'), 1500) +} + watch(() => session.activePeerId, () => scrollToBottom()) watch(() => messages.value.length, () => scrollToBottom()) @@ -105,9 +130,19 @@ async function onGlobalDrop(e: DragEvent) {
{{ peer.name }}
- - - + + + +
@@ -123,6 +158,7 @@ async function onGlobalDrop(e: DragEvent) { :peer="peer" :self="self!" @open-image="emit('open-image', $event)" + @scroll-to-message="onScrollToMessage" /> @@ -196,6 +232,19 @@ async function onGlobalDrop(e: DragEvent) { display: inline-block; } .status-dot.online { background: var(--el-color-success); } +.status-dot.ws-down { + background: var(--el-color-warning); + animation: ws-pulse 1.6s ease-in-out infinite; +} +@keyframes ws-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.ws-note { + color: var(--el-color-warning); + font-weight: 500; + margin-left: 2px; +} .chat-body { flex: 1; min-height: 0; } .chat-body-inner { padding: 16px 20px 0; } diff --git a/src/renderer/src/components/ContextMenu.vue b/src/renderer/src/components/ContextMenu.vue new file mode 100644 index 0000000..12a52c4 --- /dev/null +++ b/src/renderer/src/components/ContextMenu.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/renderer/src/components/MessageInput.vue b/src/renderer/src/components/MessageInput.vue index 5e66891..51906aa 100644 --- a/src/renderer/src/components/MessageInput.vue +++ b/src/renderer/src/components/MessageInput.vue @@ -1,12 +1,14 @@