vojo/src/app/pages/Router.tsx

437 lines
16 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,
ChannelsLanding,
} 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 { 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';
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;
}
// 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 />
</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>
<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 DirectStreamHeader 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>
}
>
<ChannelsLanding />
</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
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,
});
};