fix: make conversation restore reliable in Safari

This commit is contained in:
2026-08-03 14:32:52 +08:00
parent 328b7dd2f1
commit 3d561f049c
2 changed files with 62 additions and 21 deletions
+39 -11
View File
@@ -75,6 +75,20 @@ function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; }
function setPresence(value: string): void { presence.textContent = value; }
function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); }
function newLocalID(prefix: string): string {
// `crypto.randomUUID` is absent from older Safari / WKWebView versions.
// A local item id needs uniqueness within this browser store, not a
// transport identity, so the timestamp-and-random fallback is sufficient
// and must not prevent the form submit handler from reaching HTTP.
const uuid = globalThis.crypto?.randomUUID?.();
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
}
function storageFailureMessage(reason: unknown): string {
const detail = reason instanceof Error && reason.message ? `${reason.message}` : "";
return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`;
}
function normalizeAPIAddress(value: string): string {
const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。");
@@ -278,24 +292,38 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text || !session.uid) return;
if (!text) return;
if (!session.uid) {
addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
input.focus();
return;
}
input.value = ""; send.disabled = true;
const localID = `local_${crypto.randomUUID()}`;
const localID = newLocalID("local");
const createdAt = new Date().toISOString();
conversationStore.appendLocalText(localID, text, createdAt);
const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector<HTMLElement>(".meta");
// A browser storage failure must never prevent the user from seeing or
// submitting their message. The Store remains the durable source when it
// is available; the server sync is the recovery path when it is not.
const row = appendBubble("human", text, false, "发送中");
const meta = row.querySelector<HTMLElement>(".meta");
let storedLocally = true;
try { conversationStore.appendLocalText(localID, text, createdAt); }
catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); }
try {
const sequence = await sendPayload(text);
if (sequence) {
session.lastSeq = Math.max(session.lastSeq, sequence);
conversationStore.markSubmitted(localID, sequence, new Date().toISOString());
if (sequence && storedLocally) {
try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); }
}
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送"`;
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送`;
}
catch (reason) {
const message = reason instanceof Error ? reason.message : "发送失败";
conversationStore.markFailed(localID, message, new Date().toISOString());
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败"`;
if (storedLocally) {
try { conversationStore.markFailed(localID, message, new Date().toISOString()); }
catch (storageReason) { addSystem(storageFailureMessage(storageReason)); }
}
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`;
addSystem(message);
}
finally { send.disabled = false; input.focus(); }
@@ -312,7 +340,7 @@ export function makeProtocolEvent(
): Envelope {
return {
v: 1,
id: `msg_${crypto.randomUUID()}`,
id: newLocalID("msg"),
type,
conversation_id: conversationID,
sender,
+23 -10
View File
@@ -21,7 +21,7 @@ export type ConversationSnapshot = {
};
type PersistedConversation = {
version: 1;
version: 3;
cursor: number;
items: StoredConversationItem[];
outbox: OutboxEntry[];
@@ -38,7 +38,7 @@ const STORE_PREFIX = "lineup.conversation.v1";
*/
export class ConversationStore {
private activeConversationID: string | undefined;
private state: PersistedConversation = { version: 1, cursor: 0, items: [], outbox: [] };
private state: PersistedConversation = { version: 3, cursor: 0, items: [], outbox: [] };
public constructor(private readonly storage: Storage) {}
@@ -50,7 +50,7 @@ export class ConversationStore {
public close(): void {
this.activeConversationID = undefined;
this.state = { version: 1, cursor: 0, items: [], outbox: [] };
this.state = { version: 3, cursor: 0, items: [], outbox: [] };
}
public snapshot(): ConversationSnapshot {
@@ -85,7 +85,11 @@ export class ConversationStore {
delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) },
};
this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID);
this.state.cursor = Math.max(this.state.cursor, messageSeq);
// A send acknowledgement is not a sync acknowledgement. The legacy
// `/messages/sync` cursor is inclusive, so advancing it here would make a
// refresh start at `messageSeq + 1` and permanently skip this user's echo.
// Only a message that has actually passed through the sync path advances
// the cursor (recordSyncedUserText / recordIncoming / advanceCursor).
this.persist();
}
@@ -153,14 +157,23 @@ export class ConversationStore {
private load(conversationID: string): PersistedConversation {
try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 1, cursor: 0, items: [], outbox: [] };
const candidate = parsed as Partial<PersistedConversation>;
if (candidate.version !== 1 || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return { version: 1, cursor: 0, items: [], outbox: [] };
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 3, cursor: 0, items: [], outbox: [] };
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return { version: 3, cursor: 0, items: [], outbox: [] };
}
return { version: 1, cursor: Math.max(0, candidate.cursor), items: candidate.items, outbox: candidate.outbox };
if (candidate.version !== 3) {
// v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v3.
return { version: 3, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] };
}
return { version: 3, cursor: Math.max(0, candidate.cursor), items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] };
} catch {
return { version: 1, cursor: 0, items: [], outbox: [] };
return { version: 3, cursor: 0, items: [], outbox: [] };
}
}