feat(channels): ship M1 — Каналы segment with /channels/ routes and channels-mode RoomTimeline filter

This commit is contained in:
v.lagerev 2026-05-09 15:06:13 +03:00
parent 0a93ad5a9f
commit c31e9a6e92
17 changed files with 732 additions and 30 deletions

View file

@ -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",

View file

@ -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": "Присоединиться",

View file

@ -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(),

View 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);
}

View file

@ -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;
}

View file

@ -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={

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
export * from './Channels';
export * from './ChannelsList';
export * from './ChannelsLanding';
export * from './ChannelPickPlaceholder';
export * from './useActiveSpace';

View 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];
};

View file

@ -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}

View file

@ -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}

View file

@ -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);
};

View file

@ -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/';

View file

@ -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) {