feat(channels): ship M1 — Каналы segment with /channels/ routes and channels-mode RoomTimeline filter
This commit is contained in:
parent
c6eba1e935
commit
efe58dc2e2
17 changed files with 732 additions and 30 deletions
|
|
@ -375,7 +375,6 @@
|
||||||
"segment_dm": "DM",
|
"segment_dm": "DM",
|
||||||
"segment_channels": "Channels",
|
"segment_channels": "Channels",
|
||||||
"segment_bots": "Robots",
|
"segment_bots": "Robots",
|
||||||
"segment_coming_soon": "Coming soon",
|
|
||||||
"self_row_label": "You",
|
"self_row_label": "You",
|
||||||
"self_row_preview": "Settings & profile",
|
"self_row_preview": "Settings & profile",
|
||||||
"message_me_label": "me",
|
"message_me_label": "me",
|
||||||
|
|
@ -389,6 +388,15 @@
|
||||||
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||||
"create": "Create"
|
"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": {
|
"Call": {
|
||||||
"start": "Start call",
|
"start": "Start call",
|
||||||
"join": "Join call",
|
"join": "Join call",
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,6 @@
|
||||||
"segment_dm": "Личные",
|
"segment_dm": "Личные",
|
||||||
"segment_channels": "Каналы",
|
"segment_channels": "Каналы",
|
||||||
"segment_bots": "Роботы",
|
"segment_bots": "Роботы",
|
||||||
"segment_coming_soon": "Скоро",
|
|
||||||
"self_row_label": "Я",
|
"self_row_label": "Я",
|
||||||
"self_row_preview": "Настройки и профиль",
|
"self_row_preview": "Настройки и профиль",
|
||||||
"message_me_label": "я",
|
"message_me_label": "я",
|
||||||
|
|
@ -391,6 +390,15 @@
|
||||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||||
"create": "Создать"
|
"create": "Создать"
|
||||||
},
|
},
|
||||||
|
"Channels": {
|
||||||
|
"no_spaces_title": "Пока нет сообществ",
|
||||||
|
"no_spaces_desc": "Найдите сообщество и присоединитесь — каналы появятся здесь.",
|
||||||
|
"explore_cta": "Найти сообщество",
|
||||||
|
"pick_channel_title": "Выберите канал",
|
||||||
|
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||||
|
"badge_new": "Новое",
|
||||||
|
"root_category": "Каналы"
|
||||||
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Позвонить",
|
"start": "Позвонить",
|
||||||
"join": "Присоединиться",
|
"join": "Присоединиться",
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ import {
|
||||||
getLatestEditableEvt,
|
getLatestEditableEvt,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
getReactionContent,
|
getReactionContent,
|
||||||
|
isBridgedRoom,
|
||||||
isMembershipChanged,
|
isMembershipChanged,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
|
|
@ -116,6 +117,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
|
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
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) {
|
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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`,
|
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
|
||||||
// not the persisted `m.direct` flag — see plan §6.8.
|
// not the persisted `m.direct` flag — see plan §6.8.
|
||||||
const isOneOnOne = useIsOneOnOne();
|
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 [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
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.msc4075.rtc.notification') return false;
|
||||||
if (eventType === 'org.matrix.msc4310.rtc.decline') 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) {
|
if (eventType === StateEvent.RoomMember) {
|
||||||
// Mirror the membership-sysline gate from the renderer above so the
|
// Mirror the membership-sysline gate from the renderer above so the
|
||||||
// rail-endpoint scan and the actual render agree on visibility.
|
// rail-endpoint scan and the actual render agree on visibility.
|
||||||
|
|
@ -1878,7 +1921,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const streamRailEnd =
|
const streamRailEnd =
|
||||||
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
|
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
|
? null
|
||||||
: renderMatrixEvent(
|
: renderMatrixEvent(
|
||||||
mEvent.getType(),
|
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 { ReactNode } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
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 = {
|
type MobileFriendlyClientNavProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
@ -13,10 +21,24 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro
|
||||||
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true });
|
||||||
const exploreMatch = useMatch({ path: EXPLORE_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 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 (
|
if (
|
||||||
screenSize === ScreenSize.Mobile &&
|
screenSize === ScreenSize.Mobile &&
|
||||||
!(homeMatch || directMatch || spaceMatch || exploreMatch || botsMatch)
|
!(
|
||||||
|
homeMatch ||
|
||||||
|
directMatch ||
|
||||||
|
spaceMatch ||
|
||||||
|
exploreMatch ||
|
||||||
|
botsMatch ||
|
||||||
|
channelsMatch ||
|
||||||
|
channelsSpaceMatch
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import { ClientConfig } from '../hooks/useClientConfig';
|
||||||
import { AuthLayout, Login, Register, ResetPassword } from './auth';
|
import { AuthLayout, Login, Register, ResetPassword } from './auth';
|
||||||
import {
|
import {
|
||||||
BOTS_PATH,
|
BOTS_PATH,
|
||||||
|
CHANNELS_PATH,
|
||||||
|
CHANNELS_ROOM_PATH,
|
||||||
|
CHANNELS_SPACE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
HOME_PATH,
|
HOME_PATH,
|
||||||
|
|
@ -48,6 +51,12 @@ import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||||
import { HomeRouteRoomProvider } from './client/home';
|
import { HomeRouteRoomProvider } from './client/home';
|
||||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||||
import { BotExperienceHost, Bots } from './client/bots';
|
import { BotExperienceHost, Bots } from './client/bots';
|
||||||
|
import {
|
||||||
|
Channels,
|
||||||
|
ChannelsRootNav,
|
||||||
|
ChannelPickPlaceholder,
|
||||||
|
ChannelsLanding,
|
||||||
|
} from './client/channels';
|
||||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
|
||||||
|
|
@ -77,6 +86,7 @@ import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||||
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
||||||
import { HorseshoeContainer } from './HorseshoeContainer';
|
import { HorseshoeContainer } from './HorseshoeContainer';
|
||||||
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
||||||
|
import { ChannelsModeProvider } from '../hooks/useChannelsMode';
|
||||||
|
|
||||||
function IncomingCallsFeature() {
|
function IncomingCallsFeature() {
|
||||||
useIncomingRtcNotifications();
|
useIncomingRtcNotifications();
|
||||||
|
|
@ -256,6 +266,59 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
<Route path=":botId" element={<BotExperienceHost />} />
|
<Route path=":botId" element={<BotExperienceHost />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
<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
|
<Route
|
||||||
path={SPACE_PATH}
|
path={SPACE_PATH}
|
||||||
element={
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useMatch, useNavigate } from 'react-router-dom';
|
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 { 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 { isNativePlatform } from '../../../utils/capacitor';
|
||||||
import { useBotPresets } from '../../../features/bots/catalog';
|
import { useBotPresets } from '../../../features/bots/catalog';
|
||||||
|
|
||||||
|
const CHANNELS_TOOLTIP_SEEN_KEY = 'vojo.channelsTooltipSeen';
|
||||||
|
|
||||||
type SegmentProps = {
|
type SegmentProps = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
badge?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
||||||
({ active, disabled, label, onClick }, ref) => (
|
({ active, disabled, label, badge, onClick }, ref) => (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -34,9 +37,29 @@ const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
||||||
fontWeight: active ? 600 : 500,
|
fontWeight: active ? 600 : 500,
|
||||||
fontSize: toRem(13),
|
fontSize: toRem(13),
|
||||||
lineHeight: 1.2,
|
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>
|
</button>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -44,15 +67,48 @@ const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
|
||||||
export function DirectStreamHeader() {
|
export function DirectStreamHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const comingSoon = t('Direct.segment_coming_soon');
|
|
||||||
const bots = useBotPresets();
|
const bots = useBotPresets();
|
||||||
|
|
||||||
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
|
||||||
const botsMatch = useMatch({ path: BOTS_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;
|
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 navOpts = { replace: isNativePlatform() };
|
||||||
|
|
||||||
|
const handleChannelsClick = () => {
|
||||||
|
setShowBadge(false);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHANNELS_TOOLTIP_SEEN_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
navigate(CHANNELS_PATH, navOpts);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNavHeader>
|
<PageNavHeader>
|
||||||
<Box alignItems="Center" grow="Yes" gap="100">
|
<Box alignItems="Center" grow="Yes" gap="100">
|
||||||
|
|
@ -61,24 +117,12 @@ export function DirectStreamHeader() {
|
||||||
label={t('Direct.segment_dm')}
|
label={t('Direct.segment_dm')}
|
||||||
onClick={() => navigate(DIRECT_PATH, navOpts)}
|
onClick={() => navigate(DIRECT_PATH, navOpts)}
|
||||||
/>
|
/>
|
||||||
<TooltipProvider
|
|
||||||
delay={400}
|
|
||||||
position="Bottom"
|
|
||||||
tooltip={
|
|
||||||
<Tooltip>
|
|
||||||
<Text size="T200">{comingSoon}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<Segment
|
<Segment
|
||||||
ref={triggerRef as React.RefCallback<HTMLButtonElement>}
|
active={!!channelsMatch}
|
||||||
active={false}
|
|
||||||
disabled
|
|
||||||
label={t('Direct.segment_channels')}
|
label={t('Direct.segment_channels')}
|
||||||
|
badge={showBadge ? t('Channels.badge_new') : undefined}
|
||||||
|
onClick={handleChannelsClick}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
{showBotsSegment && (
|
{showBotsSegment && (
|
||||||
<Segment
|
<Segment
|
||||||
active={!!botsMatch}
|
active={!!botsMatch}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useMatch, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Icon,
|
Icon,
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
PopOut,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
Text,
|
Text,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
|
|
@ -48,6 +49,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
|
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
|
||||||
|
import { CHANNELS_PATH } from '../../paths';
|
||||||
import {
|
import {
|
||||||
SidebarAvatar,
|
SidebarAvatar,
|
||||||
SidebarItem,
|
SidebarItem,
|
||||||
|
|
@ -386,6 +388,7 @@ const useDnDMonitor = (
|
||||||
type SpaceTabProps = {
|
type SpaceTabProps = {
|
||||||
space: Room;
|
space: Room;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
inChannels?: boolean;
|
||||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
folder?: ISidebarFolder;
|
folder?: ISidebarFolder;
|
||||||
onDragging: (dragItem?: SidebarDraggable) => void;
|
onDragging: (dragItem?: SidebarDraggable) => void;
|
||||||
|
|
@ -395,6 +398,7 @@ type SpaceTabProps = {
|
||||||
function SpaceTab({
|
function SpaceTab({
|
||||||
space,
|
space,
|
||||||
selected,
|
selected,
|
||||||
|
inChannels,
|
||||||
onClick,
|
onClick,
|
||||||
folder,
|
folder,
|
||||||
onDragging,
|
onDragging,
|
||||||
|
|
@ -452,6 +456,13 @@ function SpaceTab({
|
||||||
size={folder ? '300' : '400'}
|
size={folder ? '300' : '400'}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
style={
|
||||||
|
inChannels
|
||||||
|
? {
|
||||||
|
boxShadow: `0 0 0 ${toRem(2)} ${color.Primary.Main}`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
roomId={space.roomId}
|
roomId={space.roomId}
|
||||||
|
|
@ -754,6 +765,17 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSpaceId = useSelectedSpace();
|
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 handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
const target = evt.currentTarget;
|
const target = evt.currentTarget;
|
||||||
|
|
@ -817,6 +839,9 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||||
key={space.roomId}
|
key={space.roomId}
|
||||||
space={space}
|
space={space}
|
||||||
selected={space.roomId === selectedSpaceId}
|
selected={space.roomId === selectedSpaceId}
|
||||||
|
inChannels={
|
||||||
|
inChannelsSegment && space.roomId === selectedSpaceId
|
||||||
|
}
|
||||||
onClick={handleSpaceClick}
|
onClick={handleSpaceClick}
|
||||||
folder={item}
|
folder={item}
|
||||||
onDragging={setDraggingItem}
|
onDragging={setDraggingItem}
|
||||||
|
|
@ -855,6 +880,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
||||||
key={space.roomId}
|
key={space.roomId}
|
||||||
space={space}
|
space={space}
|
||||||
selected={space.roomId === selectedSpaceId}
|
selected={space.roomId === selectedSpaceId}
|
||||||
|
inChannels={inChannelsSegment && space.roomId === selectedSpaceId}
|
||||||
onClick={handleSpaceClick}
|
onClick={handleSpaceClick}
|
||||||
onDragging={setDraggingItem}
|
onDragging={setDraggingItem}
|
||||||
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
|
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { generatePath, Path } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
BOTS_BOT_PATH,
|
BOTS_BOT_PATH,
|
||||||
BOTS_PATH,
|
BOTS_PATH,
|
||||||
|
CHANNELS_PATH,
|
||||||
|
CHANNELS_ROOM_PATH,
|
||||||
|
CHANNELS_SPACE_PATH,
|
||||||
DIRECT_CREATE_PATH,
|
DIRECT_CREATE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
DIRECT_ROOM_PATH,
|
DIRECT_ROOM_PATH,
|
||||||
|
|
@ -157,3 +160,21 @@ export const getCreatePath = (): string => CREATE_PATH;
|
||||||
export const getBotsPath = (): string => BOTS_PATH;
|
export const getBotsPath = (): string => BOTS_PATH;
|
||||||
export const getBotPath = (botId: string): string =>
|
export const getBotPath = (botId: string): string =>
|
||||||
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
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_PATH = '/bots/';
|
||||||
export const BOTS_BOT_PATH = '/bots/:botId/';
|
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 SPACE_SETTINGS_PATH = '/space-settings/';
|
||||||
|
|
||||||
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
export const ROOM_SETTINGS_PATH = '/room-settings/';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { matchPath } from 'react-router-dom';
|
import { matchPath } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
BOTS_PATH,
|
BOTS_PATH,
|
||||||
|
CHANNELS_PATH,
|
||||||
|
CHANNELS_ROOM_PATH,
|
||||||
|
CHANNELS_SPACE_PATH,
|
||||||
DIRECT_PATH,
|
DIRECT_PATH,
|
||||||
EXPLORE_PATH,
|
EXPLORE_PATH,
|
||||||
HOME_PATH,
|
HOME_PATH,
|
||||||
|
|
@ -8,6 +11,8 @@ import {
|
||||||
} from '../pages/paths';
|
} from '../pages/paths';
|
||||||
import {
|
import {
|
||||||
getBotsPath,
|
getBotsPath,
|
||||||
|
getChannelsPath,
|
||||||
|
getChannelsSpacePath,
|
||||||
getDirectPath,
|
getDirectPath,
|
||||||
getExplorePath,
|
getExplorePath,
|
||||||
getHomePath,
|
getHomePath,
|
||||||
|
|
@ -30,6 +35,27 @@ export const getRouteSectionParent = (pathname: string): string | null => {
|
||||||
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
|
||||||
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
|
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 spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
|
||||||
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;
|
||||||
if (encodedSpaceIdOrAlias) {
|
if (encodedSpaceIdOrAlias) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue