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