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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={() => openCreatePostModal()}
+ >
+ Share
+
+
+
+
+ );
+}
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
+
+
+
+
+
+
{
+ 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/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 7c4935f0..914345ca 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -23,6 +23,7 @@ import {
SPACE_PATH,
SPACE_ROOM_PATH,
SPACE_SEARCH_PATH,
+ SPACE_FEED_PATH,
CREATE_PATH,
} from './paths';
import { trimLeadingSlash, trimTrailingSlash } from '../utils/common';
@@ -132,6 +133,12 @@ export const getSpaceSearchPath = (spaceIdOrAlias: string): string => {
};
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/';
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();
+ });
+});