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,71 @@
import {
currentUser$,
HoppUser,
AuthEvent,
authEvents$,
authIdToken$,
} from "@helpers/fb/auth"
import {
map,
distinctUntilChanged,
filter,
Subscription,
combineLatestWith,
} from "rxjs"
import { onBeforeUnmount, onMounted } from "vue"
/**
* A Vue composable function that is called when the auth status
* is being updated to being logged in (fired multiple times),
* this is also called on component mount if the login
* was already resolved before mount.
*/
export function onLoggedIn(exec: (user: HoppUser) => void) {
let sub: Subscription | null = null
onMounted(() => {
sub = currentUser$
.pipe(
// We don't consider the state as logged in unless we also have an id token
combineLatestWith(authIdToken$),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
filter(([_, token]) => !!token),
map((user) => !!user), // Get a logged in status (true or false)
distinctUntilChanged(), // Don't propagate unless the status updates
filter((x) => x) // Don't propagate unless it is logged in
)
.subscribe(() => {
exec(currentUser$.value!)
})
})
onBeforeUnmount(() => {
sub?.unsubscribe()
})
}
/**
* A Vue composable function that calls its param function
* when a new event (login, logout etc.) happens in
* the auth system.
*
* NOTE: Unlike `onLoggedIn` for which the callback will be called once on mount with the current state,
* here the callback will only be called on authentication event occurances.
* You might want to check the auth state from an `onMounted` hook or something
* if you want to access the initial state
*
* @param func A function which accepts an event
*/
export function onAuthEvent(func: (ev: AuthEvent) => void) {
let sub: Subscription | null = null
onMounted(() => {
sub = authEvents$.subscribe((ev) => {
func(ev)
})
})
onBeforeUnmount(() => {
sub?.unsubscribe()
})
}

View File

@@ -0,0 +1,372 @@
import {
keymap,
EditorView,
ViewPlugin,
ViewUpdate,
placeholder,
} from "@codemirror/view"
import {
Extension,
EditorState,
Compartment,
EditorSelection,
} from "@codemirror/state"
import {
Language,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from "@codemirror/language"
import { defaultKeymap, indentLess, insertTab } from "@codemirror/commands"
import { Completion, autocompletion } from "@codemirror/autocomplete"
import { linter } from "@codemirror/lint"
import { watch, ref, Ref, onMounted, onBeforeUnmount } from "vue"
import { javascriptLanguage } from "@codemirror/lang-javascript"
import { xmlLanguage } from "@codemirror/lang-xml"
import { jsonLanguage } from "@codemirror/lang-json"
import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
import { html } from "@codemirror/legacy-modes/mode/xml"
import { shell } from "@codemirror/legacy-modes/mode/shell"
import { yaml } from "@codemirror/legacy-modes/mode/yaml"
import { isJSONContentType } from "@helpers/utils/contenttypes"
import { useStreamSubscriber } from "@composables/stream"
import { Completer } from "@helpers/editor/completion"
import { LinterDefinition } from "@helpers/editor/linting/linter"
import {
basicSetup,
baseTheme,
baseHighlightStyle,
} from "@helpers/editor/themes/baseTheme"
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
// TODO: Migrate from legacy mode
type ExtendedEditorConfig = {
mode: string
placeholder: string
readOnly: boolean
lineWrapping: boolean
}
type CodeMirrorOptions = {
extendedEditorConfig: Partial<ExtendedEditorConfig>
linter: LinterDefinition | null
completer: Completer | null
// NOTE: This property is not reactive
environmentHighlights: boolean
}
const hoppCompleterExt = (completer: Completer): Extension => {
return autocompletion({
override: [
async (context) => {
// Expensive operation! Disable on bigger files ?
const text = context.state.doc.toJSON().join(context.state.lineBreak)
const line = context.state.doc.lineAt(context.pos)
const lineStart = line.from
const lineNo = line.number - 1
const ch = context.pos - lineStart
// Only do trigger on type when typing a word token, else stop (unless explicit)
if (!context.matchBefore(/\w+/) && !context.explicit)
return {
from: context.pos,
options: [],
}
const result = await completer(text, { line: lineNo, ch })
// Use more completion features ?
const completions =
result?.completions.map<Completion>((comp) => ({
label: comp.text,
detail: comp.meta,
})) ?? []
return {
from: context.state.wordAt(context.pos)?.from ?? context.pos,
options: completions,
}
},
],
})
}
const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
return linter(async (view) => {
if (!hoppLinter) return []
// Requires full document scan, hence expensive on big files, force disable on big files ?
const linterResult = await hoppLinter(
view.state.doc.toJSON().join(view.state.lineBreak)
)
return linterResult.map((result) => {
const startPos =
view.state.doc.line(result.from.line).from + result.from.ch - 1
const endPos = view.state.doc.line(result.to.line).from + result.to.ch - 1
return {
from: startPos < 0 ? 0 : startPos,
to: endPos > view.state.doc.length ? view.state.doc.length : endPos,
message: result.message,
severity: result.severity,
}
})
})
}
const hoppLang = (
language: Language | undefined,
linter?: LinterDefinition | undefined,
completer?: Completer | undefined
): Extension | LanguageSupport => {
const exts: Extension[] = []
exts.push(hoppLinterExt(linter))
if (completer) exts.push(hoppCompleterExt(completer))
return language ? new LanguageSupport(language, exts) : exts
}
const getLanguage = (langMime: string): Language | null => {
if (isJSONContentType(langMime)) {
return jsonLanguage
} else if (langMime === "application/javascript") {
return javascriptLanguage
} else if (langMime === "graphql") {
return GQLLanguage
} else if (langMime === "application/xml") {
return xmlLanguage
} else if (langMime === "htmlmixed") {
return StreamLanguage.define(html)
} else if (langMime === "application/x-sh") {
return StreamLanguage.define(shell)
} else if (langMime === "text/x-yaml") {
return StreamLanguage.define(yaml)
}
// None matched, so return null
return null
}
const getEditorLanguage = (
langMime: string,
linter: LinterDefinition | undefined,
completer: Completer | undefined
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
export function useCodemirror(
el: Ref<any | null>,
value: Ref<string | undefined>,
options: CodeMirrorOptions
): { cursor: Ref<{ line: number; ch: number }> } {
const { subscribeToStream } = useStreamSubscriber()
const language = new Compartment()
const lineWrapping = new Compartment()
const placeholderConfig = new Compartment()
const cachedCursor = ref({
line: 0,
ch: 0,
})
const cursor = ref({
line: 0,
ch: 0,
})
const cachedValue = ref(value.value)
const view = ref<EditorView>()
const environmentTooltip = options.environmentHighlights
? new HoppEnvironmentPlugin(subscribeToStream, view)
: null
const initView = (el: any) => {
const extensions = [
basicSetup,
baseTheme,
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
const cursorPos = update.state.selection.main.head
const line = update.state.doc.lineAt(cursorPos)
cachedCursor.value = {
line: line.number - 1,
ch: cursorPos - line.from,
}
cursor.value = {
line: cachedCursor.value.line,
ch: cachedCursor.value.ch,
}
if (update.docChanged) {
// Expensive on big files ?
cachedValue.value = update.state.doc
.toJSON()
.join(update.state.lineBreak)
if (!options.extendedEditorConfig.readOnly)
value.value = cachedValue.value
}
}
}
),
EditorView.updateListener.of((update) => {
if (options.extendedEditorConfig.readOnly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !options.extendedEditorConfig.readOnly),
placeholderConfig.of(
placeholder(options.extendedEditorConfig.placeholder ?? "")
),
language.of(
getEditorLanguage(
options.extendedEditorConfig.mode ?? "",
options.linter ?? undefined,
options.completer ?? undefined
)
),
lineWrapping.of(
options.extendedEditorConfig.lineWrapping
? [EditorView.lineWrapping]
: []
),
keymap.of([
...defaultKeymap,
{
key: "Tab",
preventDefault: true,
run: insertTab,
},
{
key: "Shift-Tab",
preventDefault: true,
run: indentLess,
},
]),
]
if (environmentTooltip) extensions.push(environmentTooltip.extension)
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: value.value,
extensions,
}),
})
}
onMounted(() => {
if (el.value) {
if (!view.value) initView(el.value)
}
})
watch(el, () => {
if (el.value) {
if (view.value) view.value.destroy()
initView(el.value)
} else {
view.value?.destroy()
view.value = undefined
}
})
onBeforeUnmount(() => {
view.value?.destroy()
})
watch(value, (newVal) => {
if (newVal === undefined) {
view.value?.destroy()
view.value = undefined
return
}
if (!view.value && el.value) {
initView(el.value)
}
if (cachedValue.value !== newVal) {
view.value?.dispatch({
filter: false,
changes: {
from: 0,
to: view.value.state.doc.length,
insert: newVal,
},
})
}
cachedValue.value = newVal
})
watch(
() => [
options.extendedEditorConfig.mode,
options.linter,
options.completer,
],
() => {
view.value?.dispatch({
effects: language.reconfigure(
getEditorLanguage(
(options.extendedEditorConfig.mode as any) ?? "",
options.linter ?? undefined,
options.completer ?? undefined
)
),
})
}
)
watch(
() => options.extendedEditorConfig.lineWrapping,
(newMode) => {
view.value?.dispatch({
effects: lineWrapping.reconfigure(
newMode ? [EditorView.lineWrapping] : []
),
})
}
)
watch(
() => options.extendedEditorConfig.placeholder,
(newValue) => {
view.value?.dispatch({
effects: placeholderConfig.reconfigure(placeholder(newValue ?? "")),
})
}
)
watch(cursor, (newPos) => {
if (view.value) {
if (
cachedCursor.value.line !== newPos.line ||
cachedCursor.value.ch !== newPos.ch
) {
const line = view.value.state.doc.line(newPos.line + 1)
const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1)
view.value?.focus()
view.value.dispatch({
scrollIntoView: true,
selection: selUpdate,
effects: EditorView.scrollIntoView(selUpdate),
})
}
}
})
return {
cursor,
}
}

View File

@@ -0,0 +1,216 @@
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import {
reactive,
ref,
Ref,
unref,
isRef,
watchEffect,
WatchStopHandle,
watchSyncEffect,
} from "vue"
import {
client,
GQLError,
parseGQLErrorString,
} from "@helpers/backend/GQLClient"
import {
createRequest,
GraphQLRequest,
OperationResult,
TypedDocumentNode,
} from "@urql/core"
import { Source, pipe as wonkaPipe, onEnd, subscribe } from "wonka"
type MaybeRef<X> = X | Ref<X>
type UseQueryOptions<T = any, V = object> = {
query: TypedDocumentNode<T, V>
variables?: MaybeRef<V>
updateSubs?: MaybeRef<GraphQLRequest<any, object>[]>
defer?: boolean
pollDuration?: number | undefined
}
export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
_args: UseQueryOptions<DocType, DocVarType>
) => {
const stops: WatchStopHandle[] = []
const args = reactive(_args)
const loading: Ref<boolean> = ref(true)
const isStale: Ref<boolean> = ref(true)
const data: Ref<E.Either<GQLError<DocErrorType>, DocType>> = ref() as any
if (!args.updateSubs) args.updateSubs = []
const isPaused: Ref<boolean> = ref(args.defer ?? false)
const pollDuration: Ref<number | null> = ref(args.pollDuration ?? null)
const request: Ref<GraphQLRequest<DocType, DocVarType>> = ref(
createRequest<DocType, DocVarType>(
args.query,
unref<DocVarType>(args.variables as any) as any
)
) as any
const source: Ref<Source<OperationResult> | undefined> = ref()
// A ref used to force re-execution of the query
const updateTicker: Ref<boolean> = ref(true)
// Toggles between true and false to cause the polling operation to tick
const pollerTick: Ref<boolean> = ref(true)
stops.push(
watchEffect((onInvalidate) => {
if (pollDuration.value !== null && !isPaused.value) {
const handle = setInterval(() => {
pollerTick.value = !pollerTick.value
}, pollDuration.value)
onInvalidate(() => {
clearInterval(handle)
})
}
})
)
stops.push(
watchEffect(
() => {
const newRequest = createRequest<DocType, DocVarType>(
args.query,
unref<DocVarType>(args.variables as any) as any
)
if (request.value.key !== newRequest.key) {
request.value = newRequest
}
},
{ flush: "pre" }
)
)
stops.push(
watchEffect(
() => {
// Just listen to the polling ticks
// eslint-disable-next-line no-unused-expressions
pollerTick.value
// Just keep track of update ticking, but don't do anything
// eslint-disable-next-line no-unused-expressions
updateTicker.value
source.value = !isPaused.value
? client.value.executeQuery<DocType, DocVarType>(request.value, {
requestPolicy: "network-only",
})
: undefined
},
{ flush: "pre" }
)
)
watchSyncEffect((onInvalidate) => {
if (source.value) {
loading.value = true
isStale.value = false
const invalidateStops = args.updateSubs!.map((sub) => {
return wonkaPipe(
client.value.executeSubscription(sub),
onEnd(() => {
if (source.value) execute()
}),
subscribe(() => {
return execute()
})
).unsubscribe
})
invalidateStops.push(
wonkaPipe(
source.value,
onEnd(() => {
loading.value = false
isStale.value = false
}),
subscribe((res) => {
if (res.operation.key === request.value.key) {
data.value = pipe(
// The target
res.data as DocType | undefined,
// Define what happens if data does not exist (it is an error)
E.fromNullable(
pipe(
// Take the network error value
res.error?.networkError,
// If it null, set the left to the generic error name
E.fromNullable(res.error?.message),
E.match(
// The left case (network error was null)
(gqlErr) =>
<GQLError<DocErrorType>>{
type: "gql_error",
error: parseGQLErrorString(
gqlErr ?? ""
) as DocErrorType,
},
// The right case (it was a GraphQL Error)
(networkErr) =>
<GQLError<DocErrorType>>{
type: "network_error",
error: networkErr,
}
)
)
)
)
loading.value = false
}
})
).unsubscribe
)
onInvalidate(() => invalidateStops.forEach((unsub) => unsub()))
}
})
const execute = (updatedVars?: DocVarType) => {
if (updatedVars) {
if (isRef(args.variables)) {
args.variables.value = updatedVars
} else {
args.variables = updatedVars
}
}
isPaused.value = false
updateTicker.value = !updateTicker.value
}
const pause = () => {
isPaused.value = true
}
const unpause = () => {
isPaused.value = false
}
const response = reactive({
loading,
data,
pause,
unpause,
isStale,
execute,
})
return response
}

View File

@@ -0,0 +1 @@
export { useHead as usePageHead } from "@vueuse/head"

View File

@@ -0,0 +1,6 @@
import { flow } from "fp-ts/function"
import { useI18n as _useI18n } from "vue-i18n"
export const useI18n = flow(_useI18n, (x) => x.t)
export const useFullI18n = _useI18n

View File

@@ -0,0 +1,135 @@
import { computed, ComputedRef, ref, Ref } from "vue"
import IconDownload from "~icons/lucide/download"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import { pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
import { useToast } from "./toast"
import { useI18n } from "./i18n"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { HoppRESTResponse } from "@helpers/types/HoppRESTResponse"
export function useCopyResponse(responseBodyText: Ref<any>) {
const toast = useToast()
const t = useI18n()
const copyIcon = refAutoReset(IconCopy, 1000)
const copyResponse = () => {
copyToClipboard(responseBodyText.value)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
return {
copyIcon,
copyResponse,
}
}
export type downloadResponseReturnType = (() => void) | Ref<any>
export function useDownloadResponse(
contentType: string,
responseBody: Ref<string | ArrayBuffer>
) {
const downloadIcon = refAutoReset(IconDownload, 1000)
const toast = useToast()
const t = useI18n()
const downloadResponse = () => {
const dataToWrite = responseBody.value
const file = new Blob([dataToWrite], { type: contentType })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
document.body.appendChild(a)
a.click()
downloadIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
}
return {
downloadIcon,
downloadResponse,
}
}
export function usePreview(
previewEnabledDefault: boolean,
responseBodyText: Ref<string>
): {
previewFrame: any
previewEnabled: Ref<boolean>
togglePreview: () => void
} {
const previewFrame = ref<any | null>(null)
const previewEnabled = ref(previewEnabledDefault)
const url = ref("")
const togglePreview = () => {
previewEnabled.value = !previewEnabled.value
if (previewEnabled.value) {
if (previewFrame.value.getAttribute("data-previewing-url") === url.value)
return
// Use DOMParser to parse document HTML.
const previewDocument = new DOMParser().parseFromString(
responseBodyText.value,
"text/html"
)
// Inject <base href="..."> tag to head, to fix relative CSS/HTML paths.
previewDocument.head.innerHTML =
`<base href="${url.value}">` + previewDocument.head.innerHTML
// Finally, set the iframe source to the resulting HTML.
previewFrame.value.srcdoc = previewDocument.documentElement.outerHTML
previewFrame.value.setAttribute("data-previewing-url", url.value)
}
}
return {
previewFrame,
previewEnabled,
togglePreview,
}
}
export function useResponseBody(response: HoppRESTResponse): {
responseBodyText: ComputedRef<string>
} {
const responseBodyText = computed(() => {
if (
response.type === "loading" ||
response.type === "network_fail" ||
response.type === "script_fail" ||
response.type === "fail"
)
return ""
if (typeof response.body === "string") return response.body
else {
const res = new TextDecoder("utf-8").decode(response.body)
// HACK: Temporary trailing null character issue from the extension fix
return res.replace(/\0+$/, "")
}
})
return {
responseBodyText,
}
}

View File

@@ -0,0 +1,31 @@
import { onBeforeUnmount, Ref, shallowRef } from "vue"
export function usePolled<T>(
pollDurationMS: number,
pollFunc: (stopPolling: () => void) => T
): Ref<T> {
let polling = true
let handle: ReturnType<typeof setInterval> | undefined
const stopPolling = () => {
if (handle) {
clearInterval(handle)
handle = undefined
polling = false
}
}
const result = shallowRef(pollFunc(stopPolling))
if (polling) {
handle = setInterval(() => {
result.value = pollFunc(stopPolling)
}, pollDurationMS)
}
onBeforeUnmount(() => {
if (polling) stopPolling()
})
return result
}

View File

@@ -0,0 +1,38 @@
import { watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { pwaNeedsRefresh, refreshAppForPWAUpdate } from "@modules/pwa"
export const usePwaPrompt = function () {
const toast = useToast()
const t = useI18n()
watch(
pwaNeedsRefresh,
(value) => {
if (value) {
toast.show(`${t("app.new_version_found")}`, {
duration: 0,
action: [
{
text: `${t("action.dismiss")}`,
onClick: (_, toastObject) => {
toastObject.goAway(0)
},
},
{
text: `${t("app.reload")}`,
onClick: (_, toastObject) => {
toastObject.goAway(0)
refreshAppForPWAUpdate()
},
},
],
})
}
},
{
immediate: true,
}
)
}

View File

@@ -0,0 +1,33 @@
import { customRef, onBeforeUnmount, Ref, watch } from "vue"
export function pluckRef<T, K extends keyof T>(ref: Ref<T>, key: K): Ref<T[K]> {
return customRef((track, trigger) => {
const stopWatching = watch(ref, (newVal, oldVal) => {
if (newVal[key] !== oldVal[key]) {
trigger()
}
})
onBeforeUnmount(() => {
stopWatching()
})
return {
get() {
track()
return ref.value[key]
},
set(value: T[K]) {
trigger()
ref.value = Object.assign(ref.value, { [key]: value })
},
}
})
}
export function pluckMultipleFromRef<T, K extends Array<keyof T>>(
sourceRef: Ref<T>,
keys: K
): { [key in K[number]]: Ref<T[key]> } {
return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any
}

View File

@@ -0,0 +1,44 @@
import { Ref } from "vue"
import { settingsStore, SettingsType } from "~/newstore/settings"
import { pluck, distinctUntilChanged } from "rxjs/operators"
import { useStream, useStreamStatic } from "./stream"
export function useSetting<K extends keyof SettingsType>(
settingKey: K
): Ref<SettingsType[K]> {
return useStream(
settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()),
settingsStore.value[settingKey],
(value: SettingsType[K]) => {
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
settingKey,
value,
},
})
}
)
}
/**
* A static version (does not require component setup)
* of `useSetting`
*/
export function useSettingStatic<K extends keyof SettingsType>(
settingKey: K
): [Ref<SettingsType[K]>, () => void] {
return useStreamStatic(
settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()),
settingsStore.value[settingKey],
(value: SettingsType[K]) => {
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
settingKey,
value,
},
})
}
)
}

View File

@@ -0,0 +1,174 @@
import { clone, cloneDeep } from "lodash-es"
import { Observable, Subscription } from "rxjs"
import { customRef, onBeforeUnmount, readonly, Ref } from "vue"
type CloneMode = "noclone" | "shallow" | "deep"
/**
* Returns a readonly (no writes) ref for an RxJS Observable
* @param stream$ The RxJS Observable to listen to
* @param initialValue The initial value to apply until the stream emits a value
* @param cloneMode Determines whether or not and how deep to clone the emitted value.
* Useful for issues in reactivity due to reference sharing. Defaults to shallow clone
* @returns A readonly ref which has the latest value from the stream
*/
export function useReadonlyStream<T>(
stream$: Observable<T>,
initialValue: T,
cloneMode: CloneMode = "shallow"
): Ref<T> {
let sub: Subscription | null = null
onBeforeUnmount(() => {
if (sub) {
sub.unsubscribe()
}
})
const r = customRef((track, trigger) => {
let val = initialValue
sub = stream$.subscribe((value) => {
if (cloneMode === "noclone") {
val = value
} else if (cloneMode === "shallow") {
val = clone(value)
} else if (cloneMode === "deep") {
val = cloneDeep(value)
}
trigger()
})
return {
get() {
track()
return val
},
set() {
trigger() // <- Not exactly needed here
throw new Error("Cannot write to a ref from useReadonlyStream")
},
}
})
// Casting to still maintain the proper type signature for ease of use
return readonly(r) as Ref<T>
}
export function useStream<T>(
stream$: Observable<T>,
initialValue: T,
setter: (val: T) => void
) {
let sub: Subscription | null = null
onBeforeUnmount(() => {
if (sub) {
sub.unsubscribe()
}
})
return customRef((track, trigger) => {
let value = initialValue
sub = stream$.subscribe((val) => {
value = val
trigger()
})
return {
get() {
track()
return value
},
set(value: T) {
trigger()
setter(value)
},
}
})
}
/** A static (doesn't cleanup on itself and does
* not require component instace) version of useStream
*/
export function useStreamStatic<T>(
stream$: Observable<T>,
initialValue: T,
setter: (val: T) => void
): [Ref<T>, () => void] {
let sub: Subscription | null = null
const stopper = () => {
if (sub) {
sub.unsubscribe()
}
}
return [
customRef((track, trigger) => {
let value = initialValue
sub = stream$.subscribe((val) => {
value = val
trigger()
})
return {
get() {
track()
return value
},
set(value: T) {
trigger()
setter(value)
},
}
}),
stopper,
]
}
export type StreamSubscriberFunc = <T>(
stream: Observable<T>,
next?: ((value: T) => void) | undefined,
error?: ((e: any) => void) | undefined,
complete?: (() => void) | undefined
) => void
/**
* A composable that provides the ability to run streams
* and subscribe to them and respect the component lifecycle.
*/
export function useStreamSubscriber(): {
subscribeToStream: StreamSubscriberFunc
} {
const subs: Subscription[] = []
const runAndSubscribe = <T>(
stream: Observable<T>,
next?: (value: T) => void,
error?: (e: any) => void,
complete?: () => void
) => {
const sub = stream.subscribe({
next,
error,
complete: () => {
if (complete) complete()
subs.splice(subs.indexOf(sub), 1)
},
})
subs.push(sub)
}
onBeforeUnmount(() => {
subs.forEach((sub) => sub.unsubscribe())
})
return {
subscribeToStream: runAndSubscribe,
}
}

View File

@@ -0,0 +1,4 @@
import { inject } from "vue"
import { HoppColorMode } from "~/modules/theming"
export const useColorMode = () => inject("colorMode") as HoppColorMode

View File

@@ -0,0 +1,3 @@
import { useToasted } from "@hoppscotch/vue-toasted"
export const useToast = useToasted