From 2e07d3d7edd75b60847f6edd72bc3d90d97bb1a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:51:56 +0000 Subject: [PATCH 1/2] Add per-space feed page scoped to that space's rooms Adds a Feed nav entry and route for each space, mirroring the existing Home feed but restricted to posts from rooms attached to that space (recursively, excluding nested DMs), via useSpaceFeedRooms. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vz3XwBGSj72K2VdWdeupDt --- src/app/features/feed/useFeedRooms.ts | 22 +++++++- src/app/hooks/router/useSelectedSpace.ts | 12 ++++- src/app/pages/Router.tsx | 9 +++- src/app/pages/client/space/Feed.css.ts | 9 ++++ src/app/pages/client/space/Feed.tsx | 64 ++++++++++++++++++++++++ src/app/pages/client/space/Space.tsx | 25 ++++++++- src/app/pages/client/space/index.ts | 1 + src/app/pages/pathUtils.ts | 7 +++ src/app/pages/paths.ts | 1 + 9 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 src/app/pages/client/space/Feed.css.ts create mode 100644 src/app/pages/client/space/Feed.tsx diff --git a/src/app/features/feed/useFeedRooms.ts b/src/app/features/feed/useFeedRooms.ts index 0688bf8a..3b8ef1f6 100644 --- a/src/app/features/feed/useFeedRooms.ts +++ b/src/app/features/feed/useFeedRooms.ts @@ -1,6 +1,14 @@ import { useCallback } from 'react'; -import { RoomSelector, useSelectedRooms } from '../../state/hooks/roomList'; +import { useAtomValue } from 'jotai'; +import { + RoomSelector, + useRecursiveChildRoomScopeFactory, + useSelectedRooms, + useSpaceChildren, +} from '../../state/hooks/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { isRoom } from '../../utils/room'; @@ -9,3 +17,15 @@ export const useFeedRooms = (): string[] => { const selector: RoomSelector = useCallback((roomId) => isRoom(mx.getRoom(roomId)), [mx]); return useSelectedRooms(allRoomsAtom, selector); }; + +export const useSpaceFeedRooms = (spaceId: string): string[] => { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + + return useSpaceChildren( + allRoomsAtom, + spaceId, + useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents) + ); +}; diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts index b891fde7..014c58df 100644 --- a/src/app/hooks/router/useSelectedSpace.ts +++ b/src/app/hooks/router/useSelectedSpace.ts @@ -1,7 +1,7 @@ import { useMatch, useParams } from 'react-router-dom'; import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; import { useMatrixClient } from '../useMatrixClient'; -import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils'; +import { getSpaceLobbyPath, getSpaceSearchPath, getSpaceFeedPath } from '../../pages/pathUtils'; export const useSelectedSpace = (): string | undefined => { const mx = useMatrixClient(); @@ -35,3 +35,13 @@ export const useSpaceSearchSelected = (spaceIdOrAlias: string): boolean => { return !!match; }; + +export const useSpaceFeedSelected = (spaceIdOrAlias: string): boolean => { + const match = useMatch({ + path: decodeURIComponent(getSpaceFeedPath(spaceIdOrAlias)), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 80dddf07..8e63b5ac 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -44,7 +44,13 @@ import { import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; import { Home, HomeRouteRoomProvider, HomeSearch, HomeFeed } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { + RouteSpaceProvider, + Space, + SpaceRouteRoomProvider, + SpaceSearch, + SpaceFeed, +} from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; @@ -246,6 +252,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} } /> } /> + } /> (null); + const space = useSpace(); + const rooms = useSpaceFeedRooms(space.roomId); + const screenSize = useScreenSizeContext(); + const openCreatePostModal = useOpenCreatePostModal(); + + return ( + + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Feed + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 2a7c6199..52e3901a 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -38,12 +38,18 @@ import { NavItemContent, NavLink, } from '../../../components/nav'; -import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; +import { + getSpaceLobbyPath, + getSpaceRoomPath, + getSpaceSearchPath, + getSpaceFeedPath, +} from '../../pathUtils'; import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, useSpaceSearchSelected, + useSpaceFeedSelected, } from '../../../hooks/router/useSelectedSpace'; import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; @@ -391,6 +397,7 @@ export function Space() { const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const feedSelected = useSpaceFeedSelected(spaceIdOrAlias); const callEmbed = useCallEmbed(); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); @@ -483,6 +490,22 @@ export function Space() { + + + + + + + + + + Feed + + + + + + { }; return generatePath(SPACE_SEARCH_PATH, params); }; +export const getSpaceFeedPath = (spaceIdOrAlias: string): string => { + const params = { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias), + }; + return generatePath(SPACE_FEED_PATH, params); +}; export const getSpaceRoomPath = ( spaceIdOrAlias: string, roomIdOrAlias: string, diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 303451d4..0c3d4d54 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -60,6 +60,7 @@ export const DIRECT_ROOM_PATH = `/direct/${_ROOM_PATH}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${_LOBBY_PATH}`; export const SPACE_SEARCH_PATH = `/:spaceIdOrAlias/${_SEARCH_PATH}`; +export const SPACE_FEED_PATH = `/:spaceIdOrAlias/${_FEED_PATH}`; export const SPACE_ROOM_PATH = `/:spaceIdOrAlias/${_ROOM_PATH}`; export const _FEATURED_PATH = 'featured/'; From a0d77e34b277c15e2e93009243b325b625423ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 20:56:20 +0000 Subject: [PATCH 2/2] Add tests for the space-feed room scoping logic Covers getAllParents/mapParentWithChildren (the pure functions that resolve a room's ancestor spaces, including through nested sub-spaces, which useSpaceFeedRooms relies on to scope posts to a space) and the new getSpaceFeedPath route helper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vz3XwBGSj72K2VdWdeupDt --- src/app/pages/pathUtils.test.ts | 12 ++++++ src/app/utils/room.test.ts | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/app/pages/pathUtils.test.ts create mode 100644 src/app/utils/room.test.ts diff --git a/src/app/pages/pathUtils.test.ts b/src/app/pages/pathUtils.test.ts new file mode 100644 index 00000000..8c52e8ca --- /dev/null +++ b/src/app/pages/pathUtils.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { getSpaceFeedPath } from './pathUtils'; + +describe('getSpaceFeedPath', () => { + it('builds a feed path scoped to the given space', () => { + expect(getSpaceFeedPath('!space:example.org')).toBe('/!space%3Aexample.org/feed'); + }); + + it('encodes a space alias', () => { + expect(getSpaceFeedPath('#space:example.org')).toBe('/%23space%3Aexample.org/feed'); + }); +}); diff --git a/src/app/utils/room.test.ts b/src/app/utils/room.test.ts new file mode 100644 index 00000000..0fe6af8d --- /dev/null +++ b/src/app/utils/room.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { RoomToParents } from '../../types/matrix/room'; +import { getAllParents, mapParentWithChildren } from './room'; + +describe('getAllParents', () => { + it('returns an empty set for a room with no parents', () => { + const roomToParents: RoomToParents = new Map(); + expect(getAllParents(roomToParents, '!room:x')).toEqual(new Set()); + }); + + it('returns the direct parent of a room', () => { + const roomToParents: RoomToParents = new Map([['!room:x', new Set(['!space:x'])]]); + expect(getAllParents(roomToParents, '!room:x')).toEqual(new Set(['!space:x'])); + }); + + it('resolves ancestors through nested sub-spaces', () => { + // !room:x -> !subspace:x -> !space:x + const roomToParents: RoomToParents = new Map([ + ['!room:x', new Set(['!subspace:x'])], + ['!subspace:x', new Set(['!space:x'])], + ]); + expect(getAllParents(roomToParents, '!room:x')).toEqual(new Set(['!subspace:x', '!space:x'])); + }); + + it('collects all ancestors when a room has multiple parents', () => { + const roomToParents: RoomToParents = new Map([ + ['!room:x', new Set(['!spaceA:x', '!spaceB:x'])], + ]); + expect(getAllParents(roomToParents, '!room:x')).toEqual(new Set(['!spaceA:x', '!spaceB:x'])); + }); + + it('never includes the room itself, even in a cyclic map', () => { + const roomToParents: RoomToParents = new Map([ + ['!room:x', new Set(['!space:x'])], + ['!space:x', new Set(['!room:x'])], + ]); + expect(getAllParents(roomToParents, '!room:x')).toEqual(new Set(['!space:x'])); + }); +}); + +describe('mapParentWithChildren', () => { + it('maps each child to its parent room', () => { + const roomToParents: RoomToParents = new Map(); + mapParentWithChildren(roomToParents, '!space:x', ['!room1:x', '!room2:x']); + + expect(roomToParents.get('!room1:x')).toEqual(new Set(['!space:x'])); + expect(roomToParents.get('!room2:x')).toEqual(new Set(['!space:x'])); + }); + + it('adds an additional parent without dropping an existing one', () => { + const roomToParents: RoomToParents = new Map([['!room:x', new Set(['!spaceA:x'])]]); + mapParentWithChildren(roomToParents, '!spaceB:x', ['!room:x']); + + expect(roomToParents.get('!room:x')).toEqual(new Set(['!spaceA:x', '!spaceB:x'])); + }); + + it('skips a child that would create a space cycle', () => { + // !space:x is already a (transitive) child of !subspace:x, so making + // !subspace:x a child of !space:x would form a cycle. + const roomToParents: RoomToParents = new Map([['!space:x', new Set(['!subspace:x'])]]); + mapParentWithChildren(roomToParents, '!space:x', ['!subspace:x']); + + expect(roomToParents.get('!subspace:x')).toBeUndefined(); + }); +});