[skip ci] feat: prepare for public release
This commit is contained in:
331
fluxer_app/src/router/components/RootComponent.tsx
Normal file
331
fluxer_app/src/router/components/RootComponent.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* 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 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;
|
||||
|
||||
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 || !mobileLayoutState.enabled) return;
|
||||
|
||||
const handleNotificationNavigate = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'NOTIFICATION_CLICK_NAVIGATE') {
|
||||
if (hasHandledNotificationNav) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = event.data.url;
|
||||
const targetUserId = event.data.targetUserId as string | undefined;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
navigateWithHistoryStack(url);
|
||||
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()}` : '');
|
||||
|
||||
navigateWithHistoryStack(cleanPath);
|
||||
setHasHandledNotificationNav(true);
|
||||
}
|
||||
}
|
||||
|
||||
navigator.serviceWorker?.addEventListener('message', handleNotificationNavigate);
|
||||
|
||||
return () => {
|
||||
navigator.serviceWorker?.removeEventListener('message', handleNotificationNavigate);
|
||||
};
|
||||
}, [isAuthenticated, mobileLayoutState.enabled, hasHandledNotificationNav, location, navigateWithHistoryStack]);
|
||||
|
||||
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};
|
||||
Reference in New Issue
Block a user