370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {observer} from 'mobx-react-lite';
|
|
import React from 'react';
|
|
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
|
import {KeyboardModeListener} from '~/components/layout/KeyboardModeListener';
|
|
import {MobileBottomNav} from '~/components/layout/MobileBottomNav';
|
|
import {SplashScreen} from '~/components/layout/SplashScreen';
|
|
import {useLocation} from '~/lib/router';
|
|
import SessionManager from '~/lib/SessionManager';
|
|
import {Routes} from '~/Routes';
|
|
import {isAutoRedirectExemptPath} from '~/router/constants';
|
|
import * as PushSubscriptionService from '~/services/push/PushSubscriptionService';
|
|
import AccountManager from '~/stores/AccountManager';
|
|
import AuthenticationStore from '~/stores/AuthenticationStore';
|
|
import ConnectionStore from '~/stores/ConnectionStore';
|
|
import InitializationStore from '~/stores/InitializationStore';
|
|
import LocationStore from '~/stores/LocationStore';
|
|
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
|
import UserStore from '~/stores/UserStore';
|
|
import {navigateToWithMobileHistory} from '~/utils/MobileNavigation';
|
|
import {isInstalledPwa} from '~/utils/PwaUtils';
|
|
import * as RouterUtils from '~/utils/RouterUtils';
|
|
|
|
const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({children}) => {
|
|
const location = useLocation();
|
|
const isAuthenticated = AuthenticationStore.isAuthenticated;
|
|
const mobileLayoutState = MobileLayoutStore;
|
|
const [hasRestoredLocation, setHasRestoredLocation] = React.useState(false);
|
|
const currentUser = UserStore.currentUser;
|
|
const [hasHandledNotificationNav, setHasHandledNotificationNav] = React.useState(false);
|
|
const [previousMobileLayoutState, setPreviousMobileLayoutState] = React.useState(mobileLayoutState.enabled);
|
|
const lastMobileHistoryBuildRef = React.useRef<{ts: number; path: string} | null>(null);
|
|
const lastNotificationNavRef = React.useRef<{ts: number; key: string} | null>(null);
|
|
const isLocationStoreHydrated = LocationStore.isHydrated;
|
|
const canNavigateToProtectedRoutes = InitializationStore.canNavigateToProtectedRoutes;
|
|
const pendingRedirectRef = React.useRef<string | null>(null);
|
|
|
|
const hasStartedRestoreRef = React.useRef(false);
|
|
const pathname = location.pathname;
|
|
const isDesktopHandoff = location.searchParams.get('desktop_handoff') === '1';
|
|
const isAutoRedirectExemptRoute = isAutoRedirectExemptPath(pathname);
|
|
const shouldSkipAutoRedirect = isAutoRedirectExemptRoute || (pathname === Routes.LOGIN && isDesktopHandoff);
|
|
|
|
const isAuthRoute = React.useMemo(() => {
|
|
return (
|
|
pathname.startsWith(Routes.LOGIN) ||
|
|
pathname.startsWith(Routes.REGISTER) ||
|
|
pathname.startsWith(Routes.FORGOT_PASSWORD) ||
|
|
pathname.startsWith(Routes.RESET_PASSWORD) ||
|
|
pathname.startsWith(Routes.VERIFY_EMAIL) ||
|
|
pathname.startsWith(Routes.AUTHORIZE_IP) ||
|
|
pathname.startsWith(Routes.EMAIL_REVERT) ||
|
|
pathname.startsWith(Routes.OAUTH_AUTHORIZE) ||
|
|
pathname.startsWith(Routes.REPORT) ||
|
|
pathname.startsWith('/invite/') ||
|
|
pathname.startsWith('/gift/') ||
|
|
pathname.startsWith('/theme/')
|
|
);
|
|
}, [pathname]);
|
|
|
|
const shouldBypassGateway = isAuthRoute && pathname !== Routes.PENDING_VERIFICATION;
|
|
const authToken = AuthenticationStore.authToken;
|
|
|
|
const normalizeInternalUrl = React.useCallback((rawUrl: string): string => {
|
|
try {
|
|
const u = new URL(rawUrl, window.location.origin);
|
|
if (u.origin === window.location.origin) {
|
|
return u.pathname + u.search + u.hash;
|
|
}
|
|
return rawUrl;
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!SessionManager.isInitialized) return;
|
|
if (AccountManager.isSwitching) return;
|
|
|
|
const isAuth = AuthenticationStore.isAuthenticated;
|
|
|
|
if (isAuth && isAuthRoute) return;
|
|
|
|
if (shouldBypassGateway) {
|
|
if (isAuth) {
|
|
if (!shouldSkipAutoRedirect) {
|
|
RouterUtils.replaceWith(Routes.ME);
|
|
}
|
|
return;
|
|
}
|
|
if (ConnectionStore.isConnected || ConnectionStore.isConnecting || ConnectionStore.socket) {
|
|
ConnectionStore.logout();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isAuth) {
|
|
const current = pathname + window.location.search;
|
|
if (!pendingRedirectRef.current) {
|
|
pendingRedirectRef.current = current;
|
|
}
|
|
RouterUtils.replaceWith(`${Routes.LOGIN}?redirect_to=${encodeURIComponent(pendingRedirectRef.current)}`);
|
|
return;
|
|
}
|
|
|
|
if (isAuth && InitializationStore.isLoading) {
|
|
void AuthenticationActionCreators.ensureSessionStarted();
|
|
}
|
|
}, [
|
|
SessionManager.isInitialized,
|
|
authToken,
|
|
AccountManager.isSwitching,
|
|
AuthenticationStore.isAuthenticated,
|
|
ConnectionStore.isConnected,
|
|
ConnectionStore.isConnecting,
|
|
InitializationStore.isLoading,
|
|
shouldBypassGateway,
|
|
shouldSkipAutoRedirect,
|
|
pendingRedirectRef,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (!AuthenticationStore.isAuthenticated) return;
|
|
const target = pendingRedirectRef.current;
|
|
if (!target) return;
|
|
|
|
const current = location.pathname + window.location.search;
|
|
if (current !== target) {
|
|
RouterUtils.replaceWith(target);
|
|
}
|
|
|
|
pendingRedirectRef.current = null;
|
|
}, [AuthenticationStore.isAuthenticated, location.pathname]);
|
|
|
|
React.useEffect(() => {
|
|
if (
|
|
!isAuthenticated ||
|
|
hasRestoredLocation ||
|
|
hasStartedRestoreRef.current ||
|
|
!canNavigateToProtectedRoutes ||
|
|
!isLocationStoreHydrated
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (location.pathname === Routes.HOME) {
|
|
return;
|
|
}
|
|
|
|
hasStartedRestoreRef.current = true;
|
|
setHasRestoredLocation(true);
|
|
|
|
const lastLocation = LocationStore.getLastLocation();
|
|
if (lastLocation && lastLocation !== location.pathname && location.pathname === Routes.ME) {
|
|
navigateToWithMobileHistory(lastLocation, mobileLayoutState.enabled);
|
|
} else if (mobileLayoutState.enabled) {
|
|
const p = location.pathname;
|
|
if ((Routes.isDMRoute(p) && p !== Routes.ME) || (Routes.isGuildChannelRoute(p) && p.split('/').length === 4)) {
|
|
navigateToWithMobileHistory(p, true);
|
|
setHasHandledNotificationNav(true);
|
|
}
|
|
}
|
|
}, [
|
|
isAuthenticated,
|
|
hasRestoredLocation,
|
|
mobileLayoutState.enabled,
|
|
isLocationStoreHydrated,
|
|
canNavigateToProtectedRoutes,
|
|
location.pathname,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isAuthenticated || !hasRestoredLocation) return;
|
|
|
|
if (previousMobileLayoutState !== mobileLayoutState.enabled) {
|
|
setPreviousMobileLayoutState(mobileLayoutState.enabled);
|
|
|
|
if (mobileLayoutState.enabled) {
|
|
const currentPath = location.pathname;
|
|
if (
|
|
(Routes.isDMRoute(currentPath) && currentPath !== Routes.ME) ||
|
|
(Routes.isGuildChannelRoute(currentPath) && currentPath.split('/').length === 4)
|
|
) {
|
|
navigateToWithMobileHistory(currentPath, true);
|
|
}
|
|
}
|
|
}
|
|
}, [isAuthenticated, hasRestoredLocation, mobileLayoutState.enabled, previousMobileLayoutState, location.pathname]);
|
|
|
|
React.useEffect(() => {
|
|
const shouldSaveLocation = Routes.isChannelRoute(location.pathname) || Routes.isSpecialPage(location.pathname);
|
|
|
|
if (isAuthenticated && shouldSaveLocation) {
|
|
LocationStore.saveLocation(location.pathname);
|
|
}
|
|
}, [isAuthenticated, location.pathname]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isAuthenticated || !hasRestoredLocation) return;
|
|
|
|
if (previousMobileLayoutState !== mobileLayoutState.enabled) {
|
|
setPreviousMobileLayoutState(mobileLayoutState.enabled);
|
|
|
|
if (mobileLayoutState.enabled) {
|
|
const currentPath = location.pathname;
|
|
|
|
const now = Date.now();
|
|
const last = lastMobileHistoryBuildRef.current;
|
|
if (last && last.path === currentPath && now - last.ts < 1500) {
|
|
return;
|
|
}
|
|
lastMobileHistoryBuildRef.current = {ts: now, path: currentPath};
|
|
if (
|
|
(Routes.isDMRoute(currentPath) && currentPath !== Routes.ME) ||
|
|
(Routes.isGuildChannelRoute(currentPath) && currentPath.split('/').length === 4)
|
|
) {
|
|
if (Routes.isDMRoute(currentPath) && currentPath !== Routes.ME) {
|
|
RouterUtils.replaceWith(Routes.ME);
|
|
setTimeout(() => RouterUtils.transitionTo(currentPath), 0);
|
|
} else if (Routes.isGuildChannelRoute(currentPath) && currentPath.split('/').length === 4) {
|
|
const parts = currentPath.split('/');
|
|
const guildId = parts[2];
|
|
const guildPath = Routes.guildChannel(guildId);
|
|
RouterUtils.replaceWith(guildPath);
|
|
setTimeout(() => RouterUtils.transitionTo(currentPath), 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, [isAuthenticated, hasRestoredLocation, mobileLayoutState.enabled, previousMobileLayoutState, location.pathname]);
|
|
|
|
const navigateWithHistoryStack = React.useCallback(
|
|
(url: string) => {
|
|
navigateToWithMobileHistory(url, mobileLayoutState.enabled);
|
|
},
|
|
[mobileLayoutState.enabled],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!isAuthenticated) return;
|
|
|
|
const handleNotificationNavigate = (event: MessageEvent) => {
|
|
if (event.data?.type === 'NOTIFICATION_CLICK_NAVIGATE') {
|
|
const rawUrl = typeof event.data.url === 'string' ? event.data.url : null;
|
|
if (!rawUrl) return;
|
|
|
|
const targetUserId =
|
|
typeof event.data.targetUserId === 'string' ? (event.data.targetUserId as string) : undefined;
|
|
|
|
const normalizedUrl = normalizeInternalUrl(rawUrl);
|
|
const key = `${targetUserId ?? ''}:${normalizedUrl}`;
|
|
const now = Date.now();
|
|
const last = lastNotificationNavRef.current;
|
|
if (last && last.key === key && now - last.ts < 1500) {
|
|
return;
|
|
}
|
|
lastNotificationNavRef.current = {ts: now, key};
|
|
|
|
void (async () => {
|
|
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
|
|
try {
|
|
await AccountManager.switchToAccount(targetUserId);
|
|
} catch (error) {
|
|
console.error('Failed to switch account for notification', error);
|
|
}
|
|
}
|
|
|
|
if (mobileLayoutState.enabled) {
|
|
navigateWithHistoryStack(normalizedUrl);
|
|
} else {
|
|
RouterUtils.transitionTo(normalizedUrl);
|
|
}
|
|
|
|
setHasHandledNotificationNav(true);
|
|
})();
|
|
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === 'PUSH_SUBSCRIPTION_CHANGE') {
|
|
if (isInstalledPwa()) {
|
|
void PushSubscriptionService.registerPushSubscription();
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!hasHandledNotificationNav) {
|
|
const urlParams = location.searchParams;
|
|
if (urlParams.get('fromNotification') === '1') {
|
|
const newParams = new URLSearchParams(urlParams);
|
|
newParams.delete('fromNotification');
|
|
const cleanPath = location.pathname + (newParams.toString() ? `?${newParams.toString()}` : '');
|
|
|
|
if (mobileLayoutState.enabled) {
|
|
navigateWithHistoryStack(cleanPath);
|
|
} else {
|
|
RouterUtils.transitionTo(cleanPath);
|
|
}
|
|
|
|
setHasHandledNotificationNav(true);
|
|
}
|
|
}
|
|
|
|
navigator.serviceWorker?.addEventListener('message', handleNotificationNavigate);
|
|
|
|
return () => {
|
|
navigator.serviceWorker?.removeEventListener('message', handleNotificationNavigate);
|
|
};
|
|
}, [
|
|
isAuthenticated,
|
|
mobileLayoutState.enabled,
|
|
hasHandledNotificationNav,
|
|
location,
|
|
navigateWithHistoryStack,
|
|
normalizeInternalUrl,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (currentUser?.pendingManualVerification) {
|
|
if (
|
|
pathname !== Routes.PENDING_VERIFICATION &&
|
|
!pathname.startsWith('/login') &&
|
|
!pathname.startsWith('/register')
|
|
) {
|
|
RouterUtils.replaceWith(Routes.PENDING_VERIFICATION);
|
|
}
|
|
}
|
|
}, [currentUser, pathname]);
|
|
|
|
const showBottomNav =
|
|
mobileLayoutState.enabled &&
|
|
(location.pathname === Routes.ME ||
|
|
Routes.isFavoritesRoute(location.pathname) ||
|
|
location.pathname === Routes.NOTIFICATIONS ||
|
|
location.pathname === Routes.YOU ||
|
|
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
|
|
|
|
if (isAuthenticated && !canNavigateToProtectedRoutes && !shouldBypassGateway) {
|
|
return <SplashScreen />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<KeyboardModeListener />
|
|
{children}
|
|
{showBottomNav && currentUser && <MobileBottomNav currentUser={currentUser} />}
|
|
</>
|
|
);
|
|
});
|
|
|
|
export {RootComponent};
|