mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 11:47:44 +00:00
206 lines
6.5 KiB
TypeScript
206 lines
6.5 KiB
TypeScript
import {
|
|
composeOrder, readChain, readMethod, readStatus, readStep, readTheme,
|
|
type OrderRecord, type SessionRecord, type Theme,
|
|
} from "./domain.js";
|
|
import { asObject, filledText, flag, positiveInteger, text, nonNegativeInteger } from "./parse.js";
|
|
|
|
export interface StorageLike {
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
}
|
|
|
|
// Date.parse returns NaN for an unreadable value, and NaN breaks the sort comparator, so an unreadable timestamp sorts oldest.
|
|
function createdMs(o: OrderRecord): number {
|
|
const t = Date.parse(o.createdAt);
|
|
return Number.isNaN(t) ? Number.MIN_SAFE_INTEGER : t;
|
|
}
|
|
|
|
const SESSION_KEY = "sb.session.v1";
|
|
const ORDERS_KEY = "sb.orders.v1";
|
|
const THEME_KEY = "sb.theme.v1";
|
|
const CARD_RETURN_KEY = "sb.cardReturn.v1";
|
|
const CAP = 50;
|
|
|
|
function readOrder(value: unknown): OrderRecord | undefined {
|
|
const o = asObject(value);
|
|
if (o === undefined) return undefined;
|
|
const orderId = filledText(o.orderId);
|
|
const createdAt = filledText(o.createdAt);
|
|
const status = readStatus(o.status);
|
|
if (orderId === undefined || createdAt === undefined || status === undefined) return undefined;
|
|
return composeOrder(
|
|
{
|
|
orderId,
|
|
createdAt,
|
|
badgeType: text(o.badgeType) ?? "",
|
|
months: positiveInteger(o.months) ?? 0,
|
|
},
|
|
{ code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method) },
|
|
{
|
|
status,
|
|
amount: positiveInteger(o.amount),
|
|
currency: text(o.currency),
|
|
amountPaid: nonNegativeInteger(o.amountPaid),
|
|
cryptoAmountPaid: text(o.cryptoAmountPaid),
|
|
cryptoAmountDue: text(o.cryptoAmountDue),
|
|
paidInFull: flag(o.paidInFull),
|
|
address: text(o.address),
|
|
cryptoAmount: text(o.cryptoAmount),
|
|
cryptoCurrency: readChain(o.cryptoCurrency),
|
|
expiresAt: text(o.expiresAt),
|
|
},
|
|
);
|
|
}
|
|
|
|
function readSession(value: unknown): SessionRecord | undefined {
|
|
const s = asObject(value);
|
|
const step = s === undefined ? undefined : readStep(s.step);
|
|
if (s === undefined || step === undefined) return undefined;
|
|
const session: SessionRecord = { step };
|
|
const priceId = text(s.priceId);
|
|
const offerId = text(s.offerId);
|
|
const method = readMethod(s.method);
|
|
if (priceId !== undefined) session.priceId = priceId;
|
|
if (offerId !== undefined) session.offerId = offerId;
|
|
if (method !== undefined) session.method = method;
|
|
return session;
|
|
}
|
|
|
|
export class Store {
|
|
private wipes = 0;
|
|
|
|
/** `durable` is false for the in-memory fallback used when the browser refuses `localStorage`. */
|
|
constructor(private readonly storage: StorageLike, readonly durable = true) {}
|
|
|
|
canHoldACode(): boolean {
|
|
if (!this.durable) return false;
|
|
const list = this.orders();
|
|
return list.length < CAP || list.some((o) => o.code === undefined);
|
|
}
|
|
|
|
holdsCode(orderId: string, code: string | undefined): boolean {
|
|
return this.durable && code !== undefined && this.order(orderId)?.code === code;
|
|
}
|
|
|
|
private read(key: string): unknown {
|
|
try {
|
|
const raw = this.storage.getItem(key);
|
|
return raw === null ? undefined : JSON.parse(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private write(key: string, value: unknown): boolean {
|
|
try {
|
|
this.storage.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
session(): SessionRecord {
|
|
return readSession(this.read(SESSION_KEY)) ?? { step: "tier" };
|
|
}
|
|
|
|
saveSession(patch: Partial<SessionRecord>): boolean {
|
|
return this.write(SESSION_KEY, { ...this.session(), ...patch });
|
|
}
|
|
|
|
clearSession(): void {
|
|
this.forget(SESSION_KEY);
|
|
}
|
|
|
|
orders(): OrderRecord[] {
|
|
const list = this.read(ORDERS_KEY);
|
|
if (!Array.isArray(list)) return [];
|
|
return list.flatMap((o) => readOrder(o) ?? []);
|
|
}
|
|
|
|
// The record replaces the stored entry, but the browser-only fields (code, submitted, canceled) are preserved because the service never sends them back.
|
|
saveOrder(record: OrderRecord): boolean {
|
|
const list = this.orders();
|
|
const at = list.findIndex((o) => o.orderId === record.orderId);
|
|
if (at >= 0) {
|
|
const kept = list[at]!;
|
|
list[at] = {
|
|
...record,
|
|
...(record.code === undefined && kept.code !== undefined ? { code: kept.code } : {}),
|
|
...(kept.submitted === true ? { submitted: true } : {}),
|
|
...(kept.canceled === true ? { canceled: true } : {}),
|
|
};
|
|
} else {
|
|
if (list.length >= CAP) {
|
|
let victim = -1;
|
|
for (let i = list.length - 1; i >= 0; i--) if (!list[i]!.code) { victim = i; break; }
|
|
if (victim < 0) return false;
|
|
list.splice(victim, 1);
|
|
}
|
|
list.unshift(record);
|
|
}
|
|
list.sort((a, b) => createdMs(b) - createdMs(a));
|
|
return this.write(ORDERS_KEY, list);
|
|
}
|
|
|
|
markSubmitted(orderId: string): boolean {
|
|
const record = this.order(orderId);
|
|
if (record === undefined) return false;
|
|
return this.saveOrder({ ...record, submitted: true });
|
|
}
|
|
|
|
markCanceled(orderId: string): boolean {
|
|
const record = this.order(orderId);
|
|
if (record === undefined) return false;
|
|
return this.saveOrder({ ...record, canceled: true });
|
|
}
|
|
|
|
order(orderId: string): OrderRecord | undefined {
|
|
return this.orders().find((o) => o.orderId === orderId);
|
|
}
|
|
|
|
newestOpen(): OrderRecord | undefined {
|
|
return this.orders().find((o) => o.status === "open");
|
|
}
|
|
|
|
theme(): Theme {
|
|
return readTheme(this.read(THEME_KEY)) ?? "system";
|
|
}
|
|
|
|
saveTheme(theme: Theme): boolean {
|
|
return this.write(THEME_KEY, theme);
|
|
}
|
|
|
|
rememberCardReturn(orderId: string, atMs: number): boolean {
|
|
return this.write(CARD_RETURN_KEY, { orderId, at: atMs });
|
|
}
|
|
|
|
takeCardReturn(withinMs: number, nowMs: number): string | undefined {
|
|
const held = this.read(CARD_RETURN_KEY);
|
|
this.forget(CARD_RETURN_KEY);
|
|
if (typeof held !== "object" || held === null) return undefined;
|
|
const { orderId, at } = held as { orderId?: unknown; at?: unknown };
|
|
if (typeof orderId !== "string" || orderId === "" || typeof at !== "number") return undefined;
|
|
return nowMs - at <= withinMs ? orderId : undefined;
|
|
}
|
|
|
|
clearCardReturn(): void {
|
|
this.forget(CARD_RETURN_KEY);
|
|
}
|
|
|
|
forgetEverything(): void {
|
|
this.wipes += 1;
|
|
this.forget(ORDERS_KEY);
|
|
this.forget(SESSION_KEY);
|
|
}
|
|
|
|
get wipeCount(): number {
|
|
return this.wipes;
|
|
}
|
|
|
|
private forget(key: string): void {
|
|
try { this.storage.removeItem(key); } catch { /* a store that refuses this keeps the key */ }
|
|
}
|
|
}
|