secret chat

This commit is contained in:
payacom
2026-09-10 16:51:26 +03:30
parent 2d5505065b
commit 901ce94443
59 changed files with 4742 additions and 1307 deletions

View File

@@ -83,10 +83,6 @@ export function buildAcademyListSeo(
) {
const academyPage = getSeoPage("academy", lang);
const templates = getListSeoTemplates(lang).academy as Record<string, string>;
const sortLabels = getListSeoTemplates(lang).academy.sort as Record<
string,
string
>;
const hasFilters = Boolean(
filters.category ||

View File

@@ -107,9 +107,20 @@ export async function decryptDmContent(
if (!isE2eeEnvelope(raw)) return raw;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return E2EE_LOCKED_PREVIEW;
if (key) {
const plain = await decryptText(key, raw);
if (plain != null) return plain;
}
const plain = await decryptText(key, raw);
// شکستِ رمزگشایی با کلیدِ کش‌شده (یا نبودِ کلید) می‌تونه یعنی طرفِ مقابل وسطِ سشن
// هویتش رو عوض کرده (دستگاه جدید/بازیابی) و ما هنوز کلیدِ عمومیِ قدیمی‌شون رو کش
// کرده بودیم — قبل از نمایشِ «پیام رمزنگاری‌شده»، یه‌بار با کلیدِ عمومیِ کاملاً
// تازه‌واکشی‌شده دوباره امتحان کن (forceFresh)؛ این باعث می‌شه بعد از این‌جور تغییرِ
// کلید، چت خودش رو ترمیم کنه به‌جای این‌که تا رفرشِ صفحه قفل بمونه
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
if (!freshKey) return E2EE_LOCKED_PREVIEW;
const plain = await decryptText(freshKey, raw);
return plain ?? E2EE_LOCKED_PREVIEW;
}

View File

@@ -50,6 +50,64 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
});
}
/**
* رمزِ خودکارِ per-account (نه PIN دستیِ کاربر) — با یه درخواستِ احرازهویت‌شده به همین
* اکانت از هر دستگاهی قابل‌دریافته (سرور اولین‌بار خودش می‌سازتش). به‌عنوانِ رمزِ
* رمزنگاریِ بک‌آپِ کلید استفاده می‌شه تا بازیابیِ هویت روی دستگاهِ جدید کاملاً خودکار
* انجام بشه — بدونِ این‌که کاربر چیزی وارد کنه. توجه: این یعنی end-to-end واقعی دیگه
* برقرار نیست (سرور می‌تونه این رمز رو به بک‌آپِ خودش بده و کلید خصوصی رو دربیاره) —
* این تصمیمِ آگاهانه‌ایه که به نفعِ هیچ‌وقت‌گم‌نشدنِ پیام‌ها گرفته شده
*/
async function fetchAutoBackupSecret(): Promise<string | null> {
try {
const base = getApiBaseUrl();
const res = await fetch(`${base}/account/e2ee/auto-backup-secret`, {
headers: authHeaders(),
});
if (!res.ok) return null;
const data = (await res.json()) as { secret?: string | null };
return data.secret || null;
} catch {
return null;
}
}
/** بک‌آپِ خودکار رو با رمزِ per-account (نه PIN) می‌سازه/به‌روز می‌کنه — بی‌صدا و best-effort */
async function saveAutoKeyBackup(privateJwk: JsonWebKey): Promise<void> {
try {
const autoSecret = await fetchAutoBackupSecret();
if (!autoSecret) return;
const payload = await encryptPrivateKeyForBackup(privateJwk, autoSecret);
const base = getApiBaseUrl();
await fetch(`${base}/account/e2ee/key-backup`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(payload),
});
} catch {
/* best-effort — اگه الان شکست بخوره، دفعه‌ی بعدی که ensureIdentityKeys صدا زده بشه دوباره امتحان می‌شه */
}
}
/** تلاش برای بازیابیِ خودکارِ هویت با رمزِ per-account — بدون هیچ ورودیِ کاربر. اگه بک‌آپ نبود/باز نشد، null */
async function tryAutoRecoverIdentity(
uid: string,
publishedPublicKeyB64: string
): Promise<{ privateKey: CryptoKey; publicKeyB64: string } | null> {
try {
const [autoSecret, backup] = await Promise.all([
fetchAutoBackupSecret(),
fetchMyKeyBackup(),
]);
if (!autoSecret || !backup) return null;
const privateJwk = await decryptPrivateKeyFromBackup(backup, autoSecret);
const privateKey = await importPrivateKeyJwk(privateJwk);
return await storeAndAdopt(uid, privateKey, privateJwk, publishedPublicKeyB64);
} catch {
return null;
}
}
/**
* پرتاب می‌شه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
* دستگاه هیچ کلیدِ محلی‌ای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
@@ -102,6 +160,9 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
} catch {
/* publish best-effort */
}
// بک‌آپِ خودکار رو تازه نگه می‌داره — اگه قبلاً (مثلاً روی نسخه‌ی قدیمی‌تر) هیچ
// بک‌آپی ساخته نشده بود، همینجا برای اولین‌بار ساخته می‌شه، بدون نیاز به PIN کاربر
void saveAutoKeyBackup(stored.privateJwk);
return { privateKey, publicKeyB64: stored.publicKeyB64 };
}
@@ -109,6 +170,12 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
// کاربرِ تازه‌ست، نه یه دستگاهِ جدید/دیتای‌پاک‌شده برای یه هویتِ از قبل موجود
const existingPublicKey = await fetchPeerPublicKey(uid);
if (existingPublicKey) {
// قبل از throw کردن (که کاربر رو با کارتِ بازیابی مسدود می‌کنه)، یه‌بار خودکار
// (با رمزِ per-account، نه PIN) امتحان کن — این دقیقاً همون چیزیه که باعث می‌شه
// لاگین دوباره/دستگاهِ دوم هیچ‌وقت نیازی به دخالتِ کاربر یا گم‌شدنِ پیام نداشته باشه
const recovered = await tryAutoRecoverIdentity(uid, existingPublicKey);
if (recovered) return recovered;
const backup = await fetchMyKeyBackup();
throw new IdentityRecoveryNeededError(Boolean(backup));
}
@@ -122,6 +189,9 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
} catch {
/* publish best-effort */
}
// هویتِ تازه رو فوراً با رمزِ خودکار بک‌آپ می‌گیره — دستگاهِ بعدی که با همین اکانت
// وارد بشه، دیگه هیچ‌وقت به این نقطه (existingPublicKey ولی بدون بک‌آپ) نمی‌رسه
await saveAutoKeyBackup(privateJwk);
return result;
}
@@ -215,6 +285,9 @@ export async function createFreshIdentity(userId: string): Promise<{
} catch {
/* publish best-effort */
}
// این هویتِ تازه رو هم فوراً با رمزِ خودکار بک‌آپ می‌گیره — وگرنه دستگاهِ بعدی که با
// همین اکانت وارد بشه، دوباره به همین بن‌بست (بدون بک‌آپ) می‌رسید
await saveAutoKeyBackup(privateJwk);
return result;
}

View File

@@ -1,54 +1,288 @@
import { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptText,
encryptText,
generateRoomKeyRaw,
importPublicKeySpkiB64,
importRoomAesKey,
unwrapRoomKey,
wrapRoomKeyForRecipient,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys } from "./keys";
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
// E2EE برای روم‌ها هم موقتاً کاملاً غیرفعال شده. نکته‌ی مهم: ensureRoomAesKey
// قبلاً اگه روم هنوز e2ee_enabled نبود، خودش یه کلیدِ تازه می‌ساخت و با PUT به
// سرور، رمزنگاری رو *خودکار* روی روم فعال می‌کرد — پس همین‌جا کاملاً no-op
// شده تا هیچ‌جایی (حتی موقعِ ساختنِ روم جدید) دیگه رمزنگاری رو روشن نکنه
/* eslint-disable @typescript-eslint/no-unused-vars -- امضا برای سازگاری با فراخوان‌های موجود حفظ شده */
export async function ensureRoomAesKey(
myUserId: string,
roomId: string
): Promise<CryptoKey | null> {
type RoomKeysResponse = {
e2ee_enabled: boolean;
myWrappedKey: string | null;
memberPublicKeys: { userId: string; publicKey: string | null }[];
missingWrapUserIds: string[];
};
type PutRoomKeysConflict = { myWrappedKey: string | null } | null;
const roomAesCache = new Map<string, CryptoKey>();
const roomRawCache = new Map<string, Uint8Array>();
// عین inFlight توی keys.ts: بدون این، چند فراخوانیِ هم‌زمانِ همین تب (مثلاً mount شدنِ
// صفحه‌ی روم + رمزگشاییِ چند پیام هم‌زمان) هرکدوم جداگانه ensureRoomAesKey رو صدا
// می‌زدن؛ اگه روم هنوز کلید نداشت، هر کدوم یه کلیدِ تصادفیِ متفاوت می‌ساختن. این فقط
// رقابتِ داخلِ همین تب رو می‌بنده — رقابتِ بین چند عضوِ مختلف (چند دستگاه/تب) با
// claimِ اتمیکِ سمتِ سرور (putRoomE2eeKeys) حل می‌شه
const inFlight = new Map<string, Promise<CryptoKey | null>>();
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async function fetchRoomKeys(roomId: string): Promise<RoomKeysResponse | null> {
const base = getApiBaseUrl();
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
headers: authHeaders(),
});
if (!res.ok) return null;
return (await res.json()) as RoomKeysResponse;
}
/**
* اگه سرور ۴۰۹ (E2EE_ALREADY_ENABLED) برگردونه یعنی عضو دیگه‌ای هم‌زمان برنده‌ی
* فعال‌سازیِ اولیه‌ی روم شده — کلیدی که خودمون تازه ساختیم دیگه معتبر نیست؛ باید
* wrappedKeyِ واقعی (متعلق به کلیدِ برنده) که سرور برگردونده رو برگردونیم، نه که
* موفقیت رو فرض کنیم (باگِ اصلی‌ای که قبلاً E2EE روم‌ها رو دائم می‌شکست)
*/
async function putRoomKeys(
roomId: string,
body: {
e2ee_enabled?: boolean;
wrapped_keys?: { userId: string; wrappedKey: string }[];
},
): Promise<PutRoomKeysConflict> {
const base = getApiBaseUrl();
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(body),
});
if (res.status === 409) {
try {
const data = (await res.json()) as {
code?: string;
myWrappedKey?: string | null;
};
if (data?.code === "E2EE_ALREADY_ENABLED") {
return { myWrappedKey: data.myWrappedKey ?? null };
}
} catch {
/* fall through */
}
}
return null;
}
/* eslint-enable @typescript-eslint/no-unused-vars */
async function loadCachedRoomRaw(
myUserId: string,
roomId: string,
): Promise<Uint8Array | null> {
const mem = roomRawCache.get(roomId);
if (mem) return mem;
const b64 = await idbGet<string>(`roomkey:${myUserId}:${roomId}`);
if (!b64) return null;
const raw = new Uint8Array(b64ToBuf(b64));
roomRawCache.set(roomId, raw);
return raw;
}
async function persistRoomRaw(
myUserId: string,
roomId: string,
raw: Uint8Array,
): Promise<CryptoKey> {
roomRawCache.set(roomId, raw);
await idbSet(`roomkey:${myUserId}:${roomId}`, bufToB64(raw));
const aes = await importRoomAesKey(raw);
roomAesCache.set(roomId, aes);
return aes;
}
async function doEnsureRoomAesKey(
myUserId: string,
roomId: string,
forceFresh: boolean,
): Promise<CryptoKey | null> {
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 = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
if (!raw && info.myWrappedKey) {
try {
raw = await unwrapRoomKey(info.myWrappedKey, privateKey);
await persistRoomRaw(myUserId, roomId, raw);
} catch {
raw = null;
}
}
if (!raw && !info.e2ee_enabled) {
raw = await generateRoomKeyRaw();
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const m of info.memberPublicKeys) {
if (!m.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: m.userId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (!wraps.some((w) => w.userId === myUserId)) {
const myPub = info.memberPublicKeys.find((m) => m.userId === myUserId);
if (myPub?.publicKey) {
const pub = await importPublicKeySpkiB64(myPub.publicKey);
wraps.push({
userId: myUserId,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
}
}
const conflict = await putRoomKeys(roomId, {
e2ee_enabled: true,
wrapped_keys: wraps,
});
if (conflict) {
// عضو دیگه‌ای هم‌زمان برنده شد — کلیدِ خودمون رو دور می‌ریزیم و کلیدِ واقعی
// (متعلق به برنده) رو با wrappedKeyِ برگشتی از سرور باز می‌کنیم
if (!conflict.myWrappedKey) return null;
const winnerRaw = await unwrapRoomKey(conflict.myWrappedKey, privateKey);
return persistRoomRaw(myUserId, roomId, winnerRaw);
}
return persistRoomRaw(myUserId, roomId, raw);
}
if (raw) {
// Sync wraps for members missing keys
if (info.missingWrapUserIds?.length) {
const wraps: { userId: string; wrappedKey: string }[] = [];
for (const uid of info.missingWrapUserIds) {
const m = info.memberPublicKeys.find((x) => x.userId === uid);
if (!m?.publicKey) continue;
try {
const pub = await importPublicKeySpkiB64(m.publicKey);
wraps.push({
userId: uid,
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
});
} catch {
/* skip */
}
}
if (wraps.length) {
await putRoomKeys(roomId, { wrapped_keys: wraps });
}
}
return persistRoomRaw(myUserId, roomId, raw);
}
return null;
}
export async function ensureRoomAesKey(
myUserId: string,
roomId: string,
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
forceFresh = false,
): Promise<CryptoKey | null> {
if (!forceFresh) {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
const existing = inFlight.get(roomId);
if (existing) return existing;
}
const task = doEnsureRoomAesKey(myUserId, roomId, forceFresh).finally(() => {
inFlight.delete(roomId);
});
if (!forceFresh) inFlight.set(roomId, task);
return task;
}
export async function encryptRoomContent(
myUserId: string,
roomId: string,
plaintext: string
plaintext: string,
): Promise<string | null> {
return plaintext;
if (!plaintext) return plaintext;
if (isE2eeEnvelope(plaintext)) return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
}
export async function decryptRoomContent(
myUserId: string,
roomId: string,
content: string
content: string,
): Promise<string> {
// فقط پیامِ قدیمیِ رمزنگاری‌شده (از قبلِ غیرفعال‌سازیِ E2EE) به این‌جا می‌رسه
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
return content;
if (!isE2eeEnvelope(content)) return content;
const key = await ensureRoomAesKey(myUserId, roomId);
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<
T extends {
content?: string;
replyTo?: { content?: string; [k: string]: unknown } | null;
}
>(
myUserId: string,
roomId: string,
messages: T[]
): Promise<T[]> {
},
>(myUserId: string, roomId: string, messages: T[]): Promise<T[]> {
await ensureRoomAesKey(myUserId, roomId);
const out: T[] = [];
for (const msg of messages) {
let next = msg;
if (msg.content && isE2eeEnvelope(msg.content)) {
next = { ...next, content: E2EE_LOCKED_PREVIEW };
const decrypted = await decryptRoomContent(myUserId, roomId, msg.content);
next = { ...next, content: decrypted };
}
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
const replyContent = await decryptRoomContent(
myUserId,
roomId,
next.replyTo.content,
);
next = {
...next,
replyTo: { ...next.replyTo, content: replyContent },
};
}
out.push(next);
}

View File

@@ -1,5 +1,4 @@
import {
DEFAULT_LANGUAGE,
ENABLED_LANGUAGES,
getLanguageDefinition,
isAppLanguage,

View File

@@ -0,0 +1,226 @@
// مقادیر و برچسب‌های فیلدهای جدید «مشخصات فردی» (هدف از رابطه، ویژگی‌های
// شخصیتی، سبک زندگی، علاقه‌مندی‌ها و ...) که هم در صفحه‌ی ویرایش مشخصات فردی
// و هم در فیلترهای «یافتن همکار» استفاده می‌شوند — یک‌جا نگه‌داشته می‌شوند تا
// مقادیر/ترتیب در هر دو صفحه همیشه یکسان بماند.
export const RELATIONSHIP_GOAL_VALUES = [
"friendship",
"serious_partner",
"marriage_intro",
"emotional_relationship",
"travel_event_companion",
"social_networking",
"not_sure",
] as const;
export type RelationshipGoal = (typeof RELATIONSHIP_GOAL_VALUES)[number];
export const RELATIONSHIP_GOAL_MAX = 2;
export const MARITAL_STATUS_NEW_VALUES = [
"widowed",
"not_in_relationship",
"prefer_not_to_say",
] as const;
export const SEEKING_GENDER_VALUES = ["female", "male", "both", "not_important"] as const;
export type SeekingGender = (typeof SEEKING_GENDER_VALUES)[number];
export const DESIRED_AGE_RANGE_VALUES = [
"18_24",
"25_30",
"31_35",
"36_40",
"41_50",
"over_50",
"not_important",
] as const;
export type DesiredAgeRange = (typeof DESIRED_AGE_RANGE_VALUES)[number];
/** تبدیل بازه‌ی سنی انتخابی (bucket) به ageMin/ageMax عددی که سرچ سرور انتظار دارد. */
export const DESIRED_AGE_RANGE_TO_MIN_MAX: Record<
DesiredAgeRange,
{ min?: number; max?: number }
> = {
"18_24": { min: 18, max: 24 },
"25_30": { min: 25, max: 30 },
"31_35": { min: 31, max: 35 },
"36_40": { min: 36, max: 40 },
"41_50": { min: 41, max: 50 },
over_50: { min: 51 },
not_important: {},
};
export const PERSONALITY_TRAIT_VALUES = [
"kind",
"loyal",
"funny",
"calm",
"energetic",
"responsible",
"ambitious",
"emotional",
"logical",
"patient",
"honest",
"romantic",
"independent",
"family_oriented",
"adventurous",
"introvert",
"extrovert",
] as const;
export type PersonalityTrait = (typeof PERSONALITY_TRAIT_VALUES)[number];
export const PERSONALITY_TRAITS_MAX = 8;
export const LIFESTYLE_VALUES = [
"active_healthy",
"homebody_calm",
"social_outgoing",
"busy_career",
"traveler",
"night_owl",
"early_riser",
"minimalist",
"luxury_lover",
] as const;
export type Lifestyle = (typeof LIFESTYLE_VALUES)[number];
export const LIFESTYLE_MAX = 5;
export const INTEREST_VALUES = [
"movies_series",
"music",
"books_reading",
"sports",
"cooking",
"photography",
"travel",
"nature",
"gaming",
"art_painting",
"fashion",
"pets",
"dancing",
"language_learning",
] as const;
export type Interest = (typeof INTEREST_VALUES)[number];
export const INTERESTS_MAX = 8;
export const VALUE_VALUES = [
"family",
"career_growth",
"religion_spirituality",
"personal_freedom",
"financial_stability",
"health_fitness",
"personal_growth",
"excitement_new_experiences",
] as const;
export type ValueItem = (typeof VALUE_VALUES)[number];
export const VALUES_MAX = 5;
export const RELATIONSHIP_VIEW_VALUES = [
"traditional",
"modern",
"long_term",
"friendship_first",
"marriage_soon",
"friendship_only_for_now",
] as const;
export type RelationshipView = (typeof RELATIONSHIP_VIEW_VALUES)[number];
export const GEO_PREFERENCE_VALUES = [
"same_city_only",
"nearby_cities",
"whole_province",
"whole_iran",
"even_abroad",
"online_is_fine",
] as const;
export type GeoPreference = (typeof GEO_PREFERENCE_VALUES)[number];
export const CHILDREN_STATUS_VALUES = [
"no_children",
"has_children",
"open_to_children",
"not_now",
"prefer_not_to_say",
] as const;
export type ChildrenStatus = (typeof CHILDREN_STATUS_VALUES)[number];
export const SMOKING_STATUS_VALUES = [
"none",
"sometimes",
"yes",
"doesnt_matter",
"prefer_not_to_say",
] as const;
export type SmokingStatus = (typeof SMOKING_STATUS_VALUES)[number];
export const INCOME_RANGE_VALUES = [
"under_40m",
"40m_50m",
"50m_60m",
"60m_70m",
"70m_80m",
"80m_90m",
"90m_100m",
"100m_150m",
"150m_200m",
"over_200m",
] as const;
export type IncomeRange = (typeof INCOME_RANGE_VALUES)[number];
export type LabeledOption = { value: string; label: string };
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
function buildOptions(t: TranslateFn, i18nKey: string, values: readonly string[]): LabeledOption[] {
return values.map((value) => ({
value,
label: t(`settings.edit.personalDetails.${i18nKey}.${value}`),
}));
}
export const buildRelationshipGoalOptions = (t: TranslateFn) =>
buildOptions(t, "relationshipGoalOptions", RELATIONSHIP_GOAL_VALUES);
export const buildMaritalStatusNewOptions = (t: TranslateFn) =>
buildOptions(t, "maritalStatusOptions", MARITAL_STATUS_NEW_VALUES);
export const buildSeekingGenderOptions = (t: TranslateFn) =>
buildOptions(t, "seekingGenderOptions", SEEKING_GENDER_VALUES);
export const buildDesiredAgeRangeOptions = (t: TranslateFn) =>
buildOptions(t, "desiredAgeRangeOptions", DESIRED_AGE_RANGE_VALUES);
export const buildPersonalityTraitsOptions = (t: TranslateFn) =>
buildOptions(t, "personalityTraitsOptions", PERSONALITY_TRAIT_VALUES);
export const buildLifestyleOptions = (t: TranslateFn) =>
buildOptions(t, "lifestyleOptions", LIFESTYLE_VALUES);
export const buildInterestsOptions = (t: TranslateFn) =>
buildOptions(t, "interestsOptions", INTEREST_VALUES);
export const buildValuesOptions = (t: TranslateFn) =>
buildOptions(t, "valuesOptions", VALUE_VALUES);
export const buildRelationshipViewOptions = (t: TranslateFn) =>
buildOptions(t, "relationshipViewOptions", RELATIONSHIP_VIEW_VALUES);
export const buildGeoPreferenceOptions = (t: TranslateFn) =>
buildOptions(t, "geoPreferenceOptions", GEO_PREFERENCE_VALUES);
export const buildChildrenStatusOptions = (t: TranslateFn) =>
buildOptions(t, "childrenStatusOptions", CHILDREN_STATUS_VALUES);
export const buildSmokingStatusOptions = (t: TranslateFn) =>
buildOptions(t, "smokingStatusOptions", SMOKING_STATUS_VALUES);
export const buildIncomeRangeOptions = (t: TranslateFn) =>
buildOptions(t, "incomeRangeOptions", INCOME_RANGE_VALUES);
/** برای درج n آیتم آرایه‌ای در URLSearchParams طبق قرارداد بک‌اند (مقدار جدا شده با کاما). */
export function joinFilterValues(values: string[] | undefined | null): string | undefined {
if (!values || values.length === 0) return undefined;
return values.join(",");
}

25
src/lib/storyFonts.ts Normal file
View File

@@ -0,0 +1,25 @@
import localFont from "next/font/local";
import type { StoryFontFamily } from "@/api/fetchStories";
// همون خانواده‌فونتِ IRANSansX که کل اپ استفاده می‌کنه (layout.tsx فقط وزن Regular رو لود
// می‌کنه)، اینجا با همه‌ی وزن‌های لازم برای «تغییر فونت» استوری بار می‌شه — یه تعریفِ
// next/font/local با چندین فایل وزن، نه چند فونت جدا
export const storyFont = localFont({
src: [
{ path: "../../public/fonts/IRANSansX-Light.woff", weight: "300", style: "normal" },
{ path: "../../public/fonts/IRANSansX-Regular.woff", weight: "400", style: "normal" },
{ path: "../../public/fonts/IRANSansX-Medium.woff", weight: "500", style: "normal" },
{ path: "../../public/fonts/IRANSansX-Bold.woff", weight: "700", style: "normal" },
{ path: "../../public/fonts/IRANSansX-Black.woff", weight: "900", style: "normal" },
],
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export const STORY_FONT_WEIGHTS: Record<StoryFontFamily, number> = {
light: 300,
regular: 400,
medium: 500,
bold: 700,
black: 900,
};