Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/app/features/feed/useFeedRooms.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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)
);
};
12 changes: 11 additions & 1 deletion src/app/hooks/router/useSelectedSpace.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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;
};
9 changes: 8 additions & 1 deletion src/app/pages/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -246,6 +252,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
)}
<Route path={_LOBBY_PATH} element={<Lobby />} />
<Route path={_SEARCH_PATH} element={<SpaceSearch />} />
<Route path={_FEED_PATH} element={<SpaceFeed />} />
<Route
path={_ROOM_PATH}
element={
Expand Down
9 changes: 9 additions & 0 deletions src/app/pages/client/space/Feed.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';

export const CreatePostFab = style({
position: 'absolute',
bottom: config.space.S400,
insetInlineEnd: config.space.S400,
zIndex: config.zIndex.Z100,
});
64 changes: 64 additions & 0 deletions src/app/pages/client/space/Feed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useRef } from 'react';
import { Box, Icon, Icons, Text, Scroll, IconButton, Button } from 'folds';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { Feed, useSpaceFeedRooms } from '../../../features/feed';
import { useOpenCreatePostModal } from '../../../state/hooks/createPostModal';
import { useSpace } from '../../../hooks/useSpace';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import * as css from './Feed.css';

export function SpaceFeed() {
const scrollRef = useRef<HTMLDivElement>(null);
const space = useSpace();
const rooms = useSpaceFeedRooms(space.roomId);
const screenSize = useScreenSizeContext();
const openCreatePostModal = useOpenCreatePostModal();

return (
<Page>
<PageHeader balance>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Photo} />}
<Text size="H3" truncate>
Feed
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<Feed rooms={rooms} />
</PageContentCenter>
</PageContent>
</Scroll>
<Box className={css.CreatePostFab}>
<Button
variant="Primary"
radii="Pill"
size="500"
before={<Icon src={Icons.Plus} size="100" />}
onClick={() => openCreatePostModal()}
>
<Text size="B500">Share</Text>
</Button>
</Box>
</Box>
</Page>
);
}
25 changes: 24 additions & 1 deletion src/app/pages/client/space/Space.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -483,6 +490,22 @@ export function Space() {
</NavItemContent>
</NavLink>
</NavItem>
<NavItem variant="Background" radii="400" aria-selected={feedSelected}>
<NavLink to={getSpaceFeedPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Photo} size="100" filled={feedSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Feed
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory
style={{
Expand Down
1 change: 1 addition & 0 deletions src/app/pages/client/space/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './SpaceProvider';
export * from './Space';
export * from './Search';
export * from './Feed';
export * from './RoomProvider';
12 changes: 12 additions & 0 deletions src/app/pages/pathUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 7 additions & 0 deletions src/app/pages/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/app/pages/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down
65 changes: 65 additions & 0 deletions src/app/utils/room.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});