From c31e9a6e9267b450bb1ac1685c95fc229b3f0034 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sat, 9 May 2026 15:06:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(channels):=20ship=20M1=20=E2=80=94=20?= =?UTF-8?q?=D0=9A=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20segment=20with=20/channe?= =?UTF-8?q?ls/=20routes=20and=20channels-mode=20RoomTimeline=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en.json | 10 +- public/locales/ru.json | 10 +- src/app/features/room/RoomTimeline.tsx | 49 +++- src/app/hooks/useChannelsMode.ts | 15 ++ src/app/pages/MobileFriendly.tsx | 26 ++- src/app/pages/Router.tsx | 63 +++++ .../channels/ChannelPickPlaceholder.tsx | 30 +++ src/app/pages/client/channels/Channels.tsx | 60 +++++ .../pages/client/channels/ChannelsLanding.tsx | 57 +++++ .../pages/client/channels/ChannelsList.tsx | 215 ++++++++++++++++++ src/app/pages/client/channels/index.ts | 5 + .../pages/client/channels/useActiveSpace.ts | 51 +++++ .../client/direct/DirectStreamHeader.tsx | 92 ++++++-- src/app/pages/client/sidebar/SpaceTabs.tsx | 28 ++- src/app/pages/pathUtils.ts | 21 ++ src/app/pages/paths.ts | 4 + src/app/utils/routeParent.ts | 26 +++ 17 files changed, 732 insertions(+), 30 deletions(-) create mode 100644 src/app/hooks/useChannelsMode.ts create mode 100644 src/app/pages/client/channels/ChannelPickPlaceholder.tsx create mode 100644 src/app/pages/client/channels/Channels.tsx create mode 100644 src/app/pages/client/channels/ChannelsLanding.tsx create mode 100644 src/app/pages/client/channels/ChannelsList.tsx create mode 100644 src/app/pages/client/channels/index.ts create mode 100644 src/app/pages/client/channels/useActiveSpace.ts diff --git a/public/locales/en.json b/public/locales/en.json index bce5a85f..ced63d15 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index ef573486..d46b754d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Присоединиться", diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index b4b80b04..7e595ca8 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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(), diff --git a/src/app/hooks/useChannelsMode.ts b/src/app/hooks/useChannelsMode.ts new file mode 100644 index 00000000..ba47e343 --- /dev/null +++ b/src/app/hooks/useChannelsMode.ts @@ -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(false); + +export const ChannelsModeProvider = ChannelsModeContext.Provider; + +export function useChannelsMode(): boolean { + return useContext(ChannelsModeContext); +} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index f26219e9..ebeedb7c 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -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; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4e5fa0dd..f8dba35a 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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) } /> } /> + {/* 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. */} + + + + } + > + + + + } + > + + + } + /> + + + + + } + > + + + + } + > + {mobile ? null : } />} + + + + } + /> + + + } + title={ + + {t('Channels.pick_channel_title')} + + } + content={ + + + {t('Channels.pick_channel_desc')} + + + } + /> + + ); +} diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx new file mode 100644 index 00000000..e615ad4e --- /dev/null +++ b/src/app/pages/client/channels/Channels.tsx @@ -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 ( + + + +
+ + + ); +} + +// 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 // 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(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 ( + + + + + + + + ); +} diff --git a/src/app/pages/client/channels/ChannelsLanding.tsx b/src/app/pages/client/channels/ChannelsLanding.tsx new file mode 100644 index 00000000..2c92e5f4 --- /dev/null +++ b/src/app/pages/client/channels/ChannelsLanding.tsx @@ -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 ; + } + + return ( + + } + title={ + + {t('Channels.no_spaces_title')} + + } + content={ + + {t('Channels.no_spaces_desc')} + + } + options={ + + + + } + /> + + ); +} diff --git a/src/app/pages/client/channels/ChannelsList.tsx b/src/app/pages/client/channels/ChannelsList.tsx new file mode 100644 index 00000000..bf5c22bd --- /dev/null +++ b/src/app/pages/client/channels/ChannelsList.tsx @@ -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 ``. Wrong scroll target → clientHeight reads as 0 → + // virtualizer renders no rows. Match `Space.tsx` pattern. + scrollRef: MutableRefObject; +}; + +// 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/// +// path so the room timeline opens inside the channels surface, not the +// legacy /// 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 ( + + + {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 ( + +
+ + + {roomId === space.roomId ? t('Channels.root_category') : room?.name} + + +
+
+ ); + } + + return ( + + + + ); + })} +
+
+ ); +} + +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 ( + +
+ {!httpUrl && initial} +
+
+ {space.name} +
+
+ ); +} diff --git a/src/app/pages/client/channels/index.ts b/src/app/pages/client/channels/index.ts new file mode 100644 index 00000000..5868f33d --- /dev/null +++ b/src/app/pages/client/channels/index.ts @@ -0,0 +1,5 @@ +export * from './Channels'; +export * from './ChannelsList'; +export * from './ChannelsLanding'; +export * from './ChannelPickPlaceholder'; +export * from './useActiveSpace'; diff --git a/src/app/pages/client/channels/useActiveSpace.ts b/src/app/pages/client/channels/useActiveSpace.ts new file mode 100644 index 00000000..2b0bacf6 --- /dev/null +++ b/src/app/pages/client/channels/useActiveSpace.ts @@ -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]; +}; diff --git a/src/app/pages/client/direct/DirectStreamHeader.tsx b/src/app/pages/client/direct/DirectStreamHeader.tsx index 027f0a21..4be938d5 100644 --- a/src/app/pages/client/direct/DirectStreamHeader.tsx +++ b/src/app/pages/client/direct/DirectStreamHeader.tsx @@ -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( - ({ active, disabled, label, onClick }, ref) => ( + ({ active, disabled, label, badge, onClick }, ref) => ( ) ); @@ -44,15 +67,48 @@ const Segment = forwardRef( 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 ( @@ -61,24 +117,12 @@ export function DirectStreamHeader() { label={t('Direct.segment_dm')} onClick={() => navigate(DIRECT_PATH, navOpts)} /> - - {comingSoon} - - } - > - {(triggerRef) => ( - } - active={false} - disabled - label={t('Direct.segment_channels')} - /> - )} - + {showBotsSegment && ( ; 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 + } > = (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} diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 8a251029..242a6ebe 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -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); +}; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 14d8a242..2caa6417 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -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/'; diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts index 137622a1..f50194a1 100644 --- a/src/app/utils/routeParent.ts +++ b/src/app/utils/routeParent.ts @@ -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) {