feat(im): 完善未读 @ 定位与消息状态同步
- 支持未读 @ 消息历史定位与自动补载 - 使用全序游标修复相同时间戳分页遗漏 - 独立维护数据库分页边界,避免实时消息和本地占位干扰 - 统一实时与离线撤回处理,重算未读及 @ 状态 - 区分 MessageDO 与 Message 分页类型并清理冗余代码
This commit is contained in:
@@ -486,6 +486,56 @@ function waitMediaSettled(): Promise<void> {
|
||||
return Promise.race([loadAll, timeout]);
|
||||
}
|
||||
|
||||
/** 加载并定位当前会话的未读 @ 消息 */
|
||||
async function handleLocateMention() {
|
||||
const messageId = conversationStore.consumeActiveMentionMessageId();
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!messageId || !conversation) {
|
||||
return;
|
||||
}
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
const isActive = () => {
|
||||
const activeConversation = conversationStore.activeConversation;
|
||||
return (
|
||||
!!activeConversation &&
|
||||
getClientConversationId(
|
||||
activeConversation.type,
|
||||
activeConversation.targetId,
|
||||
) === clientConversationId
|
||||
);
|
||||
};
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const loadedMessages = messageStore.getMessages(clientConversationId);
|
||||
if (loadedMessages.some((item) => item.id === messageId)) {
|
||||
break;
|
||||
}
|
||||
const { hasMore } = await messageStore.loadMoreMessageList(
|
||||
clientConversationId,
|
||||
50,
|
||||
);
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
messageStore
|
||||
.getMessages(clientConversationId)
|
||||
.some((item) => item.id === messageId)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (!hasMore) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
await handleLocate(messageId, isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发
|
||||
*
|
||||
@@ -495,11 +545,14 @@ function waitMediaSettled(): Promise<void> {
|
||||
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
||||
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
||||
*/
|
||||
async function handleLocate(messageId: number) {
|
||||
async function handleLocate(messageId: number, isActive?: () => boolean) {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
if (isActive && !isActive()) {
|
||||
return;
|
||||
}
|
||||
if (!listRef.value) {
|
||||
return;
|
||||
}
|
||||
@@ -733,6 +786,17 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 定位未读 @ 消息 -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
v-if="conversationStore.activeMentionMessageId"
|
||||
class="message-panel__jump-mention sticky bottom-12 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#f56c6c] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#f56c6c]"
|
||||
@click="handleLocateMention"
|
||||
>
|
||||
<span>查看 @消息</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
@@ -827,7 +891,8 @@ watch(
|
||||
}
|
||||
|
||||
/* sticky + translate 居中:fit-content 宽度不会撑满,transform 水平 -50% 偏移;同时 transition opacity 和 transform 两个属性 */
|
||||
.message-panel__jump-bottom {
|
||||
.message-panel__jump-bottom,
|
||||
.message-panel__jump-mention {
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config';
|
||||
import {
|
||||
IM_AT_ALL_USER_ID,
|
||||
ImConversationType,
|
||||
ImMessageReceiptStatus,
|
||||
ImMessageStatus,
|
||||
@@ -94,6 +95,8 @@ function toConversationDO(conversation: Conversation): ConversationDO {
|
||||
silent: conversation.silent,
|
||||
atMe: conversation.atMe,
|
||||
atAll: conversation.atAll,
|
||||
atMessageId: conversation.atMessageId,
|
||||
atAllMessageId: conversation.atAllMessageId,
|
||||
draft: draft
|
||||
? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined }
|
||||
: undefined,
|
||||
@@ -137,23 +140,166 @@ function isValidConversationReadRecord(
|
||||
return !!record.conversationType && !!record.targetId && !!record.messageId;
|
||||
}
|
||||
|
||||
/** 获取对方普通消息最大编号 */
|
||||
function getMaxIncomingNormalMessageId(
|
||||
messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'status' | 'type'>>,
|
||||
): number {
|
||||
let maxMessageId = 0;
|
||||
/** 按读位置重算会话未读与 @ 状态 */
|
||||
function applyConversationUnreadState(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
readMessageId: number,
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
let unreadCount = 0;
|
||||
let atMessageId: number | undefined;
|
||||
let atAllMessageId: number | undefined;
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL &&
|
||||
message.id > maxMessageId
|
||||
!message.id ||
|
||||
message.id <= readMessageId ||
|
||||
message.selfSend ||
|
||||
!isNormalMessage(message.type) ||
|
||||
message.status === ImMessageStatus.RECALL
|
||||
) {
|
||||
maxMessageId = message.id;
|
||||
continue;
|
||||
}
|
||||
unreadCount++;
|
||||
if (
|
||||
currentUserId &&
|
||||
message.atUserIds?.includes(currentUserId) &&
|
||||
message.id > (atMessageId || 0)
|
||||
) {
|
||||
atMessageId = message.id;
|
||||
}
|
||||
if (
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID) &&
|
||||
message.id > (atAllMessageId || 0)
|
||||
) {
|
||||
atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
return maxMessageId;
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 无读位置时按原未读窗口应用撤回状态 */
|
||||
function applyConversationRecallStateWithoutRead(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const previousUnreadCount = conversation.unreadCount;
|
||||
const originalWasIncomingNormal =
|
||||
!!originalMessage.id &&
|
||||
!originalMessage.selfSend &&
|
||||
isNormalMessage(originalMessage.type) &&
|
||||
originalMessage.status !== ImMessageStatus.RECALL;
|
||||
const incomingNormalMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0));
|
||||
const previousUnreadMessages = [
|
||||
...incomingNormalMessages.filter(
|
||||
(message) => message.id !== originalMessage.id,
|
||||
),
|
||||
...(originalWasIncomingNormal ? [originalMessage] : []),
|
||||
]
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, previousUnreadCount);
|
||||
const recalledUnread = previousUnreadMessages.some(
|
||||
(message) => message.id === originalMessage.id,
|
||||
);
|
||||
const unreadCount = Math.max(
|
||||
0,
|
||||
previousUnreadCount - (recalledUnread ? 1 : 0),
|
||||
);
|
||||
const unreadMessages = incomingNormalMessages.slice(0, unreadCount);
|
||||
const currentUserId = getCurrentUserId();
|
||||
let atMessageId = conversation.atMessageId;
|
||||
let atAllMessageId = conversation.atAllMessageId;
|
||||
if (
|
||||
conversation.atMessageId === originalMessage.id ||
|
||||
(!conversation.atMessageId &&
|
||||
conversation.atMe &&
|
||||
!!currentUserId &&
|
||||
originalMessage.atUserIds?.includes(currentUserId))
|
||||
) {
|
||||
atMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id;
|
||||
}
|
||||
if (
|
||||
conversation.atAllMessageId === originalMessage.id ||
|
||||
(!conversation.atAllMessageId &&
|
||||
conversation.atAll &&
|
||||
originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID))
|
||||
) {
|
||||
atAllMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id;
|
||||
}
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 为旧会话回填未读 @ 消息编号 */
|
||||
function backfillConversationMentionIds(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
const unreadMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, conversation.unreadCount);
|
||||
const atMessageId =
|
||||
conversation.atMessageId ||
|
||||
(conversation.atMe && currentUserId
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id
|
||||
: undefined);
|
||||
const atAllMessageId =
|
||||
conversation.atAllMessageId ||
|
||||
(conversation.atAll
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id
|
||||
: undefined);
|
||||
const changed =
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('imConversationStore', {
|
||||
@@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道)
|
||||
conversationReads: {} as Record<string, ConversationRead>, // 会话读位置
|
||||
activeConversation: null as Conversation | null, // 当前激活的会话
|
||||
activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号
|
||||
loading: false, // 是否正在批量加载
|
||||
recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表
|
||||
}),
|
||||
@@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.conversations = [];
|
||||
this.conversationReads = {};
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
this.recentForwardConversationKeys = [];
|
||||
},
|
||||
|
||||
@@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (this.applyReadToConversation(conversation, record.messageId)) {
|
||||
changedConversations.push(conversation);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
const needsMentionBackfill =
|
||||
(conversation.atMe && !conversation.atMessageId) ||
|
||||
(conversation.atAll && !conversation.atAllMessageId);
|
||||
if (!record && !needsMentionBackfill) {
|
||||
continue;
|
||||
}
|
||||
const messages = await getDb().getAllByIndex<MessageDO>(
|
||||
@@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
'clientConversationId',
|
||||
getClientConversationId(conversation.type, conversation.targetId),
|
||||
);
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages);
|
||||
if (
|
||||
maxIncomingMessageId > 0 &&
|
||||
maxIncomingMessageId <= record.messageId
|
||||
) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
const changed = record
|
||||
? this.applyReadToConversation(
|
||||
conversation,
|
||||
record.messageId,
|
||||
messages,
|
||||
)
|
||||
: backfillConversationMentionIds(conversation, messages);
|
||||
if (changed) {
|
||||
changedConversations.push(conversation);
|
||||
}
|
||||
}
|
||||
@@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
applyReadToConversation(
|
||||
conversation: Conversation,
|
||||
messageId: number,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
if (
|
||||
!conversation.lastMessageId ||
|
||||
conversation.lastMessageId > messageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
return true;
|
||||
return applyConversationUnreadState(conversation, messages, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回后的会话未读与 @ 状态 */
|
||||
applyRecallToConversation(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const read = this.getConversationRead(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
return read
|
||||
? applyConversationUnreadState(conversation, messages, read.messageId)
|
||||
: applyConversationRecallStateWithoutRead(
|
||||
conversation,
|
||||
messages,
|
||||
originalMessage,
|
||||
);
|
||||
},
|
||||
|
||||
/** 应用会话读位置 */
|
||||
@@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
if (
|
||||
conversation &&
|
||||
this.applyReadToConversation(conversation, messageId)
|
||||
this.applyReadToConversation(
|
||||
conversation,
|
||||
messageId,
|
||||
await getStoredMessages(),
|
||||
)
|
||||
) {
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
} else if (conversation) {
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(
|
||||
await getStoredMessages(),
|
||||
);
|
||||
if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
}
|
||||
}
|
||||
if (record.conversationType !== ImConversationType.CHANNEL) {
|
||||
continue;
|
||||
@@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
/** 设置当前会话 */
|
||||
setActiveConversation(conversation: Conversation | null) {
|
||||
this.activeMentionMessageId =
|
||||
conversation?.atMessageId || conversation?.atAllMessageId;
|
||||
this.activeConversation = conversation;
|
||||
if (!conversation) {
|
||||
return;
|
||||
@@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.saveConversation(conversation);
|
||||
},
|
||||
|
||||
/** 消费当前会话待定位的未读 @ 消息编号 */
|
||||
consumeActiveMentionMessageId(): number | undefined {
|
||||
const messageId = this.activeMentionMessageId;
|
||||
this.activeMentionMessageId = undefined;
|
||||
return messageId;
|
||||
},
|
||||
|
||||
/** 创建空会话 */
|
||||
createEmptyConversation(
|
||||
type: number,
|
||||
@@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
}
|
||||
if (this.activeConversation === conversation) {
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
}
|
||||
conversation.deleted = true;
|
||||
// 2. 删除会话关联的消息和草稿
|
||||
@@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
conversation.atMessageId = undefined;
|
||||
conversation.atAllMessageId = undefined;
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = createConversationRead(type, targetId, messageId);
|
||||
this.conversationReads[key] = record;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DbTransaction } from '../../utils/db';
|
||||
import type {
|
||||
DbTransaction,
|
||||
MessageDOPageCursor,
|
||||
} from '../../utils/db';
|
||||
import type { Conversation, Message, MessageDO } from '../types';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
@@ -50,6 +53,11 @@ interface PersistMessageRecordOptions {
|
||||
mergeClientRecord?: boolean;
|
||||
}
|
||||
|
||||
interface MessagePageResult {
|
||||
messages: Message[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 拉取消息批量处理项 */
|
||||
export type PulledMessage =
|
||||
| {
|
||||
@@ -79,6 +87,27 @@ function getMessageKey(
|
||||
: getClientMessageKey(message.clientMessageId);
|
||||
}
|
||||
|
||||
/** 获取数据库分页结果的最早游标 */
|
||||
function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined {
|
||||
return message
|
||||
? {
|
||||
messageKey: message.messageKey,
|
||||
sendTime: message.sendTime,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 判断两个消息分页游标是否一致 */
|
||||
function isSameMessageDOPageCursor(
|
||||
left?: MessageDOPageCursor,
|
||||
right?: MessageDOPageCursor,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.sendTime === right.sendTime && left.messageKey === right.messageKey;
|
||||
}
|
||||
|
||||
/** 补齐客户端消息编号 */
|
||||
function ensureClientMessageId(message: Message): Message {
|
||||
if (!message.clientMessageId) {
|
||||
@@ -237,9 +266,15 @@ function syncConversationAtFlags(
|
||||
const currentUserId = getCurrentUserId();
|
||||
if (currentUserId && message.atUserIds.includes(currentUserId)) {
|
||||
conversation.atMe = true;
|
||||
if (message.id && message.id > (conversation.atMessageId || 0)) {
|
||||
conversation.atMessageId = message.id;
|
||||
}
|
||||
}
|
||||
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
|
||||
conversation.atAll = true;
|
||||
if (message.id && message.id > (conversation.atAllMessageId || 0)) {
|
||||
conversation.atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean {
|
||||
export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
messageDOPageCursors: {} as Record<
|
||||
string,
|
||||
MessageDOPageCursor | undefined
|
||||
>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
@@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
});
|
||||
});
|
||||
this.messagesByConversation = {};
|
||||
this.messageDOPageCursors = {};
|
||||
this.loadedConversationKeys = [];
|
||||
this.privateReadMaxIds = {};
|
||||
this.privateMessageMaxId = 0;
|
||||
@@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.loadedConversationKeys = retained;
|
||||
removed.forEach((key) => {
|
||||
Reflect.deleteProperty(this.messagesByConversation, key);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, key);
|
||||
});
|
||||
},
|
||||
|
||||
/** 加载当前会话最近消息 */
|
||||
async loadMoreMessageList(
|
||||
clientConversationId: string,
|
||||
beforeSendTime?: number,
|
||||
limit = 50,
|
||||
): Promise<Message[]> {
|
||||
): Promise<MessagePageResult> {
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return { messages: [], hasMore: false };
|
||||
}
|
||||
const before = this.messageDOPageCursors[clientConversationId];
|
||||
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
|
||||
const list = await getDb().getMessageListByConversation(
|
||||
const page = await getDb().getMessageListByConversation(
|
||||
clientConversationId,
|
||||
{
|
||||
beforeSendTime,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
);
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
if (
|
||||
isSameMessageDOPageCursor(
|
||||
this.messageDOPageCursors[clientConversationId],
|
||||
before,
|
||||
)
|
||||
) {
|
||||
const nextCursor = getMessageDOPageCursor(page.list[0]);
|
||||
if (nextCursor) {
|
||||
this.messageDOPageCursors[clientConversationId] = nextCursor;
|
||||
}
|
||||
}
|
||||
const messages = list.map((message) => buildMessageFromDO(message));
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const messages = page.list.map((message) =>
|
||||
buildMessageFromDO(message),
|
||||
);
|
||||
const existing = this.messagesByConversation[clientConversationId] || [];
|
||||
const existingKeys = new Set(
|
||||
existing.map((message) => getMessageKey(message, parsed.type)),
|
||||
@@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
(messageA.sendTime || 0) - (messageB.sendTime || 0),
|
||||
);
|
||||
this.touchConversationMessageCache(clientConversationId);
|
||||
return fresh;
|
||||
return { messages: fresh, hasMore: page.hasMore };
|
||||
},
|
||||
|
||||
/** 确保会话消息已加载 */
|
||||
@@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.updateMessageCursor(conversationType, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回到内存 */
|
||||
applyRecallMessageInMemory(
|
||||
/** 应用撤回到本地消息与会话状态 */
|
||||
async applyRecallMessageRecord(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
recallSignalContent: string,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
// 1. 定位被撤回的原消息
|
||||
// 1. 定位被撤回的原消息和会话
|
||||
const messageId = parseRecallMessageId(recallSignalContent);
|
||||
if (!messageId) {
|
||||
return null;
|
||||
@@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId);
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message) {
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversationType,
|
||||
targetId,
|
||||
);
|
||||
const cachedMessage = this.messagesByConversation[
|
||||
clientConversationId
|
||||
]?.find((item) => item.id === messageId);
|
||||
const storedMessage = await getDb().get<MessageDO>(
|
||||
'messages',
|
||||
getServerMessageKey(conversationType, messageId),
|
||||
tx,
|
||||
);
|
||||
const originalMessage = cachedMessage
|
||||
? buildMessageDO(cachedMessage, conversationType)
|
||||
: storedMessage;
|
||||
if (!originalMessage) {
|
||||
return null;
|
||||
}
|
||||
// 2. 更新消息和会话摘要
|
||||
message.type = ImContentType.RECALL;
|
||||
message.status = ImMessageStatus.RECALL;
|
||||
message.content = '';
|
||||
if (messages[messages.length - 1]?.id === messageId) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
// 2. 更新内存消息与数据库记录
|
||||
const recalledMessage =
|
||||
cachedMessage || buildMessageFromDO(originalMessage);
|
||||
revokeBlobUrlsInContent(recalledMessage.content);
|
||||
recalledMessage.type = ImContentType.RECALL;
|
||||
recalledMessage.status = ImMessageStatus.RECALL;
|
||||
recalledMessage.content = '';
|
||||
await this.saveMessageRecord(recalledMessage, conversationType, tx);
|
||||
// 3. 按本地完整消息和读位置重算未读与 @ 状态
|
||||
const storedMessages = await getDb().getAllByIndex<MessageDO>(
|
||||
'messages',
|
||||
'clientConversationId',
|
||||
clientConversationId,
|
||||
tx,
|
||||
);
|
||||
conversationStore.applyRecallToConversation(
|
||||
conversation,
|
||||
storedMessages,
|
||||
originalMessage,
|
||||
);
|
||||
if (conversation.lastMessageId === messageId) {
|
||||
applyConversationSummary(conversation, recalledMessage);
|
||||
}
|
||||
return { conversation, message };
|
||||
return { conversation, message: recalledMessage };
|
||||
},
|
||||
|
||||
/** 批量写入拉取消息 */
|
||||
@@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
>();
|
||||
const changedConversations = new Map<string, Conversation>();
|
||||
const recallMessages: Extract<
|
||||
PulledMessage,
|
||||
{ kind: 'recall' }
|
||||
>[] = [];
|
||||
|
||||
const addChanged = (
|
||||
conversation: Conversation,
|
||||
@@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
// 1. 先更新内存,收集需要持久化的消息和会话
|
||||
for (const pulledMessage of pulledMessages) {
|
||||
if (pulledMessage.kind === 'recall') {
|
||||
// 1.1 撤回信号更新原消息
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
pulledMessage.conversationType,
|
||||
pulledMessage.targetId,
|
||||
pulledMessage.recallSignalContent,
|
||||
);
|
||||
if (changed) {
|
||||
addChanged(changed.conversation, changed.message);
|
||||
}
|
||||
// 1.1 撤回信号在事务内读取原消息后统一处理
|
||||
recallMessages.push(pulledMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((existing) =>
|
||||
isSameMessage(existing, message),
|
||||
);
|
||||
@@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
addChanged(conversation, existing, {
|
||||
@@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 1.4 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
},
|
||||
);
|
||||
}
|
||||
// 2.2 写入本批变更会话
|
||||
// 2.2 应用本批撤回信号
|
||||
for (const recallMessage of recallMessages) {
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
recallMessage.conversationType,
|
||||
recallMessage.targetId,
|
||||
recallMessage.recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (changed) {
|
||||
changedConversations.set(
|
||||
getClientConversationId(
|
||||
changed.conversation.type,
|
||||
changed.conversation.targetId,
|
||||
),
|
||||
changed.conversation,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2.3 写入本批变更会话
|
||||
await conversationStore.saveConversationRecord(
|
||||
[...changedConversations.values()],
|
||||
tx,
|
||||
);
|
||||
// 2.3 写入本批游标
|
||||
// 2.4 写入本批游标
|
||||
await setMessageMaxId(conversationType, maxMessageId, tx);
|
||||
},
|
||||
);
|
||||
@@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((item) =>
|
||||
isSameMessage(item, message),
|
||||
);
|
||||
@@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
return getDb()
|
||||
@@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 4. 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
recallSignalContent: string,
|
||||
): Promise<void> {
|
||||
const conversationStore = useConversationStore();
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
|
||||
await this.saveMessageRecord(changed.message, conversationType, tx);
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await conversationStore.saveConversationRecord(
|
||||
changed.conversation,
|
||||
tx,
|
||||
@@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
message._localFile = undefined;
|
||||
});
|
||||
Reflect.deleteProperty(this.messagesByConversation, clientConversationId);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId);
|
||||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||
(key) => key !== clientConversationId,
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface Conversation {
|
||||
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
atMe?: boolean; // 群聊:是否有人 @我
|
||||
atAll?: boolean; // 群聊:是否有人 @全体成员
|
||||
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
|
||||
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
|
||||
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
|
||||
draft?: {
|
||||
html: string; // 输入框 HTML
|
||||
|
||||
@@ -22,6 +22,18 @@ export type DbStoreName =
|
||||
|
||||
export type DbTransaction = IDBTransaction;
|
||||
|
||||
/** 数据库消息分页游标 */
|
||||
export interface MessageDOPageCursor {
|
||||
messageKey: string;
|
||||
sendTime: number;
|
||||
}
|
||||
|
||||
/** 数据库消息分页结果 */
|
||||
export interface MessageDOPageResult {
|
||||
hasMore: boolean;
|
||||
list: MessageDO[];
|
||||
}
|
||||
|
||||
/** IM 本地存储 key */
|
||||
export const StorageKeys = {
|
||||
localStorage: {
|
||||
@@ -363,18 +375,19 @@ class DbClient {
|
||||
/** 按会话分页获取消息 */
|
||||
async getMessageListByConversation(
|
||||
clientConversationId: string,
|
||||
options?: { beforeSendTime?: number; limit?: number },
|
||||
options?: { before?: MessageDOPageCursor; limit?: number },
|
||||
tx?: DbTransaction,
|
||||
): Promise<MessageDO[]> {
|
||||
): Promise<MessageDOPageResult> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const before = options?.before;
|
||||
const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const range = IDBKeyRange.bound(
|
||||
[clientConversationId, 0],
|
||||
[clientConversationId, upper],
|
||||
false,
|
||||
true,
|
||||
!before,
|
||||
);
|
||||
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
|
||||
const read = async (tx: DbTransaction): Promise<MessageDOPageResult> => {
|
||||
const index = tx
|
||||
.objectStore('messages')
|
||||
.index('clientConversationId+sendTime');
|
||||
@@ -385,21 +398,41 @@ class DbClient {
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
request.addEventListener('success', () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || out.length >= limit) {
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const message = cursor.value as MessageDO;
|
||||
if (
|
||||
before &&
|
||||
message.sendTime === before.sendTime &&
|
||||
message.messageKey >= before.messageKey
|
||||
) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
out.push(message);
|
||||
if (out.length > limit) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
out.push(cursor.value as MessageDO);
|
||||
cursor.continue();
|
||||
});
|
||||
});
|
||||
// 气泡渲染需要按时间升序
|
||||
return out.toReversed();
|
||||
return {
|
||||
hasMore: out.length > limit,
|
||||
list: out.slice(0, limit).toReversed(),
|
||||
};
|
||||
};
|
||||
if (tx) {
|
||||
return read(tx);
|
||||
}
|
||||
return this.transaction<MessageDO[]>(['messages'], 'readonly', read);
|
||||
return this.transaction<MessageDOPageResult>(
|
||||
['messages'],
|
||||
'readonly',
|
||||
read,
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取设置 */
|
||||
@@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string {
|
||||
return `client:${clientMessageId}`;
|
||||
}
|
||||
|
||||
/** 解析本地消息主键 */
|
||||
export function parseMessageKey(
|
||||
messageKey: string,
|
||||
):
|
||||
| null
|
||||
| { clientMessageId: string; kind: 'client' }
|
||||
| { conversationType: number; id: number; kind: 'server' } {
|
||||
if (!messageKey) {
|
||||
return null;
|
||||
}
|
||||
if (messageKey.startsWith('client:')) {
|
||||
const clientMessageId = messageKey.slice('client:'.length);
|
||||
return clientMessageId ? { kind: 'client', clientMessageId } : null;
|
||||
}
|
||||
const [conversationTypeText, idText] = messageKey.split(':');
|
||||
const conversationType = Number(conversationTypeText);
|
||||
const id = Number(idText);
|
||||
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { kind: 'server', conversationType, id };
|
||||
}
|
||||
|
||||
/** 更新消息拉取游标 */
|
||||
export async function setMessageMaxId(
|
||||
conversationType: number,
|
||||
|
||||
@@ -486,6 +486,56 @@ function waitMediaSettled(): Promise<void> {
|
||||
return Promise.race([loadAll, timeout]);
|
||||
}
|
||||
|
||||
/** 加载并定位当前会话的未读 @ 消息 */
|
||||
async function handleLocateMention() {
|
||||
const messageId = conversationStore.consumeActiveMentionMessageId();
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!messageId || !conversation) {
|
||||
return;
|
||||
}
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
const isActive = () => {
|
||||
const activeConversation = conversationStore.activeConversation;
|
||||
return (
|
||||
!!activeConversation &&
|
||||
getClientConversationId(
|
||||
activeConversation.type,
|
||||
activeConversation.targetId,
|
||||
) === clientConversationId
|
||||
);
|
||||
};
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const loadedMessages = messageStore.getMessages(clientConversationId);
|
||||
if (loadedMessages.some((item) => item.id === messageId)) {
|
||||
break;
|
||||
}
|
||||
const { hasMore } = await messageStore.loadMoreMessageList(
|
||||
clientConversationId,
|
||||
50,
|
||||
);
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
messageStore
|
||||
.getMessages(clientConversationId)
|
||||
.some((item) => item.id === messageId)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (!hasMore) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
await handleLocate(messageId, isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发
|
||||
*
|
||||
@@ -495,11 +545,14 @@ function waitMediaSettled(): Promise<void> {
|
||||
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
||||
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
||||
*/
|
||||
async function handleLocate(messageId: number) {
|
||||
async function handleLocate(messageId: number, isActive?: () => boolean) {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
if (isActive && !isActive()) {
|
||||
return;
|
||||
}
|
||||
if (!listRef.value) {
|
||||
return;
|
||||
}
|
||||
@@ -733,6 +786,17 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 定位未读 @ 消息 -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
v-if="conversationStore.activeMentionMessageId"
|
||||
class="message-panel__jump-mention sticky bottom-12 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#f56c6c] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#f56c6c]"
|
||||
@click="handleLocateMention"
|
||||
>
|
||||
<span>查看 @消息</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
@@ -827,7 +891,8 @@ watch(
|
||||
}
|
||||
|
||||
/* sticky + translate 居中:fit-content 宽度不会撑满,transform 水平 -50% 偏移;同时 transition opacity 和 transform 两个属性 */
|
||||
.message-panel__jump-bottom {
|
||||
.message-panel__jump-bottom,
|
||||
.message-panel__jump-mention {
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config';
|
||||
import {
|
||||
IM_AT_ALL_USER_ID,
|
||||
ImConversationType,
|
||||
ImMessageReceiptStatus,
|
||||
ImMessageStatus,
|
||||
@@ -94,6 +95,8 @@ function toConversationDO(conversation: Conversation): ConversationDO {
|
||||
silent: conversation.silent,
|
||||
atMe: conversation.atMe,
|
||||
atAll: conversation.atAll,
|
||||
atMessageId: conversation.atMessageId,
|
||||
atAllMessageId: conversation.atAllMessageId,
|
||||
draft: draft
|
||||
? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined }
|
||||
: undefined,
|
||||
@@ -137,23 +140,166 @@ function isValidConversationReadRecord(
|
||||
return !!record.conversationType && !!record.targetId && !!record.messageId;
|
||||
}
|
||||
|
||||
/** 获取对方普通消息最大编号 */
|
||||
function getMaxIncomingNormalMessageId(
|
||||
messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'status' | 'type'>>,
|
||||
): number {
|
||||
let maxMessageId = 0;
|
||||
/** 按读位置重算会话未读与 @ 状态 */
|
||||
function applyConversationUnreadState(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
readMessageId: number,
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
let unreadCount = 0;
|
||||
let atMessageId: number | undefined;
|
||||
let atAllMessageId: number | undefined;
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL &&
|
||||
message.id > maxMessageId
|
||||
!message.id ||
|
||||
message.id <= readMessageId ||
|
||||
message.selfSend ||
|
||||
!isNormalMessage(message.type) ||
|
||||
message.status === ImMessageStatus.RECALL
|
||||
) {
|
||||
maxMessageId = message.id;
|
||||
continue;
|
||||
}
|
||||
unreadCount++;
|
||||
if (
|
||||
currentUserId &&
|
||||
message.atUserIds?.includes(currentUserId) &&
|
||||
message.id > (atMessageId || 0)
|
||||
) {
|
||||
atMessageId = message.id;
|
||||
}
|
||||
if (
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID) &&
|
||||
message.id > (atAllMessageId || 0)
|
||||
) {
|
||||
atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
return maxMessageId;
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 无读位置时按原未读窗口应用撤回状态 */
|
||||
function applyConversationRecallStateWithoutRead(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const previousUnreadCount = conversation.unreadCount;
|
||||
const originalWasIncomingNormal =
|
||||
!!originalMessage.id &&
|
||||
!originalMessage.selfSend &&
|
||||
isNormalMessage(originalMessage.type) &&
|
||||
originalMessage.status !== ImMessageStatus.RECALL;
|
||||
const incomingNormalMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0));
|
||||
const previousUnreadMessages = [
|
||||
...incomingNormalMessages.filter(
|
||||
(message) => message.id !== originalMessage.id,
|
||||
),
|
||||
...(originalWasIncomingNormal ? [originalMessage] : []),
|
||||
]
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, previousUnreadCount);
|
||||
const recalledUnread = previousUnreadMessages.some(
|
||||
(message) => message.id === originalMessage.id,
|
||||
);
|
||||
const unreadCount = Math.max(
|
||||
0,
|
||||
previousUnreadCount - (recalledUnread ? 1 : 0),
|
||||
);
|
||||
const unreadMessages = incomingNormalMessages.slice(0, unreadCount);
|
||||
const currentUserId = getCurrentUserId();
|
||||
let atMessageId = conversation.atMessageId;
|
||||
let atAllMessageId = conversation.atAllMessageId;
|
||||
if (
|
||||
conversation.atMessageId === originalMessage.id ||
|
||||
(!conversation.atMessageId &&
|
||||
conversation.atMe &&
|
||||
!!currentUserId &&
|
||||
originalMessage.atUserIds?.includes(currentUserId))
|
||||
) {
|
||||
atMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id;
|
||||
}
|
||||
if (
|
||||
conversation.atAllMessageId === originalMessage.id ||
|
||||
(!conversation.atAllMessageId &&
|
||||
conversation.atAll &&
|
||||
originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID))
|
||||
) {
|
||||
atAllMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id;
|
||||
}
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 为旧会话回填未读 @ 消息编号 */
|
||||
function backfillConversationMentionIds(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
const unreadMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, conversation.unreadCount);
|
||||
const atMessageId =
|
||||
conversation.atMessageId ||
|
||||
(conversation.atMe && currentUserId
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id
|
||||
: undefined);
|
||||
const atAllMessageId =
|
||||
conversation.atAllMessageId ||
|
||||
(conversation.atAll
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id
|
||||
: undefined);
|
||||
const changed =
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('imConversationStore', {
|
||||
@@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道)
|
||||
conversationReads: {} as Record<string, ConversationRead>, // 会话读位置
|
||||
activeConversation: null as Conversation | null, // 当前激活的会话
|
||||
activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号
|
||||
loading: false, // 是否正在批量加载
|
||||
recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表
|
||||
}),
|
||||
@@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.conversations = [];
|
||||
this.conversationReads = {};
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
this.recentForwardConversationKeys = [];
|
||||
},
|
||||
|
||||
@@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (this.applyReadToConversation(conversation, record.messageId)) {
|
||||
changedConversations.push(conversation);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
const needsMentionBackfill =
|
||||
(conversation.atMe && !conversation.atMessageId) ||
|
||||
(conversation.atAll && !conversation.atAllMessageId);
|
||||
if (!record && !needsMentionBackfill) {
|
||||
continue;
|
||||
}
|
||||
const messages = await getDb().getAllByIndex<MessageDO>(
|
||||
@@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
'clientConversationId',
|
||||
getClientConversationId(conversation.type, conversation.targetId),
|
||||
);
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages);
|
||||
if (
|
||||
maxIncomingMessageId > 0 &&
|
||||
maxIncomingMessageId <= record.messageId
|
||||
) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
const changed = record
|
||||
? this.applyReadToConversation(
|
||||
conversation,
|
||||
record.messageId,
|
||||
messages,
|
||||
)
|
||||
: backfillConversationMentionIds(conversation, messages);
|
||||
if (changed) {
|
||||
changedConversations.push(conversation);
|
||||
}
|
||||
}
|
||||
@@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
applyReadToConversation(
|
||||
conversation: Conversation,
|
||||
messageId: number,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
if (
|
||||
!conversation.lastMessageId ||
|
||||
conversation.lastMessageId > messageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
return true;
|
||||
return applyConversationUnreadState(conversation, messages, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回后的会话未读与 @ 状态 */
|
||||
applyRecallToConversation(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const read = this.getConversationRead(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
return read
|
||||
? applyConversationUnreadState(conversation, messages, read.messageId)
|
||||
: applyConversationRecallStateWithoutRead(
|
||||
conversation,
|
||||
messages,
|
||||
originalMessage,
|
||||
);
|
||||
},
|
||||
|
||||
/** 应用会话读位置 */
|
||||
@@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
if (
|
||||
conversation &&
|
||||
this.applyReadToConversation(conversation, messageId)
|
||||
this.applyReadToConversation(
|
||||
conversation,
|
||||
messageId,
|
||||
await getStoredMessages(),
|
||||
)
|
||||
) {
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
} else if (conversation) {
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(
|
||||
await getStoredMessages(),
|
||||
);
|
||||
if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
}
|
||||
}
|
||||
if (record.conversationType !== ImConversationType.CHANNEL) {
|
||||
continue;
|
||||
@@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
/** 设置当前会话 */
|
||||
setActiveConversation(conversation: Conversation | null) {
|
||||
this.activeMentionMessageId =
|
||||
conversation?.atMessageId || conversation?.atAllMessageId;
|
||||
this.activeConversation = conversation;
|
||||
if (!conversation) {
|
||||
return;
|
||||
@@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.saveConversation(conversation);
|
||||
},
|
||||
|
||||
/** 消费当前会话待定位的未读 @ 消息编号 */
|
||||
consumeActiveMentionMessageId(): number | undefined {
|
||||
const messageId = this.activeMentionMessageId;
|
||||
this.activeMentionMessageId = undefined;
|
||||
return messageId;
|
||||
},
|
||||
|
||||
/** 创建空会话 */
|
||||
createEmptyConversation(
|
||||
type: number,
|
||||
@@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
}
|
||||
if (this.activeConversation === conversation) {
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
}
|
||||
conversation.deleted = true;
|
||||
// 2. 删除会话关联的消息和草稿
|
||||
@@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
conversation.atMessageId = undefined;
|
||||
conversation.atAllMessageId = undefined;
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = createConversationRead(type, targetId, messageId);
|
||||
this.conversationReads[key] = record;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DbTransaction } from '../../utils/db';
|
||||
import type {
|
||||
DbTransaction,
|
||||
MessageDOPageCursor,
|
||||
} from '../../utils/db';
|
||||
import type { Conversation, Message, MessageDO } from '../types';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
@@ -50,6 +53,11 @@ interface PersistMessageRecordOptions {
|
||||
mergeClientRecord?: boolean;
|
||||
}
|
||||
|
||||
interface MessagePageResult {
|
||||
messages: Message[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 拉取消息批量处理项 */
|
||||
export type PulledMessage =
|
||||
| {
|
||||
@@ -79,6 +87,27 @@ function getMessageKey(
|
||||
: getClientMessageKey(message.clientMessageId);
|
||||
}
|
||||
|
||||
/** 获取数据库分页结果的最早游标 */
|
||||
function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined {
|
||||
return message
|
||||
? {
|
||||
messageKey: message.messageKey,
|
||||
sendTime: message.sendTime,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 判断两个消息分页游标是否一致 */
|
||||
function isSameMessageDOPageCursor(
|
||||
left?: MessageDOPageCursor,
|
||||
right?: MessageDOPageCursor,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.sendTime === right.sendTime && left.messageKey === right.messageKey;
|
||||
}
|
||||
|
||||
/** 补齐客户端消息编号 */
|
||||
function ensureClientMessageId(message: Message): Message {
|
||||
if (!message.clientMessageId) {
|
||||
@@ -237,9 +266,15 @@ function syncConversationAtFlags(
|
||||
const currentUserId = getCurrentUserId();
|
||||
if (currentUserId && message.atUserIds.includes(currentUserId)) {
|
||||
conversation.atMe = true;
|
||||
if (message.id && message.id > (conversation.atMessageId || 0)) {
|
||||
conversation.atMessageId = message.id;
|
||||
}
|
||||
}
|
||||
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
|
||||
conversation.atAll = true;
|
||||
if (message.id && message.id > (conversation.atAllMessageId || 0)) {
|
||||
conversation.atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean {
|
||||
export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
messageDOPageCursors: {} as Record<
|
||||
string,
|
||||
MessageDOPageCursor | undefined
|
||||
>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
@@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
});
|
||||
});
|
||||
this.messagesByConversation = {};
|
||||
this.messageDOPageCursors = {};
|
||||
this.loadedConversationKeys = [];
|
||||
this.privateReadMaxIds = {};
|
||||
this.privateMessageMaxId = 0;
|
||||
@@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.loadedConversationKeys = retained;
|
||||
removed.forEach((key) => {
|
||||
Reflect.deleteProperty(this.messagesByConversation, key);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, key);
|
||||
});
|
||||
},
|
||||
|
||||
/** 加载当前会话最近消息 */
|
||||
async loadMoreMessageList(
|
||||
clientConversationId: string,
|
||||
beforeSendTime?: number,
|
||||
limit = 50,
|
||||
): Promise<Message[]> {
|
||||
): Promise<MessagePageResult> {
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return { messages: [], hasMore: false };
|
||||
}
|
||||
const before = this.messageDOPageCursors[clientConversationId];
|
||||
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
|
||||
const list = await getDb().getMessageListByConversation(
|
||||
const page = await getDb().getMessageListByConversation(
|
||||
clientConversationId,
|
||||
{
|
||||
beforeSendTime,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
);
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
if (
|
||||
isSameMessageDOPageCursor(
|
||||
this.messageDOPageCursors[clientConversationId],
|
||||
before,
|
||||
)
|
||||
) {
|
||||
const nextCursor = getMessageDOPageCursor(page.list[0]);
|
||||
if (nextCursor) {
|
||||
this.messageDOPageCursors[clientConversationId] = nextCursor;
|
||||
}
|
||||
}
|
||||
const messages = list.map((message) => buildMessageFromDO(message));
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const messages = page.list.map((message) =>
|
||||
buildMessageFromDO(message),
|
||||
);
|
||||
const existing = this.messagesByConversation[clientConversationId] || [];
|
||||
const existingKeys = new Set(
|
||||
existing.map((message) => getMessageKey(message, parsed.type)),
|
||||
@@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
(messageA.sendTime || 0) - (messageB.sendTime || 0),
|
||||
);
|
||||
this.touchConversationMessageCache(clientConversationId);
|
||||
return fresh;
|
||||
return { messages: fresh, hasMore: page.hasMore };
|
||||
},
|
||||
|
||||
/** 确保会话消息已加载 */
|
||||
@@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.updateMessageCursor(conversationType, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回到内存 */
|
||||
applyRecallMessageInMemory(
|
||||
/** 应用撤回到本地消息与会话状态 */
|
||||
async applyRecallMessageRecord(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
recallSignalContent: string,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
// 1. 定位被撤回的原消息
|
||||
// 1. 定位被撤回的原消息和会话
|
||||
const messageId = parseRecallMessageId(recallSignalContent);
|
||||
if (!messageId) {
|
||||
return null;
|
||||
@@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId);
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message) {
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversationType,
|
||||
targetId,
|
||||
);
|
||||
const cachedMessage = this.messagesByConversation[
|
||||
clientConversationId
|
||||
]?.find((item) => item.id === messageId);
|
||||
const storedMessage = await getDb().get<MessageDO>(
|
||||
'messages',
|
||||
getServerMessageKey(conversationType, messageId),
|
||||
tx,
|
||||
);
|
||||
const originalMessage = cachedMessage
|
||||
? buildMessageDO(cachedMessage, conversationType)
|
||||
: storedMessage;
|
||||
if (!originalMessage) {
|
||||
return null;
|
||||
}
|
||||
// 2. 更新消息和会话摘要
|
||||
message.type = ImContentType.RECALL;
|
||||
message.status = ImMessageStatus.RECALL;
|
||||
message.content = '';
|
||||
if (messages[messages.length - 1]?.id === messageId) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
// 2. 更新内存消息与数据库记录
|
||||
const recalledMessage =
|
||||
cachedMessage || buildMessageFromDO(originalMessage);
|
||||
revokeBlobUrlsInContent(recalledMessage.content);
|
||||
recalledMessage.type = ImContentType.RECALL;
|
||||
recalledMessage.status = ImMessageStatus.RECALL;
|
||||
recalledMessage.content = '';
|
||||
await this.saveMessageRecord(recalledMessage, conversationType, tx);
|
||||
// 3. 按本地完整消息和读位置重算未读与 @ 状态
|
||||
const storedMessages = await getDb().getAllByIndex<MessageDO>(
|
||||
'messages',
|
||||
'clientConversationId',
|
||||
clientConversationId,
|
||||
tx,
|
||||
);
|
||||
conversationStore.applyRecallToConversation(
|
||||
conversation,
|
||||
storedMessages,
|
||||
originalMessage,
|
||||
);
|
||||
if (conversation.lastMessageId === messageId) {
|
||||
applyConversationSummary(conversation, recalledMessage);
|
||||
}
|
||||
return { conversation, message };
|
||||
return { conversation, message: recalledMessage };
|
||||
},
|
||||
|
||||
/** 批量写入拉取消息 */
|
||||
@@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
>();
|
||||
const changedConversations = new Map<string, Conversation>();
|
||||
const recallMessages: Extract<
|
||||
PulledMessage,
|
||||
{ kind: 'recall' }
|
||||
>[] = [];
|
||||
|
||||
const addChanged = (
|
||||
conversation: Conversation,
|
||||
@@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
// 1. 先更新内存,收集需要持久化的消息和会话
|
||||
for (const pulledMessage of pulledMessages) {
|
||||
if (pulledMessage.kind === 'recall') {
|
||||
// 1.1 撤回信号更新原消息
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
pulledMessage.conversationType,
|
||||
pulledMessage.targetId,
|
||||
pulledMessage.recallSignalContent,
|
||||
);
|
||||
if (changed) {
|
||||
addChanged(changed.conversation, changed.message);
|
||||
}
|
||||
// 1.1 撤回信号在事务内读取原消息后统一处理
|
||||
recallMessages.push(pulledMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((existing) =>
|
||||
isSameMessage(existing, message),
|
||||
);
|
||||
@@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
addChanged(conversation, existing, {
|
||||
@@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 1.4 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
},
|
||||
);
|
||||
}
|
||||
// 2.2 写入本批变更会话
|
||||
// 2.2 应用本批撤回信号
|
||||
for (const recallMessage of recallMessages) {
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
recallMessage.conversationType,
|
||||
recallMessage.targetId,
|
||||
recallMessage.recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (changed) {
|
||||
changedConversations.set(
|
||||
getClientConversationId(
|
||||
changed.conversation.type,
|
||||
changed.conversation.targetId,
|
||||
),
|
||||
changed.conversation,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2.3 写入本批变更会话
|
||||
await conversationStore.saveConversationRecord(
|
||||
[...changedConversations.values()],
|
||||
tx,
|
||||
);
|
||||
// 2.3 写入本批游标
|
||||
// 2.4 写入本批游标
|
||||
await setMessageMaxId(conversationType, maxMessageId, tx);
|
||||
},
|
||||
);
|
||||
@@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((item) =>
|
||||
isSameMessage(item, message),
|
||||
);
|
||||
@@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
return getDb()
|
||||
@@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 4. 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
recallSignalContent: string,
|
||||
): Promise<void> {
|
||||
const conversationStore = useConversationStore();
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
|
||||
await this.saveMessageRecord(changed.message, conversationType, tx);
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await conversationStore.saveConversationRecord(
|
||||
changed.conversation,
|
||||
tx,
|
||||
@@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
message._localFile = undefined;
|
||||
});
|
||||
Reflect.deleteProperty(this.messagesByConversation, clientConversationId);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId);
|
||||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||
(key) => key !== clientConversationId,
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface Conversation {
|
||||
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
atMe?: boolean; // 群聊:是否有人 @我
|
||||
atAll?: boolean; // 群聊:是否有人 @全体成员
|
||||
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
|
||||
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
|
||||
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
|
||||
draft?: {
|
||||
html: string; // 输入框 HTML
|
||||
|
||||
@@ -22,6 +22,18 @@ export type DbStoreName =
|
||||
|
||||
export type DbTransaction = IDBTransaction;
|
||||
|
||||
/** 数据库消息分页游标 */
|
||||
export interface MessageDOPageCursor {
|
||||
messageKey: string;
|
||||
sendTime: number;
|
||||
}
|
||||
|
||||
/** 数据库消息分页结果 */
|
||||
export interface MessageDOPageResult {
|
||||
hasMore: boolean;
|
||||
list: MessageDO[];
|
||||
}
|
||||
|
||||
/** IM 本地存储 key */
|
||||
export const StorageKeys = {
|
||||
localStorage: {
|
||||
@@ -363,18 +375,19 @@ class DbClient {
|
||||
/** 按会话分页获取消息 */
|
||||
async getMessageListByConversation(
|
||||
clientConversationId: string,
|
||||
options?: { beforeSendTime?: number; limit?: number },
|
||||
options?: { before?: MessageDOPageCursor; limit?: number },
|
||||
tx?: DbTransaction,
|
||||
): Promise<MessageDO[]> {
|
||||
): Promise<MessageDOPageResult> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const before = options?.before;
|
||||
const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const range = IDBKeyRange.bound(
|
||||
[clientConversationId, 0],
|
||||
[clientConversationId, upper],
|
||||
false,
|
||||
true,
|
||||
!before,
|
||||
);
|
||||
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
|
||||
const read = async (tx: DbTransaction): Promise<MessageDOPageResult> => {
|
||||
const index = tx
|
||||
.objectStore('messages')
|
||||
.index('clientConversationId+sendTime');
|
||||
@@ -385,21 +398,41 @@ class DbClient {
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
request.addEventListener('success', () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || out.length >= limit) {
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const message = cursor.value as MessageDO;
|
||||
if (
|
||||
before &&
|
||||
message.sendTime === before.sendTime &&
|
||||
message.messageKey >= before.messageKey
|
||||
) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
out.push(message);
|
||||
if (out.length > limit) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
out.push(cursor.value as MessageDO);
|
||||
cursor.continue();
|
||||
});
|
||||
});
|
||||
// 气泡渲染需要按时间升序
|
||||
return out.toReversed();
|
||||
return {
|
||||
hasMore: out.length > limit,
|
||||
list: out.slice(0, limit).toReversed(),
|
||||
};
|
||||
};
|
||||
if (tx) {
|
||||
return read(tx);
|
||||
}
|
||||
return this.transaction<MessageDO[]>(['messages'], 'readonly', read);
|
||||
return this.transaction<MessageDOPageResult>(
|
||||
['messages'],
|
||||
'readonly',
|
||||
read,
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取设置 */
|
||||
@@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string {
|
||||
return `client:${clientMessageId}`;
|
||||
}
|
||||
|
||||
/** 解析本地消息主键 */
|
||||
export function parseMessageKey(
|
||||
messageKey: string,
|
||||
):
|
||||
| null
|
||||
| { clientMessageId: string; kind: 'client' }
|
||||
| { conversationType: number; id: number; kind: 'server' } {
|
||||
if (!messageKey) {
|
||||
return null;
|
||||
}
|
||||
if (messageKey.startsWith('client:')) {
|
||||
const clientMessageId = messageKey.slice('client:'.length);
|
||||
return clientMessageId ? { kind: 'client', clientMessageId } : null;
|
||||
}
|
||||
const [conversationTypeText, idText] = messageKey.split(':');
|
||||
const conversationType = Number(conversationTypeText);
|
||||
const id = Number(idText);
|
||||
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { kind: 'server', conversationType, id };
|
||||
}
|
||||
|
||||
/** 更新消息拉取游标 */
|
||||
export async function setMessageMaxId(
|
||||
conversationType: number,
|
||||
|
||||
@@ -486,6 +486,56 @@ function waitMediaSettled(): Promise<void> {
|
||||
return Promise.race([loadAll, timeout]);
|
||||
}
|
||||
|
||||
/** 加载并定位当前会话的未读 @ 消息 */
|
||||
async function handleLocateMention() {
|
||||
const messageId = conversationStore.consumeActiveMentionMessageId();
|
||||
const conversation = conversationStore.activeConversation;
|
||||
if (!messageId || !conversation) {
|
||||
return;
|
||||
}
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
const isActive = () => {
|
||||
const activeConversation = conversationStore.activeConversation;
|
||||
return (
|
||||
!!activeConversation &&
|
||||
getClientConversationId(
|
||||
activeConversation.type,
|
||||
activeConversation.targetId,
|
||||
) === clientConversationId
|
||||
);
|
||||
};
|
||||
for (let guard = 0; guard < 50; guard++) {
|
||||
const loadedMessages = messageStore.getMessages(clientConversationId);
|
||||
if (loadedMessages.some((item) => item.id === messageId)) {
|
||||
break;
|
||||
}
|
||||
const { hasMore } = await messageStore.loadMoreMessageList(
|
||||
clientConversationId,
|
||||
50,
|
||||
);
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
messageStore
|
||||
.getMessages(clientConversationId)
|
||||
.some((item) => item.id === messageId)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (!hasMore) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isActive()) {
|
||||
return;
|
||||
}
|
||||
await handleLocate(messageId, isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位到聊天位置:MessageHistory 行上"定位"按钮 / 气泡内引用块点击触发
|
||||
*
|
||||
@@ -495,11 +545,14 @@ function waitMediaSettled(): Promise<void> {
|
||||
* 4. 加 --highlight class 短暂高亮,提示用户"就是这条"
|
||||
* 5. 找不到 wrapper(原消息已分页出去)时弹 warning 提示,与微信"消息已不在窗口"观感一致
|
||||
*/
|
||||
async function handleLocate(messageId: number) {
|
||||
async function handleLocate(messageId: number, isActive?: () => boolean) {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
if (isActive && !isActive()) {
|
||||
return;
|
||||
}
|
||||
if (!listRef.value) {
|
||||
return;
|
||||
}
|
||||
@@ -742,6 +795,17 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 定位未读 @ 消息 -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
v-if="conversationStore.activeMentionMessageId"
|
||||
class="message-panel__jump-mention sticky bottom-12 left-1/2 inline-flex gap-1.5 items-center w-fit mx-auto px-3.5 py-1.5 text-xs text-[#f56c6c] bg-[var(--ant-color-bg-elevated)] rounded-2xl shadow-[0_2px_8px_rgba(0,0,0,0.12)] cursor-pointer hover:text-white hover:bg-[#f56c6c]"
|
||||
@click="handleLocateMention"
|
||||
>
|
||||
<span>查看 @消息</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 回到底部浮动按钮(滚动不在底部时显示) -->
|
||||
<transition name="message-panel__jump-fade">
|
||||
<div
|
||||
@@ -839,7 +903,8 @@ watch(
|
||||
}
|
||||
|
||||
/* sticky + translate 居中:fit-content 宽度不会撑满,transform 水平 -50% 偏移;同时 transition opacity 和 transform 两个属性 */
|
||||
.message-panel__jump-bottom {
|
||||
.message-panel__jump-bottom,
|
||||
.message-panel__jump-mention {
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth';
|
||||
|
||||
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config';
|
||||
import {
|
||||
IM_AT_ALL_USER_ID,
|
||||
ImConversationType,
|
||||
ImMessageReceiptStatus,
|
||||
ImMessageStatus,
|
||||
@@ -94,6 +95,8 @@ function toConversationDO(conversation: Conversation): ConversationDO {
|
||||
silent: conversation.silent,
|
||||
atMe: conversation.atMe,
|
||||
atAll: conversation.atAll,
|
||||
atMessageId: conversation.atMessageId,
|
||||
atAllMessageId: conversation.atAllMessageId,
|
||||
draft: draft
|
||||
? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined }
|
||||
: undefined,
|
||||
@@ -137,23 +140,166 @@ function isValidConversationReadRecord(
|
||||
return !!record.conversationType && !!record.targetId && !!record.messageId;
|
||||
}
|
||||
|
||||
/** 获取对方普通消息最大编号 */
|
||||
function getMaxIncomingNormalMessageId(
|
||||
messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'status' | 'type'>>,
|
||||
): number {
|
||||
let maxMessageId = 0;
|
||||
/** 按读位置重算会话未读与 @ 状态 */
|
||||
function applyConversationUnreadState(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
readMessageId: number,
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
let unreadCount = 0;
|
||||
let atMessageId: number | undefined;
|
||||
let atAllMessageId: number | undefined;
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL &&
|
||||
message.id > maxMessageId
|
||||
!message.id ||
|
||||
message.id <= readMessageId ||
|
||||
message.selfSend ||
|
||||
!isNormalMessage(message.type) ||
|
||||
message.status === ImMessageStatus.RECALL
|
||||
) {
|
||||
maxMessageId = message.id;
|
||||
continue;
|
||||
}
|
||||
unreadCount++;
|
||||
if (
|
||||
currentUserId &&
|
||||
message.atUserIds?.includes(currentUserId) &&
|
||||
message.id > (atMessageId || 0)
|
||||
) {
|
||||
atMessageId = message.id;
|
||||
}
|
||||
if (
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID) &&
|
||||
message.id > (atAllMessageId || 0)
|
||||
) {
|
||||
atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
return maxMessageId;
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 无读位置时按原未读窗口应用撤回状态 */
|
||||
function applyConversationRecallStateWithoutRead(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const previousUnreadCount = conversation.unreadCount;
|
||||
const originalWasIncomingNormal =
|
||||
!!originalMessage.id &&
|
||||
!originalMessage.selfSend &&
|
||||
isNormalMessage(originalMessage.type) &&
|
||||
originalMessage.status !== ImMessageStatus.RECALL;
|
||||
const incomingNormalMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0));
|
||||
const previousUnreadMessages = [
|
||||
...incomingNormalMessages.filter(
|
||||
(message) => message.id !== originalMessage.id,
|
||||
),
|
||||
...(originalWasIncomingNormal ? [originalMessage] : []),
|
||||
]
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, previousUnreadCount);
|
||||
const recalledUnread = previousUnreadMessages.some(
|
||||
(message) => message.id === originalMessage.id,
|
||||
);
|
||||
const unreadCount = Math.max(
|
||||
0,
|
||||
previousUnreadCount - (recalledUnread ? 1 : 0),
|
||||
);
|
||||
const unreadMessages = incomingNormalMessages.slice(0, unreadCount);
|
||||
const currentUserId = getCurrentUserId();
|
||||
let atMessageId = conversation.atMessageId;
|
||||
let atAllMessageId = conversation.atAllMessageId;
|
||||
if (
|
||||
conversation.atMessageId === originalMessage.id ||
|
||||
(!conversation.atMessageId &&
|
||||
conversation.atMe &&
|
||||
!!currentUserId &&
|
||||
originalMessage.atUserIds?.includes(currentUserId))
|
||||
) {
|
||||
atMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id;
|
||||
}
|
||||
if (
|
||||
conversation.atAllMessageId === originalMessage.id ||
|
||||
(!conversation.atAllMessageId &&
|
||||
conversation.atAll &&
|
||||
originalMessage.atUserIds?.includes(IM_AT_ALL_USER_ID))
|
||||
) {
|
||||
atAllMessageId = unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id;
|
||||
}
|
||||
const changed =
|
||||
conversation.unreadCount !== unreadCount ||
|
||||
conversation.atMe !== !!atMessageId ||
|
||||
conversation.atAll !== !!atAllMessageId ||
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.unreadCount = unreadCount;
|
||||
conversation.atMe = !!atMessageId;
|
||||
conversation.atAll = !!atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 为旧会话回填未读 @ 消息编号 */
|
||||
function backfillConversationMentionIds(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
const currentUserId = getCurrentUserId();
|
||||
const unreadMessages = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
!!message.id &&
|
||||
!message.selfSend &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL,
|
||||
)
|
||||
.toSorted((left, right) => (right.id || 0) - (left.id || 0))
|
||||
.slice(0, conversation.unreadCount);
|
||||
const atMessageId =
|
||||
conversation.atMessageId ||
|
||||
(conversation.atMe && currentUserId
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(currentUserId),
|
||||
)?.id
|
||||
: undefined);
|
||||
const atAllMessageId =
|
||||
conversation.atAllMessageId ||
|
||||
(conversation.atAll
|
||||
? unreadMessages.find((message) =>
|
||||
message.atUserIds?.includes(IM_AT_ALL_USER_ID),
|
||||
)?.id
|
||||
: undefined);
|
||||
const changed =
|
||||
conversation.atMessageId !== atMessageId ||
|
||||
conversation.atAllMessageId !== atAllMessageId;
|
||||
conversation.atMessageId = atMessageId;
|
||||
conversation.atAllMessageId = atAllMessageId;
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('imConversationStore', {
|
||||
@@ -161,6 +307,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道)
|
||||
conversationReads: {} as Record<string, ConversationRead>, // 会话读位置
|
||||
activeConversation: null as Conversation | null, // 当前激活的会话
|
||||
activeMentionMessageId: undefined as number | undefined, // 当前会话待定位的未读 @ 消息编号
|
||||
loading: false, // 是否正在批量加载
|
||||
recentForwardConversationKeys: [] as string[], // 最近转发会话 key 列表
|
||||
}),
|
||||
@@ -271,6 +418,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.conversations = [];
|
||||
this.conversationReads = {};
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
this.recentForwardConversationKeys = [];
|
||||
},
|
||||
|
||||
@@ -312,18 +460,10 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (this.applyReadToConversation(conversation, record.messageId)) {
|
||||
changedConversations.push(conversation);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
const needsMentionBackfill =
|
||||
(conversation.atMe && !conversation.atMessageId) ||
|
||||
(conversation.atAll && !conversation.atAllMessageId);
|
||||
if (!record && !needsMentionBackfill) {
|
||||
continue;
|
||||
}
|
||||
const messages = await getDb().getAllByIndex<MessageDO>(
|
||||
@@ -331,14 +471,14 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
'clientConversationId',
|
||||
getClientConversationId(conversation.type, conversation.targetId),
|
||||
);
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages);
|
||||
if (
|
||||
maxIncomingMessageId > 0 &&
|
||||
maxIncomingMessageId <= record.messageId
|
||||
) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
const changed = record
|
||||
? this.applyReadToConversation(
|
||||
conversation,
|
||||
record.messageId,
|
||||
messages,
|
||||
)
|
||||
: backfillConversationMentionIds(conversation, messages);
|
||||
if (changed) {
|
||||
changedConversations.push(conversation);
|
||||
}
|
||||
}
|
||||
@@ -392,24 +532,28 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
applyReadToConversation(
|
||||
conversation: Conversation,
|
||||
messageId: number,
|
||||
messages: MessageDO[],
|
||||
): boolean {
|
||||
if (
|
||||
!conversation.lastMessageId ||
|
||||
conversation.lastMessageId > messageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conversation.unreadCount === 0 &&
|
||||
!conversation.atMe &&
|
||||
!conversation.atAll
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
return true;
|
||||
return applyConversationUnreadState(conversation, messages, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回后的会话未读与 @ 状态 */
|
||||
applyRecallToConversation(
|
||||
conversation: Conversation,
|
||||
messages: MessageDO[],
|
||||
originalMessage: MessageDO,
|
||||
): boolean {
|
||||
const read = this.getConversationRead(
|
||||
conversation.type,
|
||||
conversation.targetId,
|
||||
);
|
||||
return read
|
||||
? applyConversationUnreadState(conversation, messages, read.messageId)
|
||||
: applyConversationRecallStateWithoutRead(
|
||||
conversation,
|
||||
messages,
|
||||
originalMessage,
|
||||
);
|
||||
},
|
||||
|
||||
/** 应用会话读位置 */
|
||||
@@ -475,19 +619,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
if (
|
||||
conversation &&
|
||||
this.applyReadToConversation(conversation, messageId)
|
||||
this.applyReadToConversation(
|
||||
conversation,
|
||||
messageId,
|
||||
await getStoredMessages(),
|
||||
)
|
||||
) {
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
} else if (conversation) {
|
||||
const maxIncomingMessageId = getMaxIncomingNormalMessageId(
|
||||
await getStoredMessages(),
|
||||
);
|
||||
if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
changedConversations.set(clientConversationId, conversation);
|
||||
}
|
||||
}
|
||||
if (record.conversationType !== ImConversationType.CHANNEL) {
|
||||
continue;
|
||||
@@ -693,6 +831,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
|
||||
/** 设置当前会话 */
|
||||
setActiveConversation(conversation: Conversation | null) {
|
||||
this.activeMentionMessageId =
|
||||
conversation?.atMessageId || conversation?.atAllMessageId;
|
||||
this.activeConversation = conversation;
|
||||
if (!conversation) {
|
||||
return;
|
||||
@@ -702,6 +842,13 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
this.saveConversation(conversation);
|
||||
},
|
||||
|
||||
/** 消费当前会话待定位的未读 @ 消息编号 */
|
||||
consumeActiveMentionMessageId(): number | undefined {
|
||||
const messageId = this.activeMentionMessageId;
|
||||
this.activeMentionMessageId = undefined;
|
||||
return messageId;
|
||||
},
|
||||
|
||||
/** 创建空会话 */
|
||||
createEmptyConversation(
|
||||
type: number,
|
||||
@@ -755,6 +902,7 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
}
|
||||
if (this.activeConversation === conversation) {
|
||||
this.activeConversation = null;
|
||||
this.activeMentionMessageId = undefined;
|
||||
}
|
||||
conversation.deleted = true;
|
||||
// 2. 删除会话关联的消息和草稿
|
||||
@@ -798,6 +946,8 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||
conversation.unreadCount = 0;
|
||||
conversation.atMe = false;
|
||||
conversation.atAll = false;
|
||||
conversation.atMessageId = undefined;
|
||||
conversation.atAllMessageId = undefined;
|
||||
if (readMessageIdAdvanced) {
|
||||
const record = createConversationRead(type, targetId, messageId);
|
||||
this.conversationReads[key] = record;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DbTransaction } from '../../utils/db';
|
||||
import type {
|
||||
DbTransaction,
|
||||
MessageDOPageCursor,
|
||||
} from '../../utils/db';
|
||||
import type { Conversation, Message, MessageDO } from '../types';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
@@ -50,6 +53,11 @@ interface PersistMessageRecordOptions {
|
||||
mergeClientRecord?: boolean;
|
||||
}
|
||||
|
||||
interface MessagePageResult {
|
||||
messages: Message[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 拉取消息批量处理项 */
|
||||
export type PulledMessage =
|
||||
| {
|
||||
@@ -79,6 +87,27 @@ function getMessageKey(
|
||||
: getClientMessageKey(message.clientMessageId);
|
||||
}
|
||||
|
||||
/** 获取数据库分页结果的最早游标 */
|
||||
function getMessageDOPageCursor(message?: MessageDO): MessageDOPageCursor | undefined {
|
||||
return message
|
||||
? {
|
||||
messageKey: message.messageKey,
|
||||
sendTime: message.sendTime,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 判断两个消息分页游标是否一致 */
|
||||
function isSameMessageDOPageCursor(
|
||||
left?: MessageDOPageCursor,
|
||||
right?: MessageDOPageCursor,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.sendTime === right.sendTime && left.messageKey === right.messageKey;
|
||||
}
|
||||
|
||||
/** 补齐客户端消息编号 */
|
||||
function ensureClientMessageId(message: Message): Message {
|
||||
if (!message.clientMessageId) {
|
||||
@@ -237,9 +266,15 @@ function syncConversationAtFlags(
|
||||
const currentUserId = getCurrentUserId();
|
||||
if (currentUserId && message.atUserIds.includes(currentUserId)) {
|
||||
conversation.atMe = true;
|
||||
if (message.id && message.id > (conversation.atMessageId || 0)) {
|
||||
conversation.atMessageId = message.id;
|
||||
}
|
||||
}
|
||||
if (message.atUserIds.includes(IM_AT_ALL_USER_ID)) {
|
||||
conversation.atAll = true;
|
||||
if (message.id && message.id > (conversation.atAllMessageId || 0)) {
|
||||
conversation.atAllMessageId = message.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +314,10 @@ function isSameMessage(left: Message, right: Message): boolean {
|
||||
export const useMessageStore = defineStore('imMessageStore', {
|
||||
state: () => ({
|
||||
messagesByConversation: {} as Record<string, Message[]>,
|
||||
messageDOPageCursors: {} as Record<
|
||||
string,
|
||||
MessageDOPageCursor | undefined
|
||||
>,
|
||||
loadedConversationKeys: [] as string[],
|
||||
privateReadMaxIds: {} as Partial<Record<number, number>>,
|
||||
privateMessageMaxId: 0,
|
||||
@@ -304,6 +343,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
});
|
||||
});
|
||||
this.messagesByConversation = {};
|
||||
this.messageDOPageCursors = {};
|
||||
this.loadedConversationKeys = [];
|
||||
this.privateReadMaxIds = {};
|
||||
this.privateMessageMaxId = 0;
|
||||
@@ -397,29 +437,43 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.loadedConversationKeys = retained;
|
||||
removed.forEach((key) => {
|
||||
Reflect.deleteProperty(this.messagesByConversation, key);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, key);
|
||||
});
|
||||
},
|
||||
|
||||
/** 加载当前会话最近消息 */
|
||||
async loadMoreMessageList(
|
||||
clientConversationId: string,
|
||||
beforeSendTime?: number,
|
||||
limit = 50,
|
||||
): Promise<Message[]> {
|
||||
): Promise<MessagePageResult> {
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return { messages: [], hasMore: false };
|
||||
}
|
||||
const before = this.messageDOPageCursors[clientConversationId];
|
||||
// 1. 从 IndexedDB 倒序读取一页,返回前已按时间升序排列
|
||||
const list = await getDb().getMessageListByConversation(
|
||||
const page = await getDb().getMessageListByConversation(
|
||||
clientConversationId,
|
||||
{
|
||||
beforeSendTime,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
);
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const parsed = parseClientConversationId(clientConversationId);
|
||||
if (!parsed) {
|
||||
return [];
|
||||
if (
|
||||
isSameMessageDOPageCursor(
|
||||
this.messageDOPageCursors[clientConversationId],
|
||||
before,
|
||||
)
|
||||
) {
|
||||
const nextCursor = getMessageDOPageCursor(page.list[0]);
|
||||
if (nextCursor) {
|
||||
this.messageDOPageCursors[clientConversationId] = nextCursor;
|
||||
}
|
||||
}
|
||||
const messages = list.map((message) => buildMessageFromDO(message));
|
||||
// 2. 合并到内存缓存,过滤已存在的消息
|
||||
const messages = page.list.map((message) =>
|
||||
buildMessageFromDO(message),
|
||||
);
|
||||
const existing = this.messagesByConversation[clientConversationId] || [];
|
||||
const existingKeys = new Set(
|
||||
existing.map((message) => getMessageKey(message, parsed.type)),
|
||||
@@ -435,7 +489,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
(messageA.sendTime || 0) - (messageB.sendTime || 0),
|
||||
);
|
||||
this.touchConversationMessageCache(clientConversationId);
|
||||
return fresh;
|
||||
return { messages: fresh, hasMore: page.hasMore };
|
||||
},
|
||||
|
||||
/** 确保会话消息已加载 */
|
||||
@@ -492,13 +546,14 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
this.updateMessageCursor(conversationType, messageId);
|
||||
},
|
||||
|
||||
/** 应用撤回到内存 */
|
||||
applyRecallMessageInMemory(
|
||||
/** 应用撤回到本地消息与会话状态 */
|
||||
async applyRecallMessageRecord(
|
||||
conversationType: number,
|
||||
targetId: number,
|
||||
recallSignalContent: string,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
// 1. 定位被撤回的原消息
|
||||
// 1. 定位被撤回的原消息和会话
|
||||
const messageId = parseRecallMessageId(recallSignalContent);
|
||||
if (!messageId) {
|
||||
return null;
|
||||
@@ -511,19 +566,48 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const messages = this.getMessageList(conversationType, targetId);
|
||||
const message = messages.find((item) => item.id === messageId);
|
||||
if (!message) {
|
||||
const clientConversationId = getClientConversationId(
|
||||
conversationType,
|
||||
targetId,
|
||||
);
|
||||
const cachedMessage = this.messagesByConversation[
|
||||
clientConversationId
|
||||
]?.find((item) => item.id === messageId);
|
||||
const storedMessage = await getDb().get<MessageDO>(
|
||||
'messages',
|
||||
getServerMessageKey(conversationType, messageId),
|
||||
tx,
|
||||
);
|
||||
const originalMessage = cachedMessage
|
||||
? buildMessageDO(cachedMessage, conversationType)
|
||||
: storedMessage;
|
||||
if (!originalMessage) {
|
||||
return null;
|
||||
}
|
||||
// 2. 更新消息和会话摘要
|
||||
message.type = ImContentType.RECALL;
|
||||
message.status = ImMessageStatus.RECALL;
|
||||
message.content = '';
|
||||
if (messages[messages.length - 1]?.id === messageId) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
// 2. 更新内存消息与数据库记录
|
||||
const recalledMessage =
|
||||
cachedMessage || buildMessageFromDO(originalMessage);
|
||||
revokeBlobUrlsInContent(recalledMessage.content);
|
||||
recalledMessage.type = ImContentType.RECALL;
|
||||
recalledMessage.status = ImMessageStatus.RECALL;
|
||||
recalledMessage.content = '';
|
||||
await this.saveMessageRecord(recalledMessage, conversationType, tx);
|
||||
// 3. 按本地完整消息和读位置重算未读与 @ 状态
|
||||
const storedMessages = await getDb().getAllByIndex<MessageDO>(
|
||||
'messages',
|
||||
'clientConversationId',
|
||||
clientConversationId,
|
||||
tx,
|
||||
);
|
||||
conversationStore.applyRecallToConversation(
|
||||
conversation,
|
||||
storedMessages,
|
||||
originalMessage,
|
||||
);
|
||||
if (conversation.lastMessageId === messageId) {
|
||||
applyConversationSummary(conversation, recalledMessage);
|
||||
}
|
||||
return { conversation, message };
|
||||
return { conversation, message: recalledMessage };
|
||||
},
|
||||
|
||||
/** 批量写入拉取消息 */
|
||||
@@ -547,6 +631,10 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
}
|
||||
>();
|
||||
const changedConversations = new Map<string, Conversation>();
|
||||
const recallMessages: Extract<
|
||||
PulledMessage,
|
||||
{ kind: 'recall' }
|
||||
>[] = [];
|
||||
|
||||
const addChanged = (
|
||||
conversation: Conversation,
|
||||
@@ -568,15 +656,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
// 1. 先更新内存,收集需要持久化的消息和会话
|
||||
for (const pulledMessage of pulledMessages) {
|
||||
if (pulledMessage.kind === 'recall') {
|
||||
// 1.1 撤回信号更新原消息
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
pulledMessage.conversationType,
|
||||
pulledMessage.targetId,
|
||||
pulledMessage.recallSignalContent,
|
||||
);
|
||||
if (changed) {
|
||||
addChanged(changed.conversation, changed.message);
|
||||
}
|
||||
// 1.1 撤回信号在事务内读取原消息后统一处理
|
||||
recallMessages.push(pulledMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -591,6 +672,20 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((existing) =>
|
||||
isSameMessage(existing, message),
|
||||
);
|
||||
@@ -603,6 +698,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
addChanged(conversation, existing, {
|
||||
@@ -613,22 +710,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 1.4 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type ===
|
||||
conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -664,12 +747,30 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
},
|
||||
);
|
||||
}
|
||||
// 2.2 写入本批变更会话
|
||||
// 2.2 应用本批撤回信号
|
||||
for (const recallMessage of recallMessages) {
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
recallMessage.conversationType,
|
||||
recallMessage.targetId,
|
||||
recallMessage.recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (changed) {
|
||||
changedConversations.set(
|
||||
getClientConversationId(
|
||||
changed.conversation.type,
|
||||
changed.conversation.targetId,
|
||||
),
|
||||
changed.conversation,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2.3 写入本批变更会话
|
||||
await conversationStore.saveConversationRecord(
|
||||
[...changedConversations.values()],
|
||||
tx,
|
||||
);
|
||||
// 2.3 写入本批游标
|
||||
// 2.4 写入本批游标
|
||||
await setMessageMaxId(conversationType, maxMessageId, tx);
|
||||
},
|
||||
);
|
||||
@@ -708,6 +809,19 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
conversationInfo.type,
|
||||
conversationInfo.targetId,
|
||||
);
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
const isUnread =
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL;
|
||||
const existingIndex = messages.findIndex((item) =>
|
||||
isSameMessage(item, message),
|
||||
);
|
||||
@@ -720,6 +834,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
applyServerMessageUpdate(existing, message);
|
||||
if (existingIndex === messages.length - 1) {
|
||||
recomputeConversationLast(conversation, messages);
|
||||
}
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
}
|
||||
return getDb()
|
||||
@@ -752,22 +868,8 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
|
||||
// 4. 新消息更新会话摘要和未读状态
|
||||
applyConversationSummary(conversation, message);
|
||||
syncConversationAtFlags(conversation, message);
|
||||
|
||||
const isActive =
|
||||
conversationStore.activeConversation?.type === conversationInfo.type &&
|
||||
conversationStore.activeConversation?.targetId ===
|
||||
conversationInfo.targetId;
|
||||
if (
|
||||
!message.selfSend &&
|
||||
!isActive &&
|
||||
!conversationStore.isMessageCoveredByReadPosition(
|
||||
conversation,
|
||||
message,
|
||||
) &&
|
||||
isNormalMessage(message.type) &&
|
||||
message.status !== ImMessageStatus.RECALL
|
||||
) {
|
||||
if (isUnread) {
|
||||
syncConversationAtFlags(conversation, message);
|
||||
conversation.unreadCount++;
|
||||
}
|
||||
|
||||
@@ -920,17 +1022,17 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
recallSignalContent: string,
|
||||
): Promise<void> {
|
||||
const conversationStore = useConversationStore();
|
||||
const changed = this.applyRecallMessageInMemory(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await getDb()
|
||||
.transaction(['messages', 'conversations'], 'readwrite', async (tx) => {
|
||||
await this.saveMessageRecord(changed.message, conversationType, tx);
|
||||
const changed = await this.applyRecallMessageRecord(
|
||||
conversationType,
|
||||
targetId,
|
||||
recallSignalContent,
|
||||
tx,
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
await conversationStore.saveConversationRecord(
|
||||
changed.conversation,
|
||||
tx,
|
||||
@@ -1101,6 +1203,7 @@ export const useMessageStore = defineStore('imMessageStore', {
|
||||
message._localFile = undefined;
|
||||
});
|
||||
Reflect.deleteProperty(this.messagesByConversation, clientConversationId);
|
||||
Reflect.deleteProperty(this.messageDOPageCursors, clientConversationId);
|
||||
this.loadedConversationKeys = this.loadedConversationKeys.filter(
|
||||
(key) => key !== clientConversationId,
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface Conversation {
|
||||
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||
atMe?: boolean; // 群聊:是否有人 @我
|
||||
atAll?: boolean; // 群聊:是否有人 @全体成员
|
||||
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
|
||||
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
|
||||
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
|
||||
draft?: {
|
||||
html: string; // 输入框 HTML
|
||||
|
||||
@@ -22,6 +22,18 @@ export type DbStoreName =
|
||||
|
||||
export type DbTransaction = IDBTransaction;
|
||||
|
||||
/** 数据库消息分页游标 */
|
||||
export interface MessageDOPageCursor {
|
||||
messageKey: string;
|
||||
sendTime: number;
|
||||
}
|
||||
|
||||
/** 数据库消息分页结果 */
|
||||
export interface MessageDOPageResult {
|
||||
hasMore: boolean;
|
||||
list: MessageDO[];
|
||||
}
|
||||
|
||||
/** IM 本地存储 key */
|
||||
export const StorageKeys = {
|
||||
localStorage: {
|
||||
@@ -363,18 +375,19 @@ class DbClient {
|
||||
/** 按会话分页获取消息 */
|
||||
async getMessageListByConversation(
|
||||
clientConversationId: string,
|
||||
options?: { beforeSendTime?: number; limit?: number },
|
||||
options?: { before?: MessageDOPageCursor; limit?: number },
|
||||
tx?: DbTransaction,
|
||||
): Promise<MessageDO[]> {
|
||||
): Promise<MessageDOPageResult> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const before = options?.before;
|
||||
const upper = before?.sendTime ?? Number.MAX_SAFE_INTEGER;
|
||||
const range = IDBKeyRange.bound(
|
||||
[clientConversationId, 0],
|
||||
[clientConversationId, upper],
|
||||
false,
|
||||
true,
|
||||
!before,
|
||||
);
|
||||
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
|
||||
const read = async (tx: DbTransaction): Promise<MessageDOPageResult> => {
|
||||
const index = tx
|
||||
.objectStore('messages')
|
||||
.index('clientConversationId+sendTime');
|
||||
@@ -385,21 +398,41 @@ class DbClient {
|
||||
request.addEventListener('error', () => reject(request.error));
|
||||
request.addEventListener('success', () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor || out.length >= limit) {
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const message = cursor.value as MessageDO;
|
||||
if (
|
||||
before &&
|
||||
message.sendTime === before.sendTime &&
|
||||
message.messageKey >= before.messageKey
|
||||
) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
out.push(message);
|
||||
if (out.length > limit) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
out.push(cursor.value as MessageDO);
|
||||
cursor.continue();
|
||||
});
|
||||
});
|
||||
// 气泡渲染需要按时间升序
|
||||
return out.toReversed();
|
||||
return {
|
||||
hasMore: out.length > limit,
|
||||
list: out.slice(0, limit).toReversed(),
|
||||
};
|
||||
};
|
||||
if (tx) {
|
||||
return read(tx);
|
||||
}
|
||||
return this.transaction<MessageDO[]>(['messages'], 'readonly', read);
|
||||
return this.transaction<MessageDOPageResult>(
|
||||
['messages'],
|
||||
'readonly',
|
||||
read,
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取设置 */
|
||||
@@ -506,29 +539,6 @@ export function getClientMessageKey(clientMessageId: string): string {
|
||||
return `client:${clientMessageId}`;
|
||||
}
|
||||
|
||||
/** 解析本地消息主键 */
|
||||
export function parseMessageKey(
|
||||
messageKey: string,
|
||||
):
|
||||
| null
|
||||
| { clientMessageId: string; kind: 'client' }
|
||||
| { conversationType: number; id: number; kind: 'server' } {
|
||||
if (!messageKey) {
|
||||
return null;
|
||||
}
|
||||
if (messageKey.startsWith('client:')) {
|
||||
const clientMessageId = messageKey.slice('client:'.length);
|
||||
return clientMessageId ? { kind: 'client', clientMessageId } : null;
|
||||
}
|
||||
const [conversationTypeText, idText] = messageKey.split(':');
|
||||
const conversationType = Number(conversationTypeText);
|
||||
const id = Number(idText);
|
||||
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { kind: 'server', conversationType, id };
|
||||
}
|
||||
|
||||
/** 更新消息拉取游标 */
|
||||
export async function setMessageMaxId(
|
||||
conversationType: number,
|
||||
|
||||
Reference in New Issue
Block a user