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;
|
joined: boolean;
|
||||||
powerLevels?: IPowerLevels;
|
powerLevels?: IPowerLevels;
|
||||||
canEditChild: boolean;
|
canEditChild: boolean;
|
||||||
pinned?: boolean;
|
|
||||||
onTogglePin?: (roomId: string) => void;
|
|
||||||
};
|
};
|
||||||
export function HierarchyItemMenu({
|
export function HierarchyItemMenu({
|
||||||
item,
|
item,
|
||||||
joined,
|
joined,
|
||||||
powerLevels,
|
powerLevels,
|
||||||
canEditChild,
|
canEditChild,
|
||||||
pinned,
|
|
||||||
onTogglePin,
|
|
||||||
}: HierarchyItemMenuProps) {
|
}: HierarchyItemMenuProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
|
|
@ -264,20 +260,6 @@ export function HierarchyItemMenu({
|
||||||
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
||||||
{joined && (
|
{joined && (
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<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
|
<InviteMenuItem
|
||||||
item={item}
|
item={item}
|
||||||
requestClose={handleRequestClose}
|
requestClose={handleRequestClose}
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,6 @@ import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||||
import { getStateEvent } from '../../utils/room';
|
import { getStateEvent } from '../../utils/room';
|
||||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
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 { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { SpaceHierarchy } from './SpaceHierarchy';
|
import { SpaceHierarchy } from './SpaceHierarchy';
|
||||||
import { useGetRoom } from '../../hooks/useGetRoom';
|
import { useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
|
@ -167,18 +159,6 @@ export function Lobby() {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const [onTop, setOnTop] = useState(true);
|
const [onTop, setOnTop] = useState(true);
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
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());
|
const [spacesItems, setSpacesItem] = useState<Map<string, IHierarchyRoom>>(() => new Map());
|
||||||
|
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
|
|
@ -415,18 +395,6 @@ export function Lobby() {
|
||||||
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
|
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 (
|
return (
|
||||||
<PowerLevelsContextProvider value={spacePowerLevels}>
|
<PowerLevelsContextProvider value={spacePowerLevels}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
|
|
@ -499,8 +467,6 @@ export function Lobby() {
|
||||||
disabledReorder={reordering}
|
disabledReorder={reordering}
|
||||||
nextSpaceId={nextSpaceId}
|
nextSpaceId={nextSpaceId}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
pinned={sidebarSpaces.has(item.space.roomId)}
|
|
||||||
togglePinToSidebar={togglePinToSidebar}
|
|
||||||
onSpacesFound={handleSpacesFound}
|
onSpacesFound={handleSpacesFound}
|
||||||
onOpenRoom={handleOpenRoom}
|
onOpenRoom={handleOpenRoom}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ type SpaceHierarchyProps = {
|
||||||
disabledReorder?: boolean;
|
disabledReorder?: boolean;
|
||||||
nextSpaceId?: string;
|
nextSpaceId?: string;
|
||||||
getRoom: (roomId: string) => Room | undefined;
|
getRoom: (roomId: string) => Room | undefined;
|
||||||
pinned: boolean;
|
|
||||||
togglePinToSidebar: (roomId: string) => void;
|
|
||||||
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
|
onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
|
||||||
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
|
onOpenRoom: MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
disabledReorder,
|
disabledReorder,
|
||||||
nextSpaceId,
|
nextSpaceId,
|
||||||
getRoom,
|
getRoom,
|
||||||
pinned,
|
|
||||||
togglePinToSidebar,
|
|
||||||
onOpenRoom,
|
onOpenRoom,
|
||||||
onSpacesFound,
|
onSpacesFound,
|
||||||
},
|
},
|
||||||
|
|
@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
canEditChild={
|
canEditChild={
|
||||||
!!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId())
|
!!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 { ReactNode } from 'react';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
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 = {
|
type MobileFriendlyPageNavProps = {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Box } from 'folds';
|
import { Box } from 'folds';
|
||||||
|
|
||||||
type ClientLayoutProps = {
|
export function ClientLayout({ children }: { children: ReactNode }) {
|
||||||
nav: ReactNode;
|
return <Box grow="Yes">{children}</Box>;
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
|
||||||
return (
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Box shrink="No">{nav}</Box>
|
|
||||||
<Box grow="Yes">{children}</Box>
|
|
||||||
</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 './RoomProvider';
|
||||||
export * from './DirectCreate';
|
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 './Search';
|
||||||
export * from './RoomProvider';
|
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 './InboxTab';
|
||||||
export * from './ExploreTab';
|
|
||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './UnverifiedTab';
|
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 './SpaceProvider';
|
||||||
export * from './Space';
|
export * from './SpaceTombstone';
|
||||||
export * from './Search';
|
export * from './Search';
|
||||||
export * from './RoomProvider';
|
export * from './RoomProvider';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ export enum AccountDataEvent {
|
||||||
Direct = 'm.direct',
|
Direct = 'm.direct',
|
||||||
IgnoredUserList = 'm.ignored_user_list',
|
IgnoredUserList = 'm.ignored_user_list',
|
||||||
|
|
||||||
CinnySpaces = 'in.cinny.spaces',
|
|
||||||
|
|
||||||
ElementRecentEmoji = 'io.element.recent_emoji',
|
ElementRecentEmoji = 'io.element.recent_emoji',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export enum StateEvent {
|
||||||
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
||||||
|
|
||||||
SpaceChild = 'm.space.child',
|
SpaceChild = 'm.space.child',
|
||||||
|
GameCultStewardships = 'org.gamecult.stewardships',
|
||||||
SpaceParent = 'm.space.parent',
|
SpaceParent = 'm.space.parent',
|
||||||
|
|
||||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue