mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user