feat(desktop): bundled renderer, modules and instance accounts (#3254)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 16:48:07 +02:00
1 parent c748c8af4e
commit 7c5fa2180a
1275 files changed
+215074 -81246

No files matched your search

+22 -1
View File
@@ -23,10 +23,13 @@ import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
import SessionManager from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
import {startDesktopJumpListBridge} from '@app/features/platform/utils/DesktopJumpListBridge';
import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLocaleBridge';
@@ -71,6 +74,8 @@ const SKIP_TO_CONTENT_DESCRIPTOR = msg({
comment: 'Accessible skip-link label for keyboard users.',
});
const logger = new Logger('App');
interface AppWrapperProps {
children: ReactNode;
}
@@ -122,6 +127,22 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
showMyselfTypingHelper.start();
return () => showMyselfTypingHelper.stop();
}, []);
useEffect(() => {
let active = true;
void SessionManager.initialize()
.then(() => {
if (active) {
GatewaySessions.startRestoredSession();
}
})
.catch((error: unknown) => {
logger.error('Failed to initialize the restored gateway session', error);
});
return () => {
active = false;
GatewaySessions.dispose();
};
}, []);
useEffect(
() =>
reaction(
@@ -155,7 +176,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useWindowEventListeners();
useRemScaleTracking();
usePlatformClasses(platform, isNative);
useThemeCssVariables({
+86 -27
View File
@@ -12,6 +12,12 @@ import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {installLocaleSwitchWatchdog} from '@app/features/i18n/utils/LocaleSwitchWatchdog';
import {installTranslationDomGuard} from '@app/features/i18n/utils/TranslationDomGuard';
import {installScrollRestoration} from '@app/features/platform/components/router/ScrollRestoration';
import {isDesktopLocalAppDocument} from '@app/features/platform/DesktopLocalAppRuntime';
import type {
AppStorageBootstrapHandle,
AppStorageSessionAccount,
} from '@app/features/platform/state/AppStorageBootstrap';
import type {Account} from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {
getFormattedClientInfo,
@@ -24,11 +30,13 @@ import {scheduleNonLatinScriptFaces} from '@app/features/theme/fonts/ScriptFontL
import {installVoiceSubscriptionDebugApi} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugApi';
import {PASSKEY_BRIDGE_PATH} from '@fluxer/constants/src/PasskeyConstants';
import {i18n} from '@lingui/core';
import type {ReactNode} from 'react';
import {type ReactNode, startTransition} from 'react';
import ReactDOM from 'react-dom/client';
const logger = new Logger('index');
const SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS = 5000;
if (typeof window !== 'undefined' && window.history) {
bootstrapSyntheticHistory();
installScrollRestoration();
@@ -47,18 +55,22 @@ function createRoot(): ReactDOM.Root {
function mountRoot(content: ReactNode, dataFlxScope: string): void {
installTranslationDomGuard();
createRoot().render(
<AppErrorBoundary
fallback={(error) => (
<AppI18nProvider i18n={i18n}>
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
</AppI18nProvider>
)}
data-flx={`${dataFlxScope}.app-error-boundary`}
>
{content}
</AppErrorBoundary>,
);
const root = createRoot();
startTransition(() => {
root.render(
<AppErrorBoundary
fallback={(error) => (
<AppI18nProvider i18n={i18n}>
<ErrorFallback error={error ?? undefined} data-flx={`${dataFlxScope}.error-fallback`} />
</AppI18nProvider>
)}
data-flx={`${dataFlxScope}.app-error-boundary`}
>
{content}
</AppErrorBoundary>,
);
});
globalThis.window?.electron?.notifyFirstContentPainted?.();
}
async function logClientInfo(): Promise<void> {
@@ -82,14 +94,25 @@ async function preloadMarkdownParser(): Promise<void> {
}
}
async function bootstrapThemeStudio(): Promise<void> {
function storageSessionAccount(account: Account | null): AppStorageSessionAccount | null {
if (account === null) {
return null;
}
if (account.instance === undefined) {
throw new Error(`Authenticated account ${account.storageKey} has no instance runtime`);
}
return {accountKey: account.storageKey, userId: account.userId, token: account.token, instance: account.instance};
}
async function bootstrapThemeStudio(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: AccountManager}] = await Promise.all([
const [{ThemeStudioStandaloneApp}, {setupHttp}, {default: Accounts}] = await Promise.all([
loadLazyModule(() => import('@app/features/theme_studio/ThemeStudioStandaloneApp')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
]);
await AccountManager.bootstrap();
await Accounts.bootstrap();
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
setupHttp();
await markdownParserReady;
mountRoot(
@@ -132,7 +155,39 @@ async function probeSignedOutDomainMigration(): Promise<void> {
}
}
async function bootstrapApp(): Promise<void> {
async function activateSignedOutDocumentInstance(): Promise<void> {
if (isDesktopLocalAppDocument()) {
return;
}
try {
const {default: RuntimeConfig} = await loadLazyModule(() => import('@app/features/app/state/RuntimeConfig'));
if (RuntimeConfig.getSnapshotOrNull() !== null) {
return;
}
const resolution = await RuntimeConfig.resolveEndpoint({
input: window.location.origin,
signal: AbortSignal.timeout(SIGNED_OUT_INSTANCE_DISCOVERY_TIMEOUT_MS),
});
if (RuntimeConfig.getSnapshotOrNull() === null) {
RuntimeConfig.applySnapshot(resolution.snapshot);
}
} catch (error) {
logger.warn('Failed to activate the instance this page belongs to:', error);
}
}
async function prepareSignedOutFirstScreen(): Promise<void> {
try {
const {prepareSignedOutFirstScreen: prepare} = await loadLazyModule(
() => import('@app/features/auth/flow/AuthFirstScreen'),
);
await prepare();
} catch (error) {
logger.warn('Failed to prepare the signed-out first screen:', error);
}
}
async function bootstrapApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
const markdownParserReady = preloadMarkdownParser();
const [
{App},
@@ -140,7 +195,7 @@ async function bootstrapApp(): Promise<void> {
{default: CaptchaInterceptor},
{initializeEmojiParser},
{registerServiceWorker},
{default: AccountManager},
{default: Accounts},
{default: ChannelDisplayName},
_channelFrecency,
_geoIp,
@@ -152,15 +207,15 @@ async function bootstrapApp(): Promise<void> {
{default: StatusPage},
{installMigratedDeviceRemap},
] = await Promise.all([
loadLazyModule(() => import('@app/app/App')),
loadLazyModule(() => import(/* webpackChunkName: "boot-app" */ '@app/app/App')),
loadLazyModule(() => import('@app/app/SetupHttp')),
loadLazyModule(() => import('@app/features/auth/altcha/CaptchaInterceptor')),
loadLazyModule(() => import('@app/features/messaging/utils/markdown/EmojiProviderSetup')),
loadLazyModule(() => import('@app/features/platform/service_worker/Register')),
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
loadLazyModule(() => import('@app/features/auth/state/Accounts')),
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()),
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
loadLazyModule(() => import('@app/features/ui/state/Notification')),
@@ -178,10 +233,14 @@ async function bootstrapApp(): Promise<void> {
CaptchaInterceptor.setI18n(reactiveI18n);
void StatusPage.checkIncidents();
StatusPage.startPolling();
await Promise.all([AccountManager.bootstrap(), installMigratedDeviceRemap()]);
if (AccountManager.currentUserId === null) {
await Promise.all([Accounts.bootstrap(), installMigratedDeviceRemap()]);
await storageBootstrap.finalizeAfterSessionResolution(storageSessionAccount(Accounts.currentAccount));
if (Accounts.currentUserId === null) {
void probeSignedOutDomainMigration();
}
if (Accounts.currentAccount === null) {
await Promise.all([activateSignedOutDocumentInstance(), prepareSignedOutFirstScreen()]);
}
setupHttp();
initializeEmojiParser();
await markdownParserReady;
@@ -190,15 +249,15 @@ async function bootstrapApp(): Promise<void> {
registerServiceWorker();
}
export async function runApp(): Promise<void> {
export async function runApp(storageBootstrap: AppStorageBootstrapHandle): Promise<void> {
scheduleNonLatinScriptFaces();
await initI18n();
installLocaleSwitchWatchdog();
installSelfXssNotice();
void logClientInfo();
if (window.location.pathname === Routes.THEME_STUDIO) {
await bootstrapThemeStudio();
await bootstrapThemeStudio(storageBootstrap);
} else {
await bootstrapApp();
await bootstrapApp(storageBootstrap);
}
}
File diff suppressed because it is too large. Load diff
+613
View File
@@ -0,0 +1,613 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import {WELCOME_ROTATION} from '@app/features/app/components/setup/SetupWizardWelcomeRotation';
import {
AuthShellHintKind,
recordAuthShellHint,
resolveAuthShellPlaceholder,
} from '@app/features/app/components/skeleton/AuthShellHint';
import {SHELL_HINT_NAGBAR_TONE_ORDER, SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
import {
LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER,
LEGACY_SHELL_HINT_SEED_VERSION,
legacyShellHintSeed,
} from '@fluxer/desktop_ipc/src/LegacyShellHintSeed';
import {createElement, type ReactNode} from 'react';
import {createRoot} from 'react-dom/client';
import {afterEach, expect, test, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: ({children}: {children?: ReactNode}) => children ?? null,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
}));
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
function readBootScript(): string {
const body = html.slice(html.indexOf('<div id="root"></div>'));
const match = /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body);
expect(match).not.toBeNull();
return match?.[1] ?? '';
}
interface ShellHintOverrides {
readonly [key: string]: unknown;
}
const ACCOUNT_KEY = 'https://one.example/api::100';
function desktopDMHint(overrides: ShellHintOverrides = {}): Record<string, unknown> {
return {
v: SHELL_HINT_VERSION,
t: Date.now(),
p: '/channels/@me',
a: ACCOUNT_KEY,
mo: 0,
ua: 0,
fs: 0,
sw: 320,
sv: 'dm',
ck: 'other',
cb: 1,
rf: 1,
rv: 1,
ru: [],
rti: 0,
ro: 0,
ri: [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
],
rgi: -1,
rbm: 15,
rbi: -1,
rs: 0,
gb: 0,
ga: 1778,
gn: 0,
gd: 0,
gm: 0,
sr: 6,
sam: 7,
ss: 1,
cid: '',
ch: 2,
ct: 0,
cnw: 0,
ctw: 0,
cda: -1,
cma: -1,
cfv: 1,
cst: 0,
cuv: 0,
ml: 0,
co: 4,
com: 2,
cdv: 0,
pc: 0,
pg: 16,
pf: 16,
ps: 16,
pa: 0,
pt: 56,
pv: 800,
nr: [],
vh: 0,
vc: 0,
mr: 0,
ms: 0,
mn: 0,
...overrides,
};
}
function setViewportWidth(width: number): void {
Object.defineProperty(window, 'innerWidth', {value: width, configurable: true});
const matches = width >= 1024;
Object.defineProperty(window, 'matchMedia', {
value: (query: string) => ({matches: query.includes('1024') ? matches : false, media: query}),
configurable: true,
});
}
function failScript(): void {
const script = document.createElement('script');
document.body.appendChild(script);
script.dispatchEvent(new Event('error', {bubbles: false}));
}
const bootRoots: Array<HTMLElement> = [];
interface BootScriptOptions {
readonly signedIn?: boolean;
readonly storage?: Readonly<Record<string, string>>;
}
function runBootScript(
hint: Record<string, unknown> | null,
pathname = '/channels/@me',
{signedIn = true, storage = {}}: BootScriptOptions = {},
): HTMLElement | null {
document.body.innerHTML = '<div id="root"></div>';
window.history.pushState({}, '', pathname);
localStorage.clear();
if (signedIn) {
localStorage.setItem('fluxer:gateway:preboot:session', '1');
}
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
if (hint != null) {
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
}
for (const [key, value] of Object.entries(storage)) {
localStorage.setItem(key, value);
}
const root = document.getElementById('root');
if (root != null) {
bootRoots.push(root);
}
new Function(readBootScript())();
return document.querySelector<HTMLElement>('#fluxer-boot-shell');
}
afterEach(async () => {
for (const root of bootRoots.splice(0)) {
root.replaceChildren(document.createElement('span'));
root.replaceChildren();
}
await Promise.resolve();
localStorage.clear();
document.body.innerHTML = '';
vi.useRealTimers();
});
test('a desktop hint paints the boot shell', () => {
setViewportWidth(1400);
const shell = runBootScript(desktopDMHint());
expect(shell).not.toBeNull();
expect(shell?.dataset.mobile).toBe('0');
expect(shell?.dataset.userArea).toBe('1');
expect(shell?.firstElementChild?.className).toBe('fluxer-boot-app');
});
test('the boot shell is parsed in an inert template so the render-blocking expectation outlives it', () => {
setViewportWidth(1400);
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
const rootWrites: Array<string> = [];
const innerHTML = vi.spyOn(Element.prototype, 'innerHTML', 'set').mockImplementation(function (
this: Element,
value: string,
) {
if (this.id === 'root') {
rootWrites.push(value);
}
descriptor?.set?.call(this, value);
});
const createElement = vi.spyOn(document, 'createElement');
try {
const shell = runBootScript(desktopDMHint());
expect(shell?.querySelector('svg')).not.toBeNull();
expect(shell?.parentElement?.id).toBe('root');
expect(rootWrites).toEqual([]);
expect(createElement.mock.calls.map(([tagName]) => tagName)).toContain('template');
} finally {
createElement.mockRestore();
innerHTML.mockRestore();
}
});
function railGuildSlots(shell: HTMLElement | null): number {
return shell?.querySelectorAll('.fluxer-boot-rail-items > .fluxer-boot-slot').length ?? 0;
}
test('a hint written for another account is ignored and the default shell paints instead', () => {
setViewportWidth(1400);
const shell = runBootScript(desktopDMHint({a: 'https://two.example/api::200', ri: [[0, 0]]}));
expect(shell?.dataset.hint).toBe('default');
expect(railGuildSlots(shell)).toBe(6);
});
test('a signed-in boot without a hint paints the generic friends shell at the DM root', () => {
setViewportWidth(1400);
const shell = runBootScript(null);
expect(shell?.dataset.hint).toBe('default');
expect(shell?.dataset.userArea).toBe('1');
expect(railGuildSlots(shell)).toBe(6);
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
});
test('a signed-in boot without a hint paints a guild chat shell with the member list on a channel route', () => {
setViewportWidth(1400);
const shell = runBootScript(null, '/channels/100/200');
expect(shell?.dataset.hint).toBe('default');
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-member-list')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-composer')).not.toBeNull();
});
test('a signed-in boot without a hint paints the mobile channel list with a bottom nav on a phone', () => {
setViewportWidth(390);
const shell = runBootScript(null, '/channels/@me');
expect(shell?.dataset.mobile).toBe('1');
expect(shell?.dataset.bottomNav).toBe('1');
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
});
test('a signed-in boot never paints a shell on a route the app renders without guild chrome', () => {
setViewportWidth(1400);
expect(runBootScript(null, '/notifications')).toBeNull();
expect(runBootScript(null, '/channels/@discover')).toBeNull();
});
test('a days-old hint for the same account and route still paints as remembered', () => {
setViewportWidth(1400);
const shell = runBootScript(desktopDMHint({t: Date.now() - 3 * 86_400_000}));
expect(shell?.dataset.hint).toBe('hint');
expect(railGuildSlots(shell)).toBe(4);
});
test('a hint for another route keeps its rail and global chrome but resolves the route it lands on', () => {
setViewportWidth(1400);
const shell = runBootScript(desktopDMHint({nr: [[0, 0, 1]]}), '/channels/100/200');
expect(shell?.dataset.hint).toBe('derived');
expect(railGuildSlots(shell)).toBe(4);
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(1);
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-dsb')).toBeNull();
});
test('a hint seeded from the legacy desktop client paints its rail, nagbars and sidebar on the first boot', () => {
setViewportWidth(1400);
const seed = legacyShellHintSeed(
{
SkeletonLayoutMemory: JSON.stringify({
version: 9,
chrome: {
dmSidebar: {
isMobile: false,
friendsVisible: true,
personalNotesVisible: true,
premiumVisible: false,
sectionVisible: true,
channelRowCount: 2,
channelSubtextFlags: [true, false],
},
guildRail: {
inlineDmRowCount: 0,
inlineDmUnreadFlags: [],
selectedInlineDmRowIndex: -1,
outageVisible: false,
fluxerVisible: true,
favoritesVisible: true,
discoveryVisible: true,
addGuildVisible: true,
downloadVisible: false,
helpVisible: true,
selectedItemIndex: -1,
organizedItems: [
{kind: 'guild', indicator: 'none'},
{kind: 'guild', indicator: 'unread'},
],
scrollTopPx: 0,
},
},
nagbar: {
rows: [
{tone: NagbarToneKind.ALERT, hasActions: true, dismissible: false},
{tone: NagbarToneKind.BRAND, hasActions: true, dismissible: true},
],
},
}),
'fluxer:ui:sidebar-width': '306',
},
ACCOUNT_KEY,
Date.now(),
);
const hint = JSON.parse(seed ?? '');
expect(hint.v).toBe(SHELL_HINT_VERSION);
expect(LEGACY_SHELL_HINT_SEED_VERSION).toBe(SHELL_HINT_VERSION);
const shell = runBootScript(hint);
expect(shell?.dataset.hint).toBe('hint');
expect(railGuildSlots(shell)).toBe(2);
expect(shell?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
expect(shell?.querySelector('.fluxer-boot-dsb')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
expect(shell?.firstElementChild?.getAttribute('style')).toContain('--layout-sidebar-width:19.125rem');
const guild = runBootScript(hint, '/channels/100/200');
expect(guild?.dataset.hint).toBe('derived');
expect(railGuildSlots(guild)).toBe(2);
expect(guild?.querySelectorAll('.fluxer-boot-nagbar')).toHaveLength(2);
});
test('the legacy seed encodes nagbar tones in the order the boot shell decodes them', () => {
expect(LEGACY_SHELL_HINT_NAGBAR_TONE_ORDER).toEqual(SHELL_HINT_NAGBAR_TONE_ORDER);
});
test('the DM root paints the remembered route because the app restores it on boot', () => {
setViewportWidth(1400);
const shell = runBootScript(
desktopDMHint({p: '/channels/100/200', sv: 'guild', ck: 'chat', cb: 0, cid: '200', ch: 0}),
'/channels/@me',
);
expect(shell?.dataset.hint).toBe('hint');
expect(shell?.querySelector('.fluxer-boot-gsb')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-chat')).not.toBeNull();
});
test('a document script that never loads drops the boot shell because nothing else can mount the app', () => {
setViewportWidth(1400);
expect(runBootScript(desktopDMHint())).not.toBeNull();
failScript();
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
});
test('the backstop removes a boot shell React never cleared', () => {
vi.useFakeTimers();
setViewportWidth(1400);
expect(runBootScript(desktopDMHint())).not.toBeNull();
vi.advanceTimersByTime(60_000);
expect(document.querySelector('#fluxer-boot-shell')).toBeNull();
});
test('the error listener and the backstop stop existing once React has cleared the boot shell', async () => {
vi.useFakeTimers();
setViewportWidth(1400);
expect(runBootScript(desktopDMHint())).not.toBeNull();
document.getElementById('root')?.replaceChildren();
await Promise.resolve();
const marker = document.createElement('div');
marker.id = 'fluxer-boot-shell';
document.body.appendChild(marker);
failScript();
vi.advanceTimersByTime(60_000);
expect(document.querySelector('#fluxer-boot-shell')).not.toBeNull();
});
test('React clearing its own container is what removes the boot shell', async () => {
const container = document.createElement('div');
container.id = 'root';
const shell = document.createElement('div');
shell.id = 'fluxer-boot-shell';
container.appendChild(shell);
document.body.appendChild(container);
createRoot(container).render(createElement('span', null, 'mounted'));
await vi.waitFor(() => {
expect(container.textContent).toContain('mounted');
});
expect(container.querySelector('#fluxer-boot-shell')).toBeNull();
});
test('the zoom script syncs --custom-zoom with the persisted preboot mirror before paint', () => {
const chunk = html.split(/<script[^>]*>/u).find((part) => part.includes('zoom-preboot'));
expect(chunk).toBeDefined();
const script = (chunk ?? '').split('</script>')[0];
expect(script).toContain('zoom-preboot');
const run = (mirror: string | null): string => {
document.documentElement.style.removeProperty('--custom-zoom');
if (mirror === null) {
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
} else {
localStorage.setItem('fluxer:accessibility:zoom-preboot', mirror);
}
new Function(script)();
return document.documentElement.style.getPropertyValue('--custom-zoom');
};
expect(run('125')).toBe('125');
expect(run('75')).toBe('75');
expect(run(null)).toBe('');
expect(run('9999')).toBe('');
expect(run('not-a-number')).toBe('');
localStorage.removeItem('fluxer:accessibility:zoom-preboot');
});
function runSignedOutBootScript(pathname: string, storage: Readonly<Record<string, string>> = {}): HTMLElement | null {
return runBootScript(null, pathname, {signedIn: false, storage});
}
function setNativePlatform(native: boolean): void {
document.documentElement.classList.toggle('platform-native', native);
}
test('a signed-out boot paints the sign-in card at its final size instead of a flat background', () => {
setViewportWidth(1400);
const shell = runSignedOutBootScript('/login');
expect(shell?.dataset.auth).toBe('card');
expect(shell?.dataset.hint).toBe('default');
expect(shell?.dataset.height).toBe('214');
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.height).toBe('13.375rem');
expect(shell?.querySelectorAll('.fluxer-boot-auth-list > .fluxer-boot-block')).toHaveLength(2);
});
test('a signed-out boot on an app route paints the sign-in card the app redirects to', () => {
setViewportWidth(1400);
expect(runSignedOutBootScript('/channels/@me')?.dataset.category).toBe('login');
expect(runSignedOutBootScript('/channels/100/200')?.dataset.category).toBe('login');
});
test('a signed-out boot sizes the register card and the phone layout from their own defaults', () => {
setViewportWidth(1400);
expect(runSignedOutBootScript('/register')?.dataset.height).toBe('674');
setViewportWidth(390);
const mobile = runSignedOutBootScript('/register');
expect(mobile?.dataset.mobile).toBe('1');
expect(mobile?.dataset.height).toBe('592');
expect(mobile?.querySelector('.fluxer-boot-auth-mmain')).not.toBeNull();
});
test('a remembered auth card size wins over the default', () => {
setViewportWidth(1400);
recordAuthShellHint({pathname: '/login', mobile: false, kind: AuthShellHintKind.CARD, heightPx: 388, widthPx: 512});
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
const shell = runSignedOutBootScript('/login', storage);
expect(shell?.dataset.hint).toBe('hint');
expect(shell?.dataset.height).toBe('388');
expect(shell?.querySelector<HTMLElement>('.fluxer-boot-auth-card')?.style.maxWidth).toBe('32rem');
});
test('a remembered phone card is not used on a desktop-sized window', () => {
setViewportWidth(1400);
recordAuthShellHint({pathname: '/login', mobile: true, kind: AuthShellHintKind.CARD, heightPx: 500, widthPx: 342});
const storage = {'fluxer:ui:auth-shell-hint': localStorage.getItem('fluxer:ui:auth-shell-hint') ?? ''};
expect(runSignedOutBootScript('/login', storage)?.dataset.height).toBe('214');
});
test('a fresh desktop install paints the fullscreen welcome skeleton', () => {
setViewportWidth(1400);
setNativePlatform(true);
try {
const shell = runSignedOutBootScript('/login');
expect(shell?.dataset.auth).toBe('full');
expect(shell?.querySelector('.fluxer-boot-auth-full')).not.toBeNull();
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Welcome');
} finally {
setNativePlatform(false);
}
});
test('the fullscreen welcome greets in the stored locale before the browser language', () => {
setViewportWidth(1400);
setNativePlatform(true);
try {
const shell = runSignedOutBootScript('/login', {locale: 'de'});
expect(shell?.querySelector('.fluxer-boot-auth-wtext')?.textContent).toBe('Willkommen');
} finally {
setNativePlatform(false);
}
});
test('the boot welcome words match the welcome rotation', () => {
const table = /var t=(\{[^}]*\});/u.exec(readBootScript());
expect(table).not.toBeNull();
const words = new Function(`return ${table?.[1] ?? '{}'}`)() as Record<string, string>;
expect(words).toEqual(Object.fromEntries(WELCOME_ROTATION.map((entry) => [entry.code, entry.text])));
});
test('a shell that stamps a stored session paints the app shell even before the preboot mirror exists', () => {
setViewportWidth(1400);
document.documentElement.setAttribute('data-boot-session', '1');
try {
const shell = runSignedOutBootScript('/channels/@me');
expect(shell?.dataset.hint).toBe('default');
expect(shell?.dataset.auth).toBeUndefined();
} finally {
document.documentElement.removeAttribute('data-boot-session');
}
});
test('a session an older client stored paints the app shell before the preboot mirror exists', () => {
setViewportWidth(1400);
const shell = runSignedOutBootScript('/channels/200/300', {token: 'legacy-token', userId: '100'});
expect(shell?.dataset.hint).toBe('default');
expect(shell?.dataset.auth).toBeUndefined();
});
test('a half or emptied older session still paints the sign-in card', () => {
setViewportWidth(1400);
expect(runSignedOutBootScript('/channels/@me', {token: 'legacy-token'})?.dataset.auth).toBe('card');
expect(runSignedOutBootScript('/channels/@me', {token: 'null', userId: '100'})?.dataset.auth).toBe('card');
expect(
runSignedOutBootScript('/channels/@me', {
token: 'legacy-token',
userId: '100',
'fluxer:gateway:preboot:session': '0',
})?.dataset.auth,
).toBe('card');
});
test('a signed-out boot leaves routes without an auth card alone', () => {
setViewportWidth(1400);
expect(runSignedOutBootScript('/oauth2/authorize')).toBeNull();
});
test.each([
{pathname: '/login', width: 1400, native: false},
{pathname: '/login', width: 390, native: false},
{pathname: '/login', width: 1400, native: true},
{pathname: '/register', width: 1400, native: false},
{pathname: '/register', width: 390, native: false},
{pathname: '/forgot', width: 1400, native: false},
{pathname: '/invite/abc', width: 390, native: false},
])(
'the inline auth skeleton picks what the app placeholder picks for $pathname at $width px',
({pathname, width, native}) => {
setViewportWidth(width);
setNativePlatform(native);
try {
const shell = runSignedOutBootScript(pathname);
const placeholder = resolveAuthShellPlaceholder(pathname, {
mobile: width < 640,
native,
passkeyLikely: false,
});
expect(shell?.dataset.category).toBe(placeholder?.category);
expect(shell?.dataset.auth).toBe(placeholder?.kind);
expect(shell?.dataset.hint).toBe(placeholder?.source);
expect(Number(shell?.dataset.height)).toBe(placeholder?.heightPx);
} finally {
setNativePlatform(false);
}
},
);
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {FriendsSkeleton} from '@app/features/app/components/skeleton/FriendsSkeleton';
import {SHELL_HINT_VERSION} from '@app/features/app/components/skeleton/ShellHint';
import {
type RememberedSkeletonActiveNowCard,
type RememberedSkeletonFriendsLayout,
SKELETON_DEFAULT_FRIENDS_LAYOUT,
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
import {act, createElement, type ReactNode} from 'react';
import {createRoot} from 'react-dom/client';
import {afterEach, expect, test, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: ({children}: {children?: ReactNode}) => children ?? null,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
}));
const memory = vi.hoisted(() => ({friends: null as RememberedSkeletonFriendsLayout | null}));
vi.mock('@app/features/app/components/skeleton/SkeletonLayoutMemory', async (importOriginal) => {
const actual = await importOriginal<typeof import('@app/features/app/components/skeleton/SkeletonLayoutMemory')>();
return {...actual, getRememberedSkeletonFriendsLayout: () => memory.friends};
});
const ACCOUNT_KEY = 'https://one.example/api::100';
const html = readFileSync(resolve(process.cwd(), 'index.html'), 'utf8');
function readBootScript(): string {
const body = html.slice(html.indexOf('<div id="root"></div>'));
return /<script[^>]*>([\s\S]*?)<\/script>/u.exec(body)?.[1] ?? '';
}
function friendsHint(extra: Record<string, unknown>): Record<string, unknown> {
return {
v: SHELL_HINT_VERSION,
t: Date.now(),
p: '/channels/@me',
a: ACCOUNT_KEY,
mo: 0,
ua: 0,
sv: 'dm',
ck: 'other',
cb: 1,
...extra,
};
}
function bootSidebar(hint: Record<string, unknown> | null): Element | null {
document.body.innerHTML = '<div id="root"></div>';
window.history.pushState({}, '', '/channels/@me');
localStorage.clear();
localStorage.setItem('fluxer:gateway:preboot:session', '1');
localStorage.setItem('fluxer:auth:active-account-key', ACCOUNT_KEY);
if (hint != null) {
localStorage.setItem('fluxer:ui:shell-hint', JSON.stringify(hint));
}
new Function(readBootScript())();
const shell = document.querySelector('#fluxer-boot-shell');
expect(shell?.querySelector('.fluxer-boot-friends')).not.toBeNull();
return shell?.querySelector('.fluxer-boot-fr-side') ?? null;
}
async function reactSidebar(friends: RememberedSkeletonFriendsLayout | null): Promise<Element | null> {
memory.friends = friends;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => root.render(createElement(FriendsSkeleton)));
const sidebar = container.querySelector('flx-app-friends-skeleton-sidebar');
const copy = sidebar?.cloneNode(true) as Element | undefined;
await act(async () => root.unmount());
return copy ?? null;
}
function bootGeometry(sidebar: Element | null): Array<string> {
return Array.from(sidebar?.querySelectorAll('.fluxer-boot-block') ?? []).map((block) => {
const style = block.getAttribute('style') ?? '';
const width = /width:([^;]+);/.exec(style)?.[1];
const height = /height:([^;]+);/.exec(style)?.[1];
return `${width}x${height}`;
});
}
function reactGeometry(sidebar: Element | null): Array<string> {
return Array.from(sidebar?.querySelectorAll('flx-skeleton-line, flx-skeleton-block') ?? []).map((block) => {
const style = (block as HTMLElement).style;
return `${style.getPropertyValue('--skeleton-width')}x${style.getPropertyValue('--skeleton-height')}`;
});
}
function layoutWith(activeNowVisible: boolean, cards: ReadonlyArray<RememberedSkeletonActiveNowCard>) {
return {...SKELETON_DEFAULT_FRIENDS_LAYOUT, activeNowVisible, activeNowCards: cards};
}
afterEach(() => {
memory.friends = null;
localStorage.clear();
document.body.innerHTML = '';
});
test('with nobody active the boot shell paints the same empty state React paints', async () => {
const boot = bootSidebar(friendsHint({fa: 1, fn: []}));
const react = await reactSidebar(layoutWith(true, []));
expect(boot?.querySelector('.fluxer-boot-fr-empty')).not.toBeNull();
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
});
test('remembered cards paint with the same count and geometry as the React skeleton', async () => {
const cards = [
{participantCount: 0, streaming: false},
{participantCount: 3, streaming: true},
{participantCount: 7, streaming: false},
];
const boot = bootSidebar(
friendsHint({
fa: 1,
fn: [
[0, 0],
[3, 1],
[7, 0],
],
}),
);
const react = await reactSidebar(layoutWith(true, cards));
expect(boot?.querySelectorAll('.fluxer-boot-fr-card')).toHaveLength(3);
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
});
test('a hidden active now column paints no sidebar in either skeleton', async () => {
expect(bootSidebar(friendsHint({fa: 0, fn: []}))).toBeNull();
expect(await reactSidebar(layoutWith(false, []))).toBeNull();
});
test('without any remembered layout neither skeleton invents activity cards', async () => {
const boot = bootSidebar(null);
const react = await reactSidebar(null);
expect(boot?.querySelector('.fluxer-boot-fr-card')).toBeNull();
expect(react?.querySelector('flx-app-friends-skeleton-active-card')).toBeNull();
expect(bootGeometry(boot)).toEqual(reactGeometry(react));
});
+6 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {initI18n} from '@app/app/I18n';
import {ensureActiveLocale, initI18n} from '@app/app/I18n';
import {BootstrapErrorScreen} from '@app/features/app/components/BootstrapErrorScreen';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -16,10 +16,14 @@ export async function reportBootstrapError(error: unknown): Promise<void> {
if (!container) {
throw new Error('Missing #root element');
}
await initI18n();
ensureActiveLocale();
ReactDOM.createRoot(container).render(
<AppI18nProvider i18n={i18n}>
<BootstrapErrorScreen error={normalized} data-flx="index.bootstrap-error-screen" />
</AppI18nProvider>,
);
globalThis.window?.electron?.notifyFirstContentPainted?.();
initI18n().catch((localeError: unknown) => {
logger.warn('Failed to load the locale for the bootstrap error screen:', localeError);
});
}
+16
View File
@@ -206,6 +206,16 @@ function activateLocale(localeCode: LocaleCode, messages: Messages): void {
AppStorage.setItem('locale', localeCode);
}
export function ensureActiveLocale(): void {
if (i18n.locale) {
return;
}
i18n.loadAndActivate({locale: DEFAULT_LOCALE, messages: messagesEnUS});
if (typeof document !== 'undefined') {
document.documentElement.lang = DEFAULT_LOCALE;
}
}
const inFlightLoads = new Map<LocaleCode, Promise<Messages>>();
let requestedLocale: LocaleCode | null = null;
@@ -246,6 +256,11 @@ export function applyLocaleChange(localeCode: string): LocaleCode {
}
let initPromise: Promise<typeof i18n> | null = null;
let initialized = false;
export function isI18nInitialized(): boolean {
return initialized;
}
export async function initI18n(forceLocale?: string) {
if (!initPromise) {
@@ -257,6 +272,7 @@ export async function initI18n(forceLocale?: string) {
logger.error('Failed to initialize i18n, falling back to default locale', error);
activateLocale(DEFAULT_LOCALE, messagesEnUS);
}
initialized = true;
return i18n;
})();
}
+87 -9
View File
@@ -362,6 +362,7 @@ html.platform-native.platform-macos {
}
html {
--custom-zoom: 100;
background-color: var(--background-secondary);
color: var(--text-primary);
overflow: hidden;
@@ -369,35 +370,112 @@ html {
user-select: var(--user-select);
-webkit-user-select: var(--user-select);
font-family: var(--font-sans);
font-size: 16px;
font-size: calc(var(--custom-zoom, 100) * 0.16px);
line-height: 1.5;
font-optical-sizing: auto;
}
html.platform-native {
--custom-zoom: 100;
font-size: calc(var(--custom-zoom, 100) * 1%);
}
html.allow-transparency,
html.allow-transparency body {
background-color: transparent;
}
#fluxer-startup-native-titlebar {
.fluxer-native-titlebar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--native-titlebar-height);
display: none;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: 0 0 0 calc(var(--spacing-3) + env(safe-area-inset-left));
background: var(--background-secondary);
-webkit-app-region: drag;
z-index: var(--z-index-titlebar);
}
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
.fluxer-native-titlebar[data-platform='macos'] {
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
}
html.platform-native:not(.window-focused) .fluxer-native-titlebar,
html.platform-native.window-focus-activation-guard .fluxer-native-titlebar {
pointer-events: auto;
}
.fluxer-native-titlebar__left {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--text-primary-muted);
opacity: 0.8;
}
.fluxer-native-titlebar__wordmark {
display: block;
height: 0.8125rem;
width: auto;
color: var(--text-primary-muted);
overflow: visible;
}
.fluxer-native-titlebar__spacer {
flex: 1 1 auto;
}
.fluxer-native-titlebar__controls {
display: flex;
align-items: stretch;
height: 100%;
gap: 0;
-webkit-app-region: no-drag;
}
.fluxer-native-titlebar__control {
-webkit-app-region: no-drag;
appearance: none;
box-sizing: border-box;
border: 0;
background: transparent;
color: var(--text-secondary);
width: 3rem;
height: 100%;
padding: 0;
border-radius: 0;
display: grid;
place-items: center;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease;
cursor: pointer;
}
.fluxer-native-titlebar__control svg {
width: 1.0625rem;
height: 1.0625rem;
}
.fluxer-native-titlebar__control:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
border-color: var(--user-area-divider-color);
}
.fluxer-native-titlebar__control:active {
background: var(--background-modifier-selected);
}
.fluxer-native-titlebar__control--close:hover {
background: #e81123;
color: #fff;
border-color: transparent;
}
.fluxer-native-titlebar__control--close:active {
background: #b50d1a;
}
html,
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {currentInstanceTarget} from '@app/features/platform/transport/InstanceHTTP';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {i18n} from '@lingui/core';
const logger = new Logger('ShortLinkAcceptModalOpener');
function openAcceptModal(kind: string, open: () => Promise<void>): void {
void open().catch((error: unknown) => {
logger.error(`Failed to open the ${kind} accept modal`, error);
});
}
export function openInvite(code: string): void {
openAcceptModal('invite', async () => {
const commands = await import('@app/features/invite/commands/InviteCommands');
await commands.openAcceptModal(code, currentInstanceTarget());
});
}
export function openGift(code: string): void {
openAcceptModal('gift', async () => {
const commands = await import('@app/features/gift/commands/GiftCommands');
await commands.openAcceptModal(code, currentInstanceTarget());
});
}
export function openTheme(themeId: string): void {
openAcceptModal('theme', async () => {
const commands = await import('@app/features/theme/commands/ThemeCommands');
commands.openAcceptModal(themeId, i18n, RuntimeConfig.getSnapshot());
});
}
@@ -15,18 +15,19 @@ import {
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
import Initialization from '@app/features/app/state/Initialization';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import AccountManager from '@app/features/auth/state/AccountManager';
import Accounts from '@app/features/auth/state/Accounts';
import Authentication from '@app/features/auth/state/Authentication';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {useLastLocationPersistence} from '@app/features/navigation/hooks/useLastLocationPersistence';
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
import {navigateToWithMobileHistory} from '@app/features/navigation/utils/MobileNavigation';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
import SessionManager from '@app/features/platform/state/AuthSession';
import {resolveDocumentURLFromRoot} from '@app/features/platform/URLOriginUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {useHolidayClasses} from '@app/features/theme/hooks/useHolidayClasses';
import Location from '@app/features/ui/state/Location';
@@ -102,7 +103,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
if (
isAuthenticated &&
isLocationStateHydrated &&
!AccountManager.isSwitching &&
!Accounts.isSwitching &&
pathname === Routes.ME &&
!hasRestoredLocation
) {
@@ -115,7 +116,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
if (
!isAuthenticated ||
!isLocationStateHydrated ||
AccountManager.isSwitching ||
Accounts.isSwitching ||
pathname !== Routes.ME ||
hasRestoredLocation
) {
@@ -142,7 +143,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
}, [isAuthenticated]);
const normalizeInternalUrl = useCallback((rawUrl: string): string => {
try {
const u = new URL(rawUrl, window.location.origin);
const u = resolveDocumentURLFromRoot(rawUrl);
if (u.origin === window.location.origin) {
return u.pathname + u.search + u.hash;
}
@@ -157,7 +158,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
}, [shouldDismissTransientOverlays]);
useEffect(() => {
if (!SessionManager.isInitialized) return;
if (AccountManager.isSwitching) return;
if (Accounts.isSwitching) return;
const isAuth = Authentication.isAuthenticated;
if (isAuth && isStandaloneRoute) return;
if (shouldBypassGateway) {
@@ -180,17 +181,13 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: pendingRedirectRef.current}));
return;
}
if (isAuth && Initialization.isLoading) {
void AuthenticationCommands.ensureSessionStarted();
}
}, [
SessionManager.isInitialized,
authToken,
AccountManager.isSwitching,
Accounts.isSwitching,
Authentication.isAuthenticated,
GatewayConnection.isConnected,
GatewayConnection.isConnecting,
Initialization.isLoading,
shouldBypassGateway,
shouldSkipAutoRedirect,
pendingRedirectRef,
@@ -238,12 +235,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
canNavigateToProtectedRoutes,
location.pathname,
]);
useEffect(() => {
const shouldSaveLocation = Routes.isChannelRoute(location.pathname) || Routes.isSpecialPage(location.pathname);
if (isAuthenticated && shouldSaveLocation) {
Location.saveLocation(location.pathname);
}
}, [isAuthenticated, location.pathname]);
useLastLocationPersistence(location.pathname);
useEffect(() => {
if (!isAuthenticated || !hasRestoredLocation) return;
const previousMobileLayoutState = previousMobileLayoutStateRef.current;
@@ -297,11 +289,22 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
}
lastNotificationNavRef.current = {ts: now, key};
void (async () => {
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
const targetAccountKey =
targetUserId == null ? null : (Accounts.getAccount(targetUserId)?.storageKey ?? null);
if (targetUserId != null && targetUserId !== Accounts.currentUserId) {
if (targetAccountKey == null || !Accounts.canSwitchAccounts) {
logger.error('Cannot activate the notification target account', {targetUserId});
return;
}
try {
await AccountManager.switchToAccount(targetUserId);
await Accounts.switchToAccount(targetAccountKey);
} catch (error) {
logger.error('Failed to switch account for notification', error);
return;
}
if (Accounts.currentUserId !== targetUserId) {
logger.error('Notification target account was not activated', {targetUserId});
return;
}
}
if (mobileLayoutState.enabled) {
@@ -327,7 +330,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
if (urlParams.get('fromNotification') === '1') {
const newParams = new URLSearchParams(urlParams);
newParams.delete('fromNotification');
const cleanUrl = new URL(location.pathname, window.location.origin);
const cleanUrl = resolveDocumentURLFromRoot(location.pathname);
cleanUrl.search = newParams.toString();
const cleanPath = `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`;
if (mobileLayoutState.enabled) {
@@ -370,7 +373,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
)}
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
<AppSkeleton
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
key={`loading-skeleton:${Accounts.currentUserId ?? 'unauthenticated'}`}
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
isExiting={!shouldShowLoadingSkeleton}
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import {resolveForgotPasswordRedirect} from '@app/features/auth/flow/AccountRecoveryRedirects';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import Authentication from '@app/features/auth/state/Authentication';
import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect';
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {Redirect, type RouteContext, type To} from '@app/features/platform/components/router/RouterTypes';
import SessionManager from '@app/features/platform/state/AuthSession';
import {resolveDocumentURLFromRoot} from '@app/features/platform/URLOriginUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
const logger = new Logger('AuthRouteRedirectPolicy');
export type AuthRouteEnterHandler = (context: RouteContext) => Redirect | undefined;
function resolveToPath(to: To): string {
if (typeof to === 'string') {
return to;
}
const url = resolveDocumentURLFromRoot(to.to);
if (to.search) {
url.search = '';
for (const [key, value] of Object.entries(to.search)) {
if (value === undefined) continue;
if (value === null) {
url.searchParams.set(key, '');
} else {
url.searchParams.set(key, String(value));
}
}
}
if (to.hash) {
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
}
return url.pathname + url.search + url.hash;
}
function isStillOnRouteContext(context: RouteContext): boolean {
const current = window.location.pathname + window.location.search + window.location.hash;
return current === context.url.pathname + context.url.search + context.url.hash;
}
function applyDeferredSessionRedirect(context: RouteContext, handler: AuthRouteEnterHandler): void {
if (!isStillOnRouteContext(context)) {
return;
}
const redirect = handler(context);
if (redirect instanceof Redirect) {
RouterUtils.replaceWith(resolveToPath(redirect.to));
}
}
export function isDesktopHandoffLocation(): boolean {
return isHandoffRequest(new URLSearchParams(window.location.search));
}
export function resolveCurrentRedirectTarget(fallback: string): string {
const queryParams = new URLSearchParams(window.location.search);
return safeRedirectTargetOrFallback(queryParams.get('redirect_to'), fallback);
}
export function afterSessionInitialization(handler: AuthRouteEnterHandler): AuthRouteEnterHandler {
return (context: RouteContext): Redirect | undefined => {
if (SessionManager.isInitialized) {
return handler(context);
}
void SessionManager.initialize()
.then(() => {
applyDeferredSessionRedirect(context, handler);
})
.catch((error: unknown) => {
logger.error('Failed to initialize the auth session for a route decision', error);
});
return undefined;
};
}
export function whenAuthenticated(handler: AuthRouteEnterHandler): AuthRouteEnterHandler {
return afterSessionInitialization((context) => (Authentication.isAuthenticated ? handler(context) : undefined));
}
export const requireAuthentication: AuthRouteEnterHandler = afterSessionInitialization((context) =>
Authentication.isAuthenticated
? undefined
: new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: context.url.pathname + context.url.search})),
);
export function resolveAuthenticatedLoginEntry(): Redirect | undefined {
if (isDesktopHandoffLocation()) {
return undefined;
}
const queryParams = new URLSearchParams(window.location.search);
const redirectTarget = safeRedirectTarget(queryParams.get('redirect_to'));
if (redirectTarget == null || redirectTarget === '') {
return new Redirect(Routes.ME);
}
return new Redirect(redirectTarget);
}
export function resolveEmailFeatureEntry(): Redirect | undefined {
const snapshot = RuntimeConfig.getSnapshotOrNull();
if (snapshot === null || snapshot.features.emails_enabled) {
return undefined;
}
return new Redirect(Routes.LOGIN);
}
function resolveHomeEntry(): Redirect {
return new Redirect(Routes.ME);
}
function activeSnapshotUsesUsernameSignIn(): boolean {
const snapshot = RuntimeConfig.getSnapshotOrNull();
return snapshot !== null && usesUsernameSignIn(snapshot.features);
}
export function resolvePasswordResetEntry(): Redirect | undefined {
if (activeSnapshotUsesUsernameSignIn()) {
return undefined;
}
return resolveEmailFeatureEntry();
}
export function resolveRecoverAccountEntry(context: RouteContext): Redirect | undefined {
const snapshot = RuntimeConfig.getSnapshotOrNull();
if (snapshot !== null && !usesUsernameSignIn(snapshot.features)) {
return new Redirect(Routes.LOGIN);
}
return whenAuthenticated(resolveHomeEntry)(context);
}
export function resolveForgotPasswordEntry(context: RouteContext): Redirect | undefined {
const snapshot = RuntimeConfig.getSnapshotOrNull();
const redirect = snapshot === null ? null : resolveForgotPasswordRedirect(snapshot);
if (redirect !== null) {
return new Redirect(redirect);
}
return whenAuthenticated(resolveHomeEntry)(context);
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import * as AuthRouteRedirectPolicy from '@app/app/router/policy/AuthRouteRedirectPolicy';
import {Redirect, type RouteContext} from '@app/features/platform/components/router/RouterTypes';
export interface ShortLinkRouteEnterHandlers {
readonly onRegisterEnter: AuthRouteRedirectPolicy.AuthRouteEnterHandler;
readonly onLoginEnter: AuthRouteRedirectPolicy.AuthRouteEnterHandler;
}
interface ShortLinkRouteEnterRequest {
readonly paramName: string;
readonly openAcceptModal: (value: string) => void;
}
interface AcceptShortLinkRequest {
readonly context: RouteContext;
readonly paramName: string;
readonly openAcceptModal: (value: string) => void;
}
function acceptShortLink({context, paramName, openAcceptModal}: AcceptShortLinkRequest): Redirect {
const value = context.params[paramName];
if (value != null && value !== '') {
openAcceptModal(value);
}
return new Redirect(AuthRouteRedirectPolicy.resolveCurrentRedirectTarget(Routes.ME));
}
function acceptShortLinkUnlessDesktopHandoff(request: AcceptShortLinkRequest): Redirect | undefined {
if (AuthRouteRedirectPolicy.isDesktopHandoffLocation()) {
return undefined;
}
return acceptShortLink(request);
}
export function createHandlers({paramName, openAcceptModal}: ShortLinkRouteEnterRequest): ShortLinkRouteEnterHandlers {
return {
onRegisterEnter: AuthRouteRedirectPolicy.whenAuthenticated((context) =>
acceptShortLink({context, paramName, openAcceptModal}),
),
onLoginEnter: AuthRouteRedirectPolicy.whenAuthenticated((context) =>
acceptShortLinkUnlessDesktopHandoff({context, paramName, openAcceptModal}),
),
};
}
+38 -27
View File
@@ -2,6 +2,7 @@
import {Routes} from '@app/app/Routes';
import {GuildChannelRouter} from '@app/app/router/components/GuildChannelRouter';
import {requireAuthentication, whenAuthenticated} from '@app/app/router/policy/AuthRouteRedirectPolicy';
import {rootRoute} from '@app/app/router/routes/RootRoutes';
import {AppBadge} from '@app/features/app/components/AppNotificationBadge';
import {AppLayout} from '@app/features/app/components/layout/AppLayout';
@@ -9,14 +10,15 @@ import {DiscoveryLayout} from '@app/features/app/components/layout/DiscoveryLayo
import {FavoritesLayout} from '@app/features/app/components/layout/FavoritesLayout';
import {GuildsLayout} from '@app/features/app/components/layout/GuildsLayout';
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Authentication from '@app/features/auth/state/Authentication';
import {
AuthenticatedRuntimeInvariantError,
readAuthenticatedRuntimeContext,
} from '@app/features/auth/state/AuthenticatedRuntime';
import {ChannelIndexPage} from '@app/features/channel/components/ChannelIndexPage';
import {ChannelLayout} from '@app/features/channel/components/ChannelLayout';
import {DMLayout} from '@app/features/channel/components/direct_message/DirectMessageLayout';
import Channels from '@app/features/channel/state/Channels';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {navigateToLinkedUserProfile} from '@app/features/navigation/utils/DeepLinkUtils';
import {getDirectMessagesFallbackPath} from '@app/features/navigation/utils/DefaultLandingUtils';
@@ -34,10 +36,18 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {observer} from 'mobx-react-lite';
import {useEffect, useState} from 'react';
import {type ReactNode, useEffect, useState} from 'react';
type AppRouteComponentProps = Record<string, unknown>;
function authenticatedRuntimeDisablesDirectMessages(): boolean {
const runtimeContext = readAuthenticatedRuntimeContext();
if (runtimeContext === null) {
throw new AuthenticatedRuntimeInvariantError('An authenticated route requires an active account runtime');
}
return runtimeContext.runtime.community.direct_messages_disabled;
}
const YouPage = createNamedLoadableComponent<AppRouteComponentProps>({
displayName: 'YouPage',
load: async () => (await import('@app/features/app/components/pages/YouPage')).YouPage,
@@ -77,24 +87,24 @@ const StatusChangeBottomSheet = createNamedLoadableComponent<AppRouteComponentPr
(await import('@app/features/user/components/modals/StatusChangeBottomSheet')).StatusChangeBottomSheet,
});
const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'appLayout',
onEnter: () => {
if (!SessionManager.isInitialized) {
return undefined;
}
if (!Authentication.isAuthenticated) {
const current = window.location.pathname + window.location.search;
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
}
return undefined;
},
layout: ({children}) => (
const ProtectedAppLayout = observer(({children}: {children: ReactNode}) => {
if (readAuthenticatedRuntimeContext() === null && !SessionManager.isSwitching) {
return null;
}
return (
<>
<AppBadge data-flx="app.router.app-routes.layout.app-badge" />
<AppLayout data-flx="app.router.app-routes.layout.app-layout">{children}</AppLayout>
</>
);
});
const appLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'appLayout',
onEnter: requireAuthentication,
layout: ({children}) => (
<ProtectedAppLayout data-flx="app.router.app-routes.layout.protected-app-layout">{children}</ProtectedAppLayout>
),
});
const guildsLayoutRoute = createRoute({
@@ -162,6 +172,7 @@ export const premiumCallbackRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'premiumCallback',
path: Routes.PREMIUM_CALLBACK,
onEnter: requireAuthentication,
preload: PremiumCallbackPage.preload,
component: () => <PremiumCallbackPage data-flx="app.router.app-routes.premium-callback-page" />,
});
@@ -197,12 +208,12 @@ const meRoute = createRoute({
getParentRoute: () => guildsLayoutRoute,
id: 'me',
path: '/channels/@me',
onEnter: () => {
if (RuntimeConfig.directMessagesDisabled) {
onEnter: whenAuthenticated(() => {
if (authenticatedRuntimeDisablesDirectMessages()) {
return new Redirect(getDirectMessagesFallbackPath());
}
return undefined;
},
}),
component: observer(() => {
const isMobileLayout = MobileLayout.enabled;
useEffect(() => {
@@ -297,9 +308,9 @@ const channelRoute = createRoute({
getParentRoute: () => channelsRoute,
id: 'channel',
path: '/channels/:guildId/:channelId',
onEnter: (ctx) => {
onEnter: whenAuthenticated((ctx) => {
const {guildId, channelId} = ctx.params;
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
return new Redirect(getDirectMessagesFallbackPath());
}
const channel = Channels.getChannel(channelId);
@@ -307,7 +318,7 @@ const channelRoute = createRoute({
return new Redirect(Routes.guildChannel(guildId));
}
return undefined;
},
}),
component: () => (
<ChannelLayout data-flx="app.router.app-routes.channel-layout--2">
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--2" />
@@ -318,9 +329,9 @@ const messageRoute = createRoute({
getParentRoute: () => channelRoute,
id: 'message',
path: '/channels/:guildId/:channelId/:messageId',
onEnter: (ctx) => {
onEnter: whenAuthenticated((ctx) => {
const {guildId, channelId} = ctx.params;
if (guildId === ME && RuntimeConfig.directMessagesDisabled) {
if (guildId === ME && authenticatedRuntimeDisablesDirectMessages()) {
return new Redirect(getDirectMessagesFallbackPath());
}
const channel = Channels.getChannel(channelId);
@@ -328,7 +339,7 @@ const messageRoute = createRoute({
return new Redirect(Routes.guildChannel(guildId));
}
return undefined;
},
}),
component: () => (
<ChannelLayout data-flx="app.router.app-routes.channel-layout--3">
<ChannelIndexPage data-flx="app.router.app-routes.channel-index-page--3" />
+109 -218
View File
@@ -1,25 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import * as AuthRouteRedirectPolicy from '@app/app/router/policy/AuthRouteRedirectPolicy';
import * as ShortLinkRouteEnterPolicy from '@app/app/router/policy/ShortLinkRouteEnterPolicy';
import {rootRoute} from '@app/app/router/routes/RootRoutes';
import * as ShortLinkAcceptModalOpener from '@app/app/router/ShortLinkAcceptModalOpener';
import {AuthLayout} from '@app/features/app/components/layout/AuthLayout';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {
type AuthRoutePage,
createAuthRoutePage,
createNamedAuthRoutePage,
} from '@app/features/auth/flow/AuthLoadableRoutePage';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import Authentication from '@app/features/auth/state/Authentication';
import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect';
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {createRoute} from '@app/features/platform/components/router/RouterBuilder';
import type {RouteConfig, RouteContext} from '@app/features/platform/components/router/RouterTypes';
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
import SessionManager from '@app/features/platform/state/AuthSession';
import {type AuthRoutePage, createAuthRoutePage} from '@app/features/auth/flow/AuthLoadableRoutePage';
import {createRoute, type RouteBuilder} from '@app/features/platform/components/router/RouterBuilder';
import {NotFound, Redirect, type RouteContext} from '@app/features/platform/components/router/RouterTypes';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import {i18n} from '@lingui/core';
const AuthorizeIPPage = createAuthRoutePage(
'AuthorizeIPPage',
@@ -50,8 +41,8 @@ const ResetPasswordPage = createAuthRoutePage(
'ResetPasswordPage',
() => import('@app/features/auth/components/pages/ResetPasswordPage'),
);
const SsoCallbackPage = createAuthRoutePage(
'SsoCallbackPage',
const SSOCallbackPage = createAuthRoutePage(
'SSOCallbackPage',
() => import('@app/features/auth/components/pages/SsoCallbackPage'),
);
const VerifyEmailPage = createAuthRoutePage(
@@ -74,10 +65,9 @@ const InviteRegisterPage = createAuthRoutePage(
'InviteRegisterPage',
() => import('@app/features/invite/components/pages/InviteRegisterPage'),
);
const ReportPage = createNamedAuthRoutePage('ReportPage', async () => {
const module = await import('@app/features/moderation/components/pages/ReportPage');
return module.ReportPage;
});
const ReportPage = createAuthRoutePage('ReportPage', async () => ({
default: (await import('@app/features/moderation/components/pages/ReportPage')).ReportPage,
}));
const ThemeLoginPage = createAuthRoutePage(
'ThemeLoginPage',
() => import('@app/features/theme/components/pages/ThemeLoginPage'),
@@ -87,81 +77,23 @@ const ThemeRegisterPage = createAuthRoutePage(
() => import('@app/features/theme/components/pages/ThemeRegisterPage'),
);
const currentRedirectTarget = (fallback: string): string => {
const qp = new URLSearchParams(window.location.search);
return safeRedirectTargetOrFallback(qp.get('redirect_to'), fallback);
};
const resolveToPath = (to: Redirect['to']): string => {
if (typeof to === 'string') {
return to;
}
const url = new URL(to.to, window.location.origin);
if (to.search) {
url.search = '';
for (const [k, v] of Object.entries(to.search)) {
if (v === undefined) continue;
if (v === null) {
url.searchParams.set(k, '');
} else {
url.searchParams.set(k, String(v));
}
}
}
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 redirectWhenEmailsDisabled: RouteConfig['onEnter'] = () => {
if (!RuntimeConfig.emailsEnabled) {
return new Redirect(Routes.LOGIN);
}
return undefined;
};
const redirectWhenPasswordResetUnavailable: RouteConfig['onEnter'] = () => {
if (!RuntimeConfig.emailsEnabled && !RuntimeConfig.usesUsernameSignIn) {
return new Redirect(Routes.LOGIN);
}
return undefined;
};
const whenAuthenticated = (handler: AuthRedirectHandler) => {
return (ctx: RouteContext): Redirect | undefined => {
const execute = (): Redirect | undefined => handler(ctx);
if (SessionManager.isInitialized) {
return Authentication.isAuthenticated ? execute() : undefined;
}
void SessionManager.initialize().then(() => {
if (Authentication.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 data-flx="app.router.auth-routes.layout.auth-layout">{children}</AuthLayout>,
});
type AuthPageEnterHandler = (context: RouteContext) => Redirect | NotFound | undefined;
interface AuthPageRouteOptions {
id: string;
path: string;
page: AuthRoutePage;
dataFlx: string;
onEnter?: RouteConfig['onEnter'];
onEnter?: AuthPageEnterHandler;
}
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions) {
function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageRouteOptions): RouteBuilder {
return createRoute({
getParentRoute: () => authLayoutRoute,
id,
@@ -172,7 +104,7 @@ function createAuthPageRoute({id, path, page: Page, dataFlx, onEnter}: AuthPageR
});
}
function createAuthRedirectRoute(id: string, path: string) {
function createAuthRedirectRoute(id: string, path: string): RouteBuilder {
return createRoute({
getParentRoute: () => authLayoutRoute,
id,
@@ -181,25 +113,84 @@ function createAuthRedirectRoute(id: string, path: string) {
});
}
interface ShortLinkRoutePaths {
readonly register: string;
readonly login: string;
}
interface ShortLinkAuthRouteOptions {
id: string;
dataFlxPrefix: string;
paths: ShortLinkRoutePaths;
registerPage: AuthRoutePage;
loginPage: AuthRoutePage;
handlers: {readonly onRegisterEnter: AuthPageEnterHandler; readonly onLoginEnter: AuthPageEnterHandler};
}
function createShortLinkAuthRoutes({
id,
dataFlxPrefix,
paths,
registerPage,
loginPage,
handlers,
}: ShortLinkAuthRouteOptions): Array<RouteBuilder> {
return [
createAuthPageRoute({
id: `${id}Register`,
path: paths.register,
page: registerPage,
dataFlx: `${dataFlxPrefix}-register-page`,
onEnter: handlers.onRegisterEnter,
}),
createAuthPageRoute({
id: `${id}Login`,
path: paths.login,
page: loginPage,
dataFlx: `${dataFlxPrefix}-login-page`,
onEnter: handlers.onLoginEnter,
}),
];
}
const inviteRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
paramName: 'code',
openAcceptModal: ShortLinkAcceptModalOpener.openInvite,
});
const giftShortLinkHandlers = ShortLinkRouteEnterPolicy.createHandlers({
paramName: 'code',
openAcceptModal: ShortLinkAcceptModalOpener.openGift,
});
function whenGiftingAvailable(handler: AuthRouteRedirectPolicy.AuthRouteEnterHandler): AuthPageEnterHandler {
return (context: RouteContext): Redirect | NotFound | undefined => {
if (RuntimeConfig.getSnapshotOrNull() !== null && !shouldShowPremiumFeatures()) {
return new NotFound();
}
return handler(context);
};
}
const giftRouteEnterHandlers = {
onRegisterEnter: whenGiftingAvailable(giftShortLinkHandlers.onRegisterEnter),
onLoginEnter: whenGiftingAvailable(giftShortLinkHandlers.onLoginEnter),
};
const themeRouteEnterHandlers = ShortLinkRouteEnterPolicy.createHandlers({
paramName: 'themeId',
openAcceptModal: ShortLinkAcceptModalOpener.openTheme,
});
const loginRoute = createAuthPageRoute({
id: 'login',
path: '/login',
page: LoginPage,
dataFlx: 'app.router.auth-routes.login-page',
onEnter: whenAuthenticated(() => {
const search = window.location.search;
const qp = new URLSearchParams(search);
if (isHandoffRequest(qp)) {
return undefined;
}
const redirectTo = safeRedirectTarget(qp.get('redirect_to'));
return new Redirect(redirectTo || Routes.ME);
}),
onEnter: AuthRouteRedirectPolicy.whenAuthenticated(AuthRouteRedirectPolicy.resolveAuthenticatedLoginEntry),
});
const ssoCallbackRoute = createAuthPageRoute({
id: 'ssoCallback',
path: Routes.SSO_CALLBACK,
page: SsoCallbackPage,
page: SSOCallbackPage,
dataFlx: 'app.router.auth-routes.sso-callback-page',
});
const inviteBaseRoute = createAuthRedirectRoute('inviteBase', '/invite');
@@ -216,137 +207,58 @@ const oauthAuthorizeRoute = createAuthPageRoute({
path: Routes.OAUTH_AUTHORIZE,
page: OAuthAuthorizePage,
dataFlx: 'app.router.auth-routes.o-auth-authorize-page',
onEnter: () => {
const current = window.location.pathname + window.location.search;
if (!SessionManager.isInitialized) {
void SessionManager.initialize().then(() => {
if (!Authentication.isAuthenticated) {
RouterUtils.replaceWith(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
}
});
return undefined;
}
if (!Authentication.isAuthenticated) {
return new Redirect(setPathQueryParams(Routes.LOGIN, {redirect_to: current}));
}
return undefined;
},
onEnter: AuthRouteRedirectPolicy.requireAuthentication,
});
const inviteRegisterRoute = createAuthPageRoute({
id: 'inviteRegister',
path: '/invite/:code',
page: InviteRegisterPage,
dataFlx: 'app.router.auth-routes.invite-register-page',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params['code'];
if (code) {
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
commands.openAcceptModal(code);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
const inviteRoutes = createShortLinkAuthRoutes({
id: 'invite',
dataFlxPrefix: 'app.router.auth-routes.invite',
paths: {register: Routes.INVITE_REGISTER, login: Routes.INVITE_LOGIN},
registerPage: InviteRegisterPage,
loginPage: InviteLoginPage,
handlers: inviteRouteEnterHandlers,
});
const inviteLoginRoute = createAuthPageRoute({
id: 'inviteLogin',
path: '/invite/:code/login',
page: InviteLoginPage,
dataFlx: 'app.router.auth-routes.invite-login-page',
onEnter: whenAuthenticated((ctx) => {
const qp = new URLSearchParams(window.location.search);
if (isHandoffRequest(qp)) {
return undefined;
}
const code = ctx.params['code'];
if (code) {
void import('@app/features/invite/commands/InviteCommands').then((commands) => {
commands.openAcceptModal(code);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
});
const giftRegisterRoute = createAuthPageRoute({
id: 'giftRegister',
path: '/gift/:code',
page: GiftRegisterPage,
dataFlx: 'app.router.auth-routes.gift-register-page',
onEnter: whenAuthenticated((ctx) => {
const code = ctx.params['code'];
if (code) {
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
commands.openAcceptModal(code);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
});
const giftLoginRoute = createAuthPageRoute({
id: 'giftLogin',
path: '/gift/:code/login',
page: GiftLoginPage,
dataFlx: 'app.router.auth-routes.gift-login-page',
onEnter: whenAuthenticated((ctx) => {
const qp = new URLSearchParams(window.location.search);
if (isHandoffRequest(qp)) {
return undefined;
}
const code = ctx.params['code'];
if (code) {
void import('@app/features/gift/commands/GiftCommands').then((commands) => {
commands.openAcceptModal(code);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
const giftRoutes = createShortLinkAuthRoutes({
id: 'gift',
dataFlxPrefix: 'app.router.auth-routes.gift',
paths: {register: Routes.GIFT_REGISTER, login: Routes.GIFT_LOGIN},
registerPage: GiftRegisterPage,
loginPage: GiftLoginPage,
handlers: giftRouteEnterHandlers,
});
const forgotPasswordRoute = createAuthPageRoute({
id: 'forgotPassword',
path: Routes.FORGOT_PASSWORD,
page: ForgotPasswordPage,
dataFlx: 'app.router.auth-routes.forgot-password-page',
onEnter: (ctx) => {
if (RuntimeConfig.usesUsernameSignIn) {
return new Redirect(Routes.RECOVER_ACCOUNT);
}
if (!RuntimeConfig.emailsEnabled) {
return new Redirect(Routes.LOGIN);
}
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
},
onEnter: AuthRouteRedirectPolicy.resolveForgotPasswordEntry,
});
const recoverAccountRoute = createAuthPageRoute({
id: 'recoverAccount',
path: Routes.RECOVER_ACCOUNT,
page: RecoverAccountPage,
dataFlx: 'app.router.auth-routes.recover-account-page',
onEnter: (ctx) => {
if (!RuntimeConfig.usesUsernameSignIn) {
return new Redirect(Routes.LOGIN);
}
return whenAuthenticated(() => new Redirect(Routes.ME))(ctx);
},
onEnter: AuthRouteRedirectPolicy.resolveRecoverAccountEntry,
});
const resetPasswordRoute = createAuthPageRoute({
id: 'resetPassword',
path: Routes.RESET_PASSWORD,
page: ResetPasswordPage,
dataFlx: 'app.router.auth-routes.reset-password-page',
onEnter: redirectWhenPasswordResetUnavailable,
onEnter: AuthRouteRedirectPolicy.resolvePasswordResetEntry,
});
const emailRevertRoute = createAuthPageRoute({
id: 'emailRevert',
path: Routes.EMAIL_REVERT,
page: EmailRevertPage,
dataFlx: 'app.router.auth-routes.email-revert-page',
onEnter: redirectWhenEmailsDisabled,
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
});
const verifyEmailRoute = createAuthPageRoute({
id: 'verifyEmail',
path: Routes.VERIFY_EMAIL,
page: VerifyEmailPage,
dataFlx: 'app.router.auth-routes.verify-email-page',
onEnter: redirectWhenEmailsDisabled,
onEnter: AuthRouteRedirectPolicy.resolveEmailFeatureEntry,
});
const authorizeIPRoute = createAuthPageRoute({
id: 'authorizeIP',
@@ -366,34 +278,14 @@ const themeRegisterRoute = createAuthPageRoute({
path: Routes.THEME_REGISTER,
page: ThemeRegisterPage,
dataFlx: 'app.router.auth-routes.theme-register-page',
onEnter: whenAuthenticated((ctx) => {
const themeId = ctx.params.themeId;
if (themeId) {
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
commands.openAcceptModal(themeId, i18n);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
onEnter: themeRouteEnterHandlers.onRegisterEnter,
});
const themeLoginRoute = createAuthPageRoute({
id: 'themeLogin',
path: Routes.THEME_LOGIN,
page: ThemeLoginPage,
dataFlx: 'app.router.auth-routes.theme-login-page',
onEnter: whenAuthenticated((ctx) => {
const qp = new URLSearchParams(window.location.search);
if (isHandoffRequest(qp)) {
return undefined;
}
const themeId = ctx.params.themeId;
if (themeId) {
void import('@app/features/theme/commands/ThemeCommands').then((commands) => {
commands.openAcceptModal(themeId, i18n);
});
}
return new Redirect(currentRedirectTarget(Routes.ME));
}),
onEnter: themeRouteEnterHandlers.onLoginEnter,
});
export const authRouteTree = authLayoutRoute.addChildren([
@@ -404,8 +296,8 @@ export const authRouteTree = authLayoutRoute.addChildren([
inviteBaseRoute,
giftBaseRoute,
themeBaseRoute,
inviteRegisterRoute,
inviteLoginRoute,
...inviteRoutes,
...giftRoutes,
themeRegisterRoute,
themeLoginRoute,
forgotPasswordRoute,
@@ -416,5 +308,4 @@ export const authRouteTree = authLayoutRoute.addChildren([
authorizeIPRoute,
pendingRoute,
reportRoute,
...(shouldShowPremiumFeatures() ? [giftRegisterRoute, giftLoginRoute] : []),
]);
@@ -2,7 +2,9 @@
import {Routes} from '@app/app/Routes';
import {RootComponent} from '@app/app/router/components/RootComponent';
import {afterSessionInitialization} from '@app/app/router/policy/AuthRouteRedirectPolicy';
import {NotFoundPage} from '@app/features/app/components/pages/NotFoundPage';
import {readAuthenticatedRuntimeContext} from '@app/features/auth/state/AuthenticatedRuntime';
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
import {createRootRoute, createRoute} from '@app/features/platform/components/router/RouterBuilder';
import {Redirect} from '@app/features/platform/components/router/RouterTypes';
@@ -21,7 +23,12 @@ export const homeRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'home',
path: '/',
onEnter: () => new Redirect(getDefaultLandingPath()),
onEnter: afterSessionInitialization(() => {
if (readAuthenticatedRuntimeContext() === null) {
return new Redirect(Routes.LOGIN);
}
return new Redirect(getDefaultLandingPath());
}),
});
export const appRoute = createRoute({
getParentRoute: () => rootRoute,