From 91d567f9cc48dea7d605b55c6014174806b8d9ab Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 15:44:16 +0300 Subject: [PATCH 01/15] fix: add resend email cooldown and allow email change for all auth types Add 60-second cooldown timer on resend verification email button to prevent spam. Remove auth_type restriction on change email button so OAuth users can also change their email. --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/Profile.tsx | 56 +++++++++++++++++++++++++------------------ 5 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index ea37eb5..4360b98 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2902,6 +2902,7 @@ "notVerified": "Not verified", "verificationRequired": "Please verify your email to use email login.", "resendVerification": "Resend Verification Email", + "resendIn": "Resend in {{seconds}} sec.", "verificationResent": "Verification email resent!", "canLoginWithEmail": "You can now log in using your email and password.", "emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.", diff --git a/src/locales/fa.json b/src/locales/fa.json index 20cc322..68f682c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2402,6 +2402,7 @@ "notVerified": "تایید نشده", "verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.", "resendVerification": "ارسال مجدد ایمیل تایید", + "resendIn": "ارسال مجدد در {{seconds}} ثانیه", "verificationResent": "ایمیل تایید مجدداً ارسال شد!", "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", "emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.", diff --git a/src/locales/ru.json b/src/locales/ru.json index 29147d4..e14a4e9 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3454,6 +3454,7 @@ "notVerified": "Не подтверждён", "verificationRequired": "Подтвердите email для использования входа по почте.", "resendVerification": "Отправить письмо повторно", + "resendIn": "Повторить через {{seconds}} сек.", "verificationResent": "Письмо отправлено повторно!", "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", "emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 18dceb5..1f856e7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2401,6 +2401,7 @@ "notVerified": "未验证", "verificationRequired": "请验证邮箱以使用邮箱登录。", "resendVerification": "重新发送验证邮件", + "resendIn": "{{seconds}} 秒后重新发送", "verificationResent": "验证邮件已重新发送!", "canLoginWithEmail": "现在您可以使用邮箱和密码登录。", "emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。", diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index b811f23..1437b26 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -77,6 +77,7 @@ export default function Profile() { const [changeCode, setChangeCode] = useState(''); const [changeError, setChangeError] = useState(null); const [resendCooldown, setResendCooldown] = useState(0); + const [verificationResendCooldown, setVerificationResendCooldown] = useState(0); const newEmailInputRef = useRef(null); const codeInputRef = useRef(null); @@ -171,6 +172,7 @@ export default function Profile() { onSuccess: () => { setSuccess(t('profile.verificationResent')); setError(null); + setVerificationResendCooldown(UI.RESEND_COOLDOWN_SEC); }, onError: (err: { response?: { data?: { detail?: string } } }) => { setError(err.response?.data?.detail || t('common.error')); @@ -228,7 +230,7 @@ export default function Profile() { }, }); - // Resend cooldown timer + // Resend cooldown timers useEffect(() => { if (resendCooldown <= 0) return; const timer = setInterval(() => { @@ -237,6 +239,14 @@ export default function Profile() { return () => clearInterval(timer); }, [resendCooldown]); + useEffect(() => { + if (verificationResendCooldown <= 0) return; + const timer = setInterval(() => { + setVerificationResendCooldown((prev) => Math.max(0, prev - 1)); + }, 1000); + return () => clearInterval(timer); + }, [verificationResendCooldown]); + // Auto-focus inputs on step change useEffect(() => { const timer = setTimeout(() => { @@ -454,34 +464,34 @@ export default function Profile() { - {(user.auth_type === 'telegram' || user.auth_type === 'email') && ( - - )} + )} - {user.email_verified && - (user.auth_type === 'telegram' || user.auth_type === 'email') && ( -
-

{t('profile.canLoginWithEmail')}

- -
- )} + {user.email_verified && ( +
+

{t('profile.canLoginWithEmail')}

+ +
+ )} {/* Inline email change flow */} From 321bedcb61a231d3dd8ecba8623d1ee9d632b9b7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 15:44:23 +0300 Subject: [PATCH 02/15] fix: stack promo offer discounts with promo group discounts Redesign applyPromoDiscount to apply promo offer on top of promo group prices instead of treating them as mutually exclusive. Calculate combined discount percent from the deepest original price. Show promo banner in confirm step regardless of existing server discounts. --- src/pages/Subscription.tsx | 401 +++++++++++++++++-------------------- 1 file changed, 181 insertions(+), 220 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index eaf5a1b..3f5f5cd 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -89,24 +89,47 @@ export default function Subscription() { // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; - // Helper to apply promo discount to a price + // Helper to apply promo discount to a price, stacking with existing promo group discount const applyPromoDiscount = ( priceKopeks: number, - hasExistingDiscount: boolean = false, + existingOriginalPrice?: number | null, ): { price: number; original: number | null; percent: number | null; + isPromoGroup: boolean; } => { - // Only apply promo discount if no existing discount (from promo group) and we have an active promo discount - if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) { - return { price: priceKopeks, original: null, percent: null }; + const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; + const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; + + if (!hasExisting && !hasPromo) { + return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; } - const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100)); + + let finalPrice = priceKopeks; + if (hasPromo) { + finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); + } + + if (hasExisting) { + // Promo group discount exists — calculate combined percent from deepest original + const combinedPercent = hasPromo + ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) + : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); + return { + price: finalPrice, + original: existingOriginalPrice!, + percent: combinedPercent, + isPromoGroup: true, + }; + } + + // Only promo offer discount (no promo group) return { - price: discountedPrice, + price: finalPrice, original: priceKopeks, - percent: activeDiscount.discount_percent, + percent: activeDiscount!.discount_percent!, + isPromoGroup: false, }; }; @@ -2416,41 +2439,34 @@ export default function Subscription() { const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0; const originalDailyPrice = tariff.original_daily_price_kopeks || 0; - const hasExistingDailyDiscount = originalDailyPrice > dailyPrice; if (dailyPrice > 0) { - // Apply promo discount if no existing discount const promoDaily = applyPromoDiscount( dailyPrice, - hasExistingDailyDiscount, + originalDailyPrice > dailyPrice ? originalDailyPrice : undefined, ); return ( {formatPrice(promoDaily.price)} - {/* Show original price from promo group or promo offer */} - {(hasExistingDailyDiscount || promoDaily.original) && ( - - {formatPrice( - hasExistingDailyDiscount - ? originalDailyPrice - : promoDaily.original!, - )} - - )} + {promoDaily.original && + promoDaily.original > promoDaily.price && ( + + {formatPrice(promoDaily.original)} + + )} {t('subscription.tariff.perDay')} {/* Show discount badge */} - {tariff.daily_discount_percent && - tariff.daily_discount_percent > 0 ? ( - - -{tariff.daily_discount_percent}% + {promoDaily.percent && promoDaily.percent > 0 && ( + + -{promoDaily.percent}% - ) : ( - promoDaily.percent && ( - - -{promoDaily.percent}% - - ) )} ); @@ -2458,14 +2474,9 @@ export default function Subscription() { // Period-based price if (tariff.periods.length > 0) { const firstPeriod = tariff.periods[0]; - const hasExistingDiscount = !!( - firstPeriod?.original_price_kopeks && - firstPeriod.original_price_kopeks > firstPeriod.price_kopeks - ); - // Apply promo discount if no existing discount const promoPeriod = applyPromoDiscount( firstPeriod?.price_kopeks || 0, - hasExistingDiscount, + firstPeriod?.original_price_kopeks, ); return ( @@ -2473,27 +2484,22 @@ export default function Subscription() { {formatPrice(promoPeriod.price)} - {/* Show original price from promo group or promo offer */} - {(hasExistingDiscount || promoPeriod.original) && ( - - {formatPrice( - hasExistingDiscount - ? firstPeriod.original_price_kopeks! - : promoPeriod.original!, - )} - - )} - {/* Show discount badge */} - {hasExistingDiscount && firstPeriod.discount_percent ? ( - - -{firstPeriod.discount_percent}% - - ) : ( - promoPeriod.percent && ( - - -{promoPeriod.percent}% + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} - ) + )} + {promoPeriod.percent && promoPeriod.percent > 0 && ( + + -{promoPeriod.percent}% + )} ); @@ -2702,24 +2708,17 @@ export default function Subscription() { {selectedTariff.periods.length > 0 && !useCustomDays && (
{selectedTariff.periods.map((period) => { - const hasExistingDiscount = !!( - period.original_price_kopeks && - period.original_price_kopeks > period.price_kopeks - ); const promoPeriod = applyPromoDiscount( period.price_kopeks, - hasExistingDiscount, + period.original_price_kopeks, ); - const displayDiscount = hasExistingDiscount - ? period.discount_percent - : promoPeriod.percent; - const displayOriginal = hasExistingDiscount - ? period.original_price_kopeks - : promoPeriod.original; + const displayDiscount = promoPeriod.percent; + const displayOriginal = promoPeriod.original; const displayPrice = promoPeriod.price; - const displayPerMonth = hasExistingDiscount - ? period.price_per_month_kopeks - : Math.round(promoPeriod.price / (period.days / 30)); + const displayPerMonth = + displayPrice !== period.price_kopeks + ? Math.round(displayPrice / Math.max(1, period.days / 30)) + : period.price_per_month_kopeks; return (
) : ( @@ -3035,16 +3049,12 @@ export default function Subscription() {
{formatPrice(promoPeriod.price)} - {(hasExistingPeriodDiscount || - promoPeriod.original) && ( - - {formatPrice( - hasExistingPeriodDiscount - ? selectedTariffPeriod.original_price_kopeks! - : promoPeriod.original!, - )} - - )} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )}
)} @@ -3177,19 +3187,10 @@ export default function Subscription() { {currentStep === 'period' && classicOptions && (
{classicOptions.periods.map((period) => { - const hasExistingDiscount = !!( - period.discount_percent && period.discount_percent > 0 - ); const promoPeriod = applyPromoDiscount( period.price_kopeks, - hasExistingDiscount, + period.original_price_kopeks, ); - const displayDiscount = hasExistingDiscount - ? period.discount_percent - : promoPeriod.percent; - const displayOriginal = hasExistingDiscount - ? period.original_price_kopeks - : promoPeriod.original; return (
@@ -3249,12 +3250,9 @@ export default function Subscription() { {currentStep === 'traffic' && selectedPeriod?.traffic.options && (
{selectedPeriod.traffic.options.map((option) => { - const hasExistingDiscount = !!( - option.discount_percent && option.discount_percent > 0 - ); const promoTraffic = applyPromoDiscount( option.price_kopeks, - hasExistingDiscount, + option.original_price_kopeks, ); return ( @@ -3268,41 +3266,24 @@ export default function Subscription() { : '' } ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`} > - {(() => { - const trafficDisplayDiscount = hasExistingDiscount - ? option.discount_percent - : promoTraffic.percent; - const trafficDisplayOriginal = hasExistingDiscount - ? option.original_price_kopeks - : promoTraffic.original; - return ( - <> - {trafficDisplayDiscount && trafficDisplayDiscount > 0 && ( -
- -{trafficDisplayDiscount}% -
- )} -
- {option.label} -
-
- - {formatPrice(promoTraffic.price)} - - {trafficDisplayOriginal && - trafficDisplayOriginal > promoTraffic.price && ( - - {formatPrice(trafficDisplayOriginal)} - - )} -
- - ); - })()} + {promoTraffic.percent && promoTraffic.percent > 0 && ( +
+ -{promoTraffic.percent}% +
+ )} +
{option.label}
+
+ {formatPrice(promoTraffic.price)} + {promoTraffic.original && promoTraffic.original > promoTraffic.price && ( + + {formatPrice(promoTraffic.original)} + + )} +
); })} @@ -3322,12 +3303,9 @@ export default function Subscription() { return true; }) .map((server) => { - const hasExistingDiscount = !!( - server.discount_percent && server.discount_percent > 0 - ); const promoServer = applyPromoDiscount( server.price_kopeks, - hasExistingDiscount, + server.original_price_kopeks, ); return ( @@ -3343,20 +3321,15 @@ export default function Subscription() { : 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50' }`} > - {(() => { - const serverDisplayDiscount = hasExistingDiscount - ? server.discount_percent - : promoServer.percent; - return serverDisplayDiscount && serverDisplayDiscount > 0 ? ( -
- -{serverDisplayDiscount}% -
- ) : null; - })()} + {promoServer.percent && promoServer.percent > 0 ? ( +
+ -{promoServer.percent}% +
+ ) : null}
- {(() => { - const serverOriginal = hasExistingDiscount - ? server.original_price_kopeks - : promoServer.original; - return serverOriginal && serverOriginal > promoServer.price ? ( - - {formatPrice(serverOriginal)} - - ) : null; - })()} + {promoServer.original && + promoServer.original > promoServer.price ? ( + + {formatPrice(promoServer.original)} + + ) : null}
@@ -3448,28 +3417,26 @@ export default function Subscription() { ) : preview ? (
{/* Active promo discount banner */} - {activeDiscount?.is_active && - activeDiscount.discount_percent && - !preview.original_price_kopeks && ( -
- - - - - {t('promo.discountApplied')} -{activeDiscount.discount_percent}% - -
- )} + {activeDiscount?.is_active && activeDiscount.discount_percent && ( +
+ + + + + {t('promo.discountApplied')} -{activeDiscount.discount_percent}% + +
+ )} {preview.breakdown.map((item, idx) => (
@@ -3479,16 +3446,10 @@ export default function Subscription() { ))} {(() => { - // Apply promo discount if not already applied by server - const hasServerDiscount = !!preview.original_price_kopeks; const promoTotal = applyPromoDiscount( preview.total_price_kopeks, - hasServerDiscount, + preview.original_price_kopeks, ); - const displayTotal = promoTotal.price; - const displayOriginal = hasServerDiscount - ? preview.original_price_kopeks - : promoTotal.original; return (
@@ -3497,11 +3458,11 @@ export default function Subscription() {
- {formatPrice(displayTotal)} + {formatPrice(promoTotal.price)}
- {displayOriginal && ( + {promoTotal.original && promoTotal.original > promoTotal.price && (
- {formatPrice(displayOriginal)} + {formatPrice(promoTotal.original)}
)}
From 65add9a111086f970c77d686447016551ca9ab0f Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 15:50:37 +0300 Subject: [PATCH 03/15] fix: preserve + chars in deep link URL params for crypto links URLSearchParams decodes + as space, breaking base64-encoded crypto deep links (ss://, vless://, etc). Use raw param extraction with manual decoding to preserve + characters. --- src/pages/DeepLinkRedirect.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index f594932..4c83b8c 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -61,9 +61,17 @@ export default function DeepLinkRedirect() { const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; + // Get raw URL parameter preserving '+' chars (URLSearchParams decodes '+' as space, breaking base64 in crypto links) + const getRawParam = (key: string) => { + const params = new URLSearchParams(window.location.search); + const raw = params.get(key); + if (!raw) return ''; + return decodeURIComponent(raw.replace(/ /g, '+')); + }; + // Get parameters - const deepLink = searchParams.get('url') || searchParams.get('deeplink') || ''; - const subscriptionUrl = searchParams.get('sub') || ''; + const deepLink = getRawParam('url') || getRawParam('deeplink') || ''; + const subscriptionUrl = getRawParam('sub') || ''; const appParam = searchParams.get('app') || ''; // Detect app from deep link From ed65c29bacbfc50cdfa11e58f0cb638c6c8c1841 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 15:59:43 +0300 Subject: [PATCH 04/15] fix: parse raw query string for deep link params to avoid double-decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix used URLSearchParams.get() which already fully decodes, then called decodeURIComponent() again causing double-decode of % sequences. Parse raw query string pairs directly instead — decodeURIComponent does not treat + as space, preserving base64. --- src/pages/DeepLinkRedirect.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index 4c83b8c..60c0828 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -61,12 +61,19 @@ export default function DeepLinkRedirect() { const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; - // Get raw URL parameter preserving '+' chars (URLSearchParams decodes '+' as space, breaking base64 in crypto links) - const getRawParam = (key: string) => { - const params = new URLSearchParams(window.location.search); - const raw = params.get(key); - if (!raw) return ''; - return decodeURIComponent(raw.replace(/ /g, '+')); + // Parse raw query string to preserve '+' chars in base64 crypto links. + // URLSearchParams decodes '+' as space, breaking ss://, vless:// etc. + // decodeURIComponent does NOT treat '+' as space, so parsing raw pairs is correct. + const getRawParam = (key: string): string => { + const search = window.location.search.substring(1); + for (const pair of search.split('&')) { + const idx = pair.indexOf('='); + if (idx === -1) continue; + if (decodeURIComponent(pair.substring(0, idx)) === key) { + return decodeURIComponent(pair.substring(idx + 1)); + } + } + return ''; }; // Get parameters From 0b4e8253aa7a55b2cac7f6632912816b3234adc3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 16:23:33 +0300 Subject: [PATCH 05/15] fix: render newlines in tariff description Add whitespace-pre-line to tariff description div so newline characters entered via admin panel are rendered as line breaks. --- src/pages/Subscription.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 3f5f5cd..7756531 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -2361,7 +2361,9 @@ export default function Subscription() {
{tariff.name}
{tariff.description && ( -
{tariff.description}
+
+ {tariff.description} +
)}
{isCurrentTariff && ( From 9a84e13e6cd4dcc3a6d5e7f95fddb4c9c1ec076e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:14:47 +0300 Subject: [PATCH 06/15] perf: fix critical WebGL GPU resource leaks in Aurora - Fix isWebglAvailable() leaking a full WebGL context on every check by replacing OGL Renderer with raw canvas.getContext() + loseContext() - Add visibilitychange listener to fully stop rAF loop when tab hidden and restart on visibility restore (was running 24/7 in background) - Properly destroy WebGL context on unmount via WEBGL_lose_context extension and gl.deleteProgram() (was only nulling JS refs) - Reduce target FPS from 20 to 10 (slow gradient, visually identical) --- src/components/layout/AppShell/Aurora.tsx | 56 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 278f70f..534dc00 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -132,12 +132,13 @@ let _webglAvailable: boolean | null = null; function isWebglAvailable(): boolean { if (_webglAvailable === null) { try { - const renderer = new Renderer({ - alpha: true, - antialias: false, - powerPreference: 'low-power', - }); - _webglAvailable = !!renderer.gl; + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + _webglAvailable = !!gl; + if (gl) { + const loseCtx = gl.getExtension('WEBGL_lose_context'); + if (loseCtx) loseCtx.loseContext(); + } } catch { _webglAvailable = false; } @@ -258,15 +259,16 @@ function AuroraImpl() { resize(); let lastTime = 0; - const targetFPS = 20; + const targetFPS = 10; const frameInterval = 1000 / targetFPS; const speed = 0.3; function animate(currentTime: number) { - animationFrameRef.current = requestAnimationFrame(animate); - const delta = currentTime - lastTime; - if (delta < frameInterval) return; + if (delta < frameInterval) { + animationFrameRef.current = requestAnimationFrame(animate); + return; + } lastTime = currentTime - (delta % frameInterval); @@ -274,15 +276,45 @@ function AuroraImpl() { programRef.current.uniforms.uTime.value += speed * 0.01; rendererRef.current.render({ scene: mesh }); } + + animationFrameRef.current = requestAnimationFrame(animate); } + + function handleVisibilityChange() { + if (document.hidden) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = 0; + } else { + lastTime = 0; + animationFrameRef.current = requestAnimationFrame(animate); + } + } + document.addEventListener('visibilitychange', handleVisibilityChange); + animationFrameRef.current = requestAnimationFrame(animate); return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('resize', resize); cancelAnimationFrame(animationFrameRef.current); - if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) { - container.removeChild(rendererRef.current.gl.canvas); + + if (rendererRef.current) { + const glCtx = rendererRef.current.gl; + + // Delete GPU resources + if (programRef.current) { + glCtx.deleteProgram(programRef.current.program); + } + + // Force-release the WebGL context + const loseCtx = glCtx.getExtension('WEBGL_lose_context'); + if (loseCtx) loseCtx.loseContext(); + + if (container.contains(glCtx.canvas)) { + container.removeChild(glCtx.canvas); + } } + rendererRef.current = null; programRef.current = null; }; From 860493058a7d583edaea0e5261db1e485a016fc8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:14:57 +0300 Subject: [PATCH 07/15] perf: fix GPU-heavy CSS patterns - Remove mix-blend-mode: overlay from body::before noise texture (was forcing fullscreen GPU compositing on every paint) - Disable noise texture on mobile via display:none media query - Replace backdrop-filter animation in backdropFadeIn/Out keyframes with opacity-only animation (backdrop-filter transition is expensive) - Replace toast progress bar width animation with scaleX (GPU-composited) - Remove redundant will-change: transform from .glass and .wave-blob --- src/components/Toast.tsx | 3 ++- src/styles/globals.css | 26 ++++++++++++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 0b85413..48abd30 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -221,9 +221,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { {/* Progress bar */}
diff --git a/src/styles/globals.css b/src/styles/globals.css index 3778807..15cec3f 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -192,7 +192,7 @@ position: relative; } - /* Global Noise Texture */ + /* Global Noise Texture — no mix-blend-mode to avoid fullscreen GPU compositing */ body::before { content: ''; position: fixed; @@ -201,7 +201,13 @@ pointer-events: none; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); opacity: 0.03; - mix-blend-mode: overlay; + } + + /* Disable noise on mobile to save GPU */ + @media (max-width: 1023px) { + body::before { + display: none; + } } /* Optimize main scrollable content */ @@ -440,9 +446,7 @@ img.twemoji { /* Glass effect - Dark (optimized for mobile) */ .glass { @apply border border-dark-700/30 bg-dark-900/80; - /* GPU acceleration */ transform: translateZ(0); - will-change: transform; } /* Enable backdrop-blur only on desktop where it's performant */ @@ -1431,13 +1435,13 @@ input[type='checkbox']:hover:not(:checked) { animation: wheel-glow 2s ease-in-out infinite; } -/* Toast progress bar animation */ +/* Toast progress bar animation — use scaleX instead of width to avoid layout thrashing */ @keyframes shrink { from { - width: 100%; + transform: scaleX(1); } to { - width: 0%; + transform: scaleX(0); } } @@ -1454,10 +1458,8 @@ input[type='checkbox']:hover:not(:checked) { .wave-blob { position: absolute; border-radius: 50%; - filter: blur(60px); /* Reduced from 80px for better performance */ + filter: blur(60px); opacity: 0.5; - /* GPU acceleration */ - will-change: transform; transform: translateZ(0); backface-visibility: hidden; } @@ -1644,22 +1646,18 @@ input[type='checkbox']:hover:not(:checked) { @keyframes backdropFadeIn { from { opacity: 0; - backdrop-filter: blur(0); } to { opacity: 1; - backdrop-filter: blur(8px); } } @keyframes backdropFadeOut { from { opacity: 1; - backdrop-filter: blur(8px); } to { opacity: 0; - backdrop-filter: blur(0); } } From 17b2f2e90328b9388175d1047fa01bf6257d584c Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:15:03 +0300 Subject: [PATCH 08/15] perf: extract Twemoji options to module-level const Prevent MutationObserver re-initialization on every render by moving the options object out of JSX (was creating a new object reference on each render of AppWithNavigator). --- src/AppWithNavigator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index 6f6dfeb..bd5b6f2 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -16,6 +16,8 @@ import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { isInTelegramWebApp } from './hooks/useTelegramSDK'; +const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; + /** * Manages Telegram BackButton visibility based on navigation location. * Shows back button on non-root routes, hides on root. @@ -77,7 +79,7 @@ export function AppWithNavigator() { - + From 7cf72735ece0510acc7a4e6af8997e8e7acdc9d8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Feb 2026 17:15:11 +0300 Subject: [PATCH 09/15] fix: plug memory leaks in blob URLs and traffic cache - Add URL.revokeObjectURL on unmount and media removal in AdminBroadcastCreate, AdminPinnedMessageCreate, Support - Track blob URLs via refs to revoke before creating new ones - Add clearCreateAttachment/clearReplyAttachment helpers in Support to properly revoke blob URLs when clearing attachments - Cap adminTraffic cache at 20 entries with FIFO eviction to prevent unbounded memory growth from different filter combinations --- src/api/adminTraffic.ts | 11 ++++++ src/pages/AdminBroadcastCreate.tsx | 19 +++++++++- src/pages/AdminPinnedMessageCreate.tsx | 17 ++++++++- src/pages/Support.tsx | 51 +++++++++++++++++++++----- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 437e126..130a95c 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -63,6 +63,7 @@ export type TrafficParams = { }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const MAX_CACHE_ENTRIES = 20; const trafficCache = new Map(); @@ -106,6 +107,16 @@ export const adminTrafficApi = { trafficCache.set(key, { data, timestamp: Date.now() }); + // Evict oldest entries to prevent unbounded memory growth + if (trafficCache.size > MAX_CACHE_ENTRIES) { + const iterator = trafficCache.keys(); + while (trafficCache.size > MAX_CACHE_ENTRIES) { + const oldest = iterator.next(); + if (oldest.done) break; + trafficCache.delete(oldest.value); + } + } + return data; }, diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 6e514f8..5e6073b 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -135,6 +135,14 @@ export default function AdminBroadcastCreate() { const [mediaPreview, setMediaPreview] = useState(null); const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); + const mediaPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + }; + }, []); // Email-specific state const [emailSubject, setEmailSubject] = useState(''); @@ -271,7 +279,10 @@ export default function AdminBroadcastCreate() { if (file.type.startsWith('image/')) { setMediaType('photo'); - setMediaPreview(URL.createObjectURL(file)); + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + const url = URL.createObjectURL(file); + mediaPreviewRef.current = url; + setMediaPreview(url); } else if (file.type.startsWith('video/')) { setMediaType('video'); setMediaPreview(null); @@ -295,6 +306,10 @@ export default function AdminBroadcastCreate() { // Remove media const handleRemoveMedia = () => { + if (mediaPreviewRef.current) { + URL.revokeObjectURL(mediaPreviewRef.current); + mediaPreviewRef.current = null; + } setMediaFile(null); setMediaPreview(null); setUploadedFileId(null); diff --git a/src/pages/AdminPinnedMessageCreate.tsx b/src/pages/AdminPinnedMessageCreate.tsx index b405da7..4398c4b 100644 --- a/src/pages/AdminPinnedMessageCreate.tsx +++ b/src/pages/AdminPinnedMessageCreate.tsx @@ -86,6 +86,14 @@ export default function AdminPinnedMessageCreate() { const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null); + const mediaPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + }; + }, []); // Load existing message for editing const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({ @@ -138,7 +146,10 @@ export default function AdminPinnedMessageCreate() { let detectedType: 'photo' | 'video' = 'photo'; if (file.type.startsWith('image/')) { detectedType = 'photo'; - setMediaPreview(URL.createObjectURL(file)); + if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); + const url = URL.createObjectURL(file); + mediaPreviewRef.current = url; + setMediaPreview(url); } else if (file.type.startsWith('video/')) { detectedType = 'video'; setMediaPreview(null); @@ -159,6 +170,10 @@ export default function AdminPinnedMessageCreate() { // Remove media const handleRemoveMedia = () => { + if (mediaPreviewRef.current) { + URL.revokeObjectURL(mediaPreviewRef.current); + mediaPreviewRef.current = null; + } setMediaFile(null); setMediaPreview(null); setUploadedFileId(null); diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index a1bd3d4..822d512 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; @@ -151,7 +151,7 @@ export default function Support() { log.debug('Component loaded'); const { t } = useTranslation(); - const { isAdmin } = useAuthStore(); + const isAdmin = useAuthStore((state) => state.isAdmin); const queryClient = useQueryClient(); const { openTelegramLink, openLink } = usePlatform(); const [selectedTicket, setSelectedTicket] = useState(null); @@ -166,6 +166,34 @@ export default function Support() { const [replyAttachment, setReplyAttachment] = useState(null); const createFileInputRef = useRef(null); const replyFileInputRef = useRef(null); + const createPreviewRef = useRef(null); + const replyPreviewRef = useRef(null); + + // Revoke blob URLs on unmount to prevent memory leaks + useEffect(() => { + const createRef = createPreviewRef; + const replyRef = replyPreviewRef; + return () => { + if (createRef.current) URL.revokeObjectURL(createRef.current); + if (replyRef.current) URL.revokeObjectURL(replyRef.current); + }; + }, []); + + const clearCreateAttachment = () => { + if (createPreviewRef.current) { + URL.revokeObjectURL(createPreviewRef.current); + createPreviewRef.current = null; + } + clearCreateAttachment(); + }; + + const clearReplyAttachment = () => { + if (replyPreviewRef.current) { + URL.revokeObjectURL(replyPreviewRef.current); + replyPreviewRef.current = null; + } + clearReplyAttachment(); + }; // Get support configuration const { data: supportConfig, isLoading: configLoading } = useQuery({ @@ -213,8 +241,11 @@ export default function Support() { return; } - // Create preview + // Revoke old blob URL before creating new one + const previewRef = setAttachment === setCreateAttachment ? createPreviewRef : replyPreviewRef; + if (previewRef.current) URL.revokeObjectURL(previewRef.current); const preview = URL.createObjectURL(file); + previewRef.current = preview; setAttachment({ file, preview, uploading: true }); try { @@ -250,7 +281,7 @@ export default function Support() { setShowCreateForm(false); setNewTitle(''); setNewMessage(''); - setCreateAttachment(null); + clearCreateAttachment(); setSelectedTicket(ticket); }, }); @@ -268,7 +299,7 @@ export default function Support() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] }); setReplyMessage(''); - setReplyAttachment(null); + clearReplyAttachment(); }, }); @@ -444,7 +475,7 @@ export default function Support() { onClick={() => { setShowCreateForm(true); setSelectedTicket(null); - setCreateAttachment(null); + clearCreateAttachment(); }} > @@ -469,7 +500,7 @@ export default function Support() { onClick={() => { setSelectedTicket(ticket as unknown as TicketDetail); setShowCreateForm(false); - setReplyAttachment(null); + clearReplyAttachment(); }} className={`w-full rounded-bento border p-4 text-left transition-all ${ selectedTicket?.id === ticket.id @@ -574,7 +605,7 @@ export default function Support() { {createAttachment ? ( setCreateAttachment(null)} + onRemove={() => clearCreateAttachment()} /> ) : (