chore: split app to commons and web (squash commit)

This commit is contained in:
Andrew Bastin
2022-12-02 02:57:46 -05:00
parent fb827e3586
commit 3d004f2322
535 changed files with 1487 additions and 501 deletions

View File

@@ -0,0 +1,32 @@
import { HoppModule } from "."
export const showChat = () => {
;(window as any).$crisp.push([
"do",
"chat:show",
(window as any).$crisp.push(["do", "chat:open"]),
])
}
export default <HoppModule>{
onVueAppInit() {
// TODO: Env variable this ?
;(window as any).$crisp = []
;(window as any).CRISP_WEBSITE_ID = "3ad30257-c192-4773-955d-fb05a4b41af3"
const d = document
const s = d.createElement("script")
s.src = "https://client.crisp.chat/l.js"
s.async = true
d.getElementsByTagName("head")[0].appendChild(s)
;(window as any).$crisp.push(["do", "chat:hide"])
;(window as any).$crisp.push([
"on",
"chat:closed",
() => {
;(window as any).$crisp.push(["do", "chat:hide"])
},
])
},
}

View File

@@ -0,0 +1,21 @@
import { createHead, useHead } from "@vueuse/head"
import { APP_INFO } from "~/../meta"
import { HoppModule } from "."
export default <HoppModule>{
onVueAppInit(app) {
const head = createHead({
title: `${APP_INFO.name}${APP_INFO.shortDescription}`,
titleTemplate(title) {
return title === "Hoppscotch" ? title : `${title} • Hoppscotch`
},
})
app.use(head)
},
onRootSetup() {
// Load the defaults into the app
useHead({})
},
}

View File

@@ -0,0 +1,58 @@
import { HoppModule } from "."
import {
changeExtensionStatus,
ExtensionStatus,
} from "~/newstore/HoppExtension"
import { ref } from "vue"
import { defineSubscribableObject } from "~/helpers/strategies/ExtensionStrategy"
/* Module defining the hooking mechanism between Hoppscotch and the Hoppscotch Browser Extension */
export default <HoppModule>{
onVueAppInit() {
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
changeExtensionStatus(window.__HOPP_EXTENSION_STATUS_PROXY__.status)
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
"status",
(status: ExtensionStatus) => changeExtensionStatus(status)
)
} else {
const statusProxy = defineSubscribableObject({
status: "waiting" as ExtensionStatus,
})
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
statusProxy.subscribe("status", (status: ExtensionStatus) =>
changeExtensionStatus(status)
)
/**
* Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
* then we use a poll to find the version, this will get the version for 0.24 and any other version
* of the extension, but will have a slight lag.
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/
extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
if (extensionPollIntervalId.value)
clearInterval(extensionPollIntervalId.value)
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
if (
version.major === 0 &&
version.minor <= 23 &&
window.__HOPP_EXTENSION_STATUS_PROXY__
) {
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
}
}
}, 2000)
}
},
}

View File

@@ -0,0 +1,156 @@
import * as R from "fp-ts/Record"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { createI18n, I18n, I18nOptions } from "vue-i18n"
import { HoppModule } from "."
import languages from "../../languages.json"
import en from "../../locales/en.json"
import { throwError } from "~/helpers/functional/error"
import { getLocalConfig, setLocalConfig } from "~/newstore/localpersistence"
/*
In context of this file, we have 2 main kinds of things.
1. Locale -> A locale is termed as the i18n entries present in the /locales folder
2. Language -> A language is an entry in the /languages.json folder
Each language entry should correspond to a locale entry.
*/
/*
* As we migrate out of Nuxt I18n into our own system for i18n management,
* Some stuff has changed regarding how it works.
*
* The previous system works by using paths to represent locales to load.
* Basically, /es/realtime will load the /realtime page but with 'es' language
*
* In the new system instead of relying on the lang code, we store the language
* in the application local config store (localStorage). The URLs don't have
* a locale path effect
*/
// TODO: Syncing into settings ?
const LOCALES = import.meta.glob("../../locales/*.json")
type LanguagesDef = {
code: string
file: string
iso: string
name: string
dir?: "ltr" | "rtl" // Text Orientation (defaults to 'ltr')
}
const FALLBACK_LANG_CODE = "en"
// TypeScript cannot understand dir is restricted to "ltr" or "rtl" yet, hence assertion
export const APP_LANGUAGES: LanguagesDef[] = languages as LanguagesDef[]
export const APP_LANG_CODES = languages.map(({ code }) => code)
export const FALLBACK_LANG = pipe(
APP_LANGUAGES,
A.findFirst((x) => x.code === FALLBACK_LANG_CODE),
O.getOrElseW(() =>
throwError(`Could not find the fallback language '${FALLBACK_LANG_CODE}'`)
)
)
// A reference to the i18n instance
let i18nInstance: I18n<any, any, any> | null = null
const resolveCurrentLocale = () =>
pipe(
// Resolve from locale and make sure it is in languages
getLocalConfig("locale"),
O.fromNullable,
O.filter((locale) =>
pipe(
APP_LANGUAGES,
A.some(({ code }) => code === locale)
)
),
// Else load from navigator.language
O.alt(() =>
pipe(
APP_LANGUAGES,
A.findFirst(({ code }) => navigator.language.startsWith(code)), // en-US should also match to en
O.map(({ code }) => code)
)
),
// Else load fallback
O.getOrElse(() => FALLBACK_LANG_CODE)
)
/**
* Changes the application language. This function returns a promise as
* the locale files are lazy loaded on demand
* @param locale The locale code of the language to load
*/
export const changeAppLanguage = async (locale: string) => {
const localeData = (
(await pipe(
LOCALES,
R.lookup(`../../locales/${locale}.json`),
O.getOrElseW(() =>
throwError(
`Tried to change app language to non-existent locale '${locale}'`
)
)
)()) as any
).default
if (!i18nInstance) {
throw new Error("Tried to change language without active i18n instance")
}
i18nInstance.global.setLocaleMessage(locale, localeData)
// TODO: Look into the type issues here
i18nInstance.global.locale.value = locale
setLocalConfig("locale", locale)
}
export default <HoppModule>{
onVueAppInit(app) {
const i18n = createI18n(<I18nOptions>{
locale: "en", // TODO: i18n system!
fallbackLocale: "en",
legacy: false,
allowComposition: true,
// TODO: Fix this to allow for dynamic imports
messages: {
en,
},
})
app.use(i18n)
i18nInstance = i18n
// TODO: Global loading state to hide the resolved lang loading
const currentLocale = resolveCurrentLocale()
changeAppLanguage(currentLocale)
setLocalConfig("locale", currentLocale)
},
onBeforeRouteChange(to, _, router) {
// Convert old locale path format to new format
const oldLocalePathLangCode = APP_LANG_CODES.find((langCode) =>
to.path.startsWith(`/${langCode}/`)
)
// Change language to the correct lang code
if (oldLocalePathLangCode) {
changeAppLanguage(oldLocalePathLangCode)
router.replace(to.path.substring(`/${oldLocalePathLangCode}`.length))
}
},
}

View File

@@ -0,0 +1,52 @@
import { App } from "vue"
import { pipe } from "fp-ts/function"
import * as A from "fp-ts/Array"
import { RouteLocationNormalized, Router } from "vue-router"
export type HoppModule = {
/**
* Define this function to get access to Vue App instance and augment
* it (installing components, directives and plugins). Also useful for
* early generic initializations. This function should be called first
*/
onVueAppInit?: (app: App) => void
/**
* Called when the router is done initializing.
* Used if a module requires access to the router instance
*/
onRouterInit?: (app: App, router: Router) => void
/**
* Called when the root component (App.vue) is running setup.
* This function is generally called last in the lifecycle.
* This function executes with a component setup context, so you can
* run composables within this and it should just be scoped to the
* root component
*/
onRootSetup?: () => void
/**
* Called by the router to tell all the modules before a route navigation
* is made.
*/
onBeforeRouteChange?: (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
router: Router
) => void
/**
* Called by the router to tell all the modules that a route navigation has completed
*/
onAfterRouteChange?: (to: RouteLocationNormalized, router: Router) => void
}
/**
* All the modules Hoppscotch loads into the app
*/
export const HOPP_MODULES = pipe(
import.meta.glob("@modules/*.ts", { eager: true }),
Object.values,
A.map(({ default: defaultVal }) => defaultVal as HoppModule)
)

View File

@@ -0,0 +1,56 @@
import { HoppModule } from "."
import NProgress from "nprogress"
let deferedProgressHandle: ReturnType<typeof setTimeout> | null = null
/**
* Starts animating the global progress bar
* @param deferTime How much time to defer the global progress bar rendering to
*/
export const startPageProgress = (deferTime?: number) => {
if (deferedProgressHandle) clearTimeout(deferedProgressHandle)
// If deferTime is specified, queue it
if (deferTime !== undefined) {
deferedProgressHandle = setTimeout(() => {
NProgress.start()
}, deferTime)
return
}
NProgress.start()
}
export const completePageProgress = () => {
if (deferedProgressHandle) {
clearTimeout(deferedProgressHandle)
deferedProgressHandle = null
}
NProgress.done()
}
export const removePageProgress = () => {
if (deferedProgressHandle) {
clearTimeout(deferedProgressHandle)
deferedProgressHandle = null
}
NProgress.remove()
}
export default <HoppModule>{
onVueAppInit() {
NProgress.configure({ showSpinner: false })
},
onBeforeRouteChange(to, from) {
// Show progressbar on page change
if (to.path !== from.path) {
startPageProgress(500)
}
},
onAfterRouteChange() {
completePageProgress()
},
}

View File

@@ -0,0 +1,78 @@
import { HoppModule } from "."
import { ref, onMounted } from "vue"
import { usePwaPrompt } from "@composables/pwa"
import { registerSW } from "virtual:pwa-register"
export type HoppPWARegistrationStatus =
| { status: "NOT_INSTALLED" }
| { status: "INSTALLED"; registration: ServiceWorkerRegistration | undefined }
| { status: "INSTALL_FAILED"; error: any }
export const pwaNeedsRefresh = ref(false)
export const pwaReadyForOffline = ref(false)
export const pwaDefferedPrompt = ref<Event | null>(null)
export const pwaRegistered = ref<HoppPWARegistrationStatus>({
status: "NOT_INSTALLED",
})
let updateApp: (reloadPage?: boolean) => Promise<void> | undefined
export const refreshAppForPWAUpdate = async () => {
await updateApp?.(true)
}
export const installPWA = async () => {
if (pwaDefferedPrompt.value) {
;(pwaDefferedPrompt.value as any).prompt()
const { outcome }: { outcome: string } = await (
pwaDefferedPrompt.value as any
).userChoice
if (outcome === "accepted") {
console.info("Hoppscotch was installed successfully.")
} else {
console.info(
"Hoppscotch could not be installed. (Installation rejected by user.)"
)
}
pwaDefferedPrompt.value = null
}
}
// TODO: Update install prompt stuff
export default <HoppModule>{
onVueAppInit() {
window.addEventListener("beforeinstallprompt", (event) => {
pwaDefferedPrompt.value = event
})
updateApp = registerSW({
immediate: true,
onNeedRefresh() {
pwaNeedsRefresh.value = true
},
onOfflineReady() {
pwaReadyForOffline.value = true
},
onRegistered(registration) {
pwaRegistered.value = {
status: "INSTALLED",
registration,
}
},
onRegisterError(error) {
pwaRegistered.value = {
status: "INSTALL_FAILED",
error,
}
},
})
},
onRootSetup() {
onMounted(() => {
usePwaPrompt()
})
},
}

View File

@@ -0,0 +1,75 @@
import { HoppModule, HOPP_MODULES } from "."
import {
createRouter,
createWebHistory,
RouteLocationNormalized,
} from "vue-router"
import { setupLayouts } from "virtual:generated-layouts"
import generatedRoutes from "virtual:generated-pages"
import { logPageView } from "~/helpers/fb/analytics"
import { readonly, ref } from "vue"
const routes = setupLayouts(generatedRoutes)
/**
* A reactive value signifying whether we are currently navigating
* into the first route the application is routing into.
* Useful, if you want to do stuff for the initial page load (for example splash screens!)
*/
const _isLoadingInitialRoute = ref(false)
/**
* Says whether a given route looks like an initial route which
* is loaded as the first route.
*
* NOTE: This function assumes Vue Router represents that initial route
* in the way we expect (fullPath == "/" and name == undefined). If this
* function breaks later on, most probs vue-router updated its semantics
* and we have to correct this function.
*/
function isInitialRoute(route: RouteLocationNormalized) {
return route.fullPath === "/" && route.name === undefined
}
/**
* A reactive value signifying whether we are currently navigating
* into the first route the application is routing into.
* Useful, if you want to do stuff for the initial page load (for example splash screens!)
*
* NOTE: This reactive value is READONLY
*/
export const isLoadingInitialRoute = readonly(_isLoadingInitialRoute)
export default <HoppModule>{
onVueAppInit(app) {
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from) => {
_isLoadingInitialRoute.value = isInitialRoute(from)
HOPP_MODULES.forEach((mod) => {
mod.onBeforeRouteChange?.(to, from, router)
})
})
// Instead of this a better architecture is for the router
// module to expose a stream of router events that can be independently
// subbed to
router.afterEach((to) => {
logPageView(to.fullPath)
_isLoadingInitialRoute.value = false
HOPP_MODULES.forEach((mod) => {
mod.onAfterRouteChange?.(to, router)
})
})
app.use(router)
HOPP_MODULES.forEach((mod) => mod.onRouterInit?.(app, router))
},
}

View File

@@ -0,0 +1,198 @@
import { HoppModule } from "."
import * as Sentry from "@sentry/vue"
import { BrowserTracing } from "@sentry/tracing"
import { Route } from "@sentry/vue/types/router"
import { RouteLocationNormalized, Router } from "vue-router"
import { settingsStore } from "~/newstore/settings"
import { App } from "vue"
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
import { currentUser$ } from "~/helpers/fb/auth"
/**
* The tag names we allow giving to Sentry
*/
type SentryTag = "BACKEND_OPERATIONS"
interface SentryVueRouter {
onError: (fn: (err: Error) => void) => void
beforeEach: (fn: (to: Route, from: Route, next: () => void) => void) => void
}
function normalizedRouteToSentryRoute(route: RouteLocationNormalized): Route {
return {
matched: route.matched,
// route.params' type translates just to a fancy version of this, hence assertion
params: route.params as Route["params"],
path: route.path,
// route.query's type translates just to a fancy version of this, hence assertion
query: route.query as Route["query"],
name: route.name,
}
}
function getInstrumentationVueRouter(router: Router): SentryVueRouter {
return <SentryVueRouter>{
onError: router.onError,
beforeEach(func) {
router.beforeEach((to, from, next) => {
func(
normalizedRouteToSentryRoute(to),
normalizedRouteToSentryRoute(from),
next
)
})
},
}
}
let sentryActive = false
function initSentry(dsn: string, router: Router, app: App) {
Sentry.init({
app,
dsn,
release: import.meta.env.VITE_SENTRY_RELEASE_TAG ?? undefined,
environment: APP_IS_IN_DEV_MODE
? "dev"
: import.meta.env.VITE_SENTRY_ENVIRONMENT,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(
getInstrumentationVueRouter(router)
),
// TODO: We may want to limit this later on
tracingOrigins: [new URL(import.meta.env.VITE_BACKEND_GQL_URL).origin],
}),
],
tracesSampleRate: 0.8,
})
sentryActive = true
}
function deinitSentry() {
Sentry.close()
sentryActive = false
}
/**
* Reports a set of related errors to Sentry
* @param errs The errors to report
* @param tag The tag for the errord
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportErrors(
errs: Error[],
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
if (sentryActive) {
Sentry.withScope((scope) => {
scope.setTag("tag", tag)
if (extraTags) {
Object.entries(extraTags).forEach(([key, value]) => {
scope.setTag(key, value)
})
}
if (extras !== null && extras === undefined) scope.setExtras(extras)
scope.addAttachment({
filename: "extras-dump.json",
data: JSON.stringify(extras),
contentType: "application/json",
})
errs.forEach((err) => Sentry.captureException(err))
})
}
}
/**
* Reports a specific error to Sentry
* @param err The error to report
* @param tag The tag for the error
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportError(
err: Error,
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
reportErrors([err], tag, extraTags, extras)
}
/**
* Subscribes to events occuring in various subsystems in the app
* for personalized error reporting
*/
function subscribeToAppEventsForReporting() {
gqlClientError$.subscribe((ev) => {
switch (ev.type) {
case "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT":
reportErrors(ev.errors, "BACKEND_OPERATIONS", { from: ev.type })
break
case "CLIENT_REPORTED_ERROR":
reportError(
ev.error,
"BACKEND_OPERATIONS",
{ from: ev.type },
{ op: ev.op }
)
break
case "GQL_CLIENT_REPORTED_ERROR":
reportError(
new Error("Backend Query Failed"),
"BACKEND_OPERATIONS",
{ opType: ev.opType },
{
opResult: ev.opResult,
}
)
break
}
})
}
/**
* Subscribe to app system events for adding
* additional data tags for the error reporting
*/
function subscribeForAppDataTags() {
currentUser$.subscribe((user) => {
if (sentryActive) {
Sentry.setTag("user_logged_in", !!user)
}
})
}
export default <HoppModule>{
onRouterInit(app, router) {
if (!import.meta.env.VITE_SENTRY_DSN) {
console.log(
"Sentry tracing is not enabled because 'VITE_SENTRY_DSN' env is not defined"
)
return
}
if (settingsStore.value.TELEMETRY_ENABLED) {
initSentry(import.meta.env.VITE_SENTRY_DSN, router, app)
}
settingsStore.subject$.subscribe(({ TELEMETRY_ENABLED }) => {
if (!TELEMETRY_ENABLED && sentryActive) {
deinitSentry()
} else if (TELEMETRY_ENABLED && !sentryActive) {
initSentry(import.meta.env.VITE_SENTRY_DSN!, router, app)
}
})
subscribeToAppEventsForReporting()
subscribeForAppDataTags()
},
}

View File

@@ -0,0 +1,90 @@
import { usePreferredDark, useStorage } from "@vueuse/core"
import { App, computed, reactive, Ref, watch } from "vue"
import type { HoppBgColor } from "~/newstore/settings"
import { useSettingStatic } from "@composables/settings"
import { HoppModule } from "."
import { hoppLocalConfigStorage } from "~/newstore/localpersistence"
export type HoppColorMode = {
preference: HoppBgColor
value: Readonly<Exclude<HoppBgColor, "system">>
}
const applyColorMode = (app: App) => {
const [settingPref] = useSettingStatic("BG_COLOR")
const currentLocalPreference = useStorage<HoppBgColor>(
"nuxt-color-mode",
"system",
hoppLocalConfigStorage,
{
listenToStorageChanges: true,
}
)
const systemPrefersDark = usePreferredDark()
const selection = computed<Exclude<HoppBgColor, "system">>(() => {
if (currentLocalPreference.value === "system") {
return systemPrefersDark.value ? "dark" : "light"
} else return currentLocalPreference.value
})
watch(
selection,
(newSelection) => {
document.documentElement.setAttribute("class", newSelection)
},
{ immediate: true }
)
watch(
settingPref,
(newPref) => {
currentLocalPreference.value = newPref
},
{ immediate: true }
)
const exposed: HoppColorMode = reactive({
preference: currentLocalPreference,
// Marking as readonly to not allow writes to this ref
value: selection as Readonly<Ref<Exclude<HoppBgColor, "system">>>,
})
app.provide("colorMode", exposed)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const applyAccentColor = (_app: App) => {
const [pref] = useSettingStatic("THEME_COLOR")
watch(
pref,
(newPref) => {
document.documentElement.setAttribute("data-accent", newPref)
},
{ immediate: true }
)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const applyFontSize = (_app: App) => {
const [pref] = useSettingStatic("FONT_SIZE")
watch(
pref,
(newPref) => {
document.documentElement.setAttribute("data-font-size", newPref)
},
{ immediate: true }
)
}
export default <HoppModule>{
onVueAppInit(app) {
applyColorMode(app)
applyAccentColor(app)
applyFontSize(app)
},
}

View File

@@ -0,0 +1,32 @@
import { HoppModule } from "."
import VueTippy, { roundArrow, setDefaultProps } from "vue-tippy"
import "tippy.js/dist/tippy.css"
import "tippy.js/animations/scale-subtle.css"
import "tippy.js/dist/border.css"
import "tippy.js/dist/svg-arrow.css"
export default <HoppModule>{
onVueAppInit(app) {
app.use(VueTippy)
setDefaultProps({
animation: "scale-subtle",
appendTo: document.body,
allowHTML: false,
animateFill: false,
arrow: roundArrow + roundArrow,
popperOptions: {
// https://popper.js.org/docs/v2/utils/detect-overflow/
modifiers: [
{
name: "preventOverflow",
options: {
rootBoundary: "document",
},
},
],
},
})
},
}

View File

@@ -0,0 +1,19 @@
import Toasted from "@hoppscotch/vue-toasted"
import type { ToastOptions } from "@hoppscotch/vue-toasted"
import { HoppModule } from "."
import "@hoppscotch/vue-toasted/style.css"
// We are using a fork of Vue Toasted (github.com/clayzar/vue-toasted) which is a bit of
// an untrusted fork, we will either want to make our own fork or move to a more stable one
// The original Vue Toasted doesn't support Vue 3 and the OP has been irresponsive.
export default <HoppModule>{
onVueAppInit(app) {
app.use(Toasted, <ToastOptions>{
position: "bottom-center",
duration: 3000,
keepOnHover: true,
})
},
}

View File

@@ -0,0 +1,15 @@
import { nextTick } from "vue"
import { HoppModule } from "."
/*
Declares a `v-focus` directive that can be used for components
to acquire focus instantly once mounted
*/
export default <HoppModule>{
onVueAppInit(app) {
app.directive("focus", {
mounted: (el) => nextTick(() => el.focus()),
})
},
}