diff --git a/packages/app/src/extensions/api.extension.js b/packages/app/src/extensions/api.extension.js new file mode 100644 index 00000000..78c6cffc --- /dev/null +++ b/packages/app/src/extensions/api.extension.js @@ -0,0 +1,166 @@ +import { Extension } from "evite" +import config from "config" +import { Bridge } from "linebridge/dist/client" +import { Session } from "models" + +export default class ApiExtension extends Extension { + constructor(app, main) { + super(app, main) + + this.apiBridge = this.createBridge() + this.WSInterface = this.apiBridge.wsInterface + this.WSInterface.request = this.WSRequest + this.WSInterface.listen = this.handleWSListener + this.WSSockets = this.WSInterface.sockets + this.WSInterface.mainSocketConnected = false + } + + initializers = [ + async () => { + this.WSSockets.main.on("authenticated", () => { + console.debug("[WS] Authenticated") + }) + this.WSSockets.main.on("authenticateFailed", (error) => { + console.error("[WS] Authenticate Failed", error) + }) + + this.WSSockets.main.on("connect", () => { + window.app.eventBus.emit("websocket_connected") + this.WSInterface.mainSocketConnected = true + }) + + this.WSSockets.main.on("disconnect", (...context) => { + window.app.eventBus.emit("websocket_disconnected", ...context) + this.WSInterface.mainSocketConnected = false + }) + + this.WSSockets.main.on("connect_error", (...context) => { + window.app.eventBus.emit("websocket_connection_error", ...context) + this.WSInterface.mainSocketConnected = false + }) + + this.mainContext.setToWindowContext("api", this.apiBridge) + this.mainContext.setToWindowContext("ws", this.WSInterface) + + this.mainContext.setToWindowContext("request", this.apiBridge.endpoints) + this.mainContext.setToWindowContext("WSRequest", this.WSInterface.wsEndpoints) + } + ] + + createBridge() { + const getSessionContext = async () => { + const obj = {} + const token = await Session.token + + if (token) { + // append token to context + obj.headers = { + Authorization: `Bearer ${token ?? null}`, + } + } + + return obj + } + + const handleResponse = async (data) => { + if (data.headers?.regenerated_token) { + Session.token = data.headers.regenerated_token + console.debug("[REGENERATION] New token generated") + } + + if (data instanceof Error) { + if (data.response.status === 401) { + window.app.eventBus.emit("invalid_session") + } + } + } + + return new Bridge({ + origin: config.api.address, + wsOrigin: config.ws.address, + wsOptions: { + autoConnect: false, + }, + onRequest: getSessionContext, + onResponse: handleResponse, + }) + } + + async attachWSConnection() { + if (!this.WSInterface.sockets.main.connected) { + await this.WSInterface.sockets.main.connect() + } + + let startTime = null + let latency = null + let latencyWarning = false + + let pingInterval = setInterval(() => { + if (!this.WSInterface.mainSocketConnected) { + return clearTimeout(pingInterval) + } + + startTime = Date.now() + this.WSInterface.sockets.main.emit("ping") + }, 2000) + + this.WSInterface.sockets.main.on("pong", () => { + latency = Date.now() - startTime + + if (latency > 800 && this.WSInterface.mainSocketConnected) { + latencyWarning = true + console.error("[WS] Latency is too high > 800ms", latency) + window.app.eventBus.emit("websocket_latency_too_high", latency) + } else if (latencyWarning && this.WSInterface.mainSocketConnected) { + latencyWarning = false + window.app.eventBus.emit("websocket_latency_normal", latency) + } + }) + } + + async attachAPIConnection() { + await this.apiBridge.initialize() + } + + handleWSListener = (to, fn) => { + if (typeof to === "undefined") { + console.error("handleWSListener: to must be defined") + return false + } + if (typeof fn !== "function") { + console.error("handleWSListener: fn must be function") + return false + } + + let ns = "main" + let event = null + + if (typeof to === "string") { + event = to + } else if (typeof to === "object") { + ns = to.ns + event = to.event + } + + return window.app.ws.sockets[ns].on(event, async (...context) => { + return await fn(...context) + }) + } + + WSRequest = (socket = "main", channel, ...args) => { + return new Promise(async (resolve, reject) => { + const request = await window.app.ws.sockets[socket].emit(channel, ...args) + + request.on("responseError", (...errors) => { + return reject(...errors) + }) + request.on("response", (...responses) => { + return resolve(...responses) + }) + }) + } + + window = { + ApiController: this + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/api/index.js b/packages/app/src/extensions/api/index.js deleted file mode 100644 index edf77814..00000000 --- a/packages/app/src/extensions/api/index.js +++ /dev/null @@ -1,187 +0,0 @@ -import config from "config" -import { Bridge } from "linebridge/dist/client" -import { Session } from "models" -import io from "socket.io-client" - -class WSInterface { - constructor(params = {}) { - this.params = params - this.manager = new io.Manager(this.params.origin, { - autoConnect: true, - transports: ["websocket"], - ...this.params.managerOptions, - }) - this.sockets = {} - - this.register("/", "main") - } - - register = (socket, as) => { - if (typeof socket !== "string") { - console.error("socket must be string") - return false - } - - socket = this.manager.socket(socket) - return this.sockets[as ?? socket] = socket - } -} - -export default { - key: "apiBridge", - expose: [ - { - initialization: [ - async (app, main) => { - app.apiBridge = await app.createApiBridge() - - app.WSInterface = app.apiBridge.wsInterface - app.WSInterface.request = app.WSRequest - app.WSInterface.listen = app.handleWSListener - app.WSSockets = app.WSInterface.sockets - app.WSInterface.mainSocketConnected = false - - app.WSSockets.main.on("authenticated", () => { - console.debug("[WS] Authenticated") - }) - app.WSSockets.main.on("authenticateFailed", (error) => { - console.error("[WS] Authenticate Failed", error) - }) - - app.WSSockets.main.on("connect", () => { - window.app.eventBus.emit("websocket_connected") - app.WSInterface.mainSocketConnected = true - }) - - app.WSSockets.main.on("disconnect", (...context) => { - window.app.eventBus.emit("websocket_disconnected", ...context) - app.WSInterface.mainSocketConnected = false - }) - - app.WSSockets.main.on("connect_error", (...context) => { - window.app.eventBus.emit("websocket_connection_error", ...context) - app.WSInterface.mainSocketConnected = false - }) - - window.app.api = app.apiBridge - window.app.ws = app.WSInterface - - window.app.request = app.apiBridge.endpoints - window.app.wsRequest = app.apiBridge.wsEndpoints - }, - ], - mutateContext: { - async attachWSConnection() { - if (!this.WSInterface.sockets.main.connected) { - await this.WSInterface.sockets.main.connect() - } - - let startTime = null - let latency = null - let latencyWarning = false - - let pingInterval = setInterval(() => { - if (!this.WSInterface.mainSocketConnected) { - return clearTimeout(pingInterval) - } - - startTime = Date.now() - this.WSInterface.sockets.main.emit("ping") - }, 2000) - - this.WSInterface.sockets.main.on("pong", () => { - latency = Date.now() - startTime - - if (latency > 800 && this.WSInterface.mainSocketConnected) { - latencyWarning = true - console.error("[WS] Latency is too high > 800ms", latency) - window.app.eventBus.emit("websocket_latency_too_high", latency) - } else if (latencyWarning && this.WSInterface.mainSocketConnected) { - latencyWarning = false - window.app.eventBus.emit("websocket_latency_normal", latency) - } - }) - }, - async attachAPIConnection() { - await this.apiBridge.initialize() - }, - handleWSListener: (to, fn) => { - if (typeof to === "undefined") { - console.error("handleWSListener: to must be defined") - return false - } - if (typeof fn !== "function") { - console.error("handleWSListener: fn must be function") - return false - } - - let ns = "main" - let event = null - - if (typeof to === "string") { - event = to - } else if (typeof to === "object") { - ns = to.ns - event = to.event - } - - return window.app.ws.sockets[ns].on(event, async (...context) => { - return await fn(...context) - }) - }, - createApiBridge: async () => { - const getSessionContext = async () => { - const obj = {} - const token = await Session.token - - if (token) { - // append token to context - obj.headers = { - Authorization: `Bearer ${token ?? null}`, - } - } - - return obj - } - - const handleResponse = async (data) => { - if (data.headers?.regenerated_token) { - Session.token = data.headers.regenerated_token - console.debug("[REGENERATION] New token generated") - } - - if (data instanceof Error) { - if (data.response.status === 401) { - window.app.eventBus.emit("invalid_session") - } - } - } - - const bridge = new Bridge({ - origin: config.api.address, - wsOrigin: config.ws.address, - wsOptions: { - autoConnect: false, - }, - onRequest: getSessionContext, - onResponse: handleResponse, - }) - - return bridge - }, - WSRequest: (socket = "main", channel, ...args) => { - return new Promise(async (resolve, reject) => { - const request = await window.app.ws.sockets[socket].emit(channel, ...args) - - request.on("responseError", (...errors) => { - return reject(...errors) - }) - request.on("response", (...responses) => { - return resolve(...responses) - }) - }) - } - }, - }, - ], -} \ No newline at end of file diff --git a/packages/app/src/extensions/debug/index.jsx b/packages/app/src/extensions/debug.extension.jsx similarity index 91% rename from packages/app/src/extensions/debug/index.jsx rename to packages/app/src/extensions/debug.extension.jsx index 5f19a237..1df5ae68 100644 --- a/packages/app/src/extensions/debug/index.jsx +++ b/packages/app/src/extensions/debug.extension.jsx @@ -1,3 +1,4 @@ +import { Extension } from "evite" import React from "react" import { Window } from "components" import { Skeleton, Tabs } from "antd" @@ -121,15 +122,8 @@ class Debugger { } } -export default { - key: "visualDebugger", - expose: [ - { - initialization: [ - async (app, main) => { - main.setToWindowContext("debug", new Debugger(main)) - }, - ], - }, - ], +export default class VisualDebugger extends Extension { + window = { + debug: new Debugger(this.mainContext) + } } \ No newline at end of file diff --git a/packages/app/src/extensions/haptics/index.js b/packages/app/src/extensions/haptics/index.js deleted file mode 100644 index f9a6b5b6..00000000 --- a/packages/app/src/extensions/haptics/index.js +++ /dev/null @@ -1,68 +0,0 @@ -import Evite from "evite" - -import { Haptics, ImpactStyle } from "@capacitor/haptics" - -// This is a temporal workaround to make the extension work with the new evite extension system. -export default class HapticExtensionV2 extends Evite.Extension { - static id = "hapticsEngine" - - static compatible = ["mobile"] - - static extendsWith = ["SettingsController"] - - statement = { - test: "macarronie", - } - - initialization = [ - async (app, main) => { - console.log(this.statement.test) - } - ] - - debug = { - testVibrate: () => { - - }, - testSelectionStart: () => { - - }, - testSelectionChanged: () => { - - }, - testSelectionEnd: () => { - - }, - } - - public = { - vibrate: async function () { - const enabled = this.extended.SettingsController.get("haptic_feedback") - - if (enabled) { - await Haptics.vibrate() - } - }, - selectionStart: async function () { - const enabled = this.extended.SettingsController.get("haptic_feedback") - - if (enabled) { - await Haptics.selectionStart() - } - }, - selectionChanged: async function () { - const enabled = this.extended.SettingsController.get("haptic_feedback") - - if (enabled) { - await Haptics.selectionChanged() - } - }, - selectionEnd: async function () { - const enabled = this.extended.SettingsController.get("haptic_feedback") - - if (enabled) { - await Haptics.selectionEnd() - } - }, - } -} \ No newline at end of file diff --git a/packages/app/src/extensions/i18n.extension.js b/packages/app/src/extensions/i18n.extension.js new file mode 100644 index 00000000..86f5e80f --- /dev/null +++ b/packages/app/src/extensions/i18n.extension.js @@ -0,0 +1,73 @@ +import { Extension } from "evite" +import config from "config" +import i18n from "i18next" +import { initReactI18next } from "react-i18next" + +export const SUPPORTED_LANGUAGES = config.i18n?.languages ?? {} +export const SUPPORTED_LOCALES = SUPPORTED_LANGUAGES.map((l) => l.locale) +export const DEFAULT_LOCALE = config.i18n?.defaultLocale + +export function extractLocaleFromPath(path = "") { + const [_, maybeLocale] = path.split("/") + return SUPPORTED_LOCALES.includes(maybeLocale) ? maybeLocale : DEFAULT_LOCALE +} + +const messageImports = import.meta.glob("./translations/*.json") + +export default class I18nExtension extends Extension { + depends = ["SettingsExtension"] + + importLocale = async (locale) => { + const [, importLocale] = + Object.entries(messageImports).find(([key]) => + key.includes(`/${locale}.`) + ) || [] + + return importLocale && importLocale() + } + + loadAsyncLanguage = async function (locale) { + locale = locale ?? DEFAULT_LOCALE + + try { + const result = await this.importLocale(locale) + + if (result) { + i18n.addResourceBundle(locale, "translation", result.default || result) + i18n.changeLanguage(locale) + } + } catch (error) { + console.error(error) + } + } + + initializers = [ + async () => { + let locale = app.settings.get("language") ?? DEFAULT_LOCALE + + if (!SUPPORTED_LOCALES.includes(locale)) { + locale = DEFAULT_LOCALE + } + + const messages = await this.importLocale(locale) + + i18n + .use(initReactI18next) // passes i18n down to react-i18next + .init({ + // debug: true, + resources: { + [locale]: { translation: messages.default || messages }, + }, + lng: locale, + //fallbackLng: DEFAULT_LOCALE, + interpolation: { + escapeValue: false, // react already safes from xss + }, + }) + + this.mainContext.eventBus.on("changeLanguage", (locale) => { + this.loadAsyncLanguage(locale) + }) + }, + ] +} \ No newline at end of file diff --git a/packages/app/src/extensions/i18n/index.js b/packages/app/src/extensions/i18n/index.js deleted file mode 100644 index 41e9c530..00000000 --- a/packages/app/src/extensions/i18n/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import config from "config" -import i18n from "i18next" -import { initReactI18next } from "react-i18next" - -export const SUPPORTED_LANGUAGES = config.i18n?.languages ?? {} -export const SUPPORTED_LOCALES = SUPPORTED_LANGUAGES.map((l) => l.locale) -export const DEFAULT_LOCALE = config.i18n?.defaultLocale - -export function extractLocaleFromPath(path = "") { - const [_, maybeLocale] = path.split("/") - return SUPPORTED_LOCALES.includes(maybeLocale) ? maybeLocale : DEFAULT_LOCALE -} - -const messageImports = import.meta.glob("./translations/*.json") - -export const extension = { - key: "i18n", - expose: [ - { - initialization: [ - async (app, main) => { - let locale = app.settingsController.get("language") ?? DEFAULT_LOCALE - - if (!SUPPORTED_LOCALES.includes(locale)) { - locale = DEFAULT_LOCALE - } - - const messages = await app.importLocale(locale) - - i18n - .use(initReactI18next) // passes i18n down to react-i18next - .init({ - // debug: true, - resources: { - [locale]: { translation: messages.default || messages }, - }, - lng: locale, - //fallbackLng: DEFAULT_LOCALE, - interpolation: { - escapeValue: false, // react already safes from xss - }, - }) - - main.eventBus.on("changeLanguage", (locale) => { - app.loadAsyncLanguage(locale) - }) - }, - ], - mutateContext: { - importLocale: async (locale) => { - const [, importLocale] = - Object.entries(messageImports).find(([key]) => - key.includes(`/${locale}.`) - ) || [] - - return importLocale && importLocale() - }, - loadAsyncLanguage: async function (locale) { - locale = locale ?? DEFAULT_LOCALE - - try { - const result = await this.importLocale(locale) - - if (result) { - i18n.addResourceBundle(locale, "translation", result.default || result) - i18n.changeLanguage(locale) - } - } catch (error) { - console.error(error) - } - } - }, - - }, - ], -} - -export default extension \ No newline at end of file diff --git a/packages/app/src/extensions/index.js b/packages/app/src/extensions/index.js index 93efe2eb..d2f69401 100644 --- a/packages/app/src/extensions/index.js +++ b/packages/app/src/extensions/index.js @@ -1,11 +1,2 @@ -export * as Render from "./render" -export * as Splash from "./splash" -export * as Sound from "./sound" -export * as Theme from "./theme" -export * as i18n from "./i18n" -export * as Notifications from "./notifications" - -export { default as SettingsController } from "./settings" -export { default as API } from "./api" -export { default as Debug } from "./debug" -export { default as Shortcuts } from "./shortcuts" \ No newline at end of file +export * as Render from "./render.extension.jsx" +export * as Splash from "./splash" \ No newline at end of file diff --git a/packages/app/src/extensions/notifications/index.jsx b/packages/app/src/extensions/notifications.extension.jsx similarity index 66% rename from packages/app/src/extensions/notifications/index.jsx rename to packages/app/src/extensions/notifications.extension.jsx index fafb75e9..fd7d5776 100644 --- a/packages/app/src/extensions/notifications/index.jsx +++ b/packages/app/src/extensions/notifications.extension.jsx @@ -1,10 +1,11 @@ +import { Extension } from "evite" import React from "react" import { notification as Notf } from "antd" import { Icons, createIconRender } from "components/Icons" import { Translation } from "react-i18next" import { Haptics } from "@capacitor/haptics" -class NotificationController { +export default class NotificationController extends Extension { getSoundVolume = () => { return (window.app.settings.get("notifications_sound_volume") ?? 50) / 100 } @@ -53,34 +54,21 @@ class NotificationController { }) } } -} -const extension = { - key: "notification", - expose: [ - { - initialization: [ - async (app, main) => { - app.NotificationController = new NotificationController() + initializers = [ + function () { + this.eventBus.on("changeNotificationsSoundVolume", (value) => { + app.notifications.playAudio({ soundVolume: value }) + }) + this.eventBus.on("changeNotificationsVibrate", (value) => { + app.notifications.playHaptic({ + vibrationEnabled: value, + }) + }) + } + ] - main.eventBus.on("changeNotificationsSoundVolume", (value) => { - app.NotificationController.playAudio({ soundVolume: value }) - }) - main.eventBus.on("changeNotificationsVibrate", (value) => { - app.NotificationController.playHaptic({ - vibrationEnabled: value, - }) - }) - main.setToWindowContext("notifications", app.NotificationController) - }, - ], - }, - ], -} - -export { - extension, - NotificationController, -} - -export default extension \ No newline at end of file + window = { + notifications: this + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/render.extension.jsx b/packages/app/src/extensions/render.extension.jsx new file mode 100644 index 00000000..5c08085c --- /dev/null +++ b/packages/app/src/extensions/render.extension.jsx @@ -0,0 +1,194 @@ +import React from "react" +import { EvitePureComponent, Extension } from "evite" +import progressBar from "nprogress" +import routes from "virtual:generated-pages" + +import NotFoundRender from "./staticsRenders/404" +import CrashRender from "./staticsRenders/crash" + +export const ConnectWithApp = (component) => { + return window.app.bindContexts(component) +} + +export function GetRoutesComponentMap() { + return routes.reduce((acc, route) => { + const { path, component } = route + + acc[path] = component + + return acc + }, {}) +} + +export class RouteRender extends EvitePureComponent { + state = { + renderInitialization: true, + renderComponent: null, + renderError: null, + //pageStatement: new PageStatement(), + routes: GetRoutesComponentMap() ?? {}, + crash: null, + } + + handleBusEvents = { + "render_initialization": () => { + this.setState({ renderInitialization: true }) + }, + "render_initialization_done": () => { + this.setState({ renderInitialization: false }) + }, + "crash": (message, error) => { + this.setState({ crash: { message, error } }) + }, + "locationChange": (event) => { + this.loadRender() + }, + } + + componentDidMount() { + this._ismounted = true + this._loadBusEvents() + this.loadRender() + } + + componentWillUnmount() { + this._ismounted = false + this._unloadBusEvents() + } + + loadRender = (path) => { + if (!this._ismounted) { + console.warn("RouteRender is not mounted, skipping render load") + return false + } + + let componentModule = this.state.routes[path ?? this.props.path ?? window.location.pathname] ?? this.props.staticRenders?.NotFound ?? NotFoundRender + + // TODO: in a future use, we can use `pageStatement` class for managing statement + window.app.pageStatement = Object.freeze(componentModule.pageStatement) ?? Object.freeze({}) + + return this.setState({ renderComponent: componentModule }) + } + + componentDidCatch(info, stack) { + this.setState({ renderError: { info, stack } }) + } + + render() { + if (this.state.crash) { + const StaticCrashRender = this.props.staticRenders?.Crash ?? CrashRender + + return + } + + if (this.state.renderError) { + if (this.props.staticRenders?.RenderError) { + return React.createElement(this.props.staticRenders?.RenderError, { error: this.state.renderError }) + } + + return JSON.stringify(this.state.renderError) + } + + if (this.state.renderInitialization) { + const StaticInitializationRender = this.props.staticRenders?.initialization ?? null + + return + } + + if (!this.state.renderComponent) { + return null + } + + return React.createElement(ConnectWithApp(this.state.renderComponent), this.props) + } +} + +export class RenderExtension extends Extension { + initializers = [ + async function () { + const defaultTransitionDelay = 150 + + this.progressBar = progressBar.configure({ parent: "html", showSpinner: false }) + + this.history.listen((event) => { + this.eventBus.emit("transitionDone", event) + this.eventBus.emit("locationChange", event) + this.progressBar.done() + }) + + this.history.setLocation = (to, state, delay) => { + const lastLocation = this.history.lastLocation + + if (typeof lastLocation !== "undefined" && lastLocation?.pathname === to && lastLocation?.state === state) { + return false + } + + this.progressBar.start() + this.eventBus.emit("transitionStart", delay) + + setTimeout(() => { + this.history.push({ + pathname: to, + }, state) + this.history.lastLocation = this.history.location + }, delay ?? defaultTransitionDelay) + } + + this.setToWindowContext("setLocation", this.history.setLocation) + }, + ] + + expose = { + validateLocationSlash: (location) => { + let key = location ?? window.location.pathname + + while (key[0] === "/") { + key = key.slice(1, key.length) + } + + return key + }, + } + + window = { + isAppCapacitor: () => window.navigator.userAgent === "capacitor", + bindContexts: (component) => { + let contexts = { + main: {}, + app: {}, + } + + if (typeof component.bindApp === "string") { + if (component.bindApp === "all") { + Object.keys(app).forEach((key) => { + contexts.app[key] = app[key] + }) + } + } else { + if (Array.isArray(component.bindApp)) { + component.bindApp.forEach((key) => { + contexts.app[key] = app[key] + }) + } + } + + if (typeof component.bindMain === "string") { + if (component.bindMain === "all") { + Object.keys(main).forEach((key) => { + contexts.main[key] = main[key] + }) + } + } else { + if (Array.isArray(component.bindMain)) { + component.bindMain.forEach((key) => { + contexts.main[key] = main[key] + }) + } + } + + return (props) => React.createElement(component, { ...props, contexts }) + }, + } +} + +export default RenderExtension \ No newline at end of file diff --git a/packages/app/src/extensions/render/index.jsx b/packages/app/src/extensions/render/index.jsx deleted file mode 100644 index 0ad09810..00000000 --- a/packages/app/src/extensions/render/index.jsx +++ /dev/null @@ -1,225 +0,0 @@ -import React from "react" -import { EvitePureComponent } from "evite" -import routes from "virtual:generated-pages" -import progressBar from "nprogress" - -import NotFoundRender from "./statics/404" -import CrashRender from "./statics/crash" - -export const ConnectWithApp = (component) => { - return window.app.bindContexts(component) -} - -export function GetRoutesMap() { - return routes.map((route) => { - const { path } = route - route.name = - path - .replace(/^\//, "") - .replace(/:/, "") - .replace(/\//, "-") - .replace("all(.*)", "not-found") || "home" - - route.path = route.path.includes("*") ? "*" : route.path - - return route - }) -} - -export function GetRoutesComponentMap() { - return routes.reduce((acc, route) => { - const { path, component } = route - - acc[path] = component - - return acc - }, {}) -} - -// class PageStatement { -// constructor() { -// this.state = {} - -// } - -// getProxy() { - -// } -// } - -export class RouteRender extends EvitePureComponent { - state = { - renderInitialization: true, - renderComponent: null, - renderError: null, - //pageStatement: new PageStatement(), - routes: GetRoutesComponentMap() ?? {}, - crash: null, - } - - handleBusEvents = { - "render_initialization": () => { - this.setState({ renderInitialization: true }) - }, - "render_initialization_done": () => { - this.setState({ renderInitialization: false }) - }, - "crash": (message, error) => { - this.setState({ crash: { message, error } }) - }, - "locationChange": (event) => { - this.loadRender() - }, - } - - componentDidMount() { - this._ismounted = true - this._loadBusEvents() - this.loadRender() - } - - componentWillUnmount() { - this._ismounted = false - this._unloadBusEvents() - } - - loadRender = (path) => { - if (!this._ismounted) { - console.warn("RouteRender is not mounted, skipping render load") - return false - } - - let componentModule = this.state.routes[path ?? this.props.path ?? window.location.pathname] ?? this.props.staticRenders?.NotFound ?? NotFoundRender - - // TODO: in a future use, we can use `pageStatement` class for managing statement - window.app.pageStatement = Object.freeze(componentModule.pageStatement) ?? Object.freeze({}) - - return this.setState({ renderComponent: componentModule }) - } - - componentDidCatch(info, stack) { - this.setState({ renderError: { info, stack } }) - } - - render() { - if (this.state.crash) { - const StaticCrashRender = this.props.staticRenders?.Crash ?? CrashRender - - return - } - - if (this.state.renderError) { - if (this.props.staticRenders?.RenderError) { - return React.createElement(this.props.staticRenders?.RenderError, { error: this.state.renderError }) - } - - return JSON.stringify(this.state.renderError) - } - - if (this.state.renderInitialization) { - const StaticInitializationRender = this.props.staticRenders?.initialization ?? null - - return - } - - if (!this.state.renderComponent) { - return null - } - - return React.createElement(ConnectWithApp(this.state.renderComponent), this.props) - } -} - -export const extension = { - key: "customRender", - expose: [ - { - initialization: [ - async (app, main) => { - app.bindContexts = (component) => { - let contexts = { - main: {}, - app: {}, - } - - if (typeof component.bindApp === "string") { - if (component.bindApp === "all") { - Object.keys(app).forEach((key) => { - contexts.app[key] = app[key] - }) - } - } else { - if (Array.isArray(component.bindApp)) { - component.bindApp.forEach((key) => { - contexts.app[key] = app[key] - }) - } - } - - if (typeof component.bindMain === "string") { - if (component.bindMain === "all") { - Object.keys(main).forEach((key) => { - contexts.main[key] = main[key] - }) - } - } else { - if (Array.isArray(component.bindMain)) { - component.bindMain.forEach((key) => { - contexts.main[key] = main[key] - }) - } - } - - return (props) => React.createElement(component, { ...props, contexts }) - } - - main.setToWindowContext("bindContexts", app.bindContexts) - }, - async (app, main) => { - const defaultTransitionDelay = 150 - - main.progressBar = progressBar.configure({ parent: "html", showSpinner: false }) - - main.history.listen((event) => { - main.eventBus.emit("transitionDone", event) - main.eventBus.emit("locationChange", event) - main.progressBar.done() - }) - - main.history.setLocation = (to, state, delay) => { - const lastLocation = main.history.lastLocation - - if (typeof lastLocation !== "undefined" && lastLocation?.pathname === to && lastLocation?.state === state) { - return false - } - - main.progressBar.start() - main.eventBus.emit("transitionStart", delay) - - setTimeout(() => { - main.history.push({ - pathname: to, - }, state) - main.history.lastLocation = main.history.location - }, delay ?? defaultTransitionDelay) - } - - main.setToWindowContext("setLocation", main.history.setLocation) - }, - ], - mutateContext: { - validateLocationSlash: (location) => { - let key = location ?? window.location.pathname - - while (key[0] === "/") { - key = key.slice(1, key.length) - } - - return key - }, - }, - }, - ], -} - -export default extension \ No newline at end of file diff --git a/packages/app/src/extensions/settings/index.js b/packages/app/src/extensions/settings.extension.js similarity index 77% rename from packages/app/src/extensions/settings/index.js rename to packages/app/src/extensions/settings.extension.js index b40df8c2..97595e5c 100644 --- a/packages/app/src/extensions/settings/index.js +++ b/packages/app/src/extensions/settings.extension.js @@ -1,8 +1,10 @@ +import { Extension } from "evite" import store from "store" import defaultSettings from "schemas/defaultSettings.json" -class SettingsController { - constructor() { +export default class SettingsExtension extends Extension { + constructor(app, main) { + super(app, main) this.storeKey = "app_settings" this.settings = store.get(this.storeKey) ?? {} @@ -49,18 +51,8 @@ class SettingsController { return this.settings[key] } -} -export default { - key: "settings", - expose: [ - { - initialization: [ - (app, main) => { - app.settingsController = new SettingsController() - window.app.settings = app.settingsController - } - ] - }, - ] + window = { + "settings": this + } } \ No newline at end of file diff --git a/packages/app/src/extensions/shortcuts/index.js b/packages/app/src/extensions/shortcuts.extension.js similarity index 75% rename from packages/app/src/extensions/shortcuts/index.js rename to packages/app/src/extensions/shortcuts.extension.js index e1e680ba..933a3099 100644 --- a/packages/app/src/extensions/shortcuts/index.js +++ b/packages/app/src/extensions/shortcuts.extension.js @@ -1,5 +1,9 @@ -export class ShortcutsController { - constructor() { +import { Extension } from "evite" + +export default class ShortcutsExtension extends Extension { + constructor(app, main) { + super(app, main) + this.shortcuts = {} document.addEventListener("keydown", (event) => { @@ -15,15 +19,15 @@ export class ShortcutsController { if (typeof shortcut.shift === "boolean" && event.shiftKey !== shortcut.shift) { return } - + if (typeof shortcut.alt === "boolean" && event.altKey !== shortcut.alt) { return } - + if (typeof shortcut.meta === "boolean" && event.metaKey !== shortcut.meta) { return } - + if (shortcut.preventDefault) { event.preventDefault() } @@ -58,21 +62,8 @@ export class ShortcutsController { delete this.shortcuts[key] }) } -} -export const extension = { - key: "shortcuts", - expose: [ - { - initialization: [ - (app, main) => { - app.ShortcutsController = new ShortcutsController() - - main.setToWindowContext("ShortcutsController", app.ShortcutsController) - } - ], - }, - ] -} - -export default extension \ No newline at end of file + window = { + ShortcutsController: this + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/sound/index.js b/packages/app/src/extensions/sound.extension.js similarity index 60% rename from packages/app/src/extensions/sound/index.js rename to packages/app/src/extensions/sound.extension.js index bc950e0a..bd650e71 100644 --- a/packages/app/src/extensions/sound/index.js +++ b/packages/app/src/extensions/sound.extension.js @@ -1,14 +1,9 @@ +import { Extension } from "evite" import { Howl } from "howler" import config from "config" -export class SoundEngine { - constructor() { - this.sounds = {} - } - - initialize = async () => { - this.sounds = await this.getSounds() - } +export default class SoundEngineExtension extends Extension { + sounds = {} getSounds = async () => { // TODO: Load custom soundpacks manifests @@ -35,19 +30,14 @@ export class SoundEngine { return false } } -} -export const extension = { - key: "soundEngine", - expose: [ - { - initialization: [ - async (app, main) => { - app.SoundEngine = new SoundEngine() - main.setToWindowContext("SoundEngine", app.SoundEngine) - await app.SoundEngine.initialize() - } - ] + initializers = [ + async () => { + this.sounds = await this.getSounds() } ] + + window = { + SoundEngine: this + } } \ No newline at end of file diff --git a/packages/app/src/extensions/splash/index.jsx b/packages/app/src/extensions/splash/index.jsx deleted file mode 100644 index e5200e48..00000000 --- a/packages/app/src/extensions/splash/index.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from "react" -import ReactDOM from "react-dom" - -import "./index.less" - -export const SplashComponent = ({ props = {}, logo }) => { - return ( -
-
- -
-
- ) -} - -export const extension = (params = {}) => { - return { - key: "splash", - expose: [ - { - initialization: [ - async (app, main) => { - const fadeOutVelocity = params.velocity ?? 1000 //on milliseconds - const splashElement = document.createElement("div") - - splashElement.style = ` - position: absolute; - top: 0; - left: 0; - - width: 100vw; - height: 100vh; - ` - - const show = () => { - document.body.appendChild(splashElement) - ReactDOM.render(, splashElement) - } - - const removeSplash = () => { - splashElement.style.animation = `${params.preset ?? "fadeOut"} ${fadeOutVelocity}ms` - - setTimeout(() => { - splashElement.remove() - }, fadeOutVelocity) - } - - main.eventBus.on("splash_show", show) - main.eventBus.on("splash_close", removeSplash) - }, - ], - }, - ], - } -} - -export default extension \ No newline at end of file diff --git a/packages/app/src/extensions/splash/index.less b/packages/app/src/extensions/splash/index.less deleted file mode 100644 index d6d2e6a3..00000000 --- a/packages/app/src/extensions/splash/index.less +++ /dev/null @@ -1,44 +0,0 @@ -.splash_wrapper { - overflow: hidden; - - //background-color: rgba(240, 242, 245, 0.8); - backdrop-filter: blur(10px); - --webkit-backdrop-filter: blur(10px); - - width: 100%; - height: 100%; - z-index: 1000; - - display: flex; - flex-direction: column; - - align-items: center; - justify-content: center; -} - -.splash_logo { - width: 100%; - height: 100%; - - display: flex; - flex-direction: column; - - align-items: center; - justify-content: center; - - img { - width: fit-content; - max-width: 50%; - max-height: 50%; - filter: drop-shadow(14px 10px 10px rgba(128, 128, 128, 0.5)); - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } -} \ No newline at end of file diff --git a/packages/app/src/extensions/render/statics/404/index.jsx b/packages/app/src/extensions/staticsRenders/404/index.jsx similarity index 100% rename from packages/app/src/extensions/render/statics/404/index.jsx rename to packages/app/src/extensions/staticsRenders/404/index.jsx diff --git a/packages/app/src/extensions/render/statics/crash/index.jsx b/packages/app/src/extensions/staticsRenders/crash/index.jsx similarity index 100% rename from packages/app/src/extensions/render/statics/crash/index.jsx rename to packages/app/src/extensions/staticsRenders/crash/index.jsx diff --git a/packages/app/src/extensions/render/statics/crash/index.less b/packages/app/src/extensions/staticsRenders/crash/index.less similarity index 100% rename from packages/app/src/extensions/render/statics/crash/index.less rename to packages/app/src/extensions/staticsRenders/crash/index.less diff --git a/packages/app/src/extensions/theme/index.jsx b/packages/app/src/extensions/theme.extension.jsx similarity index 61% rename from packages/app/src/extensions/theme/index.jsx rename to packages/app/src/extensions/theme.extension.jsx index 46cfc6f9..adab64ad 100644 --- a/packages/app/src/extensions/theme/index.jsx +++ b/packages/app/src/extensions/theme.extension.jsx @@ -1,10 +1,11 @@ +import { Extension } from "evite" import config from "config" import store from "store" import { ConfigProvider } from "antd" -export class ThemeController { - constructor(params) { - this.params = { ...params } +export default class ThemeExtension extends Extension { + constructor(app, main) { + super(app, main) this.themeManifestStorageKey = "theme" this.modificationStorageKey = "themeModifications" @@ -14,45 +15,59 @@ export class ThemeController { this.mutation = null this.currentVariant = null - - this.init() - - return this } + initializers = [ + async () => { + this.mainContext.eventBus.on("darkMode", (value) => { + if (value) { + this.applyVariant("dark") + } else { + this.applyVariant("light") + } + }) + this.mainContext.eventBus.on("modifyTheme", (value) => { + this.update(value) + this.setModifications(this.mutation) + }) + + this.mainContext.eventBus.on("resetTheme", () => { + this.resetDefault() + }) + + let theme = this.getStoragedTheme() + const modifications = this.getStoragedModifications() + const variantKey = this.getStoragedVariant() + + if (!theme) { + // load default theme + theme = this.getDefaultTheme() + } else { + // load URL and initialize theme + } + + // set global theme + this.theme = theme + + // override with static vars + if (theme.staticVars) { + this.update(theme.staticVars) + } + + // override theme with modifications + if (modifications) { + this.update(modifications) + } + + // apply variation + this.applyVariant(variantKey) + }, + ] + static get currentVariant() { return document.documentElement.style.getPropertyValue("--themeVariant") } - init = () => { - let theme = this.getStoragedTheme() - const modifications = this.getStoragedModifications() - const variantKey = this.getStoragedVariant() - - if (!theme) { - // load default theme - theme = this.getDefaultTheme() - } else { - // load URL and initialize theme - } - - // set global theme - this.theme = theme - - // override with static vars - if (theme.staticVars) { - this.update(theme.staticVars) - } - - // override theme with modifications - if (modifications) { - this.update(modifications) - } - - // apply variation - this.applyVariant(variantKey) - } - getRootVariables = () => { let attributes = document.documentElement.getAttribute("style").trim().split(";") attributes = attributes.slice(0, (attributes.length - 1)) @@ -130,36 +145,8 @@ export class ThemeController { this.setVariant(variant) } } -} -export const extension = { - key: "theme", - expose: [ - { - initialization: [ - async (app, main) => { - app.ThemeController = new ThemeController() - - main.eventBus.on("darkMode", (value) => { - if (value) { - app.ThemeController.applyVariant("dark") - } else { - app.ThemeController.applyVariant("light") - } - }) - main.eventBus.on("modifyTheme", (value) => { - app.ThemeController.update(value) - app.ThemeController.setModifications(app.ThemeController.mutation) - }) - main.eventBus.on("resetTheme", () => { - app.ThemeController.resetDefault() - }) - - main.setToWindowContext("ThemeController", app.ThemeController) - }, - ], - }, - ], -} - -export default extension \ No newline at end of file + window = { + ThemeController: this + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/i18n/translations/en.json b/packages/app/src/extensions/translations/en.json similarity index 100% rename from packages/app/src/extensions/i18n/translations/en.json rename to packages/app/src/extensions/translations/en.json diff --git a/packages/app/src/extensions/i18n/translations/es.json b/packages/app/src/extensions/translations/es.json similarity index 100% rename from packages/app/src/extensions/i18n/translations/es.json rename to packages/app/src/extensions/translations/es.json