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

394 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 { Routes as _Routes } from '@discord/constants';
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 { router } from '@discord/modules';
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), '');
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 = _Routes.GUILD_DISCOVERY; break;
case 'friends': path = _Routes.FRIENDS; break;
case 'library': path = _Routes.APPLICATION_LIBRARY; break;
case 'nitro': path = _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('#')) {
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 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: _Routes.GUILD_DISCOVERY,
friends: _Routes.FRIENDS,
library: _Routes.APPLICATION_LIBRARY,
nitro: _Routes.APPLICATION_STORE,
guild: '/channels/',
settings: '/vizality/settings',
plugins: '/vizality/plugins',
themes: '/vizality/themes',
snippets: '/vizality/snippets',
'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);
}
}
}