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 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 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 { function normalizeAPIAddress(value: string): string {
const candidate = value.trim(); const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。"); if (!candidate) throw new Error("请输入 AppServer 地址。");
@@ -278,24 +292,38 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => { $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim(); 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; input.value = ""; send.disabled = true;
const localID = `local_${crypto.randomUUID()}`; const localID = newLocalID("local");
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
conversationStore.appendLocalText(localID, text, createdAt); // A browser storage failure must never prevent the user from seeing or
const row = appendBubble("human", text, false, "发送中"); const meta = row.querySelector<HTMLElement>(".meta"); // 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 { try {
const sequence = await sendPayload(text); const sequence = await sendPayload(text);
if (sequence) { if (sequence && storedLocally) {
session.lastSeq = Math.max(session.lastSeq, sequence); try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
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) { catch (reason) {
const message = reason instanceof Error ? reason.message : "发送失败"; const message = reason instanceof Error ? reason.message : "发送失败";
conversationStore.markFailed(localID, message, new Date().toISOString()); if (storedLocally) {
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败"`; try { conversationStore.markFailed(localID, message, new Date().toISOString()); }
catch (storageReason) { addSystem(storageFailureMessage(storageReason)); }
}
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`;
addSystem(message); addSystem(message);
} }
finally { send.disabled = false; input.focus(); } finally { send.disabled = false; input.focus(); }
@@ -312,7 +340,7 @@ export function makeProtocolEvent(
): Envelope { ): Envelope {
return { return {
v: 1, v: 1,
id: `msg_${crypto.randomUUID()}`, id: newLocalID("msg"),
type, type,
conversation_id: conversationID, conversation_id: conversationID,
sender, sender,
+23 -10
View File
@@ -21,7 +21,7 @@ export type ConversationSnapshot = {
}; };
type PersistedConversation = { type PersistedConversation = {
version: 1; version: 3;
cursor: number; cursor: number;
items: StoredConversationItem[]; items: StoredConversationItem[];
outbox: OutboxEntry[]; outbox: OutboxEntry[];
@@ -38,7 +38,7 @@ const STORE_PREFIX = "lineup.conversation.v1";
*/ */
export class ConversationStore { export class ConversationStore {
private activeConversationID: string | undefined; 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) {} public constructor(private readonly storage: Storage) {}
@@ -50,7 +50,7 @@ export class ConversationStore {
public close(): void { public close(): void {
this.activeConversationID = undefined; this.activeConversationID = undefined;
this.state = { version: 1, cursor: 0, items: [], outbox: [] }; this.state = { version: 3, cursor: 0, items: [], outbox: [] };
} }
public snapshot(): ConversationSnapshot { public snapshot(): ConversationSnapshot {
@@ -85,7 +85,11 @@ export class ConversationStore {
delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) }, delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) },
}; };
this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID); 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(); this.persist();
} }
@@ -153,14 +157,23 @@ export class ConversationStore {
private load(conversationID: string): PersistedConversation { private load(conversationID: string): PersistedConversation {
try { try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); 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: [] }; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 3, cursor: 0, items: [], outbox: [] };
const candidate = parsed as Partial<PersistedConversation>; const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown };
if (candidate.version !== 1 || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return { version: 1, cursor: 0, items: [], outbox: [] }; 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 { } catch {
return { version: 1, cursor: 0, items: [], outbox: [] }; return { version: 3, cursor: 0, items: [], outbox: [] };
} }
} }