Files
cinny/src/app/pages/Router.tsx
T
Lotus CIandClaude Opus 5.5 0746e80941
CI / Build & Quality Checks (pull_request) Successful in 3m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 18s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m40s
feat(widgets): keep a widget on screen while you navigate away (#205)
MSC2931: a widget with m.always_on_screen (auto-granted) can send
set_always_on_screen; the host now answers it, and a widget that asked
keeps running in a small floating window (title, Back to room, close)
when the Widgets panel closes or you switch rooms. Back to room returns
to it with the panel open on that widget, without reloading it.

Moving an iframe reloads it, so widget frames now live in an app-level
layer from the start (widgetSessions.ts) and are positioned over the
panel's placeholder while docked. A widget that did not ask is torn
down as before. One floating widget at a time; it also goes when the
widget withdraws, is removed from the room, or on logout. A permission
request while floating grants nothing extra.

Refs #205

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 21:05:31 -04:00

450 lines
15 KiB
TypeScript

import React from 'react';
import {
Outlet,
Route,
createBrowserRouter,
createHashRouter,
createRoutesFromElements,
redirect,
} from 'react-router-dom';
import { RoomSkeleton } from '../components/RoomSkeleton';
import { LobbySkeleton } from '../components/LobbySkeleton';
import { AuthSkeleton } from '../components/AuthSkeleton';
import { ClientConfig } from '../hooks/useClientConfig';
import {
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
INBOX_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
_INVITES_PATH,
_JOIN_PATH,
_LOBBY_PATH,
_NOTIFICATIONS_PATH,
_ROOM_PATH,
_SEARCH_PATH,
_SERVER_PATH,
CREATE_PATH,
} from './paths';
import {
getAppPathFromHref,
getExploreFeaturedPath,
getHomePath,
getInboxNotificationsPath,
getLoginPath,
getOriginBaseUrl,
getSpaceLobbyPath,
} from './pathUtils';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { setAfterLoginRedirectPath, takeAfterLoginPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly';
import { ClientInitStorageAtom } from './client/ClientInitStorageAtom';
import { ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager';
import { ReceiveSelfDeviceVerification } from '../components/DeviceVerification';
import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { getFallbackSession } from '../state/sessions';
import { SHARE_PAGE_PATH } from '../../swShare';
import { RouteError } from './RouteError';
import { CallStatusRenderer } from './CallStatusRenderer';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
const ResetPassword = React.lazy(() =>
import('./auth').then((m) => ({ default: m.ResetPassword })),
);
const Room = React.lazy(() => import('../features/room').then((m) => ({ default: m.Room })));
const RoomSettingsRenderer = React.lazy(() =>
import('../features/room-settings').then((m) => ({ default: m.RoomSettingsRenderer })),
);
const SpaceSettingsRenderer = React.lazy(() =>
import('../features/space-settings').then((m) => ({ default: m.SpaceSettingsRenderer })),
);
const CreateRoomModalRenderer = React.lazy(() =>
import('../features/create-room').then((m) => ({ default: m.CreateRoomModalRenderer })),
);
const CreateSpaceModalRenderer = React.lazy(() =>
import('../features/create-space').then((m) => ({ default: m.CreateSpaceModalRenderer })),
);
const SearchModalRenderer = React.lazy(() =>
import('../features/search').then((m) => ({ default: m.SearchModalRenderer })),
);
const Explore = React.lazy(() => import('./client/explore').then((m) => ({ default: m.Explore })));
const FeaturedRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.FeaturedRooms })),
);
const PublicRooms = React.lazy(() =>
import('./client/explore').then((m) => ({ default: m.PublicRooms })),
);
const Notifications = React.lazy(() =>
import('./client/inbox').then((m) => ({ default: m.Notifications })),
);
const Inbox = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Inbox })));
const Invites = React.lazy(() => import('./client/inbox').then((m) => ({ default: m.Invites })));
const Lobby = React.lazy(() => import('../features/lobby').then((m) => ({ default: m.Lobby })));
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
const mobile = screenSize === ScreenSize.Mobile;
const routes = createRoutesFromElements(
<Route HydrateFallback={() => null} errorElement={<RouteError />}>
<Route
index
loader={() => {
// A stored deep link is normally consumed by the password login;
// the OIDC callback instead reloads at the app root, so consume it
// here too (Gitea #130 — SSO users landing on a shared room link).
if (getFallbackSession()) return redirect(takeAfterLoginPath(getHomePath()));
const afterLoginPath = getAppPathFromHref(getOriginBaseUrl(), window.location.href);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
}}
/>
<Route
loader={() => {
if (getFallbackSession()) {
return redirect(getHomePath());
}
return null;
}}
element={
<React.Suspense fallback={<AuthSkeleton />}>
<AuthLayout />
<UnAuthRouteThemeManager />
</React.Suspense>
}
>
<Route
path={LOGIN_PATH}
element={
<React.Suspense fallback={null}>
<Login />
</React.Suspense>
}
/>
<Route
path={REGISTER_PATH}
element={
<React.Suspense fallback={null}>
<Register />
</React.Suspense>
}
/>
<Route
path={RESET_PASSWORD_PATH}
element={
<React.Suspense fallback={null}>
<ResetPassword />
</React.Suspense>
}
/>
</Route>
<Route
loader={() => {
const session = getFallbackSession();
if (!session) {
const afterLoginPath = getAppPathFromHref(
getOriginBaseUrl(hashRouter),
window.location.href,
);
if (afterLoginPath) setAfterLoginRedirectPath(afterLoginPath);
return redirect(getLoginPath());
}
return null;
}}
element={
<AuthRouteThemeManager>
<ClientRoot>
<ClientInitStorageAtom>
<ClientRoomsNotificationPreferences>
<ClientBindAtoms>
<ClientNonUIFeatures>
<CallEmbedProvider>
<ClientLayout
nav={
<MobileFriendlyClientNav>
<SidebarNav />
</MobileFriendlyClientNav>
}
>
<Outlet />
</ClientLayout>
<CallStatusRenderer />
<FloatingWidget />
</CallEmbedProvider>
<React.Suspense fallback={null}>
<SearchModalRenderer />
</React.Suspense>
<UserRoomProfileRenderer />
<React.Suspense fallback={null}>
<CreateRoomModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<CreateSpaceModalRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<RoomSettingsRenderer />
</React.Suspense>
<React.Suspense fallback={null}>
<SpaceSettingsRenderer />
</React.Suspense>
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
</ClientBindAtoms>
</ClientRoomsNotificationPreferences>
</ClientInitStorageAtom>
</ClientRoot>
</AuthRouteThemeManager>
}
>
<Route
path={HOME_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={HOME_PATH}>
<Home />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route
path={_CREATE_PATH}
element={
<React.Suspense fallback={null}>
<HomeCreateRoom />
</React.Suspense>
}
/>
<Route path={_JOIN_PATH} element={<p>join</p>} />
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
<Route
path={_ROOM_PATH}
element={
<HomeRouteRoomProvider>
<React.Suspense fallback={<RoomSkeleton />}>
<Room />
</React.Suspense>
</HomeRouteRoomProvider>
}
/>
</Route>
<Route
path={DIRECT_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={DIRECT_PATH}>
<Direct />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<DirectCreate />} />
<Route
path={_ROOM_PATH}
element={
<DirectRouteRoomProvider>
<React.Suspense fallback={<RoomSkeleton />}>
<Room />
</React.Suspense>
</DirectRouteRoomProvider>
}
/>
</Route>
<Route
path={SPACE_PATH}
element={
<RouteSpaceProvider>
<PageRoot
nav={
<MobileFriendlyPageNav path={SPACE_PATH}>
<Space />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
</RouteSpaceProvider>
}
>
{mobile ? null : (
<Route
index
loader={({ params }) => {
const { spaceIdOrAlias } = params;
if (spaceIdOrAlias) {
return redirect(getSpaceLobbyPath(spaceIdOrAlias));
}
return null;
}}
element={<WelcomePage />}
/>
)}
<Route
path={_LOBBY_PATH}
element={
<React.Suspense fallback={<LobbySkeleton />}>
<Lobby />
</React.Suspense>
}
/>
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
element={
<SpaceRouteRoomProvider>
<React.Suspense fallback={<RoomSkeleton />}>
<Room />
</React.Suspense>
</SpaceRouteRoomProvider>
}
/>
</Route>
<Route
path={EXPLORE_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}>
<React.Suspense fallback={null}>
<Explore />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : (
<Route
index
loader={() => redirect(getExploreFeaturedPath())}
element={<WelcomePage />}
/>
)}
<Route
path={_FEATURED_PATH}
element={
<React.Suspense fallback={null}>
<FeaturedRooms />
</React.Suspense>
}
/>
<Route
path={_SERVER_PATH}
element={
<React.Suspense fallback={null}>
<PublicRooms />
</React.Suspense>
}
/>
</Route>
<Route
path={CREATE_PATH}
element={
<React.Suspense fallback={null}>
<Create />
</React.Suspense>
}
/>
{/* [Gitea #155] PWA share-target landing page. */}
<Route
path={SHARE_PAGE_PATH}
element={
<React.Suspense fallback={null}>
<Share />
</React.Suspense>
}
/>
<Route
path={INBOX_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={INBOX_PATH}>
<React.Suspense fallback={null}>
<Inbox />
</React.Suspense>
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : (
<Route
index
loader={() => redirect(getInboxNotificationsPath())}
element={<WelcomePage />}
/>
)}
<Route
path={_NOTIFICATIONS_PATH}
element={
<React.Suspense fallback={null}>
<Notifications />
</React.Suspense>
}
/>
<Route
path={_INVITES_PATH}
element={
<React.Suspense fallback={null}>
<Invites />
</React.Suspense>
}
/>
</Route>
</Route>
<Route path="/*" element={<p>Page not found</p>} />
</Route>,
);
if (hashRouter?.enabled) {
return createHashRouter(routes, { basename: hashRouter.basename });
}
return createBrowserRouter(routes, {
basename: import.meta.env.BASE_URL,
});
};