diff --git a/package.json b/package.json index 6f33b68..391e291 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-string-replace": "^1.1.1", "react-syntax-highlighter": "^15.5.0", "react-use-error-boundary": "^3.0.0", + "react-virtualized": "^9.22.5", "remark-gfm": "^3.0.1", "reoverlay": "^1.0.3", "styled-components": "^5.3.11", @@ -67,6 +68,7 @@ "@types/react": "^18.2.22", "@types/react-dom": "^18.2.7", "@types/react-syntax-highlighter": "^15.5.7", + "@types/react-virtualized": "^9.21.22", "@types/styled-components": "^5.1.27", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ae0aea..7be5dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ dependencies: react-use-error-boundary: specifier: ^3.0.0 version: 3.0.0(react@18.2.0) + react-virtualized: + specifier: ^9.22.5 + version: 9.22.5(react-dom@18.2.0)(react@18.2.0) remark-gfm: specifier: ^3.0.1 version: 3.0.1 @@ -190,6 +193,9 @@ devDependencies: '@types/react-syntax-highlighter': specifier: ^15.5.7 version: 15.5.7 + '@types/react-virtualized': + specifier: ^9.21.22 + version: 9.21.22 '@types/styled-components': specifier: ^5.1.27 version: 5.1.27 @@ -3973,6 +3979,13 @@ packages: '@types/react': 18.2.22 dev: false + /@types/react-virtualized@9.21.22: + resolution: {integrity: sha512-YRifyCKnBG84+J/Hny0f3bo8BRrcNT74CvsAVpQpZcS83fdC7lP7RfzwL2ND8/ihhpnDFL1IbxJ9MpQNaKUDuQ==} + dependencies: + '@types/prop-types': 15.7.5 + '@types/react': 18.2.22 + dev: true + /@types/react@18.2.22: resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==} dependencies: @@ -5356,6 +5369,11 @@ packages: shallow-clone: 3.0.1 dev: true + /clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + dev: false + /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -11242,6 +11260,10 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + dev: false + /react-loading-skeleton@3.3.1(react@18.2.0): resolution: {integrity: sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==} peerDependencies: @@ -11476,6 +11498,22 @@ packages: react: 18.2.0 dev: false + /react-virtualized@9.22.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==} + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.22.15 + clsx: 1.2.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} diff --git a/src/components/ChannelList.tsx b/src/components/ChannelList.tsx index 6a8311e..16be917 100644 --- a/src/components/ChannelList.tsx +++ b/src/components/ChannelList.tsx @@ -1,53 +1,58 @@ import { ChannelType } from "@spacebarchat/spacebar-api-types/v9"; import { observer } from "mobx-react-lite"; -import React from "react"; -import styled from "styled-components"; +import { AutoSizer, List, ListRowProps } from "react-virtualized"; import { useAppStore } from "../stores/AppStore"; -import Channel from "../stores/objects/Channel"; -import Guild from "../stores/objects/Guild"; -import { Permissions } from "../utils/Permissions"; import ChannelListItem from "./ChannelListItem"; -const List = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - list-style: none; - margin: 0; -`; - export function EmptyChannelList() { - return ; + return
; } -interface Props { - guild: Guild; -} - -function ChannelList({ guild }: Props) { +function ChannelList() { const app = useAppStore(); - const renderChannelListItem = React.useCallback( - (channel: Channel) => { - const permission = Permissions.getPermission(app.account!.id, guild, channel); - if (!permission.has("VIEW_CHANNEL")) return null; + if (!app.activeGuild || !app.activeChannel) return null; + const { channelsSorted } = app.activeGuild; - const active = app.activeChannelId === channel.id; - const isCategory = channel.type === ChannelType.GuildCategory; - return ( - - ); - }, - [app.account, app.activeChannelId, guild], + const rowRenderer = ({ index, key, style }: ListRowProps) => { + const item = channelsSorted[index]; + + const active = app.activeChannelId === item.id; + const isCategory = item.type === ChannelType.GuildCategory; + return ( +
+ +
+ ); + }; + + return ( +
+ + {({ width, height }) => ( + { + const item = channelsSorted[index]; + if (item.type === ChannelType.GuildCategory) { + return 44; + } + return 33; + }} + rowRenderer={rowRenderer} + width={width} + /> + )} + +
); - - return {guild.channelsMapped.map((channel) => renderChannelListItem(channel))}; } export default observer(ChannelList); diff --git a/src/components/ChannelListItem.tsx b/src/components/ChannelListItem.tsx index db5c7cc..a20b25b 100644 --- a/src/components/ChannelListItem.tsx +++ b/src/components/ChannelListItem.tsx @@ -4,12 +4,11 @@ import { useNavigate } from "react-router-dom"; import styled from "styled-components"; import { ContextMenuContext } from "../contexts/ContextMenuContext"; import Channel from "../stores/objects/Channel"; -import Guild from "../stores/objects/Guild"; import { IContextMenuItem } from "./ContextMenuItem"; import Icon from "./Icon"; import CreateInviteModal from "./modals/CreateInviteModal"; -const ListItem = styled.li<{ isCategory?: boolean }>` +const ListItem = styled.div<{ isCategory?: boolean }>` padding: ${(props) => (props.isCategory ? "16px 8px 0 0" : "1px 8px 0 0")}; cursor: pointer; `; @@ -36,13 +35,12 @@ const Text = styled.span<{ isCategory?: boolean }>` `; interface Props { - guild: Guild; channel: Channel; isCategory: boolean; active: boolean; } -function ChannelListItem({ guild, channel, isCategory, active }: Props) { +function ChannelListItem({ channel, isCategory, active }: Props) { const navigate = useNavigate(); const { openModal } = useModals(); @@ -63,7 +61,7 @@ function ChannelListItem({ guild, channel, isCategory, active }: Props) { index: 0, label: "Create Channel Invite", onClick: () => { - openModal(CreateInviteModal, { guild_id: guild.id, channel_id: channel.id }); + openModal(CreateInviteModal, { guild_id: channel.guildId!, channel_id: channel.id }); }, iconProps: { icon: "mdiAccountPlus", @@ -79,7 +77,7 @@ function ChannelListItem({ guild, channel, isCategory, active }: Props) { // prevent navigating to non-text channels if (!channel.isTextChannel) return; - navigate(`/channels/${guild.id}/${channel.id}`); + navigate(`/channels/${channel.guildId}/${channel.id}`); }} onContextMenu={(e) => { e.preventDefault(); diff --git a/src/components/ChannelSidebar.tsx b/src/components/ChannelSidebar.tsx index 7eeb7a2..8b44f26 100644 --- a/src/components/ChannelSidebar.tsx +++ b/src/components/ChannelSidebar.tsx @@ -24,7 +24,7 @@ function ChannelSidebar() { {/* TODO: replace with dm search if no guild */} - {app.activeGuild ? : } + {app.activeGuild ? : } ); diff --git a/src/components/GuildItem.tsx b/src/components/GuildItem.tsx index aae74c1..e310255 100644 --- a/src/components/GuildItem.tsx +++ b/src/components/GuildItem.tsx @@ -82,7 +82,7 @@ function GuildItem({ guild, active }: Props) { ]); const doNavigate = () => { - const channel = guild.channelsMapped.find((x) => { + const channel = guild.channels.find((x) => { const permission = Permissions.getPermission(app.account!.id, guild, x); return permission.has("VIEW_CHANNEL") && x.type !== ChannelType.GuildCategory; }); diff --git a/src/components/messaging/SystemMessage.tsx b/src/components/messaging/SystemMessage.tsx index 0334e51..0d7ab69 100644 --- a/src/components/messaging/SystemMessage.tsx +++ b/src/components/messaging/SystemMessage.tsx @@ -70,7 +70,7 @@ function SystemMessage({ message, highlight }: Props) { return ( - + {children} diff --git a/src/components/modals/CreateInviteModal.tsx b/src/components/modals/CreateInviteModal.tsx index 9c0013c..0c47e83 100644 --- a/src/components/modals/CreateInviteModal.tsx +++ b/src/components/modals/CreateInviteModal.tsx @@ -131,7 +131,7 @@ function CreateInviteModal(props: InviteModalProps) { const [inviteExpiresAt, setInviteExpiresAt] = React.useState(null); const guild = app.guilds.get(props.guild_id); - const channel = props.channel_id ? guild?.channels.get(props.channel_id) : guild?.channels.getAll()[0]; + const channel = props.channel_id ? guild?.channels.find((x) => x.id === props.channel_id) : guild?.channels[0]; if (!guild || !channel) { closeModal(); diff --git a/src/stores/objects/Guild.ts b/src/stores/objects/Guild.ts index e4acd74..caf4727 100644 --- a/src/stores/objects/Guild.ts +++ b/src/stores/objects/Guild.ts @@ -144,7 +144,46 @@ export default class Guild { @computed get channels() { - return this.app.channels.getAll().filter((channel) => this.channels_.has(channel.id)); + return this.app.channels + .getAll() + .filter((channel) => this.channels_.has(channel.id)) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + } + + @computed + get channelsSorted() { + const channels = this.channels; + const categoryChannels = channels.filter((channel) => channel.type === ChannelType.GuildCategory); + const nonCatChannels = channels.filter((channel) => channel.type !== ChannelType.GuildCategory); + + const categories: { id: Snowflake; parent: Channel; children: Channel[] }[] = []; + const uncategorized: Channel[] = []; + + for (const channel of categoryChannels) { + categories.push({ + id: channel.id, + parent: channel, + children: [], + }); + } + + for (const channel of nonCatChannels) { + if (channel.parentId) { + const category = categories.find((category) => category.id === channel.parentId); + if (category) { + category.children.push(channel); + } + } else { + uncategorized.push(channel); + } + } + + const a = categories.map((x) => { + // return an array of parent, and children flattened + return [x.parent, ...x.children.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))]; + }); + + return [...a.flat(), ...uncategorized.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))]; } @computed @@ -154,52 +193,13 @@ export default class Guild { @action addChannel(data: APIChannel) { - this.channels_.add(data.id); this.app.channels.add(data); + this.channels_.add(data.id); } @action removeChannel(id: Snowflake) { - this.channels_.delete(id); this.app.channels.remove(id); - } - - @computed - get channelsMapped(): Channel[] { - const channels = this.channels; - - const result: { - id: string; - children: Channel[]; - category: Channel | null; - }[] = []; - - const categories = this.app.channels.sortPosition(channels.filter((x) => x.type === ChannelType.GuildCategory)); - const categorizedChannels = channels.filter((x) => x.type !== ChannelType.GuildCategory && x.parentId !== null); - const uncategorizedChannels = this.app.channels.sortPosition( - channels.filter((x) => x.type !== ChannelType.GuildCategory && x.parentId === null), - ); - - // for each category, add an object containing the category and its children - categories.forEach((category) => { - result.push({ - id: category.id, - children: this.app.channels.sortPosition(categorizedChannels.filter((x) => x.parentId === category.id)), - category: category, - }); - }); - - // add an object containing the remaining uncategorized channels - result.push({ - id: "root", - children: uncategorizedChannels, - category: null, - }); - - // flatten down to a single array where the category is the first element followed by its children - return result - .map((x) => [x.category, ...x.children]) - .flat() - .filter((x) => x !== null) as Channel[]; + this.channels_.delete(id); } }