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);
}
}