1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-25 03:32:54 +01:00

replace channel list with virtualized list

This commit is contained in:
Puyodead1 2023-09-25 09:05:45 -04:00
parent 53eaf7d41f
commit 94e9880197
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
9 changed files with 133 additions and 90 deletions

View File

@ -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",

View File

@ -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'}

View File

@ -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 <List></List>;
return <div></div>;
}
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;
const rowRenderer = ({ index, key, style }: ListRowProps) => {
const item = channelsSorted[index];
const active = app.activeChannelId === item.id;
const isCategory = item.type === ChannelType.GuildCategory;
return (
<ChannelListItem
key={channel.id}
guild={guild}
channel={channel}
isCategory={isCategory}
active={active}
/>
);
},
[app.account, app.activeChannelId, guild],
<div style={style}>
<ChannelListItem key={key} isCategory={isCategory} active={active} channel={item} />
</div>
);
};
return <List>{guild.channelsMapped.map((channel) => renderChannelListItem(channel))}</List>;
return (
<div
style={{
flex: "1 0 auto",
display: "flex",
}}
>
<AutoSizer>
{({ width, height }) => (
<List
height={height}
overscanRowCount={2}
rowCount={channelsSorted.length}
rowHeight={({ index }) => {
const item = channelsSorted[index];
if (item.type === ChannelType.GuildCategory) {
return 44;
}
return 33;
}}
rowRenderer={rowRenderer}
width={width}
/>
)}
</AutoSizer>
</div>
);
}
export default observer(ChannelList);

View File

@ -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();

View File

@ -24,7 +24,7 @@ function ChannelSidebar() {
<Wrapper>
{/* TODO: replace with dm search if no guild */}
<ChannelHeader />
{app.activeGuild ? <ChannelList guild={app.activeGuild} /> : <EmptyChannelList />}
{app.activeGuild ? <ChannelList /> : <EmptyChannelList />}
<UserPanel />
</Wrapper>
);

View File

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

View File

@ -70,7 +70,7 @@ function SystemMessage({ message, highlight }: Props) {
return (
<MessageBase header>
<MessageInfo click={false}>
<MessageInfo>
<Icon icon={icon.icon} size="16px" color={icon.color ?? "var(--text-secondary)"} />
</MessageInfo>
<SystemContent>{children}</SystemContent>

View File

@ -131,7 +131,7 @@ function CreateInviteModal(props: InviteModalProps) {
const [inviteExpiresAt, setInviteExpiresAt] = React.useState<Date | null>(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();

View File

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