You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vizality/renderer/src/api/Routes.js

395 lines
12 KiB

/**
* The routes API is used for various routing functionality, such as getting the current
* route, registering custom routes, and generally easier navigation around the app.
* @module Routes
* @memberof API
* @namespace API.Routes
* @version 1.0.0
*/
import { assertString, toSnakeCase } from '@vizality/util/string';
import { Sidebar } from '@vizality/components/dashboard';
import { Events, Regexes } from '@vizality/constants';
import { assertObject } from '@vizality/util/object';
import { isComponent } from '@vizality/util/react';
import { getCaller } from '@vizality/util/file';
import { getModule } from '@vizality/webpack';
import { API } from '@vizality/entities';
import { isValidElement } from 'react';
import DashboardRoutes from '@vizality/builtins/dashboard/routes/Routes';
/**
* Vizality route object.
* @typedef VizalityRoute
* @property {string} [id] Route ID
* @property {string} path Route path
* @property {ReactElement} render Route renderer
* @property {ReactElement|undefined} sidebar Sidebar renderer
*/
/**
* @extends API
* @extends Events
*/
export default class Routes extends API {
constructor () {
super();
/**
* All currently registered routes.
*/
this.routes = [];
}
getAllRoutes () {
return this.routes;
}
/**
* Shuts down the API, removing all listeners and stored objects.
*/
stop () {
this.unregisterAllRoutes();
this.removeAllListeners();
delete vizality.api.routes;
}
/**
* Goes back to the last non-Vizality route.
* @returns {Promise<void>}
*/
async restorePreviousRoute () {
try {
if (this.getLocation()?.pathname?.startsWith('/vizality')) {
let history = await vizality.native.app.getHistory();
if (!history) {
return;
}
history = history.reverse();
history.shift();
const match = history.find(location => !location.includes('/vizality'));
const route = match.replace(new RegExp(Regexes.DISCORD), '');
$discord.modules.router.replaceWith(route);
}
} catch (err) {
return this.error(this._labels.concat('restorePreviousRoute'), err);
}
}
/**
* Registers a route.
* @param {VizalityRoute} route Route to register
* @emits Routes#routeAdd
*/
/*
* registerRoute (route) {
* try {
* if (!route?.id) {
* throw new Error('Route must contain a valid ID!');
* }
* if (route.id !== 'dashboard') {
* if (!route.path) {
* throw new Error(`Route ID "${route.id}" cannot be registered without a valid path.`);
* }
* if (this.routes[route.id]) {
* throw new Error(`Route ID "${route.id}" is already registered!`);
* }
* if (Object.values(this.routes).find(r => r.path === route.path)) {
* throw new Error(`Route ID "${route.id}" tried to register an already-registered path "${route.path}"!`);
* }
* route.caller = getCaller();
* }
* this.routes[route.id] = route;
* if (Object.keys(this.routes)[Object.keys(this.routes).length - 1] !== 'dashboard') {
* this._reregisterDashboardRoutes();
* }
* this.emit('routeAdd', route);
* } catch (err) {
* return this.error(err);
* }
* }
*/
/**
* Registers a route.
* @param {VizalityRoute} route Route to register
* @emits Routes#Events.VIZALITY_ROUTE_ADD
*/
registerRoute (route) {
try {
assertObject(route);
route.caller = route.path === '' ? 'vizality' : getCaller();
route.id = route.id || `${toSnakeCase(route.caller?.id).toUpperCase()}_ROUTE_${this.getRoutesByCaller(route.caller?.id)?.length + 1 || '1'}`;
assertString(route.id);
assertString(route.path);
if (route.path !== '') {
if (this.isRoute(route.id)) {
throw new Error(`Route "${route.id}" is already registered!`);
}
if (this.isRoute(route.path)) {
throw new Error(`Route "${route.id}" tried to register an already-registered path "${route.path}"!`);
}
if (!isComponent(route.render) && !isValidElement(route.render)) {
throw new Error(`Route "${route.id}" did not provide a valid render property value! Must be a React element!`);
}
}
/**
* If no sidebar property is provided, use the Vizality dashboard sidebar as
* the default.
*/
if (!route.hasOwnProperty('sidebar')) {
route.sidebar = Sidebar;
}
this.routes.push(route);
if (this.routes[this.routes.length - 1]?.id !== 'dashboard') {
this._reregisterDashboardRoutes();
}
this.emit(Events.VIZALITY_ROUTE_ADD, route.id, route.path);
} catch (err) {
return this.error(this._labels.concat('registerRoute'), err);
}
}
/**
* Gets all routes matching a given caller.
* @param {string} addonId Addon ID
* @returns {Array<object|null>} Routes matching a given caller
*/
getRoutesByCaller (addonId) {
try {
assertString(addonId);
return this.routes.filter(route => route.caller?.id === addonId);
} catch (err) {
return this.error(this._labels.concat('getRoutesByCaller'), err);
}
}
/**
* Gets all routes found matching a given filter.
* @param {Function} filter Function to use to filter routes by
* @returns {Array<object|null>} Routes matching a given filter
*/
getRoutes (filter) {
try {
if (!filter?.length) return null;
return this.routes.filter(filter);
} catch (err) {
return this.error(this._labels.concat('getRoutes'), err);
}
}
/**
* Unregisters a route.
* @param {string} pathOrRouteId Route path or route ID
* @emits Routes#routeRemove
*/
unregisterRoute (pathOrRouteId) {
try {
if (!pathOrRouteId) {
throw new Error(`Invalid route path or route ID provided!`);
}
if (pathOrRouteId.startsWith('/')) {
if (!this.routes.find(r => r.path === pathOrRouteId)) {
throw new Error(`Route path "${pathOrRouteId}" is not registered, so it cannot be unregistered!`);
}
} else if (!this.routes.find(r => r.id === pathOrRouteId)) {
throw new Error(`Route ID "${pathOrRouteId}" is not registered, so it cannot be unregistered!`);
}
this.routes = this.getRoutes(route => route.id !== pathOrRouteId && route.path !== pathOrRouteId);
this.emit('routeRemove', pathOrRouteId);
} catch (err) {
return this.error(err);
}
}
/**
* Checks if a route is registered.
* @param {string} routeIdOrPath Route ID or route path
* @returns {boolean} Whether a route with a given ID or path is registered
*/
isRoute (routeIdOrPath) {
try {
assertString(routeIdOrPath);
return this.routes.some(route => route.id === routeIdOrPath || route.path === routeIdOrPath);
} catch (err) {
return this.error(this._labels.concat('isRoute'), err);
}
}
/**
* Navigates to a route.
* @param {string} pathOrRouteId Route path or route ID
* @emits Routes#routeNavigate
*/
navigateTo (pathOrRouteId) {
try {
if (!pathOrRouteId) {
throw new Error('Invalid route ID or path provided!');
}
let path;
if (!pathOrRouteId.startsWith('/')) {
switch (pathOrRouteId) {
case 'private': path = '/channels/@me/'; break;
case 'discover': path = $discord.constants.Routes.GUILD_DISCOVERY; break;
case 'friends': path = $discord.constants.Routes.FRIENDS; break;
case 'library': path = $discord.constants.Routes.APPLICATION_LIBRARY; break;
case 'nitro': path = $discord.constants.Routes.APPLICATION_STORE; break;
default: path = `/vizality/${pathOrRouteId}`;
}
} else {
path = pathOrRouteId;
}
/**
* Check if it's a Vizality route and has a # in the path.
*/
if (path.startsWith('/vizality/') && path.includes('#')) {
$discord.modules.router.transitionTo(path);
const hash = path.split('#')[1];
/**
* This is bad, but currently it's the only way I really know how to do it.
* We're adding a timeout here so that we can give the new route page elements
* (topOfElement and scroller) time to load into the DOM.
*/
return setTimeout(() => {
const topOfElement = document.querySelector(`#${hash}`)?.offsetTop;
const scroller = document.querySelector('.vz-dashboard-scroller');
/**
* If the elements don't exist at the check, just load the page normally as if
* there was no hash.
*/
if (!topOfElement || !scroller) {
return;
}
/**
* Try to set the scroller position to the hash element.
*/
return scroller.scroll({ top: topOfElement - 80, behavior: 'smooth' });
}, 250);
}
/**
* Go to the route.
*/
return $discord.modules.router.transitionTo(path);
} catch (err) {
return this.error(err);
}
}
/**
* Unregisters all routes.
* @emits Routes#routeRemoveAll
*/
unregisterAllRoutes () {
try {
this.routes = {};
this.emit('routeRemoveAll');
} catch (err) {
return this.error(err);
}
}
/**
* Unregisters all routes registered by a given addon.
* @param {string} addonId Addon ID
* @emits Routes#routeRemoveAllByAddon
*/
unregisterRoutesByAddon (addonId) {
try {
this.emit('routeRemoveAllByAddon', addonId);
} catch (err) {
return this.error(err);
}
}
/**
* Goes back to the previous route.
* @returns {void}
*/
goBack () {
try {
const { back } = getModule('transitionTo', 'replaceWith', 'getHistory');
return back();
} catch (err) {
return this.error(err);
}
}
/**
* Goes forward to the next route. Only works when a forward route exists.
* @returns {void}
*/
goForward () {
try {
const { forward } = getModule('transitionTo', 'replaceWith', 'getHistory');
return forward();
} catch (err) {
return this.error(err);
}
}
/**
* Gets some information for the current route.
* @returns {object|void}
*/
getLocation () {
try {
const location = {};
const routes = {
private: '/channels/@me/',
discover: $discord.constants.Routes.GUILD_DISCOVERY,
friends: $discord.constants.Routes.FRIENDS,
library: $discord.constants.Routes.APPLICATION_LIBRARY,
nitro: $discord.constants.Routes.APPLICATION_STORE,
guild: '/channels/',
settings: '/vizality/settings',
plugins: '/vizality/plugins',
themes: '/vizality/themes',
snippets: '/vizality/snippets',
palettes: '/vizality/palettes',
'quick-code': '/vizality/quick-code',
development: '/vizality/development',
docs: '/vizality/docs',
updater: '/vizality/updater',
changelog: '/vizality/changelog',
dashboard: '/vizality'
};
for (const route in routes) {
if (window.location.pathname.includes(routes[route])) {
location.pathname = window.location.pathname;
location.href = window.location.href;
location.name = route || 'unknown';
return location;
}
}
} catch (err) {
return this.error(err);
}
}
/**
* @note This is a hacky method used to unregister and reregister the main dashboard route
* so that it doesn't override the plugin and theme routes... Not really sure how to do
* this in a better way at the moment, but definitely should be addressed in the future.
* @private
*/
_reregisterDashboardRoutes () {
try {
if (!this.routes.find(route => route.id === 'dashboard')) {
return;
}
this.unregisterRoute('dashboard');
this.registerRoute({
id: 'dashboard',
path: '',
render: DashboardRoutes,
sidebar: Sidebar
});
} catch (err) {
return this.error(err);
}
}
}