admin-vben/apps/web-antdv-next/src/views/im/home/composables/useMessageSender.ts

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Conversation, Message } from '../types'
import { readChannelMessages as apiReadChannelMessages } from '#/api/im/message/channel'
import {
readGroupMessages as apiReadGroupMessages,
recallGroupMessage as apiRecallGroupMessage,
sendGroupMessage as apiSendGroupMessage
} from '#/api/im/message/group'
import {
getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId,
readPrivateMessages as apiReadPrivateMessages,
recallPrivateMessage as apiRecallPrivateMessage,
sendPrivateMessage as apiSendPrivateMessage
} from '#/api/im/message/private'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { MESSAGE_GROUP_READ_ENABLED, MESSAGE_PRIVATE_READ_ENABLED } from '../../utils/config'
import { ImContentType, ImConversationType, ImMessageStatus } from '../../utils/constants'
import { getClientConversationId } from '../../utils/db'
import {
generateClientMessageId,
type QuoteMessage,
serializeMessage,
type TextMessage,
withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
/** 非文本消息的扩展选项(通用) */
interface SendExtOptions {
atUserIds?: number[] // 群聊 @ 的用户编号列表
receipt?: boolean // 是否需要群回执(默认 false
targetId?: number // 覆盖默认的 targetId
/**
* 显式指定目标会话(转发 / 名片推荐场景)
*
* 不传时默认取 conversationStore.activeConversation传入时按本值发送 + 乐观更新到对应会话,
* 不要求该会话当前是激活状态(适合发给「非当前会话」的多个目标)
*/
conversation?: Conversation
/** 被引用消息(可选):写进 content.quote 用于乐观渲染,服务端按 quote.messageId 反查重算覆盖 */
quote?: QuoteMessage
/**
* 复用已存在的本地占位消息 clientMessageId媒体上传场景
*
* 媒体上传链路在请求服务端前已经 insertMessage 了占位(带 blob URL + 进度条),
* 这里跳过 buildLocalMessage / insertMessage直接拿这个 id 走 ackMessage 收尾,避免重复插入两条
*/
existingClientMessageId?: string
}
/**
* 消息发送 / 撤回 / 已读 组合式逻辑
*
* 设计要点:
* 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化
* 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL失败更新为 FAILED
* 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退
* 4. 已读上报:本端立刻清未读数并记录本地读位置;接口失败仅记录日志
*/
export const useMessageSender = () => {
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
/** 构造本地乐观消息对象 */
const buildLocalMessage = (opts: {
atUserIds?: number[]
clientMessageId: string
content: string
targetId: number
type: number
}): Message => {
return {
clientMessageId: opts.clientMessageId,
type: opts.type,
content: opts.content,
status: ImMessageStatus.SENDING,
sendTime: Date.now(),
senderId: getCurrentUserId(),
targetId: opts.targetId,
selfSend: true,
atUserIds: opts.atUserIds
}
}
/**
* 发送任意类型的消息(底层实现)
* 1. 文本、图片、文件、语音等都走这里
* 2. type / content 由调用方构造
* 3. 返回值:成功 true / 失败 false失败时本地占位已标 FAILED参数缺失等无法发送的场景也返 false
* 转发 / 名片推荐等场景按返回值决定是否继续后续动作(如有留言时仅在名片成功后再发留言)
*/
const sendRaw = async (
type: number,
content: string,
options?: SendExtOptions
): Promise<boolean> => {
// 1. 参数校验:优先用显式传入的 conversation转发场景否则取激活会话
const conversation = options?.conversation ?? conversationStore.activeConversation
if (!conversation) {
return false
}
const realTarget = options?.targetId || conversation.targetId
if (!realTarget) {
return false
}
// 2. 准备 clientMessageId媒体上传链路在 step 1 已经 insertMessage 占位,这里直接复用 id其余场景走默认乐观插入
let clientMessageId: string
if (options?.existingClientMessageId) {
clientMessageId = options.existingClientMessageId
// 占位若已被删除(上传期间用户右键删除 / 撤回 / removeMessage 等)则放弃发送,
// 否则 sendRaw 仍会把消息推到服务端,导致"本地无气泡 / 对方却收到一条"
const stillExists = messageStore
.getMessageList(conversation.type, realTarget)
.some((message) => message.clientMessageId === clientMessageId && !message._ackMerging)
if (!stillExists) {
return false
}
} else {
clientMessageId = generateClientMessageId()
const message = buildLocalMessage({
clientMessageId,
content,
targetId: realTarget,
type,
atUserIds: options?.atUserIds
})
const conversationInfo = {
type: conversation.type,
targetId: realTarget,
name: conversation.name || String(realTarget),
avatar: conversation.avatar || ''
}
void messageStore.insertMessage(conversationInfo, message).catch(() => undefined)
}
// 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 NORMAL失败更新为 FAILED
try {
if (conversation.type === ImConversationType.PRIVATE) {
const data = await apiSendPrivateMessage({
clientMessageId,
receiverId: realTarget,
type,
content
})
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
content: data.content
})
.catch(() => undefined)
} else if (conversation.type === ImConversationType.GROUP) {
const data = await apiSendGroupMessage({
clientMessageId,
groupId: realTarget,
type,
content,
atUserIds: options?.atUserIds,
receipt: options?.receipt
})
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
id: data.id,
sendTime: new Date(data.sendTime).getTime(),
status: data.status,
receiptStatus: data.receiptStatus,
readCount: data.readCount,
content: data.content
})
.catch(() => undefined)
}
return true
} catch (error) {
console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, error)
void messageStore
.ackMessage(conversation.type, realTarget, clientMessageId, {
status: ImMessageStatus.FAILED
})
.catch(() => undefined)
return false
}
}
/**
* 发送文本消息最常用的快捷入口message-input.vue 文本回车走这里
* 返回值:成功 true / 失败 false / 空文本 false与 sendRaw 对齐,转发场景按返回值判断)
*/
const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {
if (!text.trim()) {
return false
}
const payload = withQuotePayload<TextMessage>({ content: text }, options?.quote)
return sendRaw(ImContentType.TEXT, serializeMessage(payload), options)
}
/**
* 撤回某条消息
* 1. 服务端会通过 WebSocket RECALL 事件回传,本端 UI 由 websocketStore 统一更新
* 2. 此处不做乐观撤回,避免网络失败后状态不可回退
*/
const recall = async (message: Message) => {
// 参数校验:本地占位消息不能撤回
if (!message.id) {
return
}
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
// 私聊 / 群聊接口签名一致,按会话类型分发
const isPrivate = conversation.type === ImConversationType.PRIVATE
try {
await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id))
} catch (error) {
console.error('[IM] 撤回失败', { messageId: message.id, type: conversation.type }, error)
}
}
/**
* 触发当前会话的已读上报(切会话 / 进入页面时调用)
* 1. 本端立刻清未读数并推进读位置
* 2. 已读位置取已加载消息和会话末条消息的最大服务端 id
*/
const readActive = async () => {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
let loadedMaxMessageId = 0
for (const message of messageStore.getMessages(
getClientConversationId(conversation.type, conversation.targetId)
)) {
if (message.id && message.id > loadedMaxMessageId) {
loadedMaxMessageId = message.id
}
}
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type,
conversation.targetId,
maxMessageId
)
if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId)
return
}
const isPrivate = conversation.type === ImConversationType.PRIVATE
const isGroup = conversation.type === ImConversationType.GROUP
const isChannel = conversation.type === ImConversationType.CHANNEL
// 本地标记已读未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
if (!maxMessageId) {
return
}
// 接口调用:按会话类型分发,并按对应已读开关控制
if (!isPrivate && !isGroup && !isChannel) {
return
}
if (isPrivate && !MESSAGE_PRIVATE_READ_ENABLED) {
return
}
if (isGroup && !MESSAGE_GROUP_READ_ENABLED) {
return
}
try {
if (isPrivate) {
await apiReadPrivateMessages(conversation.targetId, maxMessageId)
} else if (isGroup) {
await apiReadGroupMessages(conversation.targetId, maxMessageId)
} else {
await apiReadChannelMessages(conversation.targetId, maxMessageId)
}
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) {
console.error(
'[IM] 标记已读失败',
{ type: conversation.type, targetId: conversation.targetId, maxMessageId },
error
)
}
}
/**
* 拉取「对方已读到我哪条消息」并补齐本地状态
*
* 1. 弥补离线 / 多端期间错过的 RECEIPT 推送:进入私聊会话或断线重连后调一次,
* 把对方 maxReadId 同步到本地消息 status避免对方明明读了、本端却仍显示未读
* 2. 仅私聊使用:群聊已读位置在每条消息的 readCount / receiptStatus 字段,离线拉取自带回
*/
const syncPrivateReadStatus = async (peerId: number) => {
if (!peerId) {
return
}
// 私聊已读关闭:跳过对方已读位置同步,避免无谓接口调用
if (!MESSAGE_PRIVATE_READ_ENABLED) {
return
}
const cachedMaxReadId = messageStore.getPrivateReadMaxId(peerId)
if (cachedMaxReadId !== undefined) {
if (cachedMaxReadId > 0) {
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: cachedMaxReadId
})
}
return
}
try {
// 拉取对方已读到的最大消息 id
const maxReadId = await apiGetPrivateMaxReadMessageId(peerId)
messageStore.updatePrivateReadMaxId(peerId, maxReadId)
if (!maxReadId) {
return
}
// applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息回执更新为 DONE
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.PRIVATE,
targetId: peerId,
privateReadMaxId: maxReadId
})
} catch (error) {
console.warn('[IM] 拉取对方已读位置失败', { peerId }, error)
}
}
return { send, sendRaw, recall, readActive, syncPrivateReadStatus }
}