This commit is contained in:
payacom
2026-09-01 13:51:50 +03:30
parent 1ba6646bf9
commit 76ef4e32a9
153 changed files with 10612 additions and 8768 deletions

View File

@@ -1,4 +1,5 @@
import Cookies from "js-cookie";
import { clearDmKeyCache } from "@/lib/e2ee/dm";
import {
readLocalStorage,
writeLocalStorage,
@@ -65,6 +66,12 @@ export async function setAuthSession(
): Promise<void> {
if (!token) return;
// کلید AES مشترکِ هر مکالمه بر اساس جفت‌آیدی کش می‌شه — بین سوییچ‌کردنِ اکانت‌ها
// روی یه مرورگر، اگه یه نسخه‌ی «بازنده»‌ی race (نگاه کن به keys.ts) قبلاً این کش رو
// پر کرده باشه، پاکش می‌کنیم تا هر پیام تازه با کلید هویتِ واقعاً فعلی رمزنگاری/
// رمزگشایی بشه
clearDmKeyCache();
Cookies.set(TOKEN_KEY, token, cookieOptions());
let synced = await syncServerCookie(token);
@@ -89,6 +96,7 @@ export async function setAuthSession(
/** پاک کردن کامل session */
export async function clearAuthSession(): Promise<void> {
clearDmKeyCache();
Cookies.remove(TOKEN_KEY, { path: "/" });
if (typeof window !== "undefined") {

View File

@@ -18,6 +18,18 @@ type StoredIdentity = {
let memoryPair: { userId: string; privateKey: CryptoKey; publicKeyB64: string } | null =
null;
// وقتی کلید هویتِ یه کاربر هنوز نه توی حافظه نه توی IndexedDB موجود نیست (اولین‌بار
// روی این مرورگر)، چند نقطه‌ی مختلف اپ (مثلاً ChatSocketProvider + بارگذاری لیست
// پیام‌های یه ترد) تقریباً هم‌زمان ensureIdentityKeys رو صدا می‌زنن. بدون این قفل، هر
// کدوم جداگانه یه جفت کلید تازه می‌ساختن و جدا-جدا با سرور ثبت می‌کردن (race) —
// برنده‌ی آخر که به سرور می‌رسید، کلید عمومیِ ثبت‌شده رو عوض می‌کرد و پیام‌هایی که با
// کلید «بازنده» رمزنگاری/رمزگشایی شده بودن دیگه با هم جور درنمی‌اومدن (حتی برای خودِ
// فرستنده). این Map تضمین می‌کنه برای هر کاربر فقط یه عملیاتِ ensure هم‌زمان اجرا بشه
const inFlight = new Map<
string,
Promise<{ privateKey: CryptoKey; publicKeyB64: string }>
>();
function authHeaders(): HeadersInit {
const token = Cookies.get("token");
return {
@@ -35,18 +47,10 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
});
}
export async function ensureIdentityKeys(userId: string): Promise<{
async function loadOrCreateIdentityKeys(uid: string): Promise<{
privateKey: CryptoKey;
publicKeyB64: string;
}> {
const uid = String(userId);
if (memoryPair?.userId === uid) {
return {
privateKey: memoryPair.privateKey,
publicKeyB64: memoryPair.publicKeyB64,
};
}
const storeKey = `identity:${uid}`;
const stored = await idbGet<StoredIdentity>(storeKey);
@@ -83,6 +87,28 @@ export async function ensureIdentityKeys(userId: string): Promise<{
return { privateKey: pair.privateKey, publicKeyB64 };
}
export async function ensureIdentityKeys(userId: string): Promise<{
privateKey: CryptoKey;
publicKeyB64: string;
}> {
const uid = String(userId);
if (memoryPair?.userId === uid) {
return {
privateKey: memoryPair.privateKey,
publicKeyB64: memoryPair.publicKeyB64,
};
}
const existing = inFlight.get(uid);
if (existing) return existing;
const task = loadOrCreateIdentityKeys(uid).finally(() => {
inFlight.delete(uid);
});
inFlight.set(uid, task);
return task;
}
export async function fetchPeerPublicKey(
peerUserId: string
): Promise<string | null> {

View File

@@ -33,6 +33,13 @@ export function buildExplorePostFilters(
return { exploreFilter: "predict_trends" };
case "best_month":
return { exploreFilter: "best_month" };
case "girls":
// عمداً exploreFilter ست نمی‌شه — همون مسیر جستجوی هشتگ (hashtag) موجوده
// استفاده می‌شه تا از فید شخصی‌سازی‌شده‌ی معمولی رد بشه، نه مسیر جدای
// فیلتر بر مبنای کاربر (مثل model/photographer/...) — عین RN
return { hashtag: "دخترانه" };
case "boys":
return { hashtag: "پسرانه" };
case "near_me":
if (coords) {
return {

47
src/lib/firebase.ts Normal file
View File

@@ -0,0 +1,47 @@
import { initializeApp, getApps, type FirebaseApp } from "firebase/app";
import { getMessaging, isSupported, type Messaging } from "firebase/messaging";
// این مقادیر عمومی و امن برای مرورگرند (بر خلاف کلید سرویس‌اکانت بک‌اند که فقط
// سمت سرور استفاده می‌شه) — از NEXT_PUBLIC_FIREBASE_* در .env.local میان
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
let app: FirebaseApp | null = null;
function getFirebaseApp(): FirebaseApp | null {
if (typeof window === "undefined") return null;
if (!firebaseConfig.apiKey) return null;
if (!app) {
app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig);
}
return app;
}
let messagingPromise: Promise<Messaging | null> | null = null;
/** Messaging فقط توی مرورگرهایی که Service Worker/Push API دارن پشتیبانی می‌شه
* (سافاری قدیمی، حالت خصوصیِ بعضی مرورگرها و... نه) — isSupported این رو چک می‌کنه */
export function getFirebaseMessaging(): Promise<Messaging | null> {
if (!messagingPromise) {
messagingPromise = (async () => {
const fbApp = getFirebaseApp();
if (!fbApp) return null;
try {
const supported = await isSupported();
if (!supported) return null;
return getMessaging(fbApp);
} catch {
return null;
}
})();
}
return messagingPromise;
}
export { firebaseConfig };

View File

@@ -0,0 +1,60 @@
import { getToken, onMessage, type MessagePayload } from "firebase/messaging";
import { apiClient } from "@/hooks/useAxios";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { getFirebaseMessaging } from "./firebase";
/** درخواست مجوز نوتیفیکیشن مرورگر و ثبت توکن FCM وب برای کاربر لاگین‌شده —
* عین registerPushToken (pushToken.ts) اندروید، با platform:"web" که بک‌اند
* (notificationController.js) جدا توی fcm_web_tokens ذخیره‌ش می‌کنه */
export async function registerPushToken(): Promise<void> {
try {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (!("serviceWorker" in navigator)) return;
const messaging = await getFirebaseMessaging();
if (!messaging) return;
let permission = Notification.permission;
if (permission === "default") {
permission = await Notification.requestPermission();
}
if (permission !== "granted") return;
// sw همون service-worker.js موجوده (RegisterSW.tsx) — پیام‌های پوشِ پس‌زمینه
// مستقیم داخلش هندل می‌شن (importScripts فایربیس)، پس سرویس‌ورکرِ جدا لازم نیست
const registration = await navigator.serviceWorker.ready;
const vapidKey = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY;
if (!vapidKey) return;
const fcmToken = await getToken(messaging, {
vapidKey,
serviceWorkerRegistration: registration,
});
if (!fcmToken) return;
await apiClient.post(
"/notifications/register-token",
{ fcmToken, platform: "web" },
{ baseURL: getApiBaseUrl() },
);
} catch {
// نبود مجوز/توکن نباید هیچ جریان دیگه‌ای رو بشکنه
}
}
/** پیامِ دریافت‌شده وقتی تب باز و فعاله — عین onMessage در PushNotificationListener.tsx
* اندروید؛ خودِ مرورگر برای این حالت هیچ نوتیفِ سیستمی نشون نمی‌ده */
export function subscribeForegroundPush(
onPush: (payload: MessagePayload) => void,
): () => void {
let unsubscribe: (() => void) | null = null;
let cancelled = false;
void getFirebaseMessaging().then((messaging) => {
if (!messaging || cancelled) return;
unsubscribe = onMessage(messaging, onPush);
});
return () => {
cancelled = true;
unsubscribe?.();
};
}

View File

@@ -22,15 +22,19 @@ export function isIosDevice(): boolean {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
export function isDismissed(): boolean {
const until = Number(readLocalStorage(DISMISS_KEY) || 0);
return Date.now() < until;
export function isAndroidDevice(): boolean {
if (typeof window === "undefined") return false;
return /android/i.test(window.navigator.userAgent);
}
/** عدم نمایش تا ۷ روز */
export function dismissInstallPrompt(days = 7): void {
const until = Date.now() + days * 24 * 60 * 60 * 1000;
writeLocalStorage(DISMISS_KEY, String(until));
// قبلاً ۷ روز بود؛ طبق خواسته‌ی صریح کاربر الان دائمیه — با زدنِ «بعداً» دیگه هیچ‌وقت
// دوباره نمایش داده نمی‌شه (نه فقط یه هفته)
export function isDismissed(): boolean {
return readLocalStorage(DISMISS_KEY) === "1";
}
export function dismissInstallPrompt(): void {
writeLocalStorage(DISMISS_KEY, "1");
}
export function clearInstallDismissal(): void {

View File

@@ -7,7 +7,7 @@ export type PwaLang = AppLanguage;
export const PWA_THEME_COLOR = "#FF107D";
export const PWA_BACKGROUND_COLOR = "#FF107D";
export const PWA_ID = "modstagram-pwa";
export const PWA_VERSION = "11";
export const PWA_VERSION = "12";
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";
@@ -20,6 +20,8 @@ const COPY: Partial<Record<AppLanguage, {
installTitle: string;
installDescription: string;
installButton: string;
installWebButton: string;
downloadAppButton: string;
installing: string;
later: string;
gotIt: string;
@@ -37,6 +39,8 @@ const COPY: Partial<Record<AppLanguage, {
installDescription:
"برای دسترسی سریع‌تر و تجربه شبیه اپ موبایل، مدستاگرام را روی گوشی یا سیستم خود نصب کنید.",
installButton: "نصب اپلیکیشن",
installWebButton: "نصب نسخه وب",
downloadAppButton: "دانلود اپلیکیشن",
installing: "در حال نصب...",
later: "بعداً",
gotIt: "متوجه شدم",
@@ -58,6 +62,8 @@ const COPY: Partial<Record<AppLanguage, {
installDescription:
"Install Modstagram on your device for faster access and an app-like experience.",
installButton: "Install app",
installWebButton: "Install web app",
downloadAppButton: "Download app",
installing: "Installing...",
later: "Later",
gotIt: "Got it",

View File

@@ -8,15 +8,19 @@ export const btnBase =
export const btnDefault = cn(
btnBase,
"rounded-3xl bg-white/90 px-4 text-foreground",
"rounded-2xl bg-white/90 px-4 text-foreground",
BTN_BORDER,
"hover:bg-white dark:bg-neutral-900/90 dark:hover:bg-neutral-900"
);
// عین Button.tsx اندروید: variant="primary" یه تینتِ کم‌رنگِ رنگِ اصلی (نه پرشدنِ
// توپر) با متن/بوردرِ رنگِ اصلیه، نه پس‌زمینه‌ی توپرِ صورتی + متنِ سفید — عمداً از
// کلاسِ مشترکِ btn-modern--primary استفاده نمی‌کنه چون اون کلاس رو چیپ‌های فیلتر/
// toggle هم استفاده می‌کنن که قرار نیست عوض بشن
export const btnPrimary = cn(
btnBase,
"btn-modern--primary rounded-3xl px-4 text-white",
BTN_BORDER
"rounded-2xl bg-[rgba(252,142,172,0.1)] px-4 text-[#FC8EAC] border-[#FC8EAC]",
"hover:bg-[rgba(252,142,172,0.16)]"
);
export const btnSecondary = cn(btnDefault, "font-medium text-foreground/90");