feat(channels): ship M1 — Каналы segment with /channels/ routes and channels-mode RoomTimeline filter
This commit is contained in:
parent
0a93ad5a9f
commit
c31e9a6e92
17 changed files with 732 additions and 30 deletions
|
|
@ -375,7 +375,6 @@
|
|||
"segment_dm": "DM",
|
||||
"segment_channels": "Channels",
|
||||
"segment_bots": "Robots",
|
||||
"segment_coming_soon": "Coming soon",
|
||||
"self_row_label": "You",
|
||||
"self_row_preview": "Settings & profile",
|
||||
"message_me_label": "me",
|
||||
|
|
@ -389,6 +388,15 @@
|
|||
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||
"create": "Create"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "No communities yet",
|
||||
"no_spaces_desc": "Find a community to join and channels will appear here.",
|
||||
"explore_cta": "Find a community",
|
||||
"pick_channel_title": "Pick a channel",
|
||||
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
|
||||
"badge_new": "New",
|
||||
"root_category": "Channels"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Start call",
|
||||
"join": "Join call",
|
||||
|
|
|
|||
|
|
@ -377,7 +377,6 @@
|
|||
"segment_dm": "Личные",
|
||||
"segment_channels": "Каналы",
|
||||
"segment_bots": "Роботы",
|
||||
"segment_coming_soon": "Скоро",
|
||||
"self_row_label": "Я",
|
||||
"self_row_preview": "Настройки и профиль",
|
||||
"message_me_label": "я",
|
||||
|
|
@ -391,6 +390,15 @@
|
|||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||
"create": "Создать"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "Пока нет сообществ",
|
||||
"no_spaces_desc": "Найдите сообщество и присоединитесь — каналы появятся здесь.",
|
||||
"explore_cta": "Найти сообщество",
|
||||
"pick_channel_title": "Выберите канал",
|
||||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||
"badge_new": "Новое",
|
||||
"root_category": "Каналы"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Позвонить",
|
||||
"join": "Присоединиться",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import {
|
|||
getLatestEditableEvt,
|
||||
getMemberDisplayName,
|
||||
getReactionContent,
|
||||
isBridgedRoom,
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
|
|
@ -116,6 +117,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
|
|
@ -418,6 +420,37 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
|
|||
};
|
||||
};
|
||||
|
||||
// Channels-mode visibility filter. Single source of truth for the
|
||||
// /channels/* surface — called from BOTH the rail-endpoint scan
|
||||
// (`isRenderableTimelineEvent`) AND the renderer null-gate so the two never
|
||||
// disagree on which events count as visible. Diverging the two would silently
|
||||
// break rail-start mid-conversation and miscount unread anchors.
|
||||
//
|
||||
// Filters: thread / reference relations, `m.call.*`, RTC member churn (stable
|
||||
// + msc4143 unstable), RTC notifications, polls (no MVP renderer per plan
|
||||
// §53-56). Bridged-room exception passes `m.thread`/`io.element.thread`
|
||||
// through as roots so federated-via-bridge replies stay visible until M7.
|
||||
const isChannelsModeHidden = (event: MatrixEvent, isBridged: boolean): boolean => {
|
||||
const relType = event.getRelation()?.rel_type;
|
||||
if (relType === 'm.thread' || relType === 'io.element.thread') {
|
||||
if (!isBridged) return true;
|
||||
} else if (relType === 'm.reference') {
|
||||
return true;
|
||||
}
|
||||
const eventType = event.getType();
|
||||
if (eventType.startsWith('m.call.')) return true;
|
||||
if (eventType === 'm.rtc.notification') return true;
|
||||
if (eventType === 'm.rtc.member') return true;
|
||||
if (eventType === 'org.matrix.msc4143.rtc.member') return true;
|
||||
if (eventType === 'm.poll.start') return true;
|
||||
if (eventType === 'm.poll.response') return true;
|
||||
if (eventType === 'm.poll.end') return true;
|
||||
if (eventType === 'org.matrix.msc3381.poll.start') return true;
|
||||
if (eventType === 'org.matrix.msc3381.poll.response') return true;
|
||||
if (eventType === 'org.matrix.msc3381.poll.end') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
|
@ -426,6 +459,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
|
||||
// not the persisted `m.direct` flag — see plan §6.8.
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
const channelsMode = useChannelsMode();
|
||||
// bridged-room check is cheap and stable for a route lifetime; recompute on
|
||||
// each render is fine — `isBridgedRoom` walks the room state-event index,
|
||||
// not the timeline.
|
||||
const isBridged = channelsMode && isBridgedRoom(room);
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
|
|
@ -1753,6 +1791,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
if (eventType === 'org.matrix.msc4075.rtc.notification') return false;
|
||||
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
|
||||
|
||||
// Channels-mode filter — see `isChannelsModeHidden` above for the rule
|
||||
// set and rationale. Same helper used by the renderer null-gate so the
|
||||
// rail-endpoint scan and the actual render always agree on visibility.
|
||||
if (channelsMode && isChannelsModeHidden(event, isBridged)) return false;
|
||||
|
||||
if (eventType === StateEvent.RoomMember) {
|
||||
// Mirror the membership-sysline gate from the renderer above so the
|
||||
// rail-endpoint scan and the actual render agree on visibility.
|
||||
|
|
@ -1878,7 +1921,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const streamRailEnd =
|
||||
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
// Channels-mode renderer gate — same helper as the predicate above so
|
||||
// the rail-endpoint scan and the renderer never disagree on visibility.
|
||||
const channelsModeHidden = channelsMode && isChannelsModeHidden(mEvent, isBridged);
|
||||
|
||||
const eventJSX = channelsModeHidden || reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
|
|
|
|||
15
src/app/hooks/useChannelsMode.ts
Normal file
15
src/app/hooks/useChannelsMode.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
// Routes under /channels/ render the same RoomTimeline / RoomInput as
|
||||
// /direct/ and /:spaceId/, but with extra timeline filtering: thread
|
||||
// replies, edits, reactions, RTC service events, and call events are
|
||||
// hidden from the centre column so it shows only thread-rootable messages.
|
||||
// The flag flows through context (not a prop) so the existing Room ->
|
||||
// RoomView -> RoomTimeline tree doesn't need a new threaded prop.
|
||||
const ChannelsModeContext = createContext<boolean>(false);
|
||||
|
||||
export const ChannelsModeProvider = ChannelsModeContext.Provider;
|
||||
|
||||
export function useChannelsMode(): boolean {
|
||||
return useContext(ChannelsModeContext);
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { BOTS_PATH, DIRECT_PATH, EXPLORE_PATH, HOME_PATH, SPACE_PATH } from './paths';
|
||||
import {
|
||||
BOTS_PATH,
|
||||
CHANNELS_PATH,
|
||||
CHANNELS_SPACE_PATH,
|
||||
DIRECT_PATH,
|
||||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
SPACE_PATH,
|
||||
} from './paths';
|
||||
|
||||
type MobileFriendlyClientNavProps = {
|
||||
children: ReactNode;
|
||||
|
|
@ -13,10 +21,24 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro
|
|||
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
||||
const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true });
|
||||
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: true });
|
||||
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: true });
|
||||
const channelsSpaceMatch = useMatch({
|
||||
path: CHANNELS_SPACE_PATH,
|
||||
caseSensitive: true,
|
||||
end: true,
|
||||
});
|
||||
|
||||
if (
|
||||
screenSize === ScreenSize.Mobile &&
|
||||
!(homeMatch || directMatch || spaceMatch || exploreMatch || botsMatch)
|
||||
!(
|
||||
homeMatch ||
|
||||
directMatch ||
|
||||
spaceMatch ||
|
||||
exploreMatch ||
|
||||
botsMatch ||
|
||||
channelsMatch ||
|
||||
channelsSpaceMatch
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import { ClientConfig } from '../hooks/useClientConfig';
|
|||
import { AuthLayout, Login, Register, ResetPassword } from './auth';
|
||||
import {
|
||||
BOTS_PATH,
|
||||
CHANNELS_PATH,
|
||||
CHANNELS_ROOM_PATH,
|
||||
CHANNELS_SPACE_PATH,
|
||||
DIRECT_PATH,
|
||||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
|
|
@ -48,6 +51,12 @@ 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';
|
||||
|
|
@ -77,6 +86,7 @@ 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();
|
||||
|
|
@ -256,6 +266,59 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<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>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route
|
||||
path={SPACE_PATH}
|
||||
element={
|
||||
|
|
|
|||
30
src/app/pages/client/channels/ChannelPickPlaceholder.tsx
Normal file
30
src/app/pages/client/channels/ChannelPickPlaceholder.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||
|
||||
// Phase 1 placeholder shown when /channels/:space/ resolves on desktop with
|
||||
// no :roomId yet. Phase 3 wires the list, Phase 4 wires ChannelView, this
|
||||
// stub exists only to make the route resolve to something visible.
|
||||
export function ChannelPickPlaceholder() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
{t('Channels.pick_channel_title')}
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Box style={{ maxWidth: 360 }}>
|
||||
<Text size="T300" align="Center">
|
||||
{t('Channels.pick_channel_desc')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
60
src/app/pages/client/channels/Channels.tsx
Normal file
60
src/app/pages/client/channels/Channels.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import { useSpace } from '../../../hooks/useSpace';
|
||||
import { PageNav, PageNavContent } from '../../../components/page';
|
||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
||||
import { ChannelsList, WorkspaceFooter } from './ChannelsList';
|
||||
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||
|
||||
// Stub nav rendered at the /channels/ index route when the user has no
|
||||
// orphan spaces yet. Provides the segment switcher so they can navigate
|
||||
// back to DM / Bots without going through the global rail. The list /
|
||||
// footer panes only make sense once a Space is in context.
|
||||
export function ChannelsRootNav() {
|
||||
return (
|
||||
<PageNav size="500">
|
||||
<DirectStreamHeader />
|
||||
<PageNavContent>
|
||||
<div />
|
||||
</PageNavContent>
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
||||
// Channels left pane (Mattermost-style). Renders the segment switcher at
|
||||
// the top, the joined-rooms hierarchy in the middle (sub-spaces grouped),
|
||||
// and the workspace identity plate at the bottom. M6 will make the footer
|
||||
// a dropdown for users with 2+ orphan spaces; in M1 it is read-only.
|
||||
//
|
||||
// Note: we deliberately do NOT call `useNavToActivePathMapper` here. That
|
||||
// atom is owned by the legacy /<space>/ tree (Space.tsx) so the rail's
|
||||
// "click avatar → resume your last in-space path" behaviour stays
|
||||
// well-defined. Channels has its own segment-level navigation, so a
|
||||
// rail-click on a channels-active space lands on the legacy lobby — that's
|
||||
// fine for M1; users can re-enter channels via the DirectStreamHeader.
|
||||
export function Channels() {
|
||||
const space = useSpace();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
||||
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
||||
// value but never writes it because the index route has no :spaceIdOrAlias
|
||||
// param — write happens HERE, where RouteSpaceProvider has already
|
||||
// resolved the space from the URL.
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
|
||||
} catch {
|
||||
/* private mode / quota — non-fatal */
|
||||
}
|
||||
}, [space.roomId]);
|
||||
|
||||
return (
|
||||
<PageNav size="500">
|
||||
<DirectStreamHeader />
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<ChannelsList scrollRef={scrollRef} />
|
||||
</PageNavContent>
|
||||
<WorkspaceFooter space={space} />
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
57
src/app/pages/client/channels/ChannelsLanding.tsx
Normal file
57
src/app/pages/client/channels/ChannelsLanding.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Box, Button, Icon, Icons, Text } from 'folds';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useOrphanSpaces } from '../../../state/hooks/roomList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { getChannelsSpacePath, getExplorePath } from '../../pathUtils';
|
||||
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||
import { useActiveSpace } from './useActiveSpace';
|
||||
|
||||
// Index route for /channels/. Resolves the active Space (URL > localStorage >
|
||||
// first joined orphan Space) and redirects there. If the user is in 0 orphan
|
||||
// spaces, shows an empty state with a CTA to /explore/.
|
||||
export function ChannelsLanding() {
|
||||
const mx = useMatrixClient();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||
const activeSpaceId = useActiveSpace(orphanSpaces);
|
||||
|
||||
if (activeSpaceId) {
|
||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
|
||||
return <Navigate to={getChannelsSpacePath(spaceIdOrAlias)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
{t('Channels.no_spaces_title')}
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Text size="T300" align="Center">
|
||||
{t('Channels.no_spaces_desc')}
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
<Box direction="Column" gap="200">
|
||||
<Button variant="Primary" size="300" onClick={() => navigate(getExplorePath())}>
|
||||
<Text size="B300" truncate>
|
||||
{t('Channels.explore_cta')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
215
src/app/pages/client/channels/ChannelsList.tsx
Normal file
215
src/app/pages/client/channels/ChannelsList.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import React, { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Box, color, config, toRem } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { NavCategory, NavCategoryHeader } from '../../../components/nav';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { useSpace } from '../../../hooks/useSpace';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
|
||||
import { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
||||
import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
|
||||
import { useCallEmbed } from '../../../hooks/useCallEmbed';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { getChannelsRoomPath } from '../../pathUtils';
|
||||
|
||||
type ChannelsListProps = {
|
||||
// Scroll container ref — owned by the parent `PageNavContent` so virtualizer
|
||||
// measures the actual scrollable element instead of an inner div nested
|
||||
// inside `<Scroll>`. Wrong scroll target → clientHeight reads as 0 →
|
||||
// virtualizer renders no rows. Match `Space.tsx` pattern.
|
||||
scrollRef: MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
// Joined-hierarchy list rendered inside the channels left pane. Mirrors the
|
||||
// scaffold of `Space.tsx` (virtualized hierarchy of joined rooms grouped by
|
||||
// sub-spaces) but routes selections through the /channels/<space>/<room>/
|
||||
// path so the room timeline opens inside the channels surface, not the
|
||||
// legacy /<spaceId>/<room>/ tree.
|
||||
export function ChannelsList({ scrollRef }: ChannelsListProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const callEmbed = useCallEmbed();
|
||||
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
||||
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
|
||||
const getRoom = useCallback(
|
||||
(rId: string): Room | undefined => {
|
||||
if (allJoinedRooms.has(rId)) {
|
||||
return mx.getRoom(rId) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[mx, allJoinedRooms]
|
||||
);
|
||||
|
||||
const hierarchy = useSpaceJoinedHierarchy(
|
||||
space.roomId,
|
||||
getRoom,
|
||||
useCallback(
|
||||
(parentId, roomId) => {
|
||||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||
return false;
|
||||
}
|
||||
const showRoomAnyway =
|
||||
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
|
||||
return !showRoomAnyway;
|
||||
},
|
||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed]
|
||||
),
|
||||
useCallback(
|
||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||
[closedCategories, space.roomId]
|
||||
)
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: hierarchy.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 0,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
|
||||
const getToLink = (roomId: string) =>
|
||||
getChannelsRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const { roomId } = hierarchy[vItem.index] ?? {};
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
const categoryId = makeNavCategoryId(space.roomId, roomId);
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
data-category-id={categoryId}
|
||||
onClick={handleCategoryClick}
|
||||
closed={closedCategories.has(categoryId)}
|
||||
>
|
||||
{roomId === space.roomId ? t('Channels.root_category') : room?.name}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
</div>
|
||||
</VirtualTile>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selectedRoomId === roomId}
|
||||
showAvatar={mDirects.has(roomId)}
|
||||
direct={mDirects.has(roomId)}
|
||||
linkPath={getToLink(roomId)}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</NavCategory>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkspaceFooterProps = {
|
||||
space: Room;
|
||||
};
|
||||
export function WorkspaceFooter({ space }: WorkspaceFooterProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mxcUrl = space.getMxcAvatarUrl();
|
||||
const httpUrl = mxcUrl
|
||||
? mx.mxcUrlToHttp(mxcUrl, 48, 48, 'crop', undefined, false, true) ?? undefined
|
||||
: undefined;
|
||||
const initial = (space.name || space.roomId).trim().slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${toRem(8)} ${toRem(12)}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
background: color.Surface.Container,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: toRem(28),
|
||||
height: toRem(28),
|
||||
borderRadius: toRem(8),
|
||||
background: httpUrl ? `center/cover no-repeat url("${httpUrl}")` : color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 600,
|
||||
fontSize: toRem(13),
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{!httpUrl && initial}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
color: color.Surface.OnContainer,
|
||||
fontSize: toRem(13),
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={space.name}
|
||||
>
|
||||
{space.name}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
5
src/app/pages/client/channels/index.ts
Normal file
5
src/app/pages/client/channels/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './Channels';
|
||||
export * from './ChannelsList';
|
||||
export * from './ChannelsLanding';
|
||||
export * from './ChannelPickPlaceholder';
|
||||
export * from './useActiveSpace';
|
||||
51
src/app/pages/client/channels/useActiveSpace.ts
Normal file
51
src/app/pages/client/channels/useActiveSpace.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../../utils/matrix';
|
||||
|
||||
export const ACTIVE_SPACE_KEY = 'vojo.activeSpaceId';
|
||||
|
||||
const readPersisted = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_SPACE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeDecode = (raw: string): string | undefined => {
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolves the active Space for the channels segment. Priority:
|
||||
// 1. URL `:spaceIdOrAlias` param (if joined-orphan)
|
||||
// 2. localStorage['vojo.activeSpaceId'] (if joined-orphan)
|
||||
// 3. first joined-orphan Space
|
||||
// Returns undefined when the user has 0 joined orphan spaces. Persistence
|
||||
// (writing to localStorage) lives in `Channels.tsx`, where the inner
|
||||
// route already has the resolved space context — at the index `/channels/`
|
||||
// route, useParams().spaceIdOrAlias is always undefined.
|
||||
export const useActiveSpace = (orphanSpaceIds: string[]): string | undefined => {
|
||||
const mx = useMatrixClient();
|
||||
const { spaceIdOrAlias } = useParams();
|
||||
const orphanSet = useMemo(() => new Set(orphanSpaceIds), [orphanSpaceIds]);
|
||||
|
||||
const urlSpaceId = useMemo(() => {
|
||||
if (!spaceIdOrAlias) return undefined;
|
||||
const decoded = safeDecode(spaceIdOrAlias);
|
||||
if (!decoded) return undefined;
|
||||
const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
|
||||
return resolved && orphanSet.has(resolved) ? resolved : undefined;
|
||||
}, [mx, spaceIdOrAlias, orphanSet]);
|
||||
|
||||
const persistedSpaceId = useMemo(() => {
|
||||
const stored = readPersisted();
|
||||
return stored && orphanSet.has(stored) ? stored : undefined;
|
||||
}, [orphanSet]);
|
||||
|
||||
return urlSpaceId ?? persistedSpaceId ?? orphanSpaceIds[0];
|
||||
};
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import React, { forwardRef, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import { Box, Text, Tooltip, TooltipProvider, color, toRem } from 'folds';
|
||||
import { Box, color, toRem } from 'folds';
|
||||
import { PageNavHeader } from '../../../components/page';
|
||||
import { BOTS_PATH, DIRECT_PATH } from '../../paths';
|
||||
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../paths';
|
||||
import { isNativePlatform } from '../../../utils/capacitor';
|
||||
import { useBotPresets } from '../../../features/bots/catalog';
|
||||
|
||||
const CHANNELS_TOOLTIP_SEEN_KEY = 'vojo.channelsTooltipSeen';
|
||||
|
||||
type SegmentProps = {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
badge?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
||||
({ active, disabled, label, onClick }, ref) => (
|
||||
({ active, disabled, label, badge, onClick }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
|
|
@ -34,9 +37,29 @@ const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
|||
fontWeight: active ? 600 : 500,
|
||||
fontSize: toRem(13),
|
||||
lineHeight: 1.2,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(6),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
background: color.Primary.Main,
|
||||
color: color.Primary.OnMain,
|
||||
fontSize: toRem(10),
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
padding: `${toRem(2)} ${toRem(5)}`,
|
||||
borderRadius: toRem(4),
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: toRem(0.4),
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
|
@ -44,15 +67,48 @@ const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
|||
export function DirectStreamHeader() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const comingSoon = t('Direct.segment_coming_soon');
|
||||
const bots = useBotPresets();
|
||||
|
||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
||||
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
|
||||
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
|
||||
const showBotsSegment = bots.length > 0 || !!botsMatch;
|
||||
|
||||
// Migration "NEW" badge — one-shot promo for the new Channels surface.
|
||||
// Replaces the old "Coming soon" disabled state. Hidden once the user has
|
||||
// visited /channels/* or already saw it in a prior session (persisted in
|
||||
// localStorage). Visible default until first interaction.
|
||||
const [showBadge, setShowBadge] = useState(false);
|
||||
useEffect(() => {
|
||||
if (channelsMatch) {
|
||||
try {
|
||||
localStorage.setItem(CHANNELS_TOOLTIP_SEEN_KEY, '1');
|
||||
} catch {
|
||||
/* private mode — non-fatal, badge just keeps showing */
|
||||
}
|
||||
setShowBadge(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (localStorage.getItem(CHANNELS_TOOLTIP_SEEN_KEY) === '1') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setShowBadge(true);
|
||||
}, [channelsMatch]);
|
||||
|
||||
const navOpts = { replace: isNativePlatform() };
|
||||
|
||||
const handleChannelsClick = () => {
|
||||
setShowBadge(false);
|
||||
try {
|
||||
localStorage.setItem(CHANNELS_TOOLTIP_SEEN_KEY, '1');
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
navigate(CHANNELS_PATH, navOpts);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="100">
|
||||
|
|
@ -61,24 +117,12 @@ export function DirectStreamHeader() {
|
|||
label={t('Direct.segment_dm')}
|
||||
onClick={() => navigate(DIRECT_PATH, navOpts)}
|
||||
/>
|
||||
<TooltipProvider
|
||||
delay={400}
|
||||
position="Bottom"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{comingSoon}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Segment
|
||||
ref={triggerRef as React.RefCallback<HTMLButtonElement>}
|
||||
active={false}
|
||||
disabled
|
||||
label={t('Direct.segment_channels')}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<Segment
|
||||
active={!!channelsMatch}
|
||||
label={t('Direct.segment_channels')}
|
||||
badge={showBadge ? t('Channels.badge_new') : undefined}
|
||||
onClick={handleChannelsClick}
|
||||
/>
|
||||
{showBotsSegment && (
|
||||
<Segment
|
||||
active={!!botsMatch}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
|
|
@ -48,6 +49,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
|
||||
import { CHANNELS_PATH } from '../../paths';
|
||||
import {
|
||||
SidebarAvatar,
|
||||
SidebarItem,
|
||||
|
|
@ -386,6 +388,7 @@ const useDnDMonitor = (
|
|||
type SpaceTabProps = {
|
||||
space: Room;
|
||||
selected: boolean;
|
||||
inChannels?: boolean;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
folder?: ISidebarFolder;
|
||||
onDragging: (dragItem?: SidebarDraggable) => void;
|
||||
|
|
@ -395,6 +398,7 @@ type SpaceTabProps = {
|
|||
function SpaceTab({
|
||||
space,
|
||||
selected,
|
||||
inChannels,
|
||||
onClick,
|
||||
folder,
|
||||
onDragging,
|
||||
|
|
@ -452,6 +456,13 @@ function SpaceTab({
|
|||
size={folder ? '300' : '400'}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={
|
||||
inChannels
|
||||
? {
|
||||
boxShadow: `0 0 0 ${toRem(2)} ${color.Primary.Main}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RoomAvatar
|
||||
roomId={space.roomId}
|
||||
|
|
@ -754,6 +765,17 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
|||
);
|
||||
|
||||
const selectedSpaceId = useSelectedSpace();
|
||||
// Highlights the avatar that owns the URL the user is on AND that they
|
||||
// reached through the Channels segment. Drives the violet ring
|
||||
// (color.Primary.Main) so the rail and the channels surface read as the same
|
||||
// navigation, not two parallel ones. Without this, the user sees a
|
||||
// selected outline that doesn't differentiate Space tab vs Channels
|
||||
// segment activations.
|
||||
const inChannelsSegment = !!useMatch({
|
||||
path: CHANNELS_PATH,
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const target = evt.currentTarget;
|
||||
|
|
@ -817,6 +839,9 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
|||
key={space.roomId}
|
||||
space={space}
|
||||
selected={space.roomId === selectedSpaceId}
|
||||
inChannels={
|
||||
inChannelsSegment && space.roomId === selectedSpaceId
|
||||
}
|
||||
onClick={handleSpaceClick}
|
||||
folder={item}
|
||||
onDragging={setDraggingItem}
|
||||
|
|
@ -855,6 +880,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
|||
key={space.roomId}
|
||||
space={space}
|
||||
selected={space.roomId === selectedSpaceId}
|
||||
inChannels={inChannelsSegment && space.roomId === selectedSpaceId}
|
||||
onClick={handleSpaceClick}
|
||||
onDragging={setDraggingItem}
|
||||
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { generatePath, Path } from 'react-router-dom';
|
|||
import {
|
||||
BOTS_BOT_PATH,
|
||||
BOTS_PATH,
|
||||
CHANNELS_PATH,
|
||||
CHANNELS_ROOM_PATH,
|
||||
CHANNELS_SPACE_PATH,
|
||||
DIRECT_CREATE_PATH,
|
||||
DIRECT_PATH,
|
||||
DIRECT_ROOM_PATH,
|
||||
|
|
@ -157,3 +160,21 @@ export const getCreatePath = (): string => CREATE_PATH;
|
|||
export const getBotsPath = (): string => BOTS_PATH;
|
||||
export const getBotPath = (botId: string): string =>
|
||||
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
||||
|
||||
export const getChannelsPath = (): string => CHANNELS_PATH;
|
||||
export const getChannelsSpacePath = (spaceIdOrAlias: string): string => {
|
||||
const params = {
|
||||
spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
|
||||
};
|
||||
return generatePath(CHANNELS_SPACE_PATH, params);
|
||||
};
|
||||
export const getChannelsRoomPath = (
|
||||
spaceIdOrAlias: string,
|
||||
roomIdOrAlias: string
|
||||
): string => {
|
||||
const params = {
|
||||
spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias),
|
||||
roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
|
||||
};
|
||||
return generatePath(CHANNELS_ROOM_PATH, params);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
|
|||
export const BOTS_PATH = '/bots/';
|
||||
export const BOTS_BOT_PATH = '/bots/:botId/';
|
||||
|
||||
export const CHANNELS_PATH = '/channels/';
|
||||
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
|
||||
export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/';
|
||||
|
||||
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
||||
|
||||
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { matchPath } from 'react-router-dom';
|
||||
import {
|
||||
BOTS_PATH,
|
||||
CHANNELS_PATH,
|
||||
CHANNELS_ROOM_PATH,
|
||||
CHANNELS_SPACE_PATH,
|
||||
DIRECT_PATH,
|
||||
EXPLORE_PATH,
|
||||
HOME_PATH,
|
||||
|
|
@ -8,6 +11,8 @@ import {
|
|||
} from '../pages/paths';
|
||||
import {
|
||||
getBotsPath,
|
||||
getChannelsPath,
|
||||
getChannelsSpacePath,
|
||||
getDirectPath,
|
||||
getExplorePath,
|
||||
getHomePath,
|
||||
|
|
@ -30,6 +35,27 @@ export const getRouteSectionParent = (pathname: string): string | null => {
|
|||
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
||||
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
|
||||
|
||||
if (under(CHANNELS_PATH)) {
|
||||
const roomMatch = matchPath(
|
||||
{ path: CHANNELS_ROOM_PATH, caseSensitive: true, end: false },
|
||||
pathname
|
||||
);
|
||||
const roomSpace = roomMatch?.params.spaceIdOrAlias;
|
||||
if (roomSpace) {
|
||||
return getChannelsSpacePath(decodeURIComponent(roomSpace));
|
||||
}
|
||||
const spaceMatch = matchPath(
|
||||
{ path: CHANNELS_SPACE_PATH, caseSensitive: true, end: false },
|
||||
pathname
|
||||
);
|
||||
const channelsSpace = spaceMatch?.params.spaceIdOrAlias;
|
||||
if (channelsSpace) {
|
||||
const channelsSpacePath = getChannelsSpacePath(decodeURIComponent(channelsSpace));
|
||||
return pathname === channelsSpacePath ? getChannelsPath() : channelsSpacePath;
|
||||
}
|
||||
return atRoot(CHANNELS_PATH) ? null : getChannelsPath();
|
||||
}
|
||||
|
||||
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||
if (encodedSpaceIdOrAlias) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue