[skip ci] feat: prepare for public release

This commit is contained in:
Hampus Kraft
2026-01-02 19:27:51 +00:00
parent 197b23757f
commit 5ae825fc7d
199 changed files with 38391 additions and 33358 deletions

View File

@@ -0,0 +1,88 @@
/*
* 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 {ME} from '~/Constants';
import {GuildLayout} from '~/components/layout/GuildLayout';
import {useLocation} from '~/lib/router';
import {Routes} from '~/Routes';
import ChannelStore from '~/stores/ChannelStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import {compareChannelPosition, filterViewableChannels} from '~/utils/channelShared';
import * as RouterUtils from '~/utils/RouterUtils';
export const GuildChannelRouter: React.FC<{guildId: string; children?: React.ReactNode}> = observer(
({guildId, children}) => {
const location = useLocation();
React.useEffect(() => {
if (guildId === ME || location.pathname === Routes.ME) {
return;
}
if (MobileLayoutStore.enabled) {
return;
}
if (location.pathname.startsWith('/channels/') && !location.pathname.startsWith(Routes.ME)) {
if (location.pathname.split('/').length === 3) {
const pathSegments = location.pathname.split('/');
const currentGuildId = pathSegments[2];
if (currentGuildId !== guildId) {
return;
}
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guildId);
if (selectedChannelId) {
const channel = ChannelStore.getChannel(selectedChannelId);
if (channel && channel.guildId === guildId) {
RouterUtils.replaceWith(Routes.guildChannel(guildId, selectedChannelId));
} else {
const channels = ChannelStore.getGuildChannels(guildId);
const viewableChannels = filterViewableChannels(channels).sort(compareChannelPosition);
if (viewableChannels.length > 0) {
const firstChannel = viewableChannels[0];
RouterUtils.replaceWith(Routes.guildChannel(guildId, firstChannel.id));
}
}
} else {
const channels = ChannelStore.getGuildChannels(guildId);
const viewableChannels = filterViewableChannels(channels).sort(compareChannelPosition);
if (viewableChannels.length > 0) {
const firstChannel = viewableChannels[0];
RouterUtils.replaceWith(Routes.guildChannel(guildId, firstChannel.id));
}
}
}
}
}, [guildId, location.pathname, MobileLayoutStore.enabled]);
if (guildId === ME || location.pathname === Routes.ME) {
return null;
}
return <GuildLayout>{children}</GuildLayout>;
},
);

View 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};

View File

@@ -0,0 +1,39 @@
/*
* 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 {Routes} from '~/Routes';
export const AUTO_REDIRECT_EXEMPT_PATHS = new Set<string>([
Routes.RESET_PASSWORD,
Routes.AUTHORIZE_IP,
Routes.EMAIL_REVERT,
Routes.VERIFY_EMAIL,
Routes.OAUTH_AUTHORIZE,
Routes.REPORT,
]);
const AUTO_REDIRECT_EXEMPT_PREFIXES = ['/invite/', '/gift/', '/theme/', '/oauth2/'];
export const isAutoRedirectExemptPath = (pathname: string): boolean => {
if (AUTO_REDIRECT_EXEMPT_PATHS.has(pathname)) {
return true;
}
return AUTO_REDIRECT_EXEMPT_PREFIXES.some((prefix) => pathname.startsWith(prefix));
};

View File

@@ -0,0 +1,225 @@
/*
* 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 {ChannelTypes, ME} from '~/Constants';
import {AppBadge} from '~/components/AppBadge';
import {ChannelIndexPage} from '~/components/channel/ChannelIndexPage';
import {ChannelLayout} from '~/components/channel/ChannelLayout';
import {DMLayout} from '~/components/channel/dm/DMLayout';
import {AppLayout} from '~/components/layout/AppLayout';
import {FavoritesLayout} from '~/components/layout/FavoritesLayout';
import {GuildsLayout} from '~/components/layout/GuildsLayout';
import {BookmarksBottomSheet} from '~/components/modals/BookmarksBottomSheet';
import {StatusChangeBottomSheet} from '~/components/modals/StatusChangeBottomSheet';
import {NotificationsPage} from '~/components/pages/NotificationsPage';
import PremiumCallbackPage from '~/components/pages/PremiumCallbackPage';
import {YouPage} from '~/components/pages/YouPage';
import {createRoute, Redirect, useParams} from '~/lib/router';
import SessionManager from '~/lib/SessionManager';
import {Routes} from '~/Routes';
import {GuildChannelRouter} from '~/router/components/GuildChannelRouter';
import {rootRoute} from '~/router/routes/rootRoutes';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'appLayout',
onEnter: () => {
if (!SessionManager.isInitialized) {
return undefined;
}
if (!AuthenticationStore.isAuthenticated) {
const current = window.location.pathname + window.location.search;
return new Redirect(`${Routes.LOGIN}?redirect_to=${encodeURIComponent(current)}`);
}
return undefined;
},
layout: ({children}) => (
<>
<AppBadge />
<AppLayout>{children}</AppLayout>
</>
),
});
const guildsLayoutRoute = createRoute({
getParentRoute: () => appLayoutRoute,
id: 'guildsLayout',
layout: ({children}) => <GuildsLayout>{children}</GuildsLayout>,
});
const notificationsRoute = createRoute({
getParentRoute: () => appLayoutRoute,
id: 'notifications',
path: Routes.NOTIFICATIONS,
component: () => {
const [bookmarksSheetOpen, setBookmarksSheetOpen] = React.useState(false);
return (
<>
<NotificationsPage onBookmarksClick={() => setBookmarksSheetOpen(true)} />
<BookmarksBottomSheet isOpen={bookmarksSheetOpen} onClose={() => setBookmarksSheetOpen(false)} />
</>
);
},
});
const youRoute = createRoute({
getParentRoute: () => appLayoutRoute,
id: 'you',
path: Routes.YOU,
component: () => {
const [statusSheetOpen, setStatusSheetOpen] = React.useState(false);
return (
<>
<YouPage onAvatarClick={() => setStatusSheetOpen(true)} />
<StatusChangeBottomSheet isOpen={statusSheetOpen} onClose={() => setStatusSheetOpen(false)} />
</>
);
},
});
const premiumCallbackRoute = createRoute({
getParentRoute: () => appLayoutRoute,
id: 'premiumCallback',
path: Routes.PREMIUM_CALLBACK,
component: () => <PremiumCallbackPage />,
});
const bookmarksRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'bookmarks',
path: Routes.BOOKMARKS,
component: () => <DMLayout />,
});
const mentionsRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'mentions',
path: Routes.MENTIONS,
component: () => <DMLayout />,
});
const meRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'me',
path: '/channels/@me',
component: observer(() => {
const isMobileLayout = MobileLayoutStore.enabled;
React.useEffect(() => {
if (!isMobileLayout && SelectedChannelStore.selectedChannelIds.has(ME)) {
SelectedChannelStore.clearGuildSelection(ME);
}
}, [isMobileLayout]);
return <DMLayout />;
}),
});
const favoritesRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'favorites',
path: '/channels/@favorites',
layout: ({children}) => <FavoritesLayout>{children}</FavoritesLayout>,
});
const favoritesChannelRoute = createRoute({
getParentRoute: () => favoritesRoute,
id: 'favoritesChannel',
path: '/channels/@favorites/:channelId',
component: () => (
<ChannelLayout>
<ChannelIndexPage />
</ChannelLayout>
),
});
const channelsRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'channels',
path: '/channels/:guildId',
layout: ({children}) => {
const params = useParams() as {guildId: string};
const {guildId} = params;
if (guildId === ME) {
return <DMLayout>{children}</DMLayout>;
}
return guildId ? <GuildChannelRouter guildId={guildId}>{children}</GuildChannelRouter> : null;
},
});
const channelRoute = createRoute({
getParentRoute: () => channelsRoute,
id: 'channel',
path: '/channels/:guildId/:channelId',
onEnter: (ctx) => {
const {guildId, channelId} = ctx.params;
const channel = ChannelStore.getChannel(channelId);
if (channel && (channel.type === ChannelTypes.GUILD_CATEGORY || channel.type === ChannelTypes.GUILD_LINK)) {
return new Redirect(Routes.guildChannel(guildId));
}
return undefined;
},
component: () => (
<ChannelLayout>
<ChannelIndexPage />
</ChannelLayout>
),
});
const messageRoute = createRoute({
getParentRoute: () => channelRoute,
id: 'message',
path: '/channels/:guildId/:channelId/:messageId',
onEnter: (ctx) => {
const {guildId, channelId} = ctx.params;
const channel = ChannelStore.getChannel(channelId);
if (channel && (channel.type === ChannelTypes.GUILD_CATEGORY || channel.type === ChannelTypes.GUILD_LINK)) {
return new Redirect(Routes.guildChannel(guildId));
}
return undefined;
},
component: () => (
<ChannelLayout>
<ChannelIndexPage />
</ChannelLayout>
),
});
export const appRouteTree = appLayoutRoute.addChildren([
notificationsRoute,
youRoute,
premiumCallbackRoute,
guildsLayoutRoute.addChildren([
bookmarksRoute,
mentionsRoute,
meRoute,
favoritesRoute.addChildren([favoritesChannelRoute]),
channelsRoute.addChildren([channelRoute.addChildren([messageRoute])]),
]),
]);

View File

@@ -0,0 +1,310 @@
/*
* 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 {i18n} from '@lingui/core';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as ThemeActionCreators from '~/actions/ThemeActionCreators';
import {AuthLayout} from '~/components/layout/AuthLayout';
import AuthorizeIPPage from '~/components/pages/AuthorizeIPPage';
import EmailRevertPage from '~/components/pages/EmailRevertPage';
import ForgotPasswordPage from '~/components/pages/ForgotPasswordPage';
import GiftLoginPage from '~/components/pages/GiftLoginPage';
import GiftRegisterPage from '~/components/pages/GiftRegisterPage';
import InviteLoginPage from '~/components/pages/InviteLoginPage';
import InviteRegisterPage from '~/components/pages/InviteRegisterPage';
import LoginPage from '~/components/pages/LoginPage';
import OAuthAuthorizePage from '~/components/pages/OAuthAuthorizePage';
import PendingVerificationPage from '~/components/pages/PendingVerificationPage';
import RegisterPage from '~/components/pages/RegisterPage';
import {ReportPage} from '~/components/pages/ReportPage';
import ResetPasswordPage from '~/components/pages/ResetPasswordPage';
import ThemeLoginPage from '~/components/pages/ThemeLoginPage';
import ThemeRegisterPage from '~/components/pages/ThemeRegisterPage';
import VerifyEmailPage from '~/components/pages/VerifyEmailPage';
import {createRoute, Redirect, type RouteContext} from '~/lib/router';
import SessionManager from '~/lib/SessionManager';
import {Routes} from '~/Routes';
import {rootRoute} from '~/router/routes/rootRoutes';
import AuthenticationStore from '~/stores/AuthenticationStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import * as RouterUtils from '~/utils/RouterUtils';
const resolveToPath = (to: Redirect['to']): string => {
if (typeof to === 'string') {
return to;
}
const url = new URL(to.to, window.location.origin);
if (to.search) {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(to.search)) {
if (v === undefined) continue;
if (v === null) {
sp.set(k, '');
} else {
sp.set(k, String(v));
}
}
url.search = sp.toString() ? `?${sp.toString()}` : '';
}
if (to.hash) {
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
}
return url.pathname + url.search + url.hash;
};
type AuthRedirectHandler = (ctx: RouteContext) => Redirect | undefined;
const whenAuthenticated = (handler: AuthRedirectHandler) => {
return (ctx: RouteContext): Redirect | undefined => {
const execute = (): Redirect | undefined => handler(ctx);
if (SessionManager.isInitialized) {
return AuthenticationStore.isAuthenticated ? execute() : undefined;
}
void SessionManager.initialize().then(() => {
if (AuthenticationStore.isAuthenticated) {
const res = execute();
if (res instanceof Redirect) {
RouterUtils.replaceWith(resolveToPath(res.to));
}
}
});
return undefined;
};
};
const authLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'authLayout',
layout: ({children}) => <AuthLayout>{children}</AuthLayout>,
});
const loginRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'login',
path: '/login',
onEnter: whenAuthenticated(() => {
const search = window.location.search;
const qp = new URLSearchParams(search);
const isDesktopHandoff = qp.get('desktop_handoff') === '1';
if (isDesktopHandoff) {
return undefined;
}
const redirectTo = qp.get('redirect_to');
return new Redirect(redirectTo || Routes.ME);
}),
component: () => <LoginPage />,
});
const registerRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'register',
path: '/register',
onEnter: whenAuthenticated(() => {
const search = window.location.search;
const qp = new URLSearchParams(search);
const redirectTo = qp.get('redirect_to');
return new Redirect(redirectTo || Routes.ME);
}),
component: () => <RegisterPage />,
});
const oauthAuthorizeRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'oauthAuthorize',
path: Routes.OAUTH_AUTHORIZE,
onEnter: () => {
const current = window.location.pathname + window.location.search;
if (!SessionManager.isInitialized) {
void SessionManager.initialize().then(() => {
if (!AuthenticationStore.isAuthenticated) {
RouterUtils.replaceWith(`${Routes.LOGIN}?redirect_to=${encodeURIComponent(current)}`);
}
});
return undefined;
}
if (!AuthenticationStore.isAuthenticated) {
return new Redirect(`${Routes.LOGIN}?redirect_to=${encodeURIComponent(current)}`);
}
return undefined;
},
component: () => <OAuthAuthorizePage />,
});
const inviteRegisterRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'inviteRegister',
path: '/invite/:code',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params.code;
if (code) {
InviteActionCreators.openAcceptModal(code);
}
return new Redirect(Routes.ME);
}),
component: () => <InviteRegisterPage />,
});
const inviteLoginRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'inviteLogin',
path: '/invite/:code/login',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params.code;
if (code) {
InviteActionCreators.openAcceptModal(code);
}
return new Redirect(Routes.ME);
}),
component: () => <InviteLoginPage />,
});
const giftRegisterRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'giftRegister',
path: '/gift/:code',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params.code;
if (code) {
GiftActionCreators.openAcceptModal(code);
}
return new Redirect(Routes.ME);
}),
component: () => <GiftRegisterPage />,
});
const giftLoginRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'giftLogin',
path: '/gift/:code/login',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params.code;
if (code) {
GiftActionCreators.openAcceptModal(code);
}
return new Redirect(Routes.ME);
}),
component: () => <GiftLoginPage />,
});
const forgotPasswordRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'forgotPassword',
path: Routes.FORGOT_PASSWORD,
onEnter: whenAuthenticated(() => new Redirect(Routes.ME)),
component: () => <ForgotPasswordPage />,
});
const resetPasswordRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'resetPassword',
path: Routes.RESET_PASSWORD,
component: () => <ResetPasswordPage />,
});
const emailRevertRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'emailRevert',
path: Routes.EMAIL_REVERT,
component: () => <EmailRevertPage />,
});
const verifyEmailRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'verifyEmail',
path: Routes.VERIFY_EMAIL,
component: () => <VerifyEmailPage />,
});
const authorizeIPRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'authorizeIP',
path: Routes.AUTHORIZE_IP,
component: () => <AuthorizeIPPage />,
});
const pendingVerificationRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'pendingVerification',
path: Routes.PENDING_VERIFICATION,
component: () => <PendingVerificationPage />,
});
const reportRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'report',
path: Routes.REPORT,
component: () => <ReportPage />,
});
const themeRegisterRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'themeRegister',
path: Routes.THEME_REGISTER,
onEnter: whenAuthenticated((ctx) => {
const themeId = ctx.params.themeId;
if (themeId) {
ThemeActionCreators.openAcceptModal(themeId, i18n);
}
return new Redirect(Routes.ME);
}),
component: () => <ThemeRegisterPage />,
});
const themeLoginRoute = createRoute({
getParentRoute: () => authLayoutRoute,
id: 'themeLogin',
path: Routes.THEME_LOGIN,
onEnter: whenAuthenticated((ctx) => {
const themeId = ctx.params.themeId;
if (themeId) {
ThemeActionCreators.openAcceptModal(themeId, i18n);
}
return new Redirect(Routes.ME);
}),
component: () => <ThemeLoginPage />,
});
export const authRouteTree = authLayoutRoute.addChildren([
loginRoute,
registerRoute,
oauthAuthorizeRoute,
inviteRegisterRoute,
inviteLoginRoute,
themeRegisterRoute,
themeLoginRoute,
forgotPasswordRoute,
resetPasswordRoute,
emailRevertRoute,
verifyEmailRoute,
authorizeIPRoute,
pendingVerificationRoute,
reportRoute,
...(RuntimeConfigStore.isSelfHosted() ? [] : [giftRegisterRoute, giftLoginRoute]),
]);

View File

@@ -0,0 +1,27 @@
/*
* 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 type {RouteConfig} from '~/lib/router';
import {appRouteTree} from '~/router/routes/appRoutes';
import {authRouteTree} from '~/router/routes/authRoutes';
import {homeRoute, notFoundRoute, rootRoute} from '~/router/routes/rootRoutes';
const routeTree = rootRoute.addChildren([homeRoute, notFoundRoute, authRouteTree, appRouteTree]);
export const buildRoutes = (): Array<RouteConfig> => routeTree.build();

View File

@@ -0,0 +1,40 @@
/*
* 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 {NotFoundPage} from '~/components/pages/NotFoundPage';
import {createRootRoute, createRoute, Redirect} from '~/lib/router';
import {Routes} from '~/Routes';
import {RootComponent} from '~/router/components/RootComponent';
export const rootRoute = createRootRoute({
layout: ({children}) => <RootComponent>{children}</RootComponent>,
});
export const notFoundRoute = createRoute({
id: '__notFound',
path: '/__notfound',
component: () => <NotFoundPage />,
});
export const homeRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'home',
path: '/',
onEnter: () => new Redirect(Routes.ME),
});