fix(chat): render cached history before network sync
This commit is contained in:
@@ -9,15 +9,6 @@ import {
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
|
||||
interface LoadHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
localOverwritten: boolean;
|
||||
localCount: number;
|
||||
networkCount: number;
|
||||
}
|
||||
|
||||
interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
@@ -94,14 +85,7 @@ function createTestChatMachine(
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
actors: {
|
||||
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
|
||||
messages: options.historyMessages ?? [],
|
||||
hasMore: false,
|
||||
newOffset: 0,
|
||||
localOverwritten: true,
|
||||
localCount: 0,
|
||||
networkCount: 0,
|
||||
})),
|
||||
loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
|
||||
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
||||
async () => ({
|
||||
messages: [],
|
||||
@@ -150,6 +134,35 @@ function createTestChatMachine(
|
||||
});
|
||||
}
|
||||
|
||||
function createLoadHistoryCallback(
|
||||
messages: UiMessage[],
|
||||
networkMessages: UiMessage[] = messages,
|
||||
) {
|
||||
return fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
output: {
|
||||
messages,
|
||||
hasMore: false,
|
||||
newOffset: messages.length,
|
||||
localCount: messages.length,
|
||||
},
|
||||
});
|
||||
sendBack({
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: networkMessages,
|
||||
hasMore: false,
|
||||
newOffset: networkMessages.length,
|
||||
localOverwritten: true,
|
||||
localCount: messages.length,
|
||||
networkCount: networkMessages.length,
|
||||
},
|
||||
});
|
||||
return () => undefined;
|
||||
});
|
||||
}
|
||||
|
||||
describe("chatMachine transitions", () => {
|
||||
it("keeps guest logout disabled while allowing upgrade to user login", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
@@ -187,6 +200,76 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("renders local history before network history sync finishes", async () => {
|
||||
let resolveNetwork!: () => void;
|
||||
const networkReleased = new Promise<void>((resolve) => {
|
||||
resolveNetwork = resolve;
|
||||
});
|
||||
const localMessage: UiMessage = {
|
||||
id: "local-msg",
|
||||
content: "cached local message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
};
|
||||
const networkMessage: UiMessage = {
|
||||
id: "network-msg",
|
||||
content: "fresh network message",
|
||||
isFromAI: true,
|
||||
date: "2026-07-02",
|
||||
};
|
||||
const machine = chatMachine.provide({
|
||||
actors: {
|
||||
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
output: {
|
||||
messages: [localMessage],
|
||||
hasMore: false,
|
||||
newOffset: 1,
|
||||
localCount: 1,
|
||||
},
|
||||
});
|
||||
void networkReleased.then(() => {
|
||||
sendBack({
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: {
|
||||
messages: [networkMessage],
|
||||
hasMore: false,
|
||||
newOffset: 1,
|
||||
localOverwritten: true,
|
||||
localCount: 1,
|
||||
networkCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}),
|
||||
},
|
||||
});
|
||||
const actor = createActor(machine).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ id: "local-msg", content: "cached local message" },
|
||||
]);
|
||||
|
||||
resolveNetwork();
|
||||
await waitFor(
|
||||
actor,
|
||||
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{ id: "network-msg", content: "fresh network message" },
|
||||
]);
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("ignores guest login while an authenticated user session is active", async () => {
|
||||
const actor = createActor(createTestChatMachine()).start();
|
||||
|
||||
@@ -254,14 +337,7 @@ describe("chatMachine transitions", () => {
|
||||
it("tracks replying state by queued batch instead of user message count", async () => {
|
||||
const machine = chatMachine.provide({
|
||||
actors: {
|
||||
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({
|
||||
messages: [],
|
||||
hasMore: false,
|
||||
newOffset: 0,
|
||||
localOverwritten: true,
|
||||
localCount: 0,
|
||||
networkCount: 0,
|
||||
})),
|
||||
loadHistory: createLoadHistoryCallback([]),
|
||||
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
|
||||
async () => ({
|
||||
messages: [],
|
||||
|
||||
@@ -22,6 +22,15 @@ export type ChatEvent =
|
||||
| { type: "ChatGuestLogin" }
|
||||
| { type: "ChatUserLogin"; token: string }
|
||||
| { type: "ChatLogout" }
|
||||
| {
|
||||
type: "ChatLocalHistoryLoaded";
|
||||
output: import("./chat-history-sync").LocalHistorySnapshotOutput;
|
||||
}
|
||||
| {
|
||||
type: "ChatNetworkHistoryLoaded";
|
||||
output: import("./chat-history-sync").NetworkHistorySyncOutput;
|
||||
}
|
||||
| { type: "ChatHistoryLoadFailed"; error: unknown }
|
||||
// 业务事件
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import { fromPromise } from "xstate";
|
||||
import { fromCallback, fromPromise } from "xstate";
|
||||
|
||||
import { getChatRepository } from "@/data/repositories/chat_repository";
|
||||
import { Logger, Result } from "@/utils";
|
||||
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
readLocalHistorySnapshot,
|
||||
syncNetworkHistory,
|
||||
} from "./chat-history-sync";
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
localMessagesToUi,
|
||||
} from "./chat-machine.helpers";
|
||||
import type { ChatEvent } from "./chat-events";
|
||||
|
||||
const log = new Logger("StoresChatChatHistoryFlow");
|
||||
|
||||
type UiMessage = import("@/data/dto/chat").UiMessage;
|
||||
|
||||
export const chatHistoryActors = {
|
||||
loadHistory: fromPromise<{
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
localOverwritten: boolean;
|
||||
localCount: number;
|
||||
networkCount: number;
|
||||
}>(async () => {
|
||||
return readAndSyncHistory();
|
||||
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const localSnapshot = await readLocalHistorySnapshot();
|
||||
if (cancelled) return;
|
||||
sendBack({
|
||||
type: "ChatLocalHistoryLoaded",
|
||||
output: localSnapshot,
|
||||
});
|
||||
|
||||
const networkSnapshot = await syncNetworkHistory(
|
||||
localSnapshot.localCount,
|
||||
);
|
||||
if (cancelled || !networkSnapshot) return;
|
||||
sendBack({
|
||||
type: "ChatNetworkHistoryLoaded",
|
||||
output: networkSnapshot,
|
||||
});
|
||||
})().catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
log.error("[chat-machine] loadHistoryActor failed", { error });
|
||||
sendBack({ type: "ChatHistoryLoadFailed", error });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}),
|
||||
|
||||
loadMoreHistory: fromPromise<
|
||||
|
||||
@@ -17,7 +17,17 @@ export type ReadAndSyncHistoryOutput = {
|
||||
networkCount: number;
|
||||
};
|
||||
|
||||
function createGreetingMessage(): UiMessage {
|
||||
export type LocalHistorySnapshotOutput = {
|
||||
/** Local cached messages. Empty local history includes a UI greeting. */
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
localCount: number;
|
||||
};
|
||||
|
||||
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
|
||||
|
||||
export function createGreetingMessage(): UiMessage {
|
||||
return {
|
||||
content:
|
||||
"You're here! Facebook is so restrictive, " +
|
||||
@@ -28,13 +38,7 @@ function createGreetingMessage(): UiMessage {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* History sync flow:
|
||||
* 1. Read local history for fallback.
|
||||
* 2. Read network history as the authoritative source.
|
||||
* 3. Overwrite local history with network data.
|
||||
*/
|
||||
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||
export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOutput> {
|
||||
const chatRepo = getChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
|
||||
@@ -47,27 +51,32 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||
count: localMessages.length,
|
||||
});
|
||||
|
||||
const snapshotMessages =
|
||||
localMessages.length === 0 ? [greetingMessage] : localMessages;
|
||||
if (snapshotMessages[0] === greetingMessage) {
|
||||
log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting (local)");
|
||||
}
|
||||
|
||||
return {
|
||||
messages: snapshotMessages,
|
||||
hasMore: localMessages.length >= PAGE_SIZE,
|
||||
newOffset: snapshotMessages.length,
|
||||
localCount: localMessages.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncNetworkHistory(
|
||||
localCount: number,
|
||||
): Promise<NetworkHistorySyncOutput | null> {
|
||||
const chatRepo = getChatRepository();
|
||||
const greetingMessage = createGreetingMessage();
|
||||
|
||||
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
|
||||
if (Result.isErr(networkResult)) {
|
||||
log.error("[chat-machine] loadHistory NETWORK FAILED", {
|
||||
error: networkResult.error,
|
||||
});
|
||||
|
||||
const fallbackMessages =
|
||||
localMessages.length === 0 ? [greetingMessage] : localMessages;
|
||||
if (fallbackMessages[0] === greetingMessage) {
|
||||
log.debug(
|
||||
"[chat-machine] loadHistory EMPTY -> prepend greeting (network-failed path)",
|
||||
);
|
||||
}
|
||||
return {
|
||||
messages: fallbackMessages,
|
||||
hasMore: false,
|
||||
newOffset: fallbackMessages.length,
|
||||
localOverwritten: false,
|
||||
localCount: localMessages.length,
|
||||
networkCount: 0,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
const networkUi = localMessagesToUi(networkResult.data.messages);
|
||||
@@ -95,7 +104,28 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||
hasMore: finalMessages.length >= PAGE_SIZE,
|
||||
newOffset: finalMessages.length,
|
||||
localOverwritten,
|
||||
localCount: localMessages.length,
|
||||
localCount,
|
||||
networkCount: networkUi.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* History sync flow:
|
||||
* 1. Read local history for fallback.
|
||||
* 2. Read network history as the authoritative source.
|
||||
* 3. Overwrite local history with network data.
|
||||
*/
|
||||
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
|
||||
const localSnapshot = await readLocalHistorySnapshot();
|
||||
const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount);
|
||||
if (networkSnapshot) return networkSnapshot;
|
||||
|
||||
return {
|
||||
messages: localSnapshot.messages,
|
||||
hasMore: localSnapshot.hasMore,
|
||||
newOffset: localSnapshot.newOffset,
|
||||
localOverwritten: false,
|
||||
localCount: localSnapshot.localCount,
|
||||
networkCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,3 +26,23 @@ export function applyHistoryLoadedOutput(
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyNetworkHistoryLoadedOutput(
|
||||
context: ChatState,
|
||||
output: {
|
||||
messages: UiMessage[];
|
||||
hasMore: boolean;
|
||||
newOffset: number;
|
||||
},
|
||||
): Pick<
|
||||
ChatState,
|
||||
"messages" | "hasMore" | "historyOffset" | "historyLoaded"
|
||||
> {
|
||||
const optimisticTail = context.messages.slice(context.historyOffset);
|
||||
return {
|
||||
messages: [...output.messages, ...optimisticTail],
|
||||
hasMore: output.hasMore,
|
||||
historyOffset: output.newOffset,
|
||||
historyLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
+118
-51
@@ -34,6 +34,7 @@ import type { ChatEvent } from "./chat-events";
|
||||
import {
|
||||
applyHttpSendOutput,
|
||||
applyHistoryLoadedOutput,
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
countLockedHistoryMessages,
|
||||
shouldAutoUnlockHistory,
|
||||
shouldPromptUnlockHistory,
|
||||
@@ -78,6 +79,46 @@ export const chatMachine = setup({
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
}),
|
||||
|
||||
applyNetworkHistoryLoaded: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return applyNetworkHistoryLoadedOutput(context, event.output);
|
||||
}),
|
||||
|
||||
applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
return {
|
||||
...applyNetworkHistoryLoadedOutput(context, event.output),
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
};
|
||||
}),
|
||||
|
||||
showUnlockHistoryPromptFromNetwork: assign(({ context, event }) => {
|
||||
if (event.type !== "ChatNetworkHistoryLoaded") return {};
|
||||
const nextHistory = applyNetworkHistoryLoadedOutput(
|
||||
context,
|
||||
event.output,
|
||||
);
|
||||
return {
|
||||
...nextHistory,
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
|
||||
unlockHistoryError: null,
|
||||
};
|
||||
}),
|
||||
|
||||
markHistoryLoadFailed: assign({
|
||||
isLoadingMore: false,
|
||||
historyLoaded: true,
|
||||
}),
|
||||
},
|
||||
}).createMachine({
|
||||
id: "chat",
|
||||
@@ -98,15 +139,27 @@ export const chatMachine = setup({
|
||||
},
|
||||
|
||||
guestSession: {
|
||||
invoke: {
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
invoke: [
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
},
|
||||
],
|
||||
on: {
|
||||
ChatUserLogin: {
|
||||
target: "#chat.userSession",
|
||||
actions: "startUserSession",
|
||||
},
|
||||
ChatNetworkHistoryLoaded: {
|
||||
actions: "applyNetworkHistoryLoaded",
|
||||
},
|
||||
ChatHistoryLoadFailed: {
|
||||
actions: "markHistoryLoadFailed",
|
||||
},
|
||||
ChatQueuedSendStarted: {
|
||||
actions: "markQueuedSendStarted",
|
||||
},
|
||||
@@ -120,25 +173,18 @@ export const chatMachine = setup({
|
||||
initial: "initializing",
|
||||
states: {
|
||||
initializing: {
|
||||
always: [
|
||||
{
|
||||
on: {
|
||||
ChatLocalHistoryLoaded: {
|
||||
target: "ready",
|
||||
guard: ({ context }) => context.historyLoaded,
|
||||
actions: "applyLocalHistoryLoaded",
|
||||
},
|
||||
],
|
||||
invoke: {
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
onDone: {
|
||||
actions: assign(({ event }) =>
|
||||
applyHistoryLoadedOutput(event.output),
|
||||
),
|
||||
ChatNetworkHistoryLoaded: {
|
||||
target: "ready",
|
||||
actions: "applyNetworkHistoryLoaded",
|
||||
},
|
||||
onError: {
|
||||
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空)
|
||||
actions: assign({
|
||||
historyLoaded: true,
|
||||
}),
|
||||
ChatHistoryLoadFailed: {
|
||||
target: "ready",
|
||||
actions: "markHistoryLoadFailed",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -178,15 +224,50 @@ export const chatMachine = setup({
|
||||
},
|
||||
|
||||
userSession: {
|
||||
invoke: {
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
invoke: [
|
||||
{
|
||||
id: "messageQueue",
|
||||
src: "httpMessageQueue",
|
||||
},
|
||||
{
|
||||
id: "loadHistory",
|
||||
src: "loadHistory",
|
||||
},
|
||||
],
|
||||
on: {
|
||||
ChatLogout: {
|
||||
target: "#chat.idle",
|
||||
actions: "clearChatSession",
|
||||
},
|
||||
ChatNetworkHistoryLoaded: [
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
event.type === "ChatNetworkHistoryLoaded" &&
|
||||
context.paymentUnlockPending &&
|
||||
shouldAutoUnlockHistory(event.output.messages),
|
||||
target: ".unlockingHistory",
|
||||
actions: [
|
||||
"applyNetworkHistoryLoadedAndClearPayment",
|
||||
"markUnlockHistoryStarted",
|
||||
],
|
||||
},
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
event.type === "ChatNetworkHistoryLoaded" &&
|
||||
context.paymentUnlockPending &&
|
||||
shouldPromptUnlockHistory(event.output.messages),
|
||||
target: ".ready",
|
||||
actions: "showUnlockHistoryPromptFromNetwork",
|
||||
},
|
||||
{
|
||||
guard: ({ context }) =>
|
||||
!context.isUnlockingHistory && !context.isUnlockingMessage,
|
||||
actions: "applyNetworkHistoryLoaded",
|
||||
},
|
||||
],
|
||||
ChatHistoryLoadFailed: {
|
||||
actions: "markHistoryLoadFailed",
|
||||
},
|
||||
ChatQueuedSendStarted: {
|
||||
actions: "markQueuedSendStarted",
|
||||
},
|
||||
@@ -232,53 +313,39 @@ export const chatMachine = setup({
|
||||
initial: "initializing",
|
||||
states: {
|
||||
initializing: {
|
||||
invoke: {
|
||||
src: "loadHistory",
|
||||
onDone: [
|
||||
on: {
|
||||
ChatLocalHistoryLoaded: {
|
||||
target: "ready",
|
||||
actions: "applyLocalHistoryLoaded",
|
||||
},
|
||||
ChatNetworkHistoryLoaded: [
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
event.type === "ChatNetworkHistoryLoaded" &&
|
||||
context.paymentUnlockPending &&
|
||||
shouldAutoUnlockHistory(event.output.messages),
|
||||
target: "unlockingHistory",
|
||||
actions: [
|
||||
assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
})),
|
||||
"applyNetworkHistoryLoadedAndClearPayment",
|
||||
"markUnlockHistoryStarted",
|
||||
],
|
||||
},
|
||||
{
|
||||
guard: ({ context, event }) =>
|
||||
event.type === "ChatNetworkHistoryLoaded" &&
|
||||
context.paymentUnlockPending &&
|
||||
shouldPromptUnlockHistory(event.output.messages),
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
unlockHistoryPromptVisible: true,
|
||||
lockedHistoryCount: countLockedHistoryMessages(
|
||||
event.output.messages,
|
||||
),
|
||||
unlockHistoryError: null,
|
||||
})),
|
||||
actions: "showUnlockHistoryPromptFromNetwork",
|
||||
},
|
||||
{
|
||||
target: "ready",
|
||||
actions: assign(({ event }) => ({
|
||||
...applyHistoryLoadedOutput(event.output),
|
||||
isLoadingMore: false,
|
||||
paymentUnlockPending: false,
|
||||
})),
|
||||
actions: "applyNetworkHistoryLoadedAndClearPayment",
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
ChatHistoryLoadFailed: {
|
||||
target: "ready",
|
||||
actions: assign({
|
||||
isLoadingMore: false,
|
||||
historyLoaded: true,
|
||||
}),
|
||||
actions: "markHistoryLoadFailed",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,8 +27,8 @@ export interface ChatState {
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
historyOffset: number;
|
||||
/** history 加载完成标志(initial load —— local → network → save 跑完)
|
||||
* - guestSession.initializing / user sessions initializing 走 always barrier
|
||||
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)
|
||||
* - 网络历史加载完成后会再次刷新消息列表
|
||||
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
|
||||
*/
|
||||
historyLoaded: boolean;
|
||||
|
||||
Reference in New Issue
Block a user