Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes

The navigator that replaces them lands next. SpaceTombstone moves to its own
file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with
the rail; ClientLayout loses its nav slot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-31 17:06:12 +01:00
commit f2e3223c5f
26 changed files with 83 additions and 3097 deletions

View file

@ -200,16 +200,12 @@ type HierarchyItemMenuProps = {
joined: boolean;
powerLevels?: IPowerLevels;
canEditChild: boolean;
pinned?: boolean;
onTogglePin?: (roomId: string) => void;
};
export function HierarchyItemMenu({
item,
joined,
powerLevels,
canEditChild,
pinned,
onTogglePin,
}: HierarchyItemMenuProps) {
const mx = useMatrixClient();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
{joined && (
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{onTogglePin && (
<MenuItem
size="300"
radii="300"
onClick={() => {
onTogglePin(item.roomId);
handleRequestClose();
}}
>
<Text as="span" size="T300" truncate>
{pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'}
</Text>
</MenuItem>
)}
<InviteMenuItem
item={item}
requestClose={handleRequestClose}

View file

@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
sidebarItemWithout,
useSidebarItems,
} from '../../hooks/useSidebarItems';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy';
import { useGetRoom } from '../../hooks/useGetRoom';
@ -167,18 +159,6 @@ export function Lobby() {
const screenSize = useScreenSizeContext();
const [onTop, setOnTop] = useState(true);
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
const [sidebarItems] = useSidebarItems(
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
);
const sidebarSpaces = useMemo(() => {
const sideSpaces = sidebarItems.flatMap((item) => {
if (typeof item === 'string') return item;
return item.content;
});
return new Set(sideSpaces);
}, [sidebarItems]);
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
useElementSizeObserver(
@ -415,18 +395,6 @@ export function Lobby() {
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
};
const togglePinToSidebar = useCallback(
(rId: string) => {
const newItems = sidebarItemWithout(sidebarItems, rId);
if (!sidebarSpaces.has(rId)) {
newItems.push(rId);
}
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
},
[mx, sidebarItems, sidebarSpaces]
);
return (
<PowerLevelsContextProvider value={spacePowerLevels}>
<Box grow="Yes">
@ -499,8 +467,6 @@ export function Lobby() {
disabledReorder={reordering}
nextSpaceId={nextSpaceId}
getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)}
togglePinToSidebar={togglePinToSidebar}
onSpacesFound={handleSpacesFound}
onOpenRoom={handleOpenRoom}
/>

View file

@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
disabledReorder?: boolean;
nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined;
pinned: boolean;
togglePinToSidebar: (roomId: string) => void;
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
};
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
disabledReorder,
nextSpaceId,
getRoom,
pinned,
togglePinToSidebar,
onOpenRoom,
onSpacesFound,
},
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
canEditChild={
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
}
pinned={pinned}
onTogglePin={togglePinToSidebar}
/>
)
}

View file

@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest';
import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups';
const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId });
const space = (roomId: string) => ({ roomId, space: true as const });
const base = {
spaceId: '!top',
isGroupOpen: () => true,
keepVisible: () => false,
};
const names = (out: StewardEntry<{ roomId: string }>[]) =>
out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId));
describe('groupByStewards', () => {
const stewards: Record<string, string> = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' };
const stewardOf = (id: string) => stewards[id];
it('groups by steward, sorted by name, with unstewarded rooms first', () => {
const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], {
...base,
stewardOf,
});
expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']);
});
it('collapsed groups hide rooms except kept-visible ones', () => {
const out = groupByStewards([room('!a'), room('!b'), room('!c')], {
...base,
stewardOf,
isGroupOpen: () => false,
keepVisible: (id) => id === '!c',
});
expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']);
});
it('degrades to the original order when no room has steward state', () => {
const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], {
...base,
stewardOf: () => undefined,
});
expect(names(out)).toEqual(['!1', '!s', '!2']);
});
it('does not merge runs separated by a space header', () => {
const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], {
...base,
stewardOf,
});
expect(out.filter((e) => e.type === 'group')).toHaveLength(2);
});
});
describe('parseSteward', () => {
it('accepts a string and rejects junk', () => {
expect(parseSteward({ steward: ' Void ' })).toBe('Void');
expect(parseSteward({ steward: 3 })).toBeUndefined();
expect(parseSteward({ steward: ' ' })).toBeUndefined();
expect(parseSteward(undefined)).toBeUndefined();
expect(parseSteward(null)).toBeUndefined();
});
});

View file

@ -1,91 +0,0 @@
/**
* Splits runs of rooms that carry an `org.gamecult.steward` state event into
* collapsible per-steward groups. Pure: the caller supplies the lookups, so the
* grouping is testable without a Matrix client.
*/
export const STEWARD_EVENT_TYPE = 'org.gamecult.steward';
export type HierarchyLike = { roomId: string; parentId?: string; space?: true };
export type StewardEntry<T extends HierarchyLike> =
| { type: 'item'; item: T }
| { type: 'group'; id: string; label: string; open: boolean; count: number };
export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) =>
`${spaceId}|${parentId}|steward:${steward}`;
export type StewardGroupOptions = {
spaceId: string;
/** steward name of a room, or undefined when the room has no (valid) steward state */
stewardOf: (roomId: string) => string | undefined;
isGroupOpen: (groupId: string) => boolean;
/** rooms that stay visible inside a collapsed group (selected, unread, in a call) */
keepVisible: (roomId: string) => boolean;
};
const byLabel = (a: string, b: string) =>
a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b);
export const groupByStewards = <T extends HierarchyLike>(
items: T[],
{ spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions
): StewardEntry<T>[] => {
const out: StewardEntry<T>[] = [];
const flush = (run: T[]) => {
if (run.length === 0) return;
const groups = new Map<string, T[]>();
const loose: T[] = [];
run.forEach((item) => {
const steward = stewardOf(item.roomId);
if (steward === undefined) {
loose.push(item);
return;
}
const list = groups.get(steward);
if (list) list.push(item);
else groups.set(steward, [item]);
});
// No steward state anywhere in this run: leave the list exactly as Cinny built it.
if (groups.size === 0) {
run.forEach((item) => out.push({ type: 'item', item }));
return;
}
loose.forEach((item) => out.push({ type: 'item', item }));
Array.from(groups.keys())
.sort(byLabel)
.forEach((steward) => {
const rooms = groups.get(steward) ?? [];
const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward);
const open = isGroupOpen(id);
out.push({ type: 'group', id, label: steward, open, count: rooms.length });
rooms
.filter((item) => open || keepVisible(item.roomId))
.forEach((item) => out.push({ type: 'item', item }));
});
};
let run: T[] = [];
items.forEach((item) => {
if (item.space) {
flush(run);
run = [];
out.push({ type: 'item', item });
return;
}
run.push(item);
});
flush(run);
return out;
};
/** Reads `steward` from raw state event content; anything unusable is "no steward". */
export const parseSteward = (content: unknown): string | undefined => {
if (typeof content !== 'object' || content === null) return undefined;
const { steward } = content as { steward?: unknown };
if (typeof steward !== 'string') return undefined;
const trimmed = steward.trim();
return trimmed === '' ? undefined : trimmed;
};

View file

@ -1,71 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
import { atomWithStorage } from 'jotai/utils';
import { useAtom } from 'jotai';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import {
groupByStewards,
HierarchyLike,
parseSteward,
StewardEntry,
STEWARD_EVENT_TYPE,
} from './stewardGroups';
// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere.
const stewardGroupChoicesAtom = atomWithStorage<Record<string, boolean>>(
'gamecultStewardGroupOpen',
{}
);
type Options<T> = {
mx: MatrixClient;
spaceId: string;
items: T[];
keepVisible: (roomId: string) => boolean;
};
export const useStewardGroups = <T extends HierarchyLike>({
mx,
spaceId,
items,
keepVisible,
}: Options<T>) => {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const [choices, setChoices] = useAtom(stewardGroupChoicesAtom);
const [stateVersion, setStateVersion] = useState(0);
useEffect(() => {
const onState = (mEvent: MatrixEvent) => {
if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1);
};
mx.on(RoomStateEvent.Events, onState);
return () => {
mx.removeListener(RoomStateEvent.Events, onState);
};
}, [mx]);
const stewardOf = useCallback(
(roomId: string) =>
// stateVersion is read so a late-arriving steward event re-derives this lookup
stateVersion >= 0
? parseSteward(
mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent()
)
: undefined,
[mx, stateVersion]
);
const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]);
const entries: StewardEntry<T>[] = useMemo(
() => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }),
[items, spaceId, stewardOf, isGroupOpen, keepVisible]
);
const toggleGroup = useCallback(
(id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })),
[setChoices, mobile]
);
return { entries, toggleGroup };
};

View file

@ -1,138 +0,0 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData, isSpace } from '../utils/room';
import { Membership } from '../../types/matrix/room';
import { useAccountDataCallback } from './useAccountDataCallback';
export type ISidebarFolder = {
name?: string;
id: string;
content: string[];
};
export type TSidebarItem = string | ISidebarFolder;
export type SidebarItems = Array<TSidebarItem>;
export type InCinnySpacesContent = {
shortcut?: string[];
sidebar?: SidebarItems;
};
export const parseSidebar = (
mx: MatrixClient,
orphanSpaces: string[],
content?: InCinnySpacesContent
) => {
const sidebar = content?.sidebar ?? content?.shortcut ?? [];
const orphans = new Set(orphanSpaces);
const items: SidebarItems = [];
const safeToAdd = (spaceId: string): boolean => {
if (typeof spaceId !== 'string') return false;
const space = mx.getRoom(spaceId);
if (space?.getMyMembership() !== Membership.Join) return false;
return isSpace(space);
};
sidebar.forEach((item) => {
if (typeof item === 'string') {
if (safeToAdd(item) && !items.includes(item)) {
orphans.delete(item);
items.push(item);
}
return;
}
if (
typeof item === 'object' &&
typeof item.id === 'string' &&
Array.isArray(item.content) &&
!items.find((i) => (typeof i === 'string' ? false : i.id === item.id))
) {
const safeContent = item.content.filter(safeToAdd);
safeContent.forEach((i) => orphans.delete(i));
items.push({
...item,
content: Array.from(new Set(safeContent)),
});
}
});
orphans.forEach((spaceId) => items.push(spaceId));
return items;
};
export const useSidebarItems = (
orphanSpaces: string[]
): [SidebarItems, Dispatch<SetStateAction<SidebarItems>>] => {
const mx = useMatrixClient();
const [sidebarItems, setSidebarItems] = useState(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
return parseSidebar(mx, orphanSpaces, inCinnySpacesContent);
});
useEffect(() => {
const inCinnySpacesContent = getAccountData(
mx,
AccountDataEvent.CinnySpaces
)?.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent));
}, [mx, orphanSpaces]);
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.CinnySpaces) {
const newContent = mEvent.getContent<InCinnySpacesContent>();
setSidebarItems(parseSidebar(mx, orphanSpaces, newContent));
}
},
[mx, orphanSpaces]
)
);
return [sidebarItems, setSidebarItems];
};
export const sidebarItemWithout = (items: SidebarItems, roomId: string) => {
const newItems: SidebarItems = items
.map((item) => {
if (typeof item === 'string') {
if (item === roomId) return null;
return item;
}
if (item.content.includes(roomId)) {
const newContent = item.content.filter((id) => id !== roomId);
if (newContent.length === 0) return null;
return {
...item,
content: newContent,
};
}
return item;
})
.filter((item) => item !== null) as SidebarItems;
return newItems;
};
export const makeCinnySpacesContent = (
mx: MatrixClient,
items: SidebarItems
): InCinnySpacesContent => {
const currentInSpaces =
getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent<InCinnySpacesContent>() ?? {};
const newSpacesContent: InCinnySpacesContent = {
...currentInSpaces,
sidebar: items,
};
return newSpacesContent;
};

View file

@ -1,28 +1,6 @@
import { ReactNode } from 'react';
import { useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
type MobileFriendlyClientNavProps = {
children: ReactNode;
};
export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) {
const screenSize = useScreenSizeContext();
const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true });
const directMatch = useMatch({ path: DIRECT_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 inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true });
if (
screenSize === ScreenSize.Mobile &&
!(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch)
) {
return null;
}
return children;
}
type MobileFriendlyPageNavProps = {
path: string;

View file

@ -1,15 +1,6 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
return (
<Box grow="Yes">
<Box shrink="No">{nav}</Box>
<Box grow="Yes">{children}</Box>
</Box>
);
export function ClientLayout({ children }: { children: ReactNode }) {
return <Box grow="Yes">{children}</Box>;
}

View file

@ -1,56 +0,0 @@
import React, { useRef } from 'react';
import { Scroll } from 'folds';
import {
Sidebar,
SidebarContent,
SidebarStackSeparator,
SidebarStack,
} from '../../components/sidebar';
import {
DirectTab,
HomeTab,
SpaceTabs,
InboxTab,
ExploreTab,
SettingsTab,
UnverifiedTab,
SearchTab,
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
export function SidebarNav() {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<Sidebar>
<SidebarContent
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<SidebarStack>
<HomeTab />
<DirectTab />
</SidebarStack>
<SpaceTabs scrollRef={scrollRef} />
<SidebarStackSeparator />
<SidebarStack>
<ExploreTab />
<CreateTab />
</SidebarStack>
</Scroll>
}
sticky={
<>
<SidebarStackSeparator />
<SidebarStack>
<SearchTab />
<UnverifiedTab />
<InboxTab />
<SettingsTab />
</SidebarStack>
</>
}
/>
</Sidebar>
);
}

View file

@ -1,280 +0,0 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
} from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
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 { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useDirectRooms } from './useDirectRooms';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const orphanRooms = useDirectRooms();
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function DirectHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Direct Messages
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function DirectEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Mention} />}
title={
<Text size="H5" align="Center">
No Direct Messages
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any direct messages yet.
</Text>
}
options={
<Button variant="Secondary" size="300" onClick={() => navigate(getDirectCreatePath())}>
<Text size="B300" truncate>
Direct Message
</Text>
</Button>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct');
export function Direct() {
const mx = useMatrixClient();
useNavToActivePathMapper('direct');
const scrollRef = useRef<HTMLDivElement>(null);
const directs = useDirectRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const createDirectSelected = useDirectCreateSelected();
const selectedRoomId = useSelectedRoom();
const noRoomToDisplay = directs.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedDirects = useMemo(() => {
const items = Array.from(directs).sort(factoryRoomIdByActivity(mx));
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, directs, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedDirects.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<DirectHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createDirectSelected}>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Chat
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Chats
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedDirects[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
showAvatar
direct
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -1,3 +1,2 @@
export * from './Direct';
export * from './RoomProvider';
export * from './DirectCreate';

View file

@ -1,362 +0,0 @@
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
NavButton,
NavCategory,
NavCategoryHeader,
NavEmptyCenter,
NavEmptyLayout,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import {
encodeSearchParamValueArray,
getExplorePath,
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
withSearchParam,
} from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useHomeCreateSelected,
useHomeSearchSelected,
} from '../../../hooks/router/useHomeSelected';
import { useHomeRooms } from './useHomeRooms';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
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 { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { markAsRead } from '../../../utils/notifications';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function HomeHeader() {
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
Home
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function HomeEmpty() {
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Hash} />}
title={
<Text size="H5" align="Center">
No Rooms
</Text>
}
content={
<Text size="T300" align="Center">
You do not have any rooms yet.
</Text>
}
options={
<>
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate>
Create Room
</Text>
</Button>
<Button
onClick={() => navigate(getExplorePath())}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
Explore Community Rooms
</Text>
</Button>
</>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const mx = useMatrixClient();
useNavToActivePathMapper('home');
const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms();
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const sortedRooms = useMemo(() => {
const items = Array.from(rooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx)
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
const virtualizer = useVirtualizer({
count: sortedRooms.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
return (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Create Room
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Join with Address
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</>
)}
</UseStateProvider>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -1,3 +1,2 @@
export * from './Home';
export * from './Search';
export * from './RoomProvider';

View file

@ -1,134 +0,0 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { stopPropagation } from '../../../utils/keyboard';
import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import {
encodeSearchParamValueArray,
getCreatePath,
getSpacePath,
withSearchParam,
} from '../../pathUtils';
import { useCreateSelected } from '../../../hooks/router/useCreateSelected';
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
import { _RoomSearchParams } from '../../paths';
export function CreateTab() {
const createSelected = useCreateSelected();
const navigate = useNavigate();
const [menuCords, setMenuCords] = useState<RectCords>();
const [joinAddress, setJoinAddress] = useState(false);
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect());
};
const handleCreateSpace = () => {
navigate(getCreatePath());
setMenuCords(undefined);
};
const handleJoinWithAddress = () => {
setJoinAddress(true);
setMenuCords(undefined);
};
return (
<SidebarItem active={createSelected}>
<SidebarItemTooltip tooltip="Add Space">
{(triggerRef) => (
<PopOut
anchor={menuCords}
position="Right"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate: false,
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column">
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleCreateSpace}
>
<SettingTile before={<Icon size="400" src={Icons.Space} />}>
<Text size="H6">Create Space</Text>
<Text size="T300" priority="300">
Build a space for your community.
</Text>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant="Surface"
direction="Column"
gap="100"
radii="0"
as="button"
type="button"
onClick={handleJoinWithAddress}
>
<SettingTile before={<Icon size="400" src={Icons.Link} />}>
<Text size="H6">Join with Address</Text>
<Text size="T300" priority="300">
Become a part of existing community.
</Text>
</SettingTile>
</SequenceCard>
</Box>
</Menu>
</FocusTrap>
}
>
<SidebarAvatar
className={menuCords ? ContainerColor({ variant: 'Surface' }) : undefined}
as="button"
ref={triggerRef}
outlined
onClick={handleMenu}
>
<Icon src={Icons.Plus} />
</SidebarAvatar>
{joinAddress && (
<JoinAddressPrompt
onCancel={() => setJoinAddress(false)}
onOpen={(roomIdOrAlias, viaServers) => {
setJoinAddress(false);
const path = getSpacePath(roomIdOrAlias);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</PopOut>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,138 +0,0 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
import { useDirects } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getDirectPath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useDirectSelected } from '../../../hooks/router/useDirectSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useDirectRooms } from '../direct/useDirectRooms';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
type DirectMenuProps = {
requestClose: () => void;
};
const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useDirectRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function DirectTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const directSelected = useDirectSelected();
const handleDirectClick = () => {
const activePath = navToActivePath.get('direct');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getDirectPath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={directSelected}>
<SidebarItemTooltip tooltip="Direct Messages">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleDirectClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.User} filled={directSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{directUnread && (
<SidebarItemBadge hasCount={directUnread.total > 0}>
<UnreadBadge highlight={directUnread.highlight > 0} count={directUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<DirectMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -1,64 +0,0 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
import {
getExploreFeaturedPath,
getExplorePath,
getExploreServerPath,
joinPathComponent,
} from '../../pathUtils';
import { useClientConfig } from '../../../hooks/useClientConfig';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdServer } from '../../../utils/matrix';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
export function ExploreTab() {
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const clientConfig = useClientConfig();
const navigate = useNavigate();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const exploreSelected = useExploreSelected();
const handleExploreClick = () => {
if (screenSize === ScreenSize.Mobile) {
navigate(getExplorePath());
return;
}
const activePath = navToActivePath.get('explore');
if (activePath) {
navigate(joinPathComponent(activePath));
return;
}
if (clientConfig.featuredCommunities?.openAsDefault) {
navigate(getExploreFeaturedPath());
return;
}
const userId = mx.getUserId();
const userServer = userId ? getMxIdServer(userId) : undefined;
if (userServer) {
navigate(getExploreServerPath(userServer));
return;
}
navigate(getExplorePath());
};
return (
<SidebarItem active={exploreSelected}>
<SidebarItemTooltip tooltip="Explore Community">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={handleExploreClick}>
<Icon src={Icons.Explore} filled={exploreSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,140 +0,0 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import FocusTrap from 'focus-trap-react';
import { useOrphanRooms } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getHomePath, joinPathComponent } from '../../pathUtils';
import { useRoomsUnread } from '../../../state/hooks/unread';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
import { UnreadBadge } from '../../../components/unread-badge';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useHomeRooms } from '../home/useHomeRooms';
import { markAsRead } from '../../../utils/notifications';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
type HomeMenuProps = {
requestClose: () => void;
};
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
const orphanRooms = useHomeRooms();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const mx = useMatrixClient();
const handleMarkAsRead = () => {
if (!unread) return;
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
</Menu>
);
});
export function HomeTab() {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
const homeSelected = useHomeSelected();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleHomeClick = () => {
const activePath = navToActivePath.get('home');
if (activePath && screenSize !== ScreenSize.Mobile) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getHomePath());
};
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={homeSelected}>
<SidebarItemTooltip tooltip="Home">
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleHomeClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.Home} filled={homeSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{homeUnread && (
<SidebarItemBadge hasCount={homeUnread.total > 0}>
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -1,23 +0,0 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { searchModalAtom } from '../../../state/searchModal';
export function SearchTab() {
const [opened, setOpen] = useAtom(searchModalAtom);
const open = () => setOpen(true);
return (
<SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Search">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={open}>
<Icon src={Icons.Search} filled={opened} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}

View file

@ -1,866 +0,0 @@
import React, {
MouseEventHandler,
ReactNode,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
config,
toRem,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import {
attachInstruction,
extractInstruction,
Instruction,
} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import FocusTrap from 'focus-trap-react';
import {
useOrphanSpaces,
useRecursiveChildScopeFactory,
useSpaceChildren,
} from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
SidebarStack,
SidebarStackSeparator,
SidebarFolder,
SidebarFolderDropTarget,
} from '../../../components/sidebar';
import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider';
import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace';
import { UnreadBadge } from '../../../components/unread-badge';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { RoomAvatar } from '../../../components/room-avatar';
import { nameInitials, randomStr } from '../../../utils/common';
import {
ISidebarFolder,
SidebarItems,
TSidebarItem,
makeCinnySpacesContent,
parseSidebar,
sidebarItemWithout,
useSidebarItems,
} from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { markAsRead } from '../../../utils/notifications';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { getRoomAvatarUrl } from '../../../utils/room';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
onUnpin?: (roomId: string) => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(
({ room, requestClose, onUnpin }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity));
requestClose();
};
const handleUnpin = () => {
onUnpin?.(room.roomId);
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
{onUnpin && (
<MenuItem
size="300"
radii="300"
onClick={handleUnpin}
after={<Icon size="100" src={Icons.Pin} />}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unpin
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
</Box>
</Menu>
);
}
);
type InstructionType = Instruction['type'];
type FolderDraggable = {
folder: ISidebarFolder;
spaceId?: string;
open?: boolean;
};
type SidebarDraggable = string | FolderDraggable;
const useDraggableItem = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
onDragging: (item?: SidebarDraggable) => void,
dragHandleRef?: RefObject<HTMLElement>
): boolean => {
const [dragging, setDragging] = useState(false);
useEffect(() => {
const target = targetRef.current;
const dragHandle = dragHandleRef?.current ?? undefined;
return !target
? undefined
: draggable({
element: target,
dragHandle,
getInitialData: () => ({ item }),
onDragStart: () => {
setDragging(true);
onDragging?.(item);
},
onDrop: () => {
setDragging(false);
onDragging?.(undefined);
},
});
}, [targetRef, dragHandleRef, item, onDragging]);
return dragging;
};
const useDropTarget = (
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>
): Instruction | undefined => {
const [dropState, setDropState] = useState<Instruction>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: ({ input, element }) => {
const block: Instruction['type'][] = ['reparent'];
if (typeof item === 'object' && item.spaceId) block.push('make-child');
const insData = attachInstruction(
{},
{
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: 'standard',
block,
}
);
const instruction: Instruction | null = extractInstruction(insData);
setDropState(instruction ?? undefined);
return {
item,
instructionType: instruction ? instruction.type : undefined,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef]);
return dropState;
};
function useDropTargetInstruction<T extends InstructionType>(
item: SidebarDraggable,
targetRef: RefObject<HTMLElement>,
instructionType: T
): T | undefined {
const [dropState, setDropState] = useState<T>();
useEffect(() => {
const target = targetRef.current;
if (!target) return undefined;
return dropTargetForElements({
element: target,
canDrop: ({ source }) => {
const dragItem = source.data.item as SidebarDraggable;
return dragItem !== item;
},
getData: () => {
setDropState(instructionType);
return {
item,
instructionType,
};
},
onDragLeave: () => setDropState(undefined),
onDrop: () => setDropState(undefined),
});
}, [item, targetRef, instructionType]);
return dropState;
}
const useDnDMonitor = (
scrollRef: RefObject<HTMLElement>,
onDragging: (dragItem?: SidebarDraggable) => void,
onReorder: (
draggable: SidebarDraggable,
container: SidebarDraggable,
instruction: InstructionType
) => void
) => {
useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) {
throw Error('Scroll element ref not configured');
}
return combine(
monitorForElements({
onDrop: ({ source, location }) => {
onDragging(undefined);
const { dropTargets } = location.current;
if (dropTargets.length === 0) return;
const item = source.data.item as SidebarDraggable;
const containerItem = dropTargets[0].data.item as SidebarDraggable;
const instructionType = dropTargets[0].data.instructionType as
| InstructionType
| undefined;
if (!instructionType) return;
onReorder(item, containerItem, instructionType);
},
}),
autoScrollForElements({
element: scrollElement,
})
);
}, [scrollRef, onDragging, onReorder]);
};
type SpaceTabProps = {
space: Room;
selected: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
folder?: ISidebarFolder;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
onUnpin?: (roomId: string) => void;
};
function SpaceTab({
space,
selected,
onClick,
folder,
onDragging,
disabled,
onUnpin,
}: SpaceTabProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const targetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(
() =>
folder
? {
folder,
spaceId: space.roomId,
}
: space.roomId,
[folder, space]
);
useDraggableItem(spaceDraggable, targetRef, onDragging);
const dropState = useDropTarget(spaceDraggable, targetRef);
const dropType = dropState?.type;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<RoomUnreadProvider roomId={space.roomId}>
{(unread) => (
<SidebarItem
active={selected}
ref={targetRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
data-inside-folder={!!folder}
>
<SidebarItemTooltip tooltip={disabled ? undefined : space.name}>
{(triggerRef) => (
<SidebarAvatar
as="button"
data-id={space.roomId}
ref={triggerRef}
size={folder ? '300' : '400'}
onClick={onClick}
onContextMenu={handleContextMenu}
>
<RoomAvatar
roomId={space.roomId}
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size={folder ? 'H6' : 'H4'}>{nameInitials(space.name, 2)}</Text>
)}
/>
</SidebarAvatar>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu
room={space}
requestClose={() => setMenuAnchor(undefined)}
onUnpin={onUnpin}
/>
</FocusTrap>
}
/>
)}
</SidebarItem>
)}
</RoomUnreadProvider>
);
}
type OpenedSpaceFolderProps = {
folder: ISidebarFolder;
onClose: MouseEventHandler<HTMLButtonElement>;
children?: ReactNode;
};
function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) {
const aboveTargetRef = useRef<HTMLDivElement>(null);
const belowTargetRef = useRef<HTMLDivElement>(null);
const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]);
const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above');
const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below');
return (
<SidebarFolder
state="Open"
data-drop-above={orderAbove === 'reorder-above'}
data-drop-below={orderBelow === 'reorder-below'}
>
<SidebarFolderDropTarget ref={aboveTargetRef} position="Top" />
<SidebarAvatar size="300">
<IconButton data-id={folder.id} size="300" variant="Background" onClick={onClose}>
<Icon size="400" src={Icons.ChevronTop} filled />
</IconButton>
</SidebarAvatar>
{children}
<SidebarFolderDropTarget ref={belowTargetRef} position="Bottom" />
</SidebarFolder>
);
}
type ClosedSpaceFolderProps = {
folder: ISidebarFolder;
selected: boolean;
onOpen: MouseEventHandler<HTMLButtonElement>;
onDragging: (dragItem?: SidebarDraggable) => void;
disabled?: boolean;
};
function ClosedSpaceFolder({
folder,
selected,
onOpen,
onDragging,
disabled,
}: ClosedSpaceFolderProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const handlerRef = useRef<HTMLDivElement>(null);
const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]);
useDraggableItem(spaceDraggable, handlerRef, onDragging);
const dropState = useDropTarget(spaceDraggable, handlerRef);
const dropType = dropState?.type;
const tooltipName =
folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed';
return (
<RoomsUnreadProvider rooms={folder.content}>
{(unread) => (
<SidebarItem
active={selected}
ref={handlerRef}
aria-disabled={disabled}
data-drop-child={dropType === 'make-child'}
data-drop-above={dropType === 'reorder-above'}
data-drop-below={dropType === 'reorder-below'}
>
<SidebarItemTooltip tooltip={disabled ? undefined : tooltipName}>
{(tooltipRef) => (
<SidebarFolder data-id={folder.id} as="button" ref={tooltipRef} onClick={onOpen}>
{folder.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SidebarAvatar key={sId} size="200" radii="300">
<RoomAvatar
roomId={space.roomId}
src={getRoomAvatarUrl(mx, space, 96, useAuthentication) ?? undefined}
alt={space.name}
renderFallback={() => (
<Text size="Inherit">
<b>{nameInitials(space.name, 2)}</b>
</Text>
)}
/>
</SidebarAvatar>
);
})}
</SidebarFolder>
)}
</SidebarItemTooltip>
{unread && (
<SidebarItemBadge hasCount={unread.total > 0}>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</SidebarItemBadge>
)}
</SidebarItem>
)}
</RoomsUnreadProvider>
);
}
type SpaceTabsProps = {
scrollRef: RefObject<HTMLDivElement>;
};
export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const navigate = useNavigate();
const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces);
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom());
const [draggingItem, setDraggingItem] = useState<SidebarDraggable>();
useDnDMonitor(
scrollRef,
setDraggingItem,
useCallback(
(item, containerItem, instructionType) => {
const newItems: SidebarItems = [];
const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => {
if (typeof sI === 'string' && typeof dI === 'string') {
return sI === dI;
}
if (typeof sI === 'object' && typeof dI === 'object') {
return sI.id === dI.folder.id;
}
return false;
};
const itemAsFolderContent = (i: SidebarDraggable): string[] => {
if (typeof i === 'string') {
return [i];
}
if (i.spaceId) {
return [i.spaceId];
}
return [...i.folder.content];
};
sidebarItems.forEach((i) => {
const sameFolders =
typeof item === 'object' &&
typeof containerItem === 'object' &&
item.folder.id === containerItem.folder.id;
// remove draggable space from current position or folder
if (!sameFolders && matchDest(i, item)) {
if (typeof item === 'object' && item.spaceId) {
const folderContent = item.folder.content.filter((s) => s !== item.spaceId);
if (folderContent.length === 0) {
// remove open state from local storage
setOpenedFolder({ type: 'DELETE', id: item.folder.id });
return;
}
newItems.push({
...item.folder,
content: folderContent,
});
}
return;
}
if (matchDest(i, containerItem)) {
// we can make child only if
// container item is space or closed folder
if (instructionType === 'make-child') {
const child: string[] = itemAsFolderContent(item);
if (typeof containerItem === 'string') {
const folder: ISidebarFolder = {
id: randomStr(),
content: [containerItem].concat(child),
};
newItems.push(folder);
return;
}
newItems.push({
...containerItem.folder,
content: containerItem.folder.content.concat(child),
});
return;
}
// drop inside opened folder
// or reordering inside same folder
if (typeof containerItem === 'object' && containerItem.spaceId) {
const child = itemAsFolderContent(item);
const newContent: string[] = [];
containerItem.folder.content
.filter((sId) => !child.includes(sId))
.forEach((sId) => {
if (sId === containerItem.spaceId) {
if (instructionType === 'reorder-below') {
newContent.push(sId, ...child);
}
if (instructionType === 'reorder-above') {
newContent.push(...child, sId);
}
return;
}
newContent.push(sId);
});
const folder = {
...containerItem.folder,
content: newContent,
};
newItems.push(folder);
return;
}
// drop above or below space or closed/opened folder
if (typeof item === 'string') {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item);
if (instructionType === 'reorder-above') newItems.push(i);
} else if (item.spaceId) {
if (instructionType === 'reorder-above') {
newItems.push(item.spaceId);
}
if (sameFolders && typeof i === 'object') {
// remove from folder if placing around itself
const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) };
if (newI.content.length > 0) newItems.push(newI);
} else {
newItems.push(i);
}
if (instructionType === 'reorder-below') {
newItems.push(item.spaceId);
}
} else {
if (instructionType === 'reorder-below') newItems.push(i);
newItems.push(item.folder);
if (instructionType === 'reorder-above') newItems.push(i);
}
return;
}
newItems.push(i);
});
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces]
)
);
const selectedSpaceId = useSelectedSpace();
const handleSpaceClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetSpaceId = target.getAttribute('data-id');
if (!targetSpaceId) return;
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
if (screenSize === ScreenSize.Mobile) {
navigate(spacePath);
return;
}
const activePath = navToActivePath.get(targetSpaceId);
if (activePath && activePath.pathname.startsWith(spacePath)) {
navigate(joinPathComponent(activePath));
return;
}
navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
};
const handleFolderToggle: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const targetFolderId = target.getAttribute('data-id');
if (!targetFolderId) return;
setOpenedFolder({
type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT',
id: targetFolderId,
});
};
const handleUnpin = useCallback(
(roomId: string) => {
if (orphanSpaces.includes(roomId)) return;
const newItems = sidebarItemWithout(sidebarItems, roomId);
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem]
);
if (sidebarItems.length === 0) return null;
return (
<>
<SidebarStackSeparator />
<SidebarStack>
{sidebarItems.map((item) => {
if (typeof item === 'object') {
if (openedFolder.has(item.id)) {
return (
<OpenedSpaceFolder key={item.id} folder={item} onClose={handleFolderToggle}>
{item.content.map((sId) => {
const space = mx.getRoom(sId);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
folder={item}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object'
? draggingItem.spaceId === space.roomId
: false
}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</OpenedSpaceFolder>
);
}
return (
<ClosedSpaceFolder
key={item.id}
folder={item}
selected={!!selectedSpaceId && item.content.includes(selectedSpaceId)}
onOpen={handleFolderToggle}
onDragging={setDraggingItem}
disabled={
typeof draggingItem === 'object' ? draggingItem.folder.id === item.id : false
}
/>
);
}
const space = mx.getRoom(item);
if (!space) return null;
return (
<SpaceTab
key={space.roomId}
space={space}
selected={space.roomId === selectedSpaceId}
onClick={handleSpaceClick}
onDragging={setDraggingItem}
disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false}
onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin}
/>
);
})}
</SidebarStack>
</>
);
}

View file

@ -1,8 +1,3 @@
export * from './HomeTab';
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
export * from './ExploreTab';
export * from './SettingsTab';
export * from './UnverifiedTab';
export * from './SearchTab';

View file

@ -1,571 +0,0 @@
import React, {
MouseEventHandler,
forwardRef,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Avatar,
Box,
Button,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { JoinRule, Room } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import {
NavCategory,
NavCategoryHeader,
NavItem,
NavItemContent,
NavLink,
} from '../../../components/nav';
import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import {
useSpaceLobbySelected,
useSpaceSearchSelected,
} from '../../../hooks/router/useSelectedSpace';
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 { useStewardGroups } from '../../../features/steward-groups/useStewardGroups';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { useRoomName } from '../../../hooks/useRoomMeta';
import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { markAsRead } from '../../../utils/notifications';
import { useRoomsUnread } from '../../../state/hooks/unread';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../../components/leave-space-prompt';
import { copyToClipboard } from '../../../utils/dom';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { Membership, StateEvent } from '../../../../types/matrix/room';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { useCallEmbed } from '../../../hooks/useCallEmbed';
type SpaceMenuProps = {
room: Room;
requestClose: () => void;
};
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openSpaceSettings = useOpenSpaceSettings();
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const allChild = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
const unread = useRoomsUnread(allChild, roomToUnreadAtom);
const handleMarkAsRead = () => {
allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity));
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const handleRoomSettings = () => {
openSpaceSettings(room.roomId);
requestClose();
};
const handleOpenTimeline = () => {
navigateRoom(room.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mark as Read
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Copy Link
</Text>
</MenuItem>
<MenuItem
onClick={handleRoomSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
</Text>
</MenuItem>
{developerTools && (
<MenuItem
onClick={handleOpenTimeline}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Event Timeline
</Text>
</MenuItem>
)}
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
</Text>
</MenuItem>
{promptLeave && (
<LeaveSpacePrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
});
function SpaceHeader() {
const space = useSpace();
const spaceName = useRoomName(space);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const joinRules = useStateEvent(
space,
StateEvent.RoomJoinRules
)?.getContent<RoomJoinRulesEventContent>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="H4" truncate>
{spaceName}
</Text>
{joinRules?.join_rule !== JoinRule.Public && <Icon src={Icons.Lock} size="50" />}
</Box>
<Box shrink="No">
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<SpaceMenu room={space} requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</>
);
}
type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
const mx = useMatrixClient();
const { navigateSpace } = useRoomNavigate();
const [joinState, handleJoin] = useAsyncCallback(
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? getViaServers(currentRoom) : [];
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateSpace(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId);
};
return (
<Box
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
borderWidth: config.borderWidth.B300,
}}
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
gap="300"
>
<Box direction="Column" grow="Yes" gap="100">
<Text size="L400">Space Upgraded</Text>
<Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
</Text>
)}
</Box>
<Box direction="Column" shrink="No">
{replacementRoom?.getMyMembership() === Membership.Join ||
joinState.status === AsyncStatus.Success ? (
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
<Text size="B300">Open New Space</Text>
</Button>
) : (
<Button
onClick={handleJoin}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={
joinState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Primary" fill="Solid" />
)
}
disabled={joinState.status === AsyncStatus.Loading}
>
<Text size="B300">Join New Space</Text>
</Button>
)}
</Box>
</Box>
);
}
export function Space() {
const mx = useMatrixClient();
const space = useSpace();
useNavToActivePathMapper(space.roomId);
const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const mDirects = useAtomValue(mDirectAtom);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const allRooms = useAtomValue(allRoomsAtom);
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone);
const selectedRoomId = useSelectedRoom();
const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias);
const searchSelected = useSpaceSearchSelected(spaceIdOrAlias);
const callEmbed = useCallEmbed();
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 { entries, toggleGroup } = useStewardGroups({
mx,
spaceId: space.roomId,
items: hierarchy,
keepVisible: useCallback(
(rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId,
[roomToUnread, selectedRoomId, callEmbed]
),
});
const virtualizer = useVirtualizer({
count: entries.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 0,
overscan: 10,
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
closedCategories.has(categoryId)
);
const getToLink = (roomId: string) =>
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
return (
<PageNav>
<SpaceHeader />
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
{tombstoneEvent && (
<SpaceTombstone
roomId={space.roomId}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
/>
)}
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={lobbySelected}>
<NavLink to={getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Flag} size="100" filled={lobbySelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Lobby
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
Message Search
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const entry = entries[vItem.index];
if (!entry) return null;
if (entry.type === 'group') {
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<NavCategoryHeader>
<RoomNavCategoryButton
onClick={() => toggleGroup(entry.id)}
closed={!entry.open}
>
{entry.label} · {entry.count}
</RoomNavCategoryButton>
</NavCategoryHeader>
</VirtualTile>
);
}
const { roomId } = entry.item;
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 ? 'Rooms' : 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>
</PageNavContent>
</PageNav>
);
}

View file

@ -0,0 +1,79 @@
import React, { useCallback } from 'react';
import { Box, Button, Spinner, Text, color, config } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { Membership } from '../../../../types/matrix/room';
import { getViaServers } from '../../../plugins/via-servers';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { BreakWord } from '../../../styles/Text.css';
type SpaceTombstoneProps = { roomId: string; replacementRoomId: string };
export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) {
const mx = useMatrixClient();
const { navigateSpace } = useRoomNavigate();
const [joinState, handleJoin] = useAsyncCallback(
useCallback(() => {
const currentRoom = mx.getRoom(roomId);
const via = currentRoom ? getViaServers(currentRoom) : [];
return mx.joinRoom(replacementRoomId, {
viaServers: via,
});
}, [mx, roomId, replacementRoomId])
);
const replacementRoom = mx.getRoom(replacementRoomId);
const handleOpen = () => {
if (replacementRoom) navigateSpace(replacementRoom.roomId);
if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId);
};
return (
<Box
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
borderWidth: config.borderWidth.B300,
}}
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
gap="300"
>
<Box direction="Column" grow="Yes" gap="100">
<Text size="L400">Space Upgraded</Text>
<Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
</Text>
)}
</Box>
<Box direction="Column" shrink="No">
{replacementRoom?.getMyMembership() === Membership.Join ||
joinState.status === AsyncStatus.Success ? (
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
<Text size="B300">Open New Space</Text>
</Button>
) : (
<Button
onClick={handleJoin}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={
joinState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Primary" fill="Solid" />
)
}
disabled={joinState.status === AsyncStatus.Loading}
>
<Text size="B300">Join New Space</Text>
</Button>
)}
</Box>
</Box>
);
}

View file

@ -1,4 +1,4 @@
export * from './SpaceProvider';
export * from './Space';
export * from './SpaceTombstone';
export * from './Search';
export * from './RoomProvider';

View file

@ -3,7 +3,6 @@ export enum AccountDataEvent {
Direct = 'm.direct',
IgnoredUserList = 'm.ignored_user_list',
CinnySpaces = 'in.cinny.spaces',
ElementRecentEmoji = 'io.element.recent_emoji',

View file

@ -36,6 +36,7 @@ export enum StateEvent {
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
SpaceChild = 'm.space.child',
GameCultStewardships = 'org.gamecult.stewardships',
SpaceParent = 'm.space.parent',
PoniesRoomEmotes = 'im.ponies.room_emotes',