refactor(ConversationList): streamline conversation list handling and enhance unread count management
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / ota-android (push) Successful in 15m30s
Frontend CI / build-android-apk (push) Successful in 59m14s

- Removed debug display for unread count in ConversationListRow to clean up UI.
- Updated updateConversationCacheUnreadCount to optionally handle myLastReadSeq for better synchronization of read status.
- Refactored message service to unify offset and cursor pagination methods, improving data fetching consistency.
- Introduced a new method to normalize unread counts based on read cursor, ensuring accurate display of unread messages.
- Consolidated conversation list source implementations to simplify remote data fetching logic.
This commit is contained in:
lafay
2026-03-25 04:07:48 +08:00
parent dc8f9061ab
commit 90d834695f
6 changed files with 185 additions and 113 deletions

View File

@@ -262,9 +262,6 @@ const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
</Text> </Text>
</View> </View>
)} )}
{__DEV__ && (
<Text style={{ fontSize: 10, color: '#999', marginLeft: 8 }}>[DEBUG: {item.unread_count || 0}]</Text>
)}
</View> </View>
</View> </View>
</AnimatedTouchable> </AnimatedTouchable>

View File

@@ -942,10 +942,12 @@ export const updateConversationListCacheEntry = async (
/** /**
* 更新会话 SWR 缓存中的未读数(标记已读后立即同步清零,避免下次从缓存加载时仍显示旧红点) * 更新会话 SWR 缓存中的未读数(标记已读后立即同步清零,避免下次从缓存加载时仍显示旧红点)
* @param myLastReadSeq 若传入,同时写入 my_last_read_seq便于冷启动用 last_seq 与已读游标校正未读
*/ */
export const updateConversationCacheUnreadCount = async ( export const updateConversationCacheUnreadCount = async (
conversationId: string, conversationId: string,
count: number count: number,
myLastReadSeq?: number
): Promise<void> => { ): Promise<void> => {
await enqueueWrite(async () => { await enqueueWrite(async () => {
const database = await getDb(); const database = await getDb();
@@ -959,6 +961,9 @@ export const updateConversationCacheUnreadCount = async (
const conv = safeParseJson<ConversationResponse>(listRow.data); const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) { if (conv) {
conv.unread_count = count; conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) {
conv.my_last_read_seq = myLastReadSeq;
}
await database.runAsync( await database.runAsync(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`, `UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(conversationId)] [JSON.stringify(conv), String(conversationId)]
@@ -975,6 +980,9 @@ export const updateConversationCacheUnreadCount = async (
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data); const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) { if (conv) {
conv.unread_count = count; conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) {
conv.my_last_read_seq = myLastReadSeq;
}
await database.runAsync( await database.runAsync(
`UPDATE conversation_cache SET data = ? WHERE id = ?`, `UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(conversationId)] [JSON.stringify(conv), String(conversationId)]

View File

@@ -31,17 +31,23 @@ import {
updateConversationCacheUnreadCount, updateConversationCacheUnreadCount,
} from './database'; } from './database';
/** 远端会话列表分页offset / cursor统一结果拉取成功后均已执行本地缓存同步 */
export interface RemoteConversationListPageResult {
items: ConversationResponse[];
hasMore: boolean;
/** cursor 模式下一页游标offset 模式固定为 null */
nextCursor: string | null;
/** offset 模式:下一次 loadNext 应请求的页码cursor 模式为 undefined */
nextPage?: number;
}
// 消息服务类 // 消息服务类
class MessageService { class MessageService {
private async refreshConversationsFromServer( /** 与会话 offset 列表一致:将本页会话写入 SQLite避免游标路径不落库导致冷启动未读与线上一致 */
page = 1, private async persistConversationListPageToLocalCache(
pageSize = 20 list: ConversationResponse[]
): Promise<ConversationListResponse> { ): Promise<void> {
const response = await api.get<ConversationListResponse>('/conversations', { if (!list.length) return;
page,
page_size: pageSize,
});
const list = response.data.list || [];
await saveConversationsCache(list); await saveConversationsCache(list);
await saveUsersCache( await saveUsersCache(
list.flatMap(conv => [ list.flatMap(conv => [
@@ -54,9 +60,98 @@ class MessageService {
.map(conv => conv.group) .map(conv => conv.group)
.filter((group): group is NonNullable<typeof group> => Boolean(group)) .filter((group): group is NonNullable<typeof group> => Boolean(group))
); );
}
/** offset 会话列表单页:请求 + 落库 */
private async requestOffsetConversationsPage(
page: number,
pageSize: number
): Promise<ConversationListResponse> {
const response = await api.get<ConversationListResponse>('/conversations', {
page,
page_size: pageSize,
});
const list = response.data.list || [];
await this.persistConversationListPageToLocalCache(list);
return response.data; return response.data;
} }
/** cursor 会话列表单页:请求 + 落库 */
private async requestCursorConversationsPage(
params: CursorPaginationRequest
): Promise<CursorPaginationResponse<ConversationResponse>> {
try {
const response = await api.get<CursorPaginationResponse<ConversationResponse>>(
'/conversations/cursor',
params
);
const raw = response.data;
const list = Array.isArray(raw?.list) ? raw.list : [];
await this.persistConversationListPageToLocalCache(list);
return {
list,
next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) {
console.error('获取会话列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
private async refreshConversationsFromServer(
page = 1,
pageSize = 20
): Promise<ConversationListResponse> {
return this.requestOffsetConversationsPage(page, pageSize);
}
/**
* 远端会话列表单页offset / cursor 统一入口)
* 两种模式均先写 SQLite 再返回,行为与 NetworkRemoteConversationListPagedSource 配套。
*/
async fetchRemoteConversationListPage(args: {
mode: 'offset' | 'cursor';
pageSize: number;
/** offset本次页码默认 1 */
page?: number;
/** cursor本次游标首屏不传或 null */
cursor?: string | null;
direction?: CursorPaginationRequest['direction'];
}): Promise<RemoteConversationListPageResult> {
if (args.mode === 'offset') {
const page = args.page ?? 1;
const data = await this.requestOffsetConversationsPage(page, args.pageSize);
const totalPages = Math.max(0, data.total_pages ?? 0);
const currentPage = data.page ?? page;
const hasMore = totalPages > 0 && currentPage < totalPages;
return {
items: data.list || [],
hasMore,
nextCursor: null,
nextPage: currentPage + 1,
};
}
const raw = await this.requestCursorConversationsPage({
page_size: args.pageSize,
...(args.cursor != null && args.cursor !== ''
? { cursor: args.cursor }
: {}),
...(args.direction ? { direction: args.direction } : {}),
});
const items = raw.list || [];
const nextCursor = raw.next_cursor ?? null;
const hasMore = Boolean(raw.has_more && nextCursor != null);
return { items, hasMore, nextCursor };
}
private async refreshConversationDetailFromServer(id: string): Promise<ConversationDetailResponse> { private async refreshConversationDetailFromServer(id: string): Promise<ConversationDetailResponse> {
const response = await api.get<ConversationDetailResponse>( const response = await api.get<ConversationDetailResponse>(
`/conversations/${encodeURIComponent(id)}` `/conversations/${encodeURIComponent(id)}`
@@ -433,8 +528,8 @@ class MessageService {
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, { await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
last_read_seq: Number(seq), last_read_seq: Number(seq),
}); });
// 立即清零本地缓存中的 unread_count避免回到列表时仍显示旧红点 // 立即清零本地缓存中的 unread_count并写入已读游标,避免冷启动仍显示旧红点
updateConversationCacheUnreadCount(conversationId, 0).catch(error => { updateConversationCacheUnreadCount(conversationId, 0, Number(seq)).catch(error => {
console.error('更新本地会话未读数失败:', error); console.error('更新本地会话未读数失败:', error);
}); });
} }
@@ -560,37 +655,6 @@ class MessageService {
// ==================== 游标分页方法 ==================== // ==================== 游标分页方法 ====================
/**
* 获取会话列表(游标分页)
* GET /api/v1/conversations/cursor
* @param params 游标分页请求参数
*/
async getConversationsCursor(
params: CursorPaginationRequest = {}
): Promise<CursorPaginationResponse<ConversationResponse>> {
try {
const response = await api.get<CursorPaginationResponse<ConversationResponse>>(
'/conversations/cursor',
params
);
const raw = response.data;
return {
list: Array.isArray(raw?.list) ? raw.list : [],
next_cursor: raw?.next_cursor ?? null,
prev_cursor: raw?.prev_cursor ?? null,
has_more: raw?.has_more ?? false,
};
} catch (error) {
console.error('获取会话列表失败:', error);
return {
list: [],
next_cursor: null,
prev_cursor: null,
has_more: false,
};
}
}
/** /**
* 获取消息列表(游标分页) * 获取消息列表(游标分页)
* GET /api/v1/conversations/:id/messages/cursor * GET /api/v1/conversations/:id/messages/cursor

View File

@@ -25,51 +25,28 @@ export interface IConversationListPagedSource {
readonly hasMore: boolean; readonly hasMore: boolean;
} }
/** 远端常规分页GET /conversations?page=&page_size=(始终请求网络,避免走 getConversations 的本地短路) */ /**
export class NetworkOffsetConversationListPagedSource implements IConversationListPagedSource { * 远端会话列表offset 与 cursor 共用实现)
* 均通过 messageService.fetchRemoteConversationListPage 拉取并落库,行为一致。
*/
export class NetworkRemoteConversationListPagedSource implements IConversationListPagedSource {
private nextPage = 1; private nextPage = 1;
private nextCursor: string | null = null;
private hasMoreAfterLastLoad = false; private hasMoreAfterLastLoad = false;
private loadedOnce = false; private loadedOnce = false;
private readonly pageSize: number; private readonly pageSize: number;
private readonly mode: RemoteConversationListSourceKind;
constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { constructor(
mode: RemoteConversationListSourceKind,
pageSize: number = CONVERSATION_LIST_PAGE_SIZE
) {
this.mode = mode;
this.pageSize = pageSize; this.pageSize = pageSize;
} }
restart(): void { restart(): void {
this.nextPage = 1; this.nextPage = 1;
this.hasMoreAfterLastLoad = false;
this.loadedOnce = false;
}
get hasMore(): boolean {
return this.loadedOnce && this.hasMoreAfterLastLoad;
}
async loadNext(): Promise<ConversationListPage> {
const response = await messageService.getConversations(this.nextPage, this.pageSize, true);
const items = response.list || [];
const totalPages = Math.max(0, response.total_pages ?? 0);
const currentPage = response.page ?? this.nextPage;
this.hasMoreAfterLastLoad = totalPages > 0 && currentPage < totalPages;
this.nextPage = currentPage + 1;
this.loadedOnce = true;
return { items, hasMore: this.hasMoreAfterLastLoad };
}
}
/** 远端游标接口 */
export class NetworkCursorConversationListPagedSource implements IConversationListPagedSource {
private nextCursor: string | null = null;
private hasMoreAfterLastLoad = false;
private loadedOnce = false;
private readonly pageSize: number;
constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) {
this.pageSize = pageSize;
}
restart(): void {
this.nextCursor = null; this.nextCursor = null;
this.hasMoreAfterLastLoad = false; this.hasMoreAfterLastLoad = false;
this.loadedOnce = false; this.loadedOnce = false;
@@ -80,16 +57,22 @@ export class NetworkCursorConversationListPagedSource implements IConversationLi
} }
async loadNext(): Promise<ConversationListPage> { async loadNext(): Promise<ConversationListPage> {
const response = await messageService.getConversationsCursor( const result = await messageService.fetchRemoteConversationListPage({
this.nextCursor == null mode: this.mode,
? { page_size: this.pageSize } pageSize: this.pageSize,
: { cursor: this.nextCursor, page_size: this.pageSize } ...(this.mode === 'offset'
); ? { page: this.nextPage }
const items = response.list || []; : { cursor: this.nextCursor }),
this.nextCursor = response.next_cursor ?? null; });
this.hasMoreAfterLastLoad = Boolean(response.has_more && response.next_cursor != null);
this.hasMoreAfterLastLoad = result.hasMore;
if (this.mode === 'offset') {
this.nextPage = result.nextPage ?? this.nextPage + 1;
} else {
this.nextCursor = result.nextCursor ?? null;
}
this.loadedOnce = true; this.loadedOnce = true;
return { items, hasMore: this.hasMoreAfterLastLoad }; return { items: result.items, hasMore: result.hasMore };
} }
} }
@@ -120,14 +103,12 @@ export class SqliteConversationListPagedSource implements IConversationListPaged
} }
} }
/** 未注入 remote 时,按类型创建默认远端源 */ /** 远端会话列表分页策略 */
export type RemoteConversationListSourceKind = 'cursor' | 'offset'; export type RemoteConversationListSourceKind = 'cursor' | 'offset';
export function createRemoteConversationListSource( export function createRemoteConversationListSource(
kind: RemoteConversationListSourceKind, kind: RemoteConversationListSourceKind,
pageSize: number = CONVERSATION_LIST_PAGE_SIZE pageSize: number = CONVERSATION_LIST_PAGE_SIZE
): IConversationListPagedSource { ): IConversationListPagedSource {
return kind === 'offset' return new NetworkRemoteConversationListPagedSource(kind, pageSize);
? new NetworkOffsetConversationListPagedSource(pageSize)
: new NetworkCursorConversationListPagedSource(pageSize);
} }

View File

@@ -25,8 +25,7 @@ export {
type ConversationListPage, type ConversationListPage,
type IConversationListPagedSource, type IConversationListPagedSource,
type RemoteConversationListSourceKind, type RemoteConversationListSourceKind,
NetworkCursorConversationListPagedSource, NetworkRemoteConversationListPagedSource,
NetworkOffsetConversationListPagedSource,
SqliteConversationListPagedSource, SqliteConversationListPagedSource,
createRemoteConversationListSource, createRemoteConversationListSource,
} from './conversationListSources'; } from './conversationListSources';

View File

@@ -353,6 +353,18 @@ class MessageManager {
this.state.conversationList = list; this.state.conversationList = list;
} }
/**
* 当已读游标已追上 last_seq 时,强制未读为 0修复本地 SQLite 中 unread_count 与已读状态不一致)
*/
private normalizeConversationUnreadFromReadCursor(conv: ConversationResponse): ConversationResponse {
const lastSeq = Number(conv.last_seq || 0);
const myRead = Number(conv.my_last_read_seq ?? 0);
if (lastSeq > 0 && myRead >= lastSeq && (conv.unread_count || 0) > 0) {
return { ...conv, unread_count: 0 };
}
return conv;
}
/** /**
* 将游标接口返回的单条会话写入 Map统一 string id、尊重 pendingReadMap 已读保护) * 将游标接口返回的单条会话写入 Map统一 string id、尊重 pendingReadMap 已读保护)
*/ */
@@ -368,6 +380,7 @@ class MessageManager {
} else { } else {
next = { ...conv, id }; next = { ...conv, id };
} }
next = this.normalizeConversationUnreadFromReadCursor(next);
this.state.conversations.set(id, next); this.state.conversations.set(id, next);
} }
@@ -776,14 +789,6 @@ class MessageManager {
const normalizedConversationId = this.normalizeConversationId(conversation_id); const normalizedConversationId = this.normalizeConversationId(conversation_id);
const currentUserId = this.getCurrentUserId(); const currentUserId = this.getCurrentUserId();
// 【调试日志】追踪消息来源和重复问题
// 0. 如果是ACK消息直接跳过不增加未读数ACK是发送确认不是新消息
if (_isAck) {
// 但仍然需要更新消息列表因为ACK包含完整消息内容
// 继续处理但不增加未读数
}
// 1. 消息去重检查 - 防止ACK消息和正常消息重复处理 // 1. 消息去重检查 - 防止ACK消息和正常消息重复处理
if (this.isMessageProcessed(id)) { if (this.isMessageProcessed(id)) {
return; return;
@@ -813,7 +818,6 @@ class MessageManager {
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
} else {
} }
}).catch(error => { }).catch(error => {
console.error('[MessageManager][ERROR] 获取发送者信息失败:', { userId: sender_id, error }); console.error('[MessageManager][ERROR] 获取发送者信息失败:', { userId: sender_id, error });
@@ -858,16 +862,13 @@ class MessageManager {
const isCurrentUserMessage = sender_id === currentUserId; const isCurrentUserMessage = sender_id === currentUserId;
const isActiveConversation = normalizedConversationId === this.state.currentConversationId; const isActiveConversation = normalizedConversationId === this.state.currentConversationId;
// 【调试日志】追踪未读数增加逻辑 // 确保当前用户发送的消息不会增加未读数
// 修复:确保当前用户发送的消息不会增加未读数
// 同时确保currentUserId有效避免undefined === undefined的情况 // 同时确保currentUserId有效避免undefined === undefined的情况
// ACK消息发送确认也不应该增加未读数 // ACK消息发送确认也不应该增加未读数
const shouldIncrementUnread = !isCurrentUserMessage && !isActiveConversation && !!currentUserId && !_isAck; const shouldIncrementUnread = !isCurrentUserMessage && !isActiveConversation && !!currentUserId && !_isAck;
if (shouldIncrementUnread) { if (shouldIncrementUnread) {
this.incrementUnreadCount(normalizedConversationId); this.incrementUnreadCount(normalizedConversationId);
} else {
} }
// 6. 如果是当前活动会话,自动标记已读 // 6. 如果是当前活动会话,自动标记已读
@@ -1315,9 +1316,11 @@ class MessageManager {
const isPending = !!readRecord; const isPending = !!readRecord;
// 使用智能合并:如果服务器返回 unread_count > 0 但本地有更晚的已读记录,保留本地状态 // 使用智能合并:如果服务器返回 unread_count > 0 但本地有更晚的已读记录,保留本地状态
const shouldPreserveLocalRead = isPending && (detail.unread_count || 0) > 0; const shouldPreserveLocalRead = isPending && (detail.unread_count || 0) > 0;
const safeDetail = shouldPreserveLocalRead const safeDetail = this.normalizeConversationUnreadFromReadCursor(
? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 } shouldPreserveLocalRead
: { ...(detail as ConversationResponse), id: normalizedId }; ? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 }
: { ...(detail as ConversationResponse), id: normalizedId }
);
this.state.conversations.set(normalizedId, safeDetail); this.state.conversations.set(normalizedId, safeDetail);
this.updateConversationList(); this.updateConversationList();
@@ -1681,6 +1684,25 @@ class MessageManager {
this.state.totalUnreadCount = unreadData?.total_unread_count ?? 0; this.state.totalUnreadCount = unreadData?.total_unread_count ?? 0;
this.state.systemUnreadCount = systemUnreadData?.unread_count ?? 0; this.state.systemUnreadCount = systemUnreadData?.unread_count ?? 0;
// 服务端汇总未读为 0 时,清掉内存/本地缓存中残留的逐会话红点(常见于仅暖机了 SQLite 旧数据)
if (this.state.totalUnreadCount === 0 && this.state.conversations.size > 0) {
let anyCleared = false;
for (const [cid, conv] of this.state.conversations) {
if ((conv.unread_count || 0) > 0) {
this.state.conversations.set(cid, { ...conv, unread_count: 0 });
anyCleared = true;
}
}
if (anyCleared) {
this.updateConversationList();
this.persistConversationListCache();
this.notifySubscribers({
type: 'conversations_updated',
payload: { conversations: this.state.conversationList },
timestamp: Date.now(),
});
}
}
this.notifySubscribers({ this.notifySubscribers({
type: 'unread_count_updated', type: 'unread_count_updated',
@@ -1824,10 +1846,11 @@ class MessageManager {
lastReadSeq: seq, lastReadSeq: seq,
}); });
// 2. 乐观更新本地状态立即反映到UI // 2. 乐观更新本地状态立即反映到UI;同步已读游标避免与 last_seq 不一致
const updatedConv: ConversationResponse = { const updatedConv: ConversationResponse = {
...conversation, ...conversation,
unread_count: 0, unread_count: 0,
my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq),
}; };
this.state.conversations.set(normalizedId, updatedConv); this.state.conversations.set(normalizedId, updatedConv);
this.state.totalUnreadCount = Math.max(0, this.state.totalUnreadCount - prevUnreadCount); this.state.totalUnreadCount = Math.max(0, this.state.totalUnreadCount - prevUnreadCount);