mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[chore] Refactor settings panel routing (and other fixes) (#2864)
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter");
|
||||
const syncpipe = require("syncpipe");
|
||||
|
||||
const {
|
||||
RoleContext,
|
||||
useHasPermission,
|
||||
checkPermission,
|
||||
BaseUrlContext
|
||||
} = require("./util");
|
||||
|
||||
const ActiveRouteCtx = React.createContext();
|
||||
function useActiveRoute() {
|
||||
return React.useContext(ActiveRouteCtx);
|
||||
}
|
||||
|
||||
function Sidebar(menuTree, routing) {
|
||||
const components = menuTree.map((m) => m.MenuEntry);
|
||||
|
||||
return function SidebarComponent() {
|
||||
const router = useRouter();
|
||||
const [location] = useLocation();
|
||||
|
||||
let activeRoute = routing.find((l) => {
|
||||
let [match] = router.matcher(l.routingUrl, location);
|
||||
return match;
|
||||
})?.routingUrl;
|
||||
|
||||
return (
|
||||
<nav className="menu-tree">
|
||||
<ul className="top-level">
|
||||
<ActiveRouteCtx.Provider value={activeRoute}>
|
||||
{components}
|
||||
</ActiveRouteCtx.Provider>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function ViewRouter(routing, defaultRoute) {
|
||||
return function ViewRouterComponent() {
|
||||
const permissions = React.useContext(RoleContext);
|
||||
|
||||
const filteredRoutes = React.useMemo(() => {
|
||||
return syncpipe(routing, [
|
||||
(_) => _.filter((route) => checkPermission(route.permissions, permissions)),
|
||||
(_) => _.map((route) => {
|
||||
return (
|
||||
<Route path={route.routingUrl} key={route.key}>
|
||||
<ErrorBoundary>
|
||||
{/* FIXME: implement reset */}
|
||||
<BaseUrlContext.Provider value={route.url}>
|
||||
{route.view}
|
||||
</BaseUrlContext.Provider>
|
||||
</ErrorBoundary>
|
||||
</Route>
|
||||
);
|
||||
})
|
||||
]);
|
||||
}, [permissions]);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
{filteredRoutes}
|
||||
<Redirect to={defaultRoute} />
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function MenuComponent({ type, name, url, icon, permissions, links, level, children }) {
|
||||
const activeRoute = useActiveRoute();
|
||||
|
||||
if (!useHasPermission(permissions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classes = [type];
|
||||
|
||||
if (level == 0) {
|
||||
classes.push("top-level");
|
||||
} else if (level == 1) {
|
||||
classes.push("expanding");
|
||||
} else {
|
||||
classes.push("nested");
|
||||
}
|
||||
|
||||
const isActive = links.includes(activeRoute);
|
||||
if (isActive) {
|
||||
classes.push("active");
|
||||
}
|
||||
|
||||
const className = classes.join(" ");
|
||||
|
||||
return (
|
||||
<li className={className}>
|
||||
<Link href={url}>
|
||||
<a tabIndex={level == 0 ? "-1" : null} className="title">
|
||||
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
|
||||
{name}
|
||||
</a>
|
||||
</Link>
|
||||
{(type == "category" && (level == 0 || isActive) && children?.length > 0) &&
|
||||
<ul>
|
||||
{children}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {};
|
||||
|
||||
this.resetErrorBoundary = () => {
|
||||
this.setState({});
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hadError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(_e, info) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
componentStack: info.componentStack
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hadError) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={this.state.error}
|
||||
componentStack={this.state.componentStack}
|
||||
resetErrorBoundary={this.resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorFallback({ error, componentStack, resetErrorBoundary }) {
|
||||
return (
|
||||
<div className="error">
|
||||
<p>
|
||||
{"An error occured, please report this on the "}
|
||||
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
|
||||
{" or "}
|
||||
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
|
||||
<br />Include the details below:
|
||||
</p>
|
||||
<div className="details">
|
||||
<pre>
|
||||
{error.name}: {error.message}
|
||||
|
||||
{componentStack && [
|
||||
"\n\nComponent trace:",
|
||||
componentStack
|
||||
]}
|
||||
{["\n\nError trace: ", error.stack]}
|
||||
</pre>
|
||||
</div>
|
||||
<p>
|
||||
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Sidebar,
|
||||
ViewRouter,
|
||||
MenuComponent
|
||||
};
|
98
web/source/settings/lib/navigation/error.tsx
Normal file
98
web/source/settings/lib/navigation/error.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from "react";
|
||||
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hadError?: boolean;
|
||||
componentStack?;
|
||||
error?;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
resetErrorBoundary: () => void;
|
||||
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.resetErrorBoundary = () => {
|
||||
this.setState({});
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hadError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(_e, info) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
componentStack: info.componentStack
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hadError) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={this.state.error}
|
||||
componentStack={this.state.componentStack}
|
||||
resetErrorBoundary={this.resetErrorBoundary}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorFallback({ error, componentStack, resetErrorBoundary }) {
|
||||
return (
|
||||
<div className="error">
|
||||
<p>
|
||||
{"An error occured, please report this on the "}
|
||||
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
|
||||
{" or "}
|
||||
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
|
||||
<br />Include the details below:
|
||||
</p>
|
||||
<div className="details">
|
||||
<pre>
|
||||
{error.name}: {error.message}
|
||||
|
||||
{componentStack && [
|
||||
"\n\nComponent trace:",
|
||||
componentStack
|
||||
]}
|
||||
{["\n\nError trace: ", error.stack]}
|
||||
</pre>
|
||||
</div>
|
||||
<p>
|
||||
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ErrorBoundary };
|
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { nanoid } = require("nanoid");
|
||||
const { Redirect } = require("wouter");
|
||||
|
||||
const { urlSafe } = require("./util");
|
||||
|
||||
const {
|
||||
Sidebar,
|
||||
ViewRouter,
|
||||
MenuComponent
|
||||
} = require("./components");
|
||||
|
||||
function createNavigation(rootUrl, menus) {
|
||||
const root = {
|
||||
url: rootUrl,
|
||||
links: [],
|
||||
};
|
||||
|
||||
const routing = [];
|
||||
|
||||
const menuTree = menus.map((creatorFunc) =>
|
||||
creatorFunc(root, routing)
|
||||
);
|
||||
|
||||
return {
|
||||
Sidebar: Sidebar(menuTree, routing),
|
||||
ViewRouter: ViewRouter(routing, root.redirectUrl)
|
||||
};
|
||||
}
|
||||
|
||||
function MenuEntry(name, opts, contents) {
|
||||
if (contents == undefined) { // opts argument is optional
|
||||
contents = opts;
|
||||
opts = {};
|
||||
}
|
||||
|
||||
return function createMenuEntry(root, routing) {
|
||||
const type = Array.isArray(contents) ? "category" : "view";
|
||||
|
||||
let urlParts = [root.url];
|
||||
if (opts.url != "") {
|
||||
urlParts.push(opts.url ?? urlSafe(name));
|
||||
}
|
||||
|
||||
const url = urlParts.join("/");
|
||||
let routingUrl = url;
|
||||
|
||||
if (opts.wildcard) {
|
||||
routingUrl += "/:wildcard*";
|
||||
}
|
||||
|
||||
const entry = {
|
||||
name, type,
|
||||
url, routingUrl,
|
||||
key: nanoid(),
|
||||
permissions: opts.permissions ?? false,
|
||||
icon: opts.icon,
|
||||
links: [routingUrl],
|
||||
level: (root.level ?? -1) + 1,
|
||||
redirectUrl: opts.defaultUrl
|
||||
};
|
||||
|
||||
if (type == "category") {
|
||||
let entries = contents.map((creatorFunc) => creatorFunc(entry, routing));
|
||||
let routes = [];
|
||||
|
||||
entries.forEach((e) => {
|
||||
// move empty wildcard routes to end of category, to prevent overlap
|
||||
if (e.url == entry.url) {
|
||||
routes.unshift(e);
|
||||
} else {
|
||||
routes.push(e);
|
||||
}
|
||||
});
|
||||
routes.reverse();
|
||||
|
||||
routing.push(...routes);
|
||||
|
||||
if (opts.redirectUrl != entry.url) {
|
||||
routing.push({
|
||||
key: entry.key,
|
||||
url: entry.url,
|
||||
permissions: entry.permissions,
|
||||
routingUrl: entry.redirectUrl + "/:fallback*",
|
||||
view: React.createElement(Redirect, { to: entry.redirectUrl })
|
||||
});
|
||||
entry.url = entry.redirectUrl;
|
||||
}
|
||||
|
||||
root.links.push(...entry.links);
|
||||
|
||||
entry.MenuEntry = React.createElement(
|
||||
MenuComponent,
|
||||
entry,
|
||||
entries.map((e) => e.MenuEntry)
|
||||
);
|
||||
} else {
|
||||
entry.links.push(routingUrl);
|
||||
root.links.push(routingUrl);
|
||||
|
||||
entry.view = React.createElement(contents, { baseUrl: url });
|
||||
entry.MenuEntry = React.createElement(MenuComponent, entry);
|
||||
}
|
||||
|
||||
if (root.redirectUrl == undefined) {
|
||||
root.redirectUrl = entry.url;
|
||||
}
|
||||
|
||||
return entry;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createNavigation,
|
||||
Menu: MenuEntry,
|
||||
Item: MenuEntry
|
||||
};
|
175
web/source/settings/lib/navigation/menu.tsx
Normal file
175
web/source/settings/lib/navigation/menu.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Link, useRoute } from "wouter";
|
||||
import {
|
||||
BaseUrlContext,
|
||||
MenuLevelContext,
|
||||
useBaseUrl,
|
||||
useHasPermission,
|
||||
useMenuLevel,
|
||||
} from "./util";
|
||||
import UserLogoutCard from "../../components/user-logout-card";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export interface MenuItemProps {
|
||||
/**
|
||||
* Name / title of this menu item.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Url path component for this menu item.
|
||||
*/
|
||||
itemUrl: string;
|
||||
|
||||
/**
|
||||
* If this menu item is a category containing
|
||||
* children, which child should be selected by
|
||||
* default when category title is clicked.
|
||||
*
|
||||
* Optional, use for categories only.
|
||||
*/
|
||||
defaultChild?: string;
|
||||
|
||||
/**
|
||||
* Permissions required to access this
|
||||
* menu item (none, "moderator", "admin").
|
||||
*/
|
||||
permissions?: string[];
|
||||
|
||||
/**
|
||||
* Fork-awesome string to render
|
||||
* icon for this menu item.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export function MenuItem(props: PropsWithChildren<MenuItemProps>) {
|
||||
const {
|
||||
name,
|
||||
itemUrl,
|
||||
defaultChild,
|
||||
permissions,
|
||||
icon,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
// Derive where this item is
|
||||
// in terms of URL routing.
|
||||
const baseUrl = useBaseUrl();
|
||||
const thisUrl = [ baseUrl, itemUrl ].join('/');
|
||||
|
||||
// Derive where this item is in
|
||||
// terms of nesting within the menu.
|
||||
const thisLevel = useMenuLevel();
|
||||
const nextLevel = thisLevel+1;
|
||||
const topLevel = thisLevel === 0;
|
||||
|
||||
// Check whether this item is currently active
|
||||
// (ie., user has selected it in the menu).
|
||||
//
|
||||
// This uses a wildcard to mark both parent
|
||||
// and relevant child as active.
|
||||
//
|
||||
// See:
|
||||
// https://github.com/molefrog/wouter?tab=readme-ov-file#useroute-route-matching-and-parameters
|
||||
const [isActive] = useRoute([ thisUrl, "*?" ].join("/"));
|
||||
|
||||
// Don't render item if logged-in user
|
||||
// doesn't have permissions to use it.
|
||||
if (!useHasPermission(permissions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check whether this item has children.
|
||||
const hasChildren = children !== undefined;
|
||||
const childrenArray = hasChildren && Array.isArray(children);
|
||||
|
||||
// Class name of the item varies depending
|
||||
// on where it is in the menu, and whether
|
||||
// it has children beneath it or not.
|
||||
const classNames: string[] = [];
|
||||
if (topLevel) {
|
||||
classNames.push("category", "top-level");
|
||||
} else {
|
||||
if (thisLevel === 1 && hasChildren) {
|
||||
classNames.push("category", "expanding");
|
||||
} else if (thisLevel === 1 && !hasChildren) {
|
||||
classNames.push("view", "expanding");
|
||||
} else if (thisLevel === 2) {
|
||||
classNames.push("view", "nested");
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
classNames.push("active");
|
||||
}
|
||||
|
||||
let content: React.JSX.Element | null;
|
||||
if ((isActive || topLevel) && childrenArray) {
|
||||
// Render children as a nested list.
|
||||
content = <ul>{children}</ul>;
|
||||
} else if (isActive && hasChildren) {
|
||||
// Render child as solo element.
|
||||
content = <>{children}</>;
|
||||
} else {
|
||||
// Not active: hide children.
|
||||
content = null;
|
||||
}
|
||||
|
||||
// If a default child is defined, this item should point to that.
|
||||
const href = defaultChild ? [ thisUrl, defaultChild ].join("/") : thisUrl;
|
||||
|
||||
return (
|
||||
<li key={nanoid()} className={classNames.join(" ")}>
|
||||
<Link href={href} className="title">
|
||||
<span>
|
||||
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
|
||||
{name}
|
||||
</span>
|
||||
</Link>
|
||||
{ content &&
|
||||
<BaseUrlContext.Provider value={thisUrl}>
|
||||
<MenuLevelContext.Provider value={nextLevel}>
|
||||
{content}
|
||||
</MenuLevelContext.Provider>
|
||||
</BaseUrlContext.Provider>
|
||||
}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SidebarMenuProps{}
|
||||
|
||||
export function SidebarMenu({ children }: PropsWithChildren<SidebarMenuProps>) {
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<UserLogoutCard />
|
||||
<nav className="menu-tree">
|
||||
<MenuLevelContext.Provider value={0}>
|
||||
<ul className="top-level">
|
||||
{children}
|
||||
</ul>
|
||||
</MenuLevelContext.Provider>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -18,37 +18,62 @@
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
const RoleContext = createContext([]);
|
||||
const RoleContext = createContext<string[]>([]);
|
||||
const BaseUrlContext = createContext<string>("");
|
||||
const MenuLevelContext = createContext<number>(0);
|
||||
|
||||
function urlSafe(str) {
|
||||
function urlSafe(str: string) {
|
||||
return str.toLowerCase().replace(/[\s/]+/g, "-");
|
||||
}
|
||||
|
||||
function useHasPermission(permissions) {
|
||||
const roles = useContext(RoleContext);
|
||||
function useHasPermission(permissions: string[] | undefined) {
|
||||
const roles = useContext<string[]>(RoleContext);
|
||||
return checkPermission(permissions, roles);
|
||||
}
|
||||
|
||||
function checkPermission(requiredPermissisons, user) {
|
||||
// requiredPermissions can be 'false', in which case there are no restrictions
|
||||
if (requiredPermissisons === false) {
|
||||
// checkPermission returns true if the user's roles
|
||||
// include requiredPermissions, or false otherwise.
|
||||
function checkPermission(requiredPermissions: string[] | undefined, userRoles: string[]): boolean {
|
||||
if (requiredPermissions === undefined) {
|
||||
// No perms defined, so user
|
||||
// implicitly has permission.
|
||||
return true;
|
||||
}
|
||||
|
||||
// or an array of roles, check if one of the user's roles is sufficient
|
||||
return user.some((role) => requiredPermissisons.includes(role));
|
||||
if (requiredPermissions.length === 0) {
|
||||
// No perms defined, so user
|
||||
// implicitly has permission.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if one of the user's
|
||||
// roles is sufficient.
|
||||
return userRoles.some((role) => {
|
||||
if (role === "admin") {
|
||||
// Admins can
|
||||
// see everything.
|
||||
return true;
|
||||
}
|
||||
|
||||
return requiredPermissions.includes(role);
|
||||
});
|
||||
}
|
||||
|
||||
function useBaseUrl() {
|
||||
return useContext(BaseUrlContext);
|
||||
}
|
||||
|
||||
function useMenuLevel() {
|
||||
return useContext(MenuLevelContext);
|
||||
}
|
||||
|
||||
export {
|
||||
urlSafe,
|
||||
RoleContext,
|
||||
useHasPermission,
|
||||
checkPermission,
|
||||
BaseUrlContext,
|
||||
useBaseUrl
|
||||
useBaseUrl,
|
||||
MenuLevelContext,
|
||||
useMenuLevel,
|
||||
};
|
||||
|
Reference in New Issue
Block a user