vojo/src/app/pages/Router.tsx

459 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import {
Navigate,
Outlet,
Route,
createBrowserRouter,
createHashRouter,
createRoutesFromElements,
redirect,
useParams,
} from 'react-router-dom';
import { ClientConfig } from '../hooks/useClientConfig';
import { AuthLayout, Login, Register, ResetPassword } from './auth';
import {
BOTS_PATH,
CHANNELS_PATH,
CHANNELS_ROOM_EVENT_PATH,
CHANNELS_ROOM_PATH,
CHANNELS_SPACE_PATH,
CHANNELS_THREAD_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
LOGIN_PATH,
REGISTER_PATH,
RESET_PASSWORD_PATH,
SETTINGS_PATH,
SPACE_PATH,
_CREATE_PATH,
_FEATURED_PATH,
_JOIN_PATH,
_LOBBY_PATH,
_ROOM_PATH,
_SEARCH_PATH,
_SERVER_PATH,
CREATE_PATH,
USER_LINK_HOST,
USER_LINK_PATH,
DirectCreateSearchParams,
} from './paths';
import {
getAppPathFromHref,
getDirectCreatePath,
getExploreFeaturedPath,
getHomePath,
getLoginPath,
getOriginBaseUrl,
getSpaceLobbyPath,
withSearchParam,
} from './pathUtils';
import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { HomeRouteRoomProvider } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { BotExperienceHost, Bots } from './client/bots';
import { Channels, ChannelsRootNav, ChannelPickPlaceholder } from './client/channels';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { Room } from '../features/room';
import { Lobby } from '../features/lobby';
import { WelcomePage } from './client/WelcomePage';
import { PageRoot } from '../components/page';
import { ScreenSize } from '../hooks/useScreenSize';
import { MobileFriendlyPageNav } 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 { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings';
import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
import { useShareTargetReceiver } from '../hooks/useShareTargetReceiver';
import { SettingsScreen } from '../features/settings';
import { getFallbackSession } from '../state/sessions';
import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
import { HorseshoeContainer } from './HorseshoeContainer';
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
import { ChannelsModeProvider } from '../hooks/useChannelsMode';
import { MobileTabsLayout } from '../components/mobile-tabs-pager';
function IncomingCallsFeature() {
useIncomingRtcNotifications();
useCallerAutoHangup();
usePendingCallActionConsumer();
// Native CallStyle dismissal is owned by the Android ring registry:
// VojoFirebaseMessagingService.removeIncomingRing (ownership-checked cancel)
// fires on atom REMOVE via bridge, and MainActivity.onResume calls
// cancelRenderedIncomingRings for the background→foreground handoff.
// A JS-side dismiss hook here is redundant and risks a blind tag/id cancel
// hitting a foreign ring in the same room slot.
useAppUrlOpen();
return null;
}
// Drains the native share-target slot on cold-start and listens for warm
// shareReceived events. Mounted alongside IncomingCallsFeature so it runs
// only after login (Matrix client + room list available) and stays alive
// for the whole authenticated session.
function ShareTargetFeature() {
useShareTargetReceiver();
return null;
}
// Deep-link entry for /u/<user>. `<user>` is either a bare localpart or a
// full MXID; we normalize to an MXID using USER_LINK_HOST (the deep-link's own
// host, NOT the logged-in user's homeserver — a user signed into matrix.org
// opening vojo.chat/u/test3 still means @test3:vojo.chat) and forward to the
// existing DirectCreate flow, which itself dedupes to any existing DM via
// getDMRoomFor.
function UserLinkRedirect() {
const { userIdOrLocalPart } = useParams();
if (!userIdOrLocalPart) return <Navigate to={getHomePath()} replace />;
const raw = decodeURIComponent(userIdOrLocalPart);
const mxid = isUserId(raw) && getMxIdServer(raw) ? raw : `@${raw}:${USER_LINK_HOST}`;
if (!isUserId(mxid)) return <Navigate to={getHomePath()} replace />;
const params: DirectCreateSearchParams = { userId: mxid };
return <Navigate to={withSearchParam(getDirectCreatePath(), params)} replace />;
}
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
const mobile = screenSize === ScreenSize.Mobile;
const routes = createRoutesFromElements(
<Route>
<Route
index
loader={() => {
if (getFallbackSession()) return redirect(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={
<>
<AuthLayout />
<UnAuthRouteThemeManager />
</>
}
>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<Register />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</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>
<HorseshoeContainer>
{/* SidebarNav (66px icon-rail) временно отключён —
позже растащим его 5 кнопок (Settings, Search,
Explore, Create, Unverified) по новым поверхностям
интерфейса. Сам компонент жив:
src/app/pages/client/SidebarNav.tsx + ./sidebar/*.
См. docs/plans/redesign_overview.md → sidebar_cleanup. */}
<ClientLayout nav={null}>
<Outlet />
</ClientLayout>
</HorseshoeContainer>
<IncomingCallsFeature />
<ShareTargetFeature />
</CallEmbedProvider>
<SearchModalRenderer />
<CreateRoomModalRenderer />
<CreateSpaceModalRenderer />
<RoomSettingsRenderer />
<SpaceSettingsRenderer />
<ReceiveSelfDeviceVerification />
<AutoRestoreBackupOnVerification />
</ClientNonUIFeatures>
</ClientBindAtoms>
</ClientRoomsNotificationPreferences>
</ClientInitStorageAtom>
</ClientRoot>
</AuthRouteThemeManager>
}
>
{/* Legacy /home/ tree — kept only as a redirect surface so cold-start
push deep links and pre-P3c bookmarks resolve cleanly. The Home
page itself is gone; HomeRouteRoomProvider redirects /home/{roomId}/
into /direct/{roomId}/ on mount. See plan §6.7 / §8 P3c. */}
<Route path={HOME_PATH}>
<Route index element={<Navigate to={DIRECT_PATH} replace />} />
<Route path={_CREATE_PATH} element={<Navigate to={getDirectCreatePath()} replace />} />
<Route path={_JOIN_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route path={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route
path={_ROOM_PATH}
element={
<HomeRouteRoomProvider>
<Room />
</HomeRouteRoomProvider>
}
/>
</Route>
{/* Mobile + Capacitor horizontal swipe pager. The layout-route
wrapper has no path: at listing-root URLs it overrides
rendering with `MobileTabsPager` (three panes mounted at
once, slide via CSS transform); at detail URLs and on
web/desktop/tablet it falls through to `<Outlet/>` and the
wrapped routes render exactly as before. See
`src/app/components/mobile-tabs-pager/`. */}
<Route element={<MobileTabsLayout />}>
<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>
<Room />
</DirectRouteRoomProvider>
}
/>
</Route>
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
<Route
path={BOTS_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={BOTS_PATH}>
<Bots />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path=":botId" element={<BotExperienceHost />} />
</Route>
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
generic /:spaceIdOrAlias/ catch-all doesn't swallow the prefix.
Phase 1 routes resolve to a stubbed center pane; Phase 3 + Phase 4
replace the left list and center timeline respectively. */}
<Route
path={CHANNELS_PATH}
element={
<ChannelsModeProvider value>
<Outlet />
</ChannelsModeProvider>
}
>
<Route
index
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={CHANNELS_PATH}>
<ChannelsRootNav />
</MobileFriendlyPageNav>
}
>
{mobile ? null : <WelcomePage />}
</PageRoot>
}
/>
<Route
path={CHANNELS_SPACE_PATH.slice(CHANNELS_PATH.length)}
element={
<RouteSpaceProvider>
<PageRoot
nav={
<MobileFriendlyPageNav path={CHANNELS_SPACE_PATH}>
<Channels />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
</RouteSpaceProvider>
}
>
{mobile ? null : <Route index element={<ChannelPickPlaceholder />} />}
<Route
path={CHANNELS_ROOM_PATH.slice(CHANNELS_SPACE_PATH.length)}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
>
{/* Thread drawer URL — same Room element renders, drawer
opens by reading `:rootId` via useParams. The
SpaceRouteRoomProvider lives on the parent route and
stays mounted across the room↔thread URL flip. */}
<Route
path={CHANNELS_THREAD_PATH.slice(CHANNELS_ROOM_PATH.length)}
element={null}
/>
{/* Event-anchored URL — search/inbox/mention/push permalinks.
RR6 merges child params into the parent's useParams so
Room.tsx reads `eventId` without re-routing. */}
<Route
path={CHANNELS_ROOM_EVENT_PATH.slice(CHANNELS_ROOM_PATH.length)}
element={null}
/>
</Route>
</Route>
</Route>
</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={<Lobby />} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route
path={_ROOM_PATH}
element={
<SpaceRouteRoomProvider>
<Room />
</SpaceRouteRoomProvider>
}
/>
</Route>
<Route
path={EXPLORE_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={EXPLORE_PATH}>
<Explore />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : (
<Route
index
loader={() => redirect(getExploreFeaturedPath())}
element={<WelcomePage />}
/>
)}
<Route path={_FEATURED_PATH} element={<FeaturedRooms />} />
<Route path={_SERVER_PATH} element={<PublicRooms />} />
</Route>
<Route path={CREATE_PATH} element={<Create />} />
{/* /settings shares the DIRECT_PATH shell — left page-nav stays
the DM list, the right pane swaps the chat outlet for the
Settings UI. The horseshoe rounded TL/BL on the right pane
(commits 363bd9d / 74d32eb) inherits from PageRoot for free.
On mobile MobileFriendlyPageNav hides the DM list since
/settings/ ≠ DIRECT_PATH; SettingsScreen renders the
bottom-up horseshoe sheet over the empty outlet area. */}
<Route
path={SETTINGS_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={DIRECT_PATH}>
<Direct />
</MobileFriendlyPageNav>
}
>
<SettingsScreen />
</PageRoot>
}
/>
<Route path={USER_LINK_PATH} element={<UserLinkRedirect />} />
{/* Legacy /inbox/ tree — invites moved inline into the Direct list,
the Notifications aggregator was removed. Keep the route as a
redirect so old push deep-links and bookmarks resolve cleanly. */}
<Route path="/inbox/*" element={<Navigate to={DIRECT_PATH} replace />} />
</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,
});
};