338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
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 }
|
||
}
|