This commit is contained in:
payacom
2026-08-14 21:31:11 +03:30
parent a92a3ca5e6
commit c879976c56
176 changed files with 30270 additions and 1441 deletions

View File

@@ -45,15 +45,19 @@ export function normalizeMessageContent(content: unknown): string {
export async function getDmConversationKey(
myUserId: string,
peerUserId: string
peerUserId: string,
/** Bypass the in-memory cache and re-derive from a freshly-fetched peer key — used to recover from a peer key that rotated mid-session (e.g. they linked/reset a device). */
forceFresh = false
): Promise<CryptoKey | null> {
const me = String(myUserId || "").trim();
const peer = String(peerUserId || "").trim();
if (!me || !peer) return null;
const ck = cacheKey(me, peer);
const cached = dmKeyCache.get(ck);
if (cached) return cached;
if (!forceFresh) {
const cached = dmKeyCache.get(ck);
if (cached) return cached;
}
try {
const { privateKey } = await ensureIdentityKeys(me);
@@ -102,13 +106,25 @@ export async function decryptDmContent(
try {
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return E2EE_LOCKED_PREVIEW;
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
if (key) {
const pt = await decryptText(key, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
// کلید کش‌شده ممکن است با کلید فعلی طرف مقابل هم‌خوان نباشد (مثلاً بعد از
// لینک/ریست دستگاه) — یک بار با دریافت مجدد کلید عمومی طرف مقابل تلاش کن
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
if (freshKey) {
const pt = await decryptText(freshKey, raw);
if (pt != null) {
rememberOutboundPlaintext(raw, pt);
return pt;
}
}
return E2EE_LOCKED_PREVIEW;
} catch {
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیام‌های صفحه را قفل نشان دهد

View File

@@ -82,16 +82,20 @@ async function persistRoomRaw(
export async function ensureRoomAesKey(
myUserId: string,
roomId: string
roomId: string,
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
forceFresh = false
): Promise<CryptoKey | null> {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
if (!forceFresh) {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
}
const { privateKey } = await ensureIdentityKeys(myUserId);
const info = await fetchRoomKeys(roomId);
if (!info) return null;
let raw = await loadCachedRoomRaw(myUserId, roomId);
let raw = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
if (!raw && info.myWrappedKey) {
try {
@@ -177,10 +181,21 @@ export async function decryptRoomContent(
content: string
): Promise<string> {
if (!isE2eeEnvelope(content)) return content;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return E2EE_LOCKED_PREVIEW;
const pt = await decryptText(key, content);
return pt ?? E2EE_LOCKED_PREVIEW;
if (key) {
const pt = await decryptText(key, content);
if (pt != null) return pt;
}
// کلید کش‌شده ممکن است دیگر با wrapped key فعلی سرور هم‌خوان نباشد — یک بار تازه امتحان کن
const freshKey = await ensureRoomAesKey(myUserId, roomId, true);
if (freshKey) {
const pt = await decryptText(freshKey, content);
if (pt != null) return pt;
}
return E2EE_LOCKED_PREVIEW;
}
export async function decryptRoomMessageList<

View File

@@ -11,6 +11,8 @@ export type LanguageDefinition = {
dateLocale: string;
/** Show in language picker */
enabled: boolean;
/** Shown in the quick-switch grid (fa/en only) — every other enabled language still appears on the full "select language" page. */
primary?: boolean;
};
/**
@@ -30,6 +32,7 @@ export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
ogLocale: "fa_IR",
dateLocale: "fa-IR",
enabled: true,
primary: true,
},
en: {
code: "en",
@@ -40,12 +43,13 @@ export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
ogLocale: "en_US",
dateLocale: "en-US",
enabled: true,
primary: true,
},
// Below: registered but not yet enabled — no locale files exist for these yet, so
// they render in the picker's "Other languages" section without being selectable.
// Set enabled: true once src/locales/{code}/common.json (+ seo.json) exist and are
// registered in src/lib/i18n/resources.ts.
ar: { code: "ar", nativeLabel: "العربية", englishLabel: "Arabic", direction: "rtl", htmlLang: "ar", ogLocale: "ar_SA", dateLocale: "ar-SA", enabled: false },
ar: { code: "ar", nativeLabel: "العربية", englishLabel: "Arabic", direction: "rtl", htmlLang: "ar", ogLocale: "ar_SA", dateLocale: "ar-SA", enabled: true },
ku: { code: "ku", nativeLabel: "کوردی", englishLabel: "Kurdish", direction: "rtl", htmlLang: "ku", ogLocale: "ku_TR", dateLocale: "ku", enabled: false },
tr: { code: "tr", nativeLabel: "Türkçe", englishLabel: "Turkish", direction: "ltr", htmlLang: "tr", ogLocale: "tr_TR", dateLocale: "tr-TR", enabled: false },
az: { code: "az", nativeLabel: "Azərbaycan dili", englishLabel: "Azerbaijani", direction: "ltr", htmlLang: "az", ogLocale: "az_AZ", dateLocale: "az-AZ", enabled: false },
@@ -66,7 +70,7 @@ export const LANGUAGE_REGISTRY: Record<string, LanguageDefinition> = {
bn: { code: "bn", nativeLabel: "বাংলা", englishLabel: "Bengali", direction: "ltr", htmlLang: "bn", ogLocale: "bn_BD", dateLocale: "bn-BD", enabled: false },
th: { code: "th", nativeLabel: "ไทย", englishLabel: "Thai", direction: "ltr", htmlLang: "th", ogLocale: "th_TH", dateLocale: "th-TH", enabled: false },
ms: { code: "ms", nativeLabel: "Bahasa Melayu", englishLabel: "Malay", direction: "ltr", htmlLang: "ms", ogLocale: "ms_MY", dateLocale: "ms-MY", enabled: false },
fil: { code: "fil", nativeLabel: "Filipino", englishLabel: "Filipino", direction: "ltr", htmlLang: "fil", ogLocale: "fil_PH", dateLocale: "fil-PH", enabled: false },
tl: { code: "tl", nativeLabel: "Filipino", englishLabel: "Filipino", direction: "ltr", htmlLang: "tl", ogLocale: "fil_PH", dateLocale: "fil-PH", enabled: false },
fr: { code: "fr", nativeLabel: "Français", englishLabel: "French", direction: "ltr", htmlLang: "fr", ogLocale: "fr_FR", dateLocale: "fr-FR", enabled: false },
nl: { code: "nl", nativeLabel: "Nederlands", englishLabel: "Dutch", direction: "ltr", htmlLang: "nl", ogLocale: "nl_NL", dateLocale: "nl-NL", enabled: false },
no: { code: "no", nativeLabel: "Norsk", englishLabel: "Norwegian", direction: "ltr", htmlLang: "no", ogLocale: "nb_NO", dateLocale: "nb-NO", enabled: false },
@@ -93,6 +97,11 @@ export const ENABLED_LANGUAGES = SUPPORTED_LANGUAGES.filter(
(code) => LANGUAGE_REGISTRY[code]?.enabled
);
/** Languages shown in the quick-switch grid — currently fa/en only; every enabled language is still selectable from the full "select language" page. */
export const PRIMARY_LANGUAGES = ENABLED_LANGUAGES.filter(
(code) => LANGUAGE_REGISTRY[code]?.primary
);
export function getLanguageDefinition(
code: string | null | undefined
): LanguageDefinition {

View File

@@ -2,6 +2,8 @@ import faCommon from "@/locales/fa/common.json";
import faSeo from "@/locales/fa/seo.json";
import enCommon from "@/locales/en/common.json";
import enSeo from "@/locales/en/seo.json";
import arCommon from "@/locales/ar/common.json";
import arSeo from "@/locales/ar/seo.json";
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
@@ -29,6 +31,10 @@ export const LOCALE_BUNDLES: Partial<
common: enCommon,
seo: enSeo,
},
ar: {
common: arCommon,
seo: arSeo,
},
};
export function buildI18nResources() {

28
src/lib/shopDiscount.ts Normal file
View File

@@ -0,0 +1,28 @@
type VariantLike = {
price: number;
discount_price?: number | null;
};
type ListingLike = {
variants?: VariantLike[] | null;
};
/** Highest discount percentage across a listing's variants, or null when none are discounted. */
export function bestDiscountPercent(listing: ListingLike): number | null {
if (!listing.variants || listing.variants.length === 0) return null;
let best: number | null = null;
for (const v of listing.variants) {
if (!v.discount_price || v.discount_price >= v.price || v.price <= 0) continue;
const percent = Math.round(((v.price - v.discount_price) / v.price) * 100);
if (best == null || percent > best) best = percent;
}
return best;
}
/** Discount percentage for a single matched variant, or null when not discounted. */
export function variantDiscountPercent(variant: VariantLike | null | undefined): number | null {
if (!variant?.discount_price || variant.discount_price >= variant.price || variant.price <= 0) {
return null;
}
return Math.round(((variant.price - variant.discount_price) / variant.price) * 100);
}