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:
parent
2924a3f4f3
commit
f2e3223c5f
26 changed files with 83 additions and 3097 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './Direct';
|
||||
export * from './RoomProvider';
|
||||
export * from './DirectCreate';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './Home';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
79
src/app/pages/client/space/SpaceTombstone.tsx
Normal file
79
src/app/pages/client/space/SpaceTombstone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export * from './SpaceProvider';
|
||||
export * from './Space';
|
||||
export * from './SpaceTombstone';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export enum AccountDataEvent {
|
|||
Direct = 'm.direct',
|
||||
IgnoredUserList = 'm.ignored_user_list',
|
||||
|
||||
CinnySpaces = 'in.cinny.spaces',
|
||||
|
||||
ElementRecentEmoji = 'io.element.recent_emoji',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue