feat: add message management and delivery states
This commit is contained in:
@@ -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) => {
|
||||
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
|
||||
<img :src="imageViewer" />
|
||||
</div>
|
||||
<ContextMenu />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-3
@@ -6,12 +6,15 @@ export type LnmApi = {
|
||||
self: () => Promise<DeviceSelf | null>
|
||||
listDevices: () => Promise<DeviceView[]>
|
||||
triggerScan: () => Promise<boolean>
|
||||
deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
|
||||
listMessages: (peerId: string) => Promise<MessageView[]>
|
||||
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<void>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<div class="header-info">
|
||||
<div class="header-name">{{ peer.name }}</div>
|
||||
<div class="header-meta">
|
||||
<span class="status-dot" :class="{ online: peer.online }"></span>
|
||||
<template v-if="peer.online">在线 · {{ peer.address }}</template>
|
||||
<template v-else>离线</template>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="{ online: peer.online && wsOpen, 'ws-down': peer.online && !wsOpen, offline: !peer.online }"
|
||||
/>
|
||||
<template v-if="!peer.online">
|
||||
离线 · {{ formatTime(peer.lastSeen) }}
|
||||
</template>
|
||||
<template v-else-if="!wsOpen">
|
||||
在线 · {{ peer.address }} <span class="ws-note">(等待重连…)</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
在线 · {{ peer.address }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -123,6 +158,7 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
:peer="peer"
|
||||
:self="self!"
|
||||
@open-image="emit('open-image', $event)"
|
||||
@scroll-to-message="onScrollToMessage"
|
||||
/>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ctxMenuState, pickContextMenu, closeContextMenu } from '@/composables/contextMenu'
|
||||
|
||||
const menuEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 菜单展示后, 调整位置不让菜单跑出屏幕外
|
||||
watch(() => ctxMenuState.visible, async (v) => {
|
||||
if (!v) return
|
||||
await nextTick()
|
||||
if (!menuEl.value) return
|
||||
const rect = menuEl.value.getBoundingClientRect()
|
||||
let nx = ctxMenuState.x, ny = ctxMenuState.y
|
||||
// 优先"右击位置为菜单的左上角"; 如果右侧跑出去, 左移菜单; 下侧跑出去, 上移菜单
|
||||
if (rect.right > window.innerWidth - 8) nx = window.innerWidth - rect.width - 8
|
||||
if (rect.bottom > window.innerHeight - 8) ny = window.innerHeight - rect.height - 8
|
||||
if (nx < 8) nx = 8
|
||||
if (ny < 8) ny = 8
|
||||
if (nx !== ctxMenuState.x || ny !== ctxMenuState.y) {
|
||||
menuEl.value.style.left = nx + 'px'
|
||||
menuEl.value.style.top = ny + 'px'
|
||||
}
|
||||
})
|
||||
|
||||
function pick(cmd: string) { pickContextMenu(cmd) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 透明遮罩: 点空白或右键空白都关闭 -->
|
||||
<div
|
||||
v-if="ctxMenuState.visible"
|
||||
class="ctx-overlay"
|
||||
@click="closeContextMenu"
|
||||
@contextmenu.prevent="closeContextMenu"
|
||||
/>
|
||||
<div
|
||||
v-if="ctxMenuState.visible"
|
||||
ref="menuEl"
|
||||
class="ctx-menu"
|
||||
:style="{ left: ctxMenuState.x + 'px', top: ctxMenuState.y + 'px' }"
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<div
|
||||
v-for="item in ctxMenuState.items"
|
||||
:key="item.cmd"
|
||||
class="ctx-item"
|
||||
:class="{ 'ctx-danger': item.danger, 'ctx-divided': item.divided }"
|
||||
@click="pick(item.cmd)"
|
||||
>
|
||||
<el-icon v-if="item.icon" :size="14" class="ctx-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ctx-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
}
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
min-width: 160px;
|
||||
user-select: none;
|
||||
}
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.ctx-icon {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.ctx-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.ctx-danger .ctx-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.ctx-danger:hover {
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
.ctx-divided {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { DeviceView } from '@/api'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import type { DeviceView, MessageView, ReplyRef } from '@/api'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
|
||||
import { Link, Picture, Promotion, ChatLineRound, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { Link, Picture, Promotion, ChatLineRound, UploadFilled, Close } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView }>()
|
||||
const device = useDeviceStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
const text = ref('')
|
||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -19,6 +21,50 @@ const uploading = ref(false)
|
||||
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
|
||||
const isDragging = computed(() => dragDepth.value > 0)
|
||||
|
||||
// 引用回复: 显示被引用条 + × 取消
|
||||
const replyTo = computed(() => session.replyTo)
|
||||
const replyPreview = computed(() => {
|
||||
const m = replyTo.value
|
||||
if (!m) return { author: '', text: '', icon: '' }
|
||||
const author = m.fromDeviceId === device.self?.deviceId
|
||||
? (device.self?.name || '我')
|
||||
: (props.peer.name || '对方')
|
||||
const b = m.body
|
||||
let text = ''
|
||||
let icon = ''
|
||||
if (m.type === 'text') {
|
||||
text = (b?.content || '').replace(/\s+/g, ' ').slice(0, 80)
|
||||
icon = '💬'
|
||||
} else if (m.type === 'image') {
|
||||
text = `[图片] ${b?.name || ''}`
|
||||
icon = '🖼'
|
||||
} else if (m.type === 'file') {
|
||||
text = `[文件] ${b?.name || ''}`
|
||||
icon = '📎'
|
||||
}
|
||||
return { author, text, icon }
|
||||
})
|
||||
|
||||
function cancelReply() {
|
||||
session.setReplyTo(null)
|
||||
}
|
||||
|
||||
// 切到别的设备时清掉 replyTo (跨对话的引用不合法)
|
||||
watch(() => session.activePeerId, () => session.setReplyTo(null))
|
||||
|
||||
function buildReplyRef(msg: MessageView): ReplyRef {
|
||||
const b = msg.body
|
||||
let preview = ''
|
||||
if (msg.type === 'text') preview = (b?.content || '').replace(/\s+/g, ' ').slice(0, 200)
|
||||
else if (msg.type === 'image') preview = `[图片] ${b?.name || ''}`
|
||||
else if (msg.type === 'file') preview = `[文件] ${b?.name || ''}`
|
||||
// 用真实设备名 (而不是硬编码"我"), 接收方看到的是清晰的人名
|
||||
const authorName = msg.fromDeviceId === device.self?.deviceId
|
||||
? (device.self?.name || '我')
|
||||
: (props.peer.name || '对方')
|
||||
return { messageId: msg.messageId, authorName, type: msg.type, preview }
|
||||
}
|
||||
|
||||
function autoSize() {
|
||||
if (!inputEl.value) return
|
||||
inputEl.value.style.height = 'auto'
|
||||
@@ -35,17 +81,20 @@ async function send() {
|
||||
const t = text.value.trim()
|
||||
if (!t && pendingImages.value.length === 0) return
|
||||
uploading.value = true
|
||||
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||
try {
|
||||
if (t) {
|
||||
const r = await window.api.sendText(props.peer.deviceId, t)
|
||||
const r = await window.api.sendText(props.peer.deviceId, t, replyRef ? { replyTo: replyRef } : undefined)
|
||||
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||
}
|
||||
for (const img of pendingImages.value) {
|
||||
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime)
|
||||
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime,
|
||||
replyRef ? { replyTo: replyRef } : undefined)
|
||||
if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning')
|
||||
}
|
||||
text.value = ''
|
||||
pendingImages.value = []
|
||||
session.setReplyTo(null)
|
||||
autoSize()
|
||||
} finally {
|
||||
uploading.value = false
|
||||
@@ -63,7 +112,11 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
|
||||
toast('无法获取文件路径', 'warning')
|
||||
return
|
||||
}
|
||||
const r = await window.api.sendFile(props.peer.deviceId, localPath, { asImage, withProgress: false })
|
||||
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||
const r = await window.api.sendFile(props.peer.deviceId, localPath, {
|
||||
asImage, withProgress: false,
|
||||
...(replyRef ? { replyTo: replyRef } : {}),
|
||||
})
|
||||
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||
}
|
||||
|
||||
@@ -218,6 +271,13 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
<span class="x" @click="removePending(img.id)" title="移除">×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="replyTo" class="reply-bar">
|
||||
<div class="reply-bar-text">
|
||||
<div class="reply-bar-author">回复 {{ replyPreview.author }}:</div>
|
||||
<div class="reply-bar-preview">{{ replyPreview.text }}</div>
|
||||
</div>
|
||||
<el-button :icon="Close" size="small" plain circle @click="cancelReply" />
|
||||
</div>
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
class="composer-input"
|
||||
@@ -350,6 +410,34 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
line-height: 1;
|
||||
}
|
||||
.pending-item .x:hover { background: var(--el-color-danger); }
|
||||
|
||||
.reply-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
border-radius: 4px;
|
||||
margin: 6px 10px 0;
|
||||
}
|
||||
.reply-bar-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.reply-bar-author {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.reply-bar-preview {
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.composer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { MessageView, DeviceView, DeviceSelf } from '@/api'
|
||||
import type { MessageView, DeviceView, DeviceSelf, ReplyRef } from '@/api'
|
||||
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
|
||||
import MarkdownView from './MarkdownView.vue'
|
||||
import { ElAvatar, ElButton, ElMessage, ElProgress } from 'element-plus'
|
||||
import { ElAvatar, ElButton, ElMessage, ElProgress, ElMessageBox } from 'element-plus'
|
||||
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
msg: MessageView
|
||||
@@ -13,13 +16,21 @@ const props = defineProps<{
|
||||
peer: DeviceView
|
||||
self: DeviceSelf
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-image', url: string): void
|
||||
(e: 'scroll-to-message', messageId: string): void
|
||||
}>()
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
const api = window.api
|
||||
|
||||
const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId)
|
||||
const isFileLike = computed(() => props.msg.type === 'file' || props.msg.type === 'image')
|
||||
const isReplyable = computed(() => props.msg.type === 'text' || props.msg.type === 'file' || props.msg.type === 'image')
|
||||
const canRecall = computed(() => isSelf.value && props.msg.type !== 'system')
|
||||
const replyTo = computed<ReplyRef | undefined>(() => (props.msg.body as any)?.replyTo)
|
||||
|
||||
// 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失
|
||||
const progress = computed(() => {
|
||||
@@ -27,9 +38,11 @@ const progress = computed(() => {
|
||||
if (!fid) return null
|
||||
const st = props.msg.status
|
||||
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
|
||||
// receiver: 没 status 字段, 有进度记录就显示
|
||||
// receiver: 没 status 字段, 有进度记录就显示 — 但 savedPath 已落地就视为完成, 隐藏进度
|
||||
if (isSelf.value) {
|
||||
if (st && !['pending', 'sending', 'sent'].includes(st)) return null
|
||||
} else if ((props.msg.body as any)?.savedPath) {
|
||||
return null
|
||||
}
|
||||
const p = messageStore.progressByFileId[fid]
|
||||
if (!p || !p.total) return null
|
||||
@@ -76,10 +89,11 @@ const imageUrl = computed(() => {
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.msg.status) {
|
||||
case 'pending': return '待对方上线'
|
||||
case 'pending': return '等待连接'
|
||||
case 'sending': return '发送中'
|
||||
case 'sent': return '已发送'
|
||||
case 'delivered': return '已送达'
|
||||
case 'read': return '已读'
|
||||
case 'failed': return '失败'
|
||||
default: return props.msg.status || ''
|
||||
}
|
||||
@@ -126,6 +140,116 @@ async function onRetry() {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 右键菜单: 复制 / 删除 / 删除消息+文件 ----
|
||||
|
||||
async function copyText() {
|
||||
const text = (props.msg.body as any).content || ''
|
||||
if (!text) {
|
||||
ElMessage.warning('没有可复制的文本')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败, 请检查剪贴板权限')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMessage(opts: { deleteFile?: boolean }) {
|
||||
const m = props.msg
|
||||
const filePath: string | undefined = opts.deleteFile ? (m.body as any).savedPath : undefined
|
||||
if (opts.deleteFile && !filePath) {
|
||||
ElMessage.warning('本条消息没有磁盘文件可删 (文件未落地或被清理)')
|
||||
return
|
||||
}
|
||||
const title = opts.deleteFile ? '删除消息和文件' : '删除消息'
|
||||
const body = opts.deleteFile
|
||||
? `确定删除此消息 + 磁盘上的文件吗?\n\n文件路径:\n${filePath}`
|
||||
: '确定删除此消息吗?\n\n消息将在本机消失 (不会撤回对端设备上的副本)。'
|
||||
try {
|
||||
await ElMessageBox.confirm(body, title, {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
dangerouslyUseHTMLString: false,
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const r = await api.deleteMessage(m.messageId, opts)
|
||||
if (!r.ok) {
|
||||
ElMessage.error(r.reason || '删除失败')
|
||||
return
|
||||
}
|
||||
if (opts.deleteFile && r.deletedFile) {
|
||||
ElMessage.success(`已删除文件: ${r.deletedFile}`)
|
||||
} else {
|
||||
ElMessage.success('消息已删除')
|
||||
}
|
||||
}
|
||||
|
||||
function onMsgCtxMenu(e: MouseEvent) {
|
||||
const m = props.msg
|
||||
const items: ContextMenuItem[] = []
|
||||
if (m.type === 'text' || m.type === 'file' || m.type === 'image') {
|
||||
items.push({ cmd: 'reply', label: '回复', icon: ChatLineSquare })
|
||||
}
|
||||
if (m.type === 'text') {
|
||||
items.push({ cmd: 'copy', label: '复制', icon: DocumentCopy })
|
||||
}
|
||||
if (isSelf.value && m.type !== 'system') {
|
||||
items.push({ cmd: 'recall', label: '撤回', icon: ArrowLeft })
|
||||
}
|
||||
items.push({ cmd: 'delete', label: '删除消息', icon: Delete, divided: true })
|
||||
if (m.type === 'file' || m.type === 'image') {
|
||||
items.push({ cmd: 'deleteFile', label: '删除消息和文件', icon: DocumentRemove })
|
||||
}
|
||||
showContextMenu(e, items, m, (cmd) => {
|
||||
if (cmd === 'copy') copyText()
|
||||
else if (cmd === 'reply') replyToMsg()
|
||||
else if (cmd === 'recall') recallMsg()
|
||||
else if (cmd === 'delete') deleteMessage({})
|
||||
else if (cmd === 'deleteFile') deleteMessage({ deleteFile: true })
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 引用回复 ----
|
||||
function replyToMsg() {
|
||||
session.setReplyTo(props.msg)
|
||||
ElMessage.info(`已挂起对 ${props.msg.fromDeviceId === props.self.deviceId ? '我' : props.peer.name} 的回复`)
|
||||
}
|
||||
|
||||
function replyPreview(r: ReplyRef): string {
|
||||
if (r.type === 'text') return r.preview
|
||||
if (r.type === 'image') return `[图片] ${r.preview}`
|
||||
if (r.type === 'file') return `[文件] ${r.preview}`
|
||||
return r.preview
|
||||
}
|
||||
|
||||
// 点击引用卡片 → 通知 ChatView 滚动到原消息 (高亮 + 滚入可见区域)
|
||||
function onQuoteClick(r: ReplyRef) {
|
||||
emit('scroll-to-message', r.messageId)
|
||||
}
|
||||
|
||||
// ---- 撤回 ----
|
||||
async function recallMsg() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定撤回这条消息吗?\n对方消息记录会一并删除 (包括对方已接收的文件)。\n仅 2 分钟内可撤回。',
|
||||
'撤回消息',
|
||||
{ type: 'warning', confirmButtonText: '撤回', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
} catch { return }
|
||||
const r = await api.recall(props.msg.messageId)
|
||||
if (!r.ok) {
|
||||
ElMessage.error(r.reason || '撤回失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已撤回')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +268,9 @@ async function onRetry() {
|
||||
self: isSelf,
|
||||
grouped: isConsecutive,
|
||||
}"
|
||||
@contextmenu.prevent="onMsgCtxMenu"
|
||||
:data-message-id="msg.messageId"
|
||||
:data-from-device="msg.fromDeviceId"
|
||||
>
|
||||
<!-- 头像: 同人连续消息时折叠 -->
|
||||
<div class="msg-avatar-col">
|
||||
@@ -162,12 +289,28 @@ async function onRetry() {
|
||||
|
||||
<div v-if="msg.type === 'text'" class="bubble-wrap">
|
||||
<div class="bubble text">
|
||||
<div
|
||||
v-if="replyTo"
|
||||
class="reply-quote"
|
||||
@click.stop="onQuoteClick(replyTo)"
|
||||
>
|
||||
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||
</div>
|
||||
<MarkdownView :source="text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="msg.type === 'image'" class="bubble-wrap">
|
||||
<div class="bubble image">
|
||||
<div
|
||||
v-if="replyTo"
|
||||
class="reply-quote reply-quote-on-image"
|
||||
@click.stop="onQuoteClick(replyTo)"
|
||||
>
|
||||
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||
</div>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
@@ -198,6 +341,14 @@ async function onRetry() {
|
||||
|
||||
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
||||
<div class="bubble file-card">
|
||||
<div
|
||||
v-if="replyTo"
|
||||
class="reply-quote reply-quote-on-file"
|
||||
@click.stop="onQuoteClick(replyTo)"
|
||||
>
|
||||
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<div class="file-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
@@ -249,9 +400,17 @@ async function onRetry() {
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
align-items: stretch;
|
||||
transition: background-color 1.4s ease-out;
|
||||
}
|
||||
.msg.self { flex-direction: row-reverse; }
|
||||
.msg.grouped { margin-top: 2px; }
|
||||
.msg.flash-target {
|
||||
animation: msg-flash 1.4s ease-out;
|
||||
}
|
||||
@keyframes msg-flash {
|
||||
0% { background-color: rgba(51, 112, 255, 0.18); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
.msg-avatar-col {
|
||||
width: 36px;
|
||||
@@ -351,6 +510,41 @@ async function onRetry() {
|
||||
.msg-progress :deep(.el-progress-bar__outer) {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 引用回复条 — WeChat / 飞书风格, 左边一条竖线 + 作者名 + 内容预览 */
|
||||
.reply-quote {
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
max-width: 100%;
|
||||
}
|
||||
.reply-quote:hover {
|
||||
background: rgba(51, 112, 255, 0.08);
|
||||
}
|
||||
.reply-quote-on-image,
|
||||
.reply-quote-on-file {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.reply-author {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 2px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.reply-preview {
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.msg-progress-meta {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
@@ -389,6 +583,7 @@ async function onRetry() {
|
||||
.msg-status.pending { color: var(--el-color-warning); }
|
||||
.msg-status.sending { color: var(--el-text-color-secondary); }
|
||||
.msg-status.delivered { color: var(--el-color-success); }
|
||||
.msg-status.read { color: var(--el-color-primary); }
|
||||
.msg-status :deep(.el-button) {
|
||||
font-size: 11px;
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -27,9 +27,8 @@ const form = ref<FormState>({
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const autoStartAvailable = ref(false)
|
||||
const dirty = ref(false)
|
||||
const initialAutoStart = ref(false)
|
||||
const hydrating = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const ifaces = ref<NetInterface[]>([])
|
||||
@@ -40,24 +39,24 @@ watch(() => device.settings, (s) => {
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
initialAutoStart.value = await window.api.getAutoStart()
|
||||
autoStartAvailable.value = true
|
||||
} catch {
|
||||
autoStartAvailable.value = false
|
||||
}
|
||||
await loadIfaces()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function hydrate(s: any) {
|
||||
hydrating.value = true
|
||||
form.value = {
|
||||
...s,
|
||||
autoStart: initialAutoStart.value,
|
||||
deviceName: s.deviceName ?? '',
|
||||
downloadDir: s.downloadDir ?? '',
|
||||
notifications: s.notifications ?? true,
|
||||
sound: s.sound ?? true,
|
||||
autoStart: s.autoStart ?? false,
|
||||
theme: s.theme ?? 'light',
|
||||
}
|
||||
hydrating.value = false
|
||||
}
|
||||
|
||||
watch(form, () => { dirty.value = true }, { deep: true })
|
||||
watch(form, () => { if (!hydrating.value) dirty.value = true }, { deep: true })
|
||||
|
||||
async function loadIfaces() {
|
||||
loadingIfaces.value = true
|
||||
@@ -78,11 +77,6 @@ async function pickDir() {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (autoStartAvailable.value && form.value.autoStart !== initialAutoStart.value) {
|
||||
const ok = await window.api.setAutoStart(form.value.autoStart)
|
||||
if (ok) initialAutoStart.value = form.value.autoStart
|
||||
else form.value.autoStart = initialAutoStart.value
|
||||
}
|
||||
const r = await window.api.setSettings({
|
||||
deviceName: form.value.deviceName,
|
||||
downloadDir: form.value.downloadDir,
|
||||
@@ -90,7 +84,7 @@ async function save() {
|
||||
sound: form.value.sound,
|
||||
theme: form.value.theme,
|
||||
autoStart: form.value.autoStart,
|
||||
} as any)
|
||||
})
|
||||
device.settings = r
|
||||
dirty.value = false
|
||||
ElMessage.success('设置已保存')
|
||||
@@ -148,11 +142,8 @@ const currentAddress = computed(() => device.self?.address || '-')
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="开机自启">
|
||||
<el-switch v-model="form.autoStart" :disabled="!autoStartAvailable" />
|
||||
<span class="form-hint">
|
||||
<template v-if="!autoStartAvailable">仅在打包后可用</template>
|
||||
<template v-else>登录系统时自动启动 LocalNetMsg</template>
|
||||
</span>
|
||||
<el-switch v-model="form.autoStart" />
|
||||
<span class="form-hint">登录系统时自动启动 LocalNetMsg</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useDeviceStore } from '@/stores/device'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag } from 'element-plus'
|
||||
import { Refresh, Setting, UserFilled } from '@element-plus/icons-vue'
|
||||
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag, ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Refresh, Setting, UserFilled, Delete } from '@element-plus/icons-vue'
|
||||
import { showContextMenu } from '@/composables/contextMenu'
|
||||
import type { DeviceView } from '@/api'
|
||||
|
||||
const device = useDeviceStore()
|
||||
const session = useSessionStore()
|
||||
@@ -24,6 +26,42 @@ function isSelf(id: string) {
|
||||
|
||||
const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0))
|
||||
|
||||
function onDeviceCtxMenu(e: MouseEvent, d: DeviceView) {
|
||||
showContextMenu(e, [
|
||||
{ cmd: 'delete', label: '删除设备', icon: Delete, danger: true, divided: true },
|
||||
], d, (cmd, payload) => {
|
||||
if (cmd !== 'delete') return
|
||||
const target = payload as DeviceView
|
||||
onDeviceDelete(target)
|
||||
})
|
||||
}
|
||||
|
||||
async function onDeviceDelete(d: DeviceView) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定从设备列表移除 "${d.name}" 吗?\n\n移除后将停止后台连接, 局域网内再次被发现也不会自动恢复, 如需恢复需要手动添加。`,
|
||||
'删除设备',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const r = await window.api.deleteDevice(d.deviceId)
|
||||
if (!r.ok) {
|
||||
ElMessage.error(r.reason || '删除失败')
|
||||
return
|
||||
}
|
||||
// 本地 store 也清掉 (主进程发 device:lost 只置 offline, 不会移除)
|
||||
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||
if (idx >= 0) device.devices.splice(idx, 1)
|
||||
const u = { ...device.unread }
|
||||
delete u[d.deviceId]
|
||||
device.unread = u
|
||||
// 如果当前正跟这台设备聊天, 切回空状态
|
||||
if (session.activePeerId === d.deviceId) session.setActive(null)
|
||||
ElMessage.success(`已移除 ${d.name}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!self.value) device.loadSelf().then(() => {
|
||||
if (self.value) message.setSelf(self.value.deviceId)
|
||||
@@ -74,6 +112,7 @@ onMounted(() => {
|
||||
class="device-item"
|
||||
:class="{ active: session.activePeerId === d.deviceId }"
|
||||
@click="pick(d)"
|
||||
@contextmenu.prevent="onDeviceCtxMenu($event, d)"
|
||||
>
|
||||
<el-avatar
|
||||
:size="40"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// 全局单实例右键菜单 store
|
||||
//
|
||||
// 用法:
|
||||
// const items = [
|
||||
// { cmd: 'delete', label: '删除', danger: true, divided: true },
|
||||
// ...
|
||||
// ]
|
||||
// function onCtx(e: MouseEvent) {
|
||||
// useContextMenu().show(e, items, payload /* 任意 */, (cmd, payload) => { ... })
|
||||
// }
|
||||
//
|
||||
// 单实例避免多菜单并开;position 跟鼠标坐标走,不存在 "右侧空白" / "最左侧展开" 这类
|
||||
// el-dropdown 在 v-for 里 + trigger=contextmenu 的玄学行为
|
||||
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
cmd: string
|
||||
label: string
|
||||
icon?: any
|
||||
danger?: boolean
|
||||
divided?: boolean
|
||||
}
|
||||
|
||||
export const ctxMenuState = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
items: [] as ContextMenuItem[],
|
||||
payload: null as unknown,
|
||||
})
|
||||
|
||||
let pendingHandler: ((cmd: string, payload: unknown) => void) | null = null
|
||||
|
||||
// 同步切换: 关掉旧菜单再开新的, 避免同一个菜单组件里瞬间出现两次条目
|
||||
export function showContextMenu(
|
||||
e: MouseEvent,
|
||||
items: ContextMenuItem[],
|
||||
payload: unknown,
|
||||
handler: (cmd: string, payload: unknown) => void,
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
pendingHandler = handler
|
||||
if (ctxMenuState.visible) {
|
||||
// 已经在显示中 — 直接改位置 + 条目即可 (避免叠两层)
|
||||
ctxMenuState.x = e.clientX
|
||||
ctxMenuState.y = e.clientY
|
||||
ctxMenuState.items = items
|
||||
ctxMenuState.payload = payload
|
||||
return
|
||||
}
|
||||
ctxMenuState.x = e.clientX
|
||||
ctxMenuState.y = e.clientY
|
||||
ctxMenuState.items = items
|
||||
ctxMenuState.payload = payload
|
||||
ctxMenuState.visible = true
|
||||
}
|
||||
|
||||
export function pickContextMenu(cmd: string) {
|
||||
const h = pendingHandler
|
||||
const p = ctxMenuState.payload
|
||||
ctxMenuState.visible = false
|
||||
ctxMenuState.payload = null
|
||||
ctxMenuState.items = []
|
||||
pendingHandler = null
|
||||
if (h) h(cmd, p)
|
||||
}
|
||||
|
||||
export function closeContextMenu() {
|
||||
ctxMenuState.visible = false
|
||||
ctxMenuState.payload = null
|
||||
ctxMenuState.items = []
|
||||
pendingHandler = null
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
const unread = ref<Record<string, number>>({})
|
||||
const scanning = ref(false)
|
||||
const lastScanAt = ref(0)
|
||||
// 我们 outgoing WS 接通状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
|
||||
// 默认乐观地认为 WS 通的 (首次进入 chat 还没收到 device:wsState 事件前), 直到第一次 close
|
||||
const wsOpen = ref<Record<string, boolean>>({})
|
||||
|
||||
async function refresh() {
|
||||
const [d, s, u] = await Promise.all([
|
||||
@@ -32,11 +35,15 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
setTimeout(() => { scanning.value = false }, 3000)
|
||||
}
|
||||
|
||||
function setWsOpen(deviceId: string, open: boolean) {
|
||||
wsOpen.value = { ...wsOpen.value, [deviceId]: open }
|
||||
}
|
||||
|
||||
const onlineDevices = computed(() => devices.value.filter(d => d.online))
|
||||
|
||||
return {
|
||||
self, devices, settings, unread, scanning, lastScanAt,
|
||||
refresh, loadSelf, triggerScan,
|
||||
self, devices, settings, unread, scanning, lastScanAt, wsOpen,
|
||||
refresh, loadSelf, triggerScan, setWsOpen,
|
||||
onlineDevices,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { MessageView } from '@/api'
|
||||
|
||||
export const useSessionStore = defineStore('session', () => {
|
||||
const activePeerId = ref<string | null>(null)
|
||||
const peerTyping = ref<Record<string, boolean>>({})
|
||||
// 当前会话准备要回复的消息 (引用输入框里的预览条). null = 没有挂起的回复
|
||||
const replyTo = ref<MessageView | null>(null)
|
||||
|
||||
function setActive(id: string | null) { activePeerId.value = id }
|
||||
function setTyping(deviceId: string, v: boolean) {
|
||||
peerTyping.value[deviceId] = v
|
||||
}
|
||||
function setReplyTo(msg: MessageView | null) { replyTo.value = msg }
|
||||
function clearReplyTo() { replyTo.value = null }
|
||||
|
||||
return { activePeerId, peerTyping, setActive, setTyping }
|
||||
return { activePeerId, peerTyping, replyTo, setActive, setTyping, setReplyTo, clearReplyTo }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user