export const SESSION_URL_QUERY_KEY = "c"; export const NEW_CHAT_QUERY_KEY = "new"; export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return"; export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除"; export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。"; export const SESSION_URL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export type SessionUrlQuery = { readonly present: boolean; readonly sessionId: string | null; }; export type BootstrapSessionSelection = { readonly sessionId: string; readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup" | "new-chat"; readonly missing: boolean; readonly clearStoredReturn: boolean; }; /** * The one new-chat intent. The read-only sidebar and /people「和 TA 对话」 both * link here; the person rides on the current-subject store, not the URL. * Bootstrap turns it into a local empty consultation and ignores (then clears) * the stashed `?c=` return, so no older session is reopened (BUG-1015/1038). */ export function newChatHref(): string { return "/?new=1"; } export function parseNewChatIntent(search: string): boolean { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); return params.has(NEW_CHAT_QUERY_KEY); } export function parseSessionUrlQuery(search: string): SessionUrlQuery { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); if (!params.has(SESSION_URL_QUERY_KEY)) { return { present: false, sessionId: null }; } const raw = params.get(SESSION_URL_QUERY_KEY) ?? ""; return { present: true, sessionId: SESSION_URL_ID_PATTERN.test(raw) ? raw : null, }; } export function sessionHref(search: string, sessionId: string | null, pathname = "/"): string { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); params.delete(NEW_CHAT_QUERY_KEY); if (sessionId) params.set(SESSION_URL_QUERY_KEY, sessionId); else params.delete(SESSION_URL_QUERY_KEY); const query = params.toString(); const path = pathname || "/"; return query ? `${path}?${query}` : path; } export function writeSessionUrl(sessionId: string | null, mode: "push" | "replace") { const { location, history } = globalThis.window; const next = sessionHref(location.search, sessionId, location.pathname); const current = `${location.pathname}${location.search}`; if (current === next) return; if (mode === "push") history.pushState(null, "", next); else history.replaceState(null, "", next); } export function persistLoginSessionReturn() { try { const sessionId = parseSessionUrlQuery(globalThis.window.location.search).sessionId; if (sessionId) globalThis.sessionStorage.setItem(SESSION_URL_RETURN_STORAGE_KEY, sessionId); } catch { // sessionStorage can throw in private mode; login still proceeds. } } export function readLoginSessionReturn(): string | null { try { const raw = globalThis.sessionStorage.getItem(SESSION_URL_RETURN_STORAGE_KEY); if (!raw || !SESSION_URL_ID_PATTERN.test(raw)) return null; return raw; } catch { return null; } } export function clearLoginSessionReturn() { try { globalThis.sessionStorage.removeItem(SESSION_URL_RETURN_STORAGE_KEY); } catch { // Ignore private-mode quota errors. } } export function resolveBootstrapSessionSelection(input: { readonly listedIds: readonly string[]; readonly defaultSessionId: string; readonly search: string; readonly storedReturnId: string | null; }): BootstrapSessionSelection { if (parseNewChatIntent(input.search)) { return { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false, clearStoredReturn: true, }; } const query = parseSessionUrlQuery(input.search); if (query.present) { if (query.sessionId && input.listedIds.includes(query.sessionId)) { return { sessionId: query.sessionId, urlAction: "keep", missing: false, clearStoredReturn: true, }; } if (query.sessionId) { return { sessionId: query.sessionId, urlAction: "lookup", missing: false, clearStoredReturn: false, }; } return { sessionId: input.defaultSessionId, urlAction: "replace-clear", missing: true, clearStoredReturn: true, }; } if (input.storedReturnId) { if (input.listedIds.includes(input.storedReturnId)) { return { sessionId: input.storedReturnId, urlAction: "replace-selected", missing: false, clearStoredReturn: true, }; } return { sessionId: input.storedReturnId, urlAction: "lookup", missing: false, clearStoredReturn: false, }; } return { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false, }; } /** * `origin` says where the looked-up id came from. A `?c=` is already in the * address bar, so a found session keeps it. A stashed return (the `?c=` a * secondary-page link left behind) is not: landing on it must write it back, * or the page shows that session without the URL that opens it — for a * rectification session that is a locked composer that never opens (BUG-1038). * A stashed session of another person is not a place to return to at all. */ export function bootstrapSelectionFromLookup( status: "found" | "missing" | "unavailable" | "other-subject", requestedId: string, defaultSessionId: string, origin: "url" | "stored-return" = "url", ): BootstrapSessionSelection { if (status === "found") { return { sessionId: requestedId, urlAction: origin === "stored-return" ? "replace-selected" : "keep", missing: false, clearStoredReturn: true, }; } if (status === "other-subject") { return { sessionId: defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: true, }; } if (status === "missing") { return { sessionId: defaultSessionId, urlAction: "replace-clear", missing: true, clearStoredReturn: true, }; } return { sessionId: defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false, }; }