fix: make conversation restore reliable in Safari
This commit is contained in:
+39
-11
@@ -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,
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user