feat(im): 完善未读 @ 定位与消息状态同步

- 支持未读 @ 消息历史定位与自动补载
- 使用全序游标修复相同时间戳分页遗漏
- 独立维护数据库分页边界,避免实时消息和本地占位干扰
- 统一实时与离线撤回处理,重算未读及 @ 状态
- 区分 MessageDO 与 Message 分页类型并清理冗余代码
This commit is contained in:
YunaiV
2026-07-14 16:48:33 +08:00
parent dd0809f002
commit 2084ad8ba4
15 changed files with 1500 additions and 510 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
);

View File

@@ -110,6 +110,8 @@ export interface Conversation {
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean; // 群聊:是否有人 @我
atAll?: boolean; // 群聊:是否有人 @全体成员
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
draft?: {
html: string; // 输入框 HTML

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
);

View File

@@ -110,6 +110,8 @@ export interface Conversation {
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean; // 群聊:是否有人 @我
atAll?: boolean; // 群聊:是否有人 @全体成员
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
draft?: {
html: string; // 输入框 HTML

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
);

View File

@@ -110,6 +110,8 @@ export interface Conversation {
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean; // 群聊:是否有人 @我
atAll?: boolean; // 群聊:是否有人 @全体成员
atMessageId?: number; // 最近一次未读 @我的消息编号,用于点击提醒后定位
atAllMessageId?: number; // 最近一次未读 @全体成员的消息编号,用于点击提醒后定位
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
draft?: {
html: string; // 输入框 HTML

View File

@@ -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,