mirror of
https://github.com/spacebarchat/client.git
synced 2024-11-22 02:12:38 +01:00
replace channel list with virtualized list
This commit is contained in:
parent
53eaf7d41f
commit
94e9880197
@ -53,6 +53,7 @@
|
|||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"react-use-error-boundary": "^3.0.0",
|
"react-use-error-boundary": "^3.0.0",
|
||||||
|
"react-virtualized": "^9.22.5",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"reoverlay": "^1.0.3",
|
"reoverlay": "^1.0.3",
|
||||||
"styled-components": "^5.3.11",
|
"styled-components": "^5.3.11",
|
||||||
@ -67,6 +68,7 @@
|
|||||||
"@types/react": "^18.2.22",
|
"@types/react": "^18.2.22",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@types/react-syntax-highlighter": "^15.5.7",
|
"@types/react-syntax-highlighter": "^15.5.7",
|
||||||
|
"@types/react-virtualized": "^9.21.22",
|
||||||
"@types/styled-components": "^5.1.27",
|
"@types/styled-components": "^5.1.27",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||||
"@typescript-eslint/parser": "^6.7.0",
|
"@typescript-eslint/parser": "^6.7.0",
|
||||||
|
@ -152,6 +152,9 @@ dependencies:
|
|||||||
react-use-error-boundary:
|
react-use-error-boundary:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0(react@18.2.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:
|
remark-gfm:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@ -190,6 +193,9 @@ devDependencies:
|
|||||||
'@types/react-syntax-highlighter':
|
'@types/react-syntax-highlighter':
|
||||||
specifier: ^15.5.7
|
specifier: ^15.5.7
|
||||||
version: 15.5.7
|
version: 15.5.7
|
||||||
|
'@types/react-virtualized':
|
||||||
|
specifier: ^9.21.22
|
||||||
|
version: 9.21.22
|
||||||
'@types/styled-components':
|
'@types/styled-components':
|
||||||
specifier: ^5.1.27
|
specifier: ^5.1.27
|
||||||
version: 5.1.27
|
version: 5.1.27
|
||||||
@ -3973,6 +3979,13 @@ packages:
|
|||||||
'@types/react': 18.2.22
|
'@types/react': 18.2.22
|
||||||
dev: false
|
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:
|
/@types/react@18.2.22:
|
||||||
resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==}
|
resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -5356,6 +5369,11 @@ packages:
|
|||||||
shallow-clone: 3.0.1
|
shallow-clone: 3.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/clsx@1.2.1:
|
||||||
|
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/clsx@2.0.0:
|
/clsx@2.0.0:
|
||||||
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
|
resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -11242,6 +11260,10 @@ packages:
|
|||||||
/react-is@18.2.0:
|
/react-is@18.2.0:
|
||||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
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):
|
/react-loading-skeleton@3.3.1(react@18.2.0):
|
||||||
resolution: {integrity: sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==}
|
resolution: {integrity: sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -11476,6 +11498,22 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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:
|
/react@18.2.0:
|
||||||
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
@ -1,53 +1,58 @@
|
|||||||
import { ChannelType } from "@spacebarchat/spacebar-api-types/v9";
|
import { ChannelType } from "@spacebarchat/spacebar-api-types/v9";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import React from "react";
|
import { AutoSizer, List, ListRowProps } from "react-virtualized";
|
||||||
import styled from "styled-components";
|
|
||||||
import { useAppStore } from "../stores/AppStore";
|
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";
|
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() {
|
export function EmptyChannelList() {
|
||||||
return <List></List>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
function ChannelList() {
|
||||||
guild: Guild;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChannelList({ guild }: Props) {
|
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
|
||||||
const renderChannelListItem = React.useCallback(
|
if (!app.activeGuild || !app.activeChannel) return null;
|
||||||
(channel: Channel) => {
|
const { channelsSorted } = app.activeGuild;
|
||||||
const permission = Permissions.getPermission(app.account!.id, guild, channel);
|
|
||||||
if (!permission.has("VIEW_CHANNEL")) return null;
|
|
||||||
|
|
||||||
const active = app.activeChannelId === channel.id;
|
const rowRenderer = ({ index, key, style }: ListRowProps) => {
|
||||||
const isCategory = channel.type === ChannelType.GuildCategory;
|
const item = channelsSorted[index];
|
||||||
return (
|
|
||||||
<ChannelListItem
|
const active = app.activeChannelId === item.id;
|
||||||
key={channel.id}
|
const isCategory = item.type === ChannelType.GuildCategory;
|
||||||
guild={guild}
|
return (
|
||||||
channel={channel}
|
<div style={style}>
|
||||||
isCategory={isCategory}
|
<ChannelListItem key={key} isCategory={isCategory} active={active} channel={item} />
|
||||||
active={active}
|
</div>
|
||||||
/>
|
);
|
||||||
);
|
};
|
||||||
},
|
|
||||||
[app.account, app.activeChannelId, guild],
|
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>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <List>{guild.channelsMapped.map((channel) => renderChannelListItem(channel))}</List>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(ChannelList);
|
export default observer(ChannelList);
|
||||||
|
@ -4,12 +4,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { ContextMenuContext } from "../contexts/ContextMenuContext";
|
import { ContextMenuContext } from "../contexts/ContextMenuContext";
|
||||||
import Channel from "../stores/objects/Channel";
|
import Channel from "../stores/objects/Channel";
|
||||||
import Guild from "../stores/objects/Guild";
|
|
||||||
import { IContextMenuItem } from "./ContextMenuItem";
|
import { IContextMenuItem } from "./ContextMenuItem";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import CreateInviteModal from "./modals/CreateInviteModal";
|
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")};
|
padding: ${(props) => (props.isCategory ? "16px 8px 0 0" : "1px 8px 0 0")};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
@ -36,13 +35,12 @@ const Text = styled.span<{ isCategory?: boolean }>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
guild: Guild;
|
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
isCategory: boolean;
|
isCategory: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelListItem({ guild, channel, isCategory, active }: Props) {
|
function ChannelListItem({ channel, isCategory, active }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { openModal } = useModals();
|
const { openModal } = useModals();
|
||||||
@ -63,7 +61,7 @@ function ChannelListItem({ guild, channel, isCategory, active }: Props) {
|
|||||||
index: 0,
|
index: 0,
|
||||||
label: "Create Channel Invite",
|
label: "Create Channel Invite",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
openModal(CreateInviteModal, { guild_id: guild.id, channel_id: channel.id });
|
openModal(CreateInviteModal, { guild_id: channel.guildId!, channel_id: channel.id });
|
||||||
},
|
},
|
||||||
iconProps: {
|
iconProps: {
|
||||||
icon: "mdiAccountPlus",
|
icon: "mdiAccountPlus",
|
||||||
@ -79,7 +77,7 @@ function ChannelListItem({ guild, channel, isCategory, active }: Props) {
|
|||||||
// prevent navigating to non-text channels
|
// prevent navigating to non-text channels
|
||||||
if (!channel.isTextChannel) return;
|
if (!channel.isTextChannel) return;
|
||||||
|
|
||||||
navigate(`/channels/${guild.id}/${channel.id}`);
|
navigate(`/channels/${channel.guildId}/${channel.id}`);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -24,7 +24,7 @@ function ChannelSidebar() {
|
|||||||
<Wrapper>
|
<Wrapper>
|
||||||
{/* TODO: replace with dm search if no guild */}
|
{/* TODO: replace with dm search if no guild */}
|
||||||
<ChannelHeader />
|
<ChannelHeader />
|
||||||
{app.activeGuild ? <ChannelList guild={app.activeGuild} /> : <EmptyChannelList />}
|
{app.activeGuild ? <ChannelList /> : <EmptyChannelList />}
|
||||||
<UserPanel />
|
<UserPanel />
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
@ -82,7 +82,7 @@ function GuildItem({ guild, active }: Props) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const doNavigate = () => {
|
const doNavigate = () => {
|
||||||
const channel = guild.channelsMapped.find((x) => {
|
const channel = guild.channels.find((x) => {
|
||||||
const permission = Permissions.getPermission(app.account!.id, guild, x);
|
const permission = Permissions.getPermission(app.account!.id, guild, x);
|
||||||
return permission.has("VIEW_CHANNEL") && x.type !== ChannelType.GuildCategory;
|
return permission.has("VIEW_CHANNEL") && x.type !== ChannelType.GuildCategory;
|
||||||
});
|
});
|
||||||
|
@ -70,7 +70,7 @@ function SystemMessage({ message, highlight }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase header>
|
<MessageBase header>
|
||||||
<MessageInfo click={false}>
|
<MessageInfo>
|
||||||
<Icon icon={icon.icon} size="16px" color={icon.color ?? "var(--text-secondary)"} />
|
<Icon icon={icon.icon} size="16px" color={icon.color ?? "var(--text-secondary)"} />
|
||||||
</MessageInfo>
|
</MessageInfo>
|
||||||
<SystemContent>{children}</SystemContent>
|
<SystemContent>{children}</SystemContent>
|
||||||
|
@ -131,7 +131,7 @@ function CreateInviteModal(props: InviteModalProps) {
|
|||||||
const [inviteExpiresAt, setInviteExpiresAt] = React.useState<Date | null>(null);
|
const [inviteExpiresAt, setInviteExpiresAt] = React.useState<Date | null>(null);
|
||||||
|
|
||||||
const guild = app.guilds.get(props.guild_id);
|
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) {
|
if (!guild || !channel) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
@ -144,7 +144,46 @@ export default class Guild {
|
|||||||
|
|
||||||
@computed
|
@computed
|
||||||
get channels() {
|
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
|
@computed
|
||||||
@ -154,52 +193,13 @@ export default class Guild {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
addChannel(data: APIChannel) {
|
addChannel(data: APIChannel) {
|
||||||
this.channels_.add(data.id);
|
|
||||||
this.app.channels.add(data);
|
this.app.channels.add(data);
|
||||||
|
this.channels_.add(data.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeChannel(id: Snowflake) {
|
removeChannel(id: Snowflake) {
|
||||||
this.channels_.delete(id);
|
|
||||||
this.app.channels.remove(id);
|
this.app.channels.remove(id);
|
||||||
}
|
this.channels_.delete(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[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user