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,61 @@
<!-- The Catch-All Page -->
<!-- Reserved for Critical Errors and 404 ONLY -->
<template>
<div
class="flex flex-col items-center justify-center"
:class="{ 'min-h-screen': statusCode !== 404 }"
>
<img
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center mb-2 h-46 w-46"
:alt="message"
/>
<h1 class="mb-2 text-4xl heading">
{{ statusCode }}
</h1>
<p class="mb-4 text-secondaryLight">{{ message }}</p>
<p class="mt-4 space-x-2">
<ButtonSecondary to="/" :icon="IconHome" filled :label="t('app.home')" />
<ButtonSecondary
:icon="IconRefreshCW"
:label="t('app.reload')"
filled
@click="reloadApplication"
/>
</p>
</div>
</template>
<script setup lang="ts">
import IconRefreshCW from "~icons/lucide/refresh-cw"
import IconHome from "~icons/lucide/home"
import { PropType, computed } from "vue"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
export type ErrorPageData = {
message: string
statusCode: number
}
const colorMode = useColorMode()
const t = useI18n()
const props = defineProps({
error: {
type: Object as PropType<ErrorPageData | null>,
default: null,
},
})
const statusCode = computed(() => props.error?.statusCode ?? 404)
const message = computed(
() => props.error?.message ?? t("error.page_not_found")
)
const reloadApplication = () => {
window.location.reload()
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="flex flex-col items-center justify-center min-h-screen">
<SmartSpinner v-if="signingInWithEmail" />
<AppLogo v-else class="w-16 h-16 rounded" />
<pre v-if="error" class="mt-4 text-secondaryLight">{{ error }}</pre>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { useI18n } from "@composables/i18n"
import { initializeFirebase } from "~/helpers/fb"
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
export default defineComponent({
setup() {
return {
t: useI18n(),
}
},
data() {
return {
signingInWithEmail: false,
error: null,
}
},
beforeMount() {
initializeFirebase()
},
async mounted() {
if (isSignInWithEmailLink(window.location.href)) {
this.signingInWithEmail = true
let email = getLocalConfig("emailForSignIn")
if (!email) {
email = window.prompt(
"Please provide your email for confirmation"
) as string
}
await signInWithEmailLink(email, window.location.href)
.then(() => {
removeLocalConfig("emailForSignIn")
this.$router.push({ path: "/" })
})
.catch((e) => {
this.signingInWithEmail = false
this.error = e.message
})
.finally(() => {
this.signingInWithEmail = false
})
}
},
})
</script>
<route lang="yaml">
meta:
layout: empty
</route>

View File

@@ -0,0 +1,43 @@
<template>
<AppPaneLayout layout-id="graphql">
<template #primary>
<GraphqlRequest :conn="gqlConn" />
<GraphqlRequestOptions :conn="gqlConn" />
</template>
<template #secondary>
<GraphqlResponse :conn="gqlConn" />
</template>
<template #sidebar>
<GraphqlSidebar :conn="gqlConn" />
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from "vue"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { usePageHead } from "@composables/head"
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
import { GQLConnection } from "@helpers/GQLConnection"
const t = useI18n()
usePageHead({
title: computed(() => t("navigation.graphql")),
})
const gqlConn = new GQLConnection()
const isLoading = useReadonlyStream(gqlConn.isLoading$, false)
onBeforeUnmount(() => {
if (gqlConn.connected$.value) {
gqlConn.disconnect()
}
})
watch(isLoading, () => {
if (isLoading.value) startPageProgress()
else completePageProgress()
})
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div></div>
</template>
<script setup lang="ts">
import axios from "axios"
import * as TO from "fp-ts/TaskOption"
import * as TE from "fp-ts/TaskEither"
import * as RA from "fp-ts/ReadonlyArray"
import { onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import { appendRESTCollections } from "~/newstore/collections"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { URLImporters } from "~/helpers/import-export/import/importers"
import { IMPORTER_INVALID_FILE_FORMAT } from "~/helpers/import-export/import"
import { OPENAPI_DEREF_ERROR } from "~/helpers/import-export/import/openapi"
import { isOfType } from "~/helpers/functional/primtive"
import { TELeftType } from "~/helpers/functional/taskEither"
const route = useRoute()
const router = useRouter()
const toast = useToast()
const t = useI18n()
const IMPORTER_INVALID_TYPE = "importer_invalid_type" as const
const IMPORTER_INVALID_FETCH = "importer_invalid_fetch" as const
const importCollections = (url: unknown, type: unknown) =>
pipe(
TE.Do,
TE.bind("importer", () =>
pipe(
URLImporters,
RA.findFirst(
(importer) =>
importer.applicableTo.includes("url-import") && importer.id === type
),
TE.fromOption(() => IMPORTER_INVALID_TYPE)
)
),
TE.bindW("content", () =>
pipe(
url,
TO.fromPredicate(isOfType("string")),
TO.chain(fetchUrlData),
TE.fromTaskOption(() => IMPORTER_INVALID_FETCH)
)
),
TE.chainW(({ importer, content }) =>
pipe(
content.data,
TO.fromPredicate(isOfType("string")),
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT),
TE.chain((data) => importer.importer([data]))
)
)
)
type ImportCollectionsError = TELeftType<ReturnType<typeof importCollections>>
onMounted(async () => {
const { query } = route
const url = query.url
const type = query.type
const result = await importCollections(url, type)()
pipe(result, E.fold(handleImportFailure, handleImportSuccess))
router.replace("/")
})
const IMPORT_ERROR_MAP: Record<ImportCollectionsError, string> = {
[IMPORTER_INVALID_TYPE]: "import.import_from_url_invalid_type",
[IMPORTER_INVALID_FETCH]: "import.import_from_url_invalid_fetch",
[IMPORTER_INVALID_FILE_FORMAT]: "import.import_from_url_invalid_file_format",
[OPENAPI_DEREF_ERROR]: "import.import_from_url_invalid_file_format",
} as const
const handleImportFailure = (error: ImportCollectionsError) => {
toast.error(t(IMPORT_ERROR_MAP[error]).toString())
}
const handleImportSuccess = (
collections: HoppCollection<HoppRESTRequest>[]
) => {
appendRESTCollections(collections)
toast.success(t("import.import_from_url_success").toString())
}
const fetchUrlData = (url: string) =>
TO.tryCatch(() =>
axios.get(url, {
responseType: "text",
transitional: { forcedJSONParsing: false },
})
)
</script>

View File

@@ -0,0 +1,173 @@
<template>
<AppPaneLayout layout-id="http">
<template #primary>
<HttpRequest />
<HttpRequestOptions />
</template>
<template #secondary>
<HttpResponse />
</template>
<template #sidebar>
<HttpSidebar />
</template>
</AppPaneLayout>
</template>
<script lang="ts">
import {
defineComponent,
onBeforeMount,
onBeforeUnmount,
onMounted,
Ref,
ref,
watch,
} from "vue"
import type { Subscription } from "rxjs"
import {
HoppRESTRequest,
HoppRESTAuthOAuth2,
safelyExtractRESTRequest,
isEqualHoppRESTRequest,
} from "@hoppscotch/data"
import {
getRESTRequest,
setRESTRequest,
setRESTAuth,
restAuth$,
getDefaultRESTRequest,
} from "~/newstore/RESTSession"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { pluckRef } from "@composables/ref"
import { useI18n } from "@composables/i18n"
import { useStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { onLoggedIn } from "@composables/auth"
import { loadRequestFromSync, startRequestSync } from "~/helpers/fb/request"
import { oauthRedirect } from "~/helpers/oauth"
import { useRoute } from "vue-router"
function bindRequestToURLParams() {
const route = useRoute()
// Get URL parameters and set that as the request
onMounted(() => {
const query = route.query
// If query params are empty, or contains code or error param (these are from Oauth Redirect)
// We skip URL params parsing
if (Object.keys(query).length === 0 || query.code || query.error) return
setRESTRequest(
safelyExtractRESTRequest(
translateExtURLParams(query),
getDefaultRESTRequest()
)
)
})
}
function oAuthURL() {
const auth = useStream(
restAuth$,
{ authType: "none", authActive: true },
setRESTAuth
)
const oauth2Token = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "token")
onBeforeMount(async () => {
try {
const tokenInfo = await oauthRedirect()
if (Object.prototype.hasOwnProperty.call(tokenInfo, "access_token")) {
if (typeof tokenInfo === "object") {
oauth2Token.value = tokenInfo.access_token
}
}
// eslint-disable-next-line no-empty
} catch (_) {}
})
}
function setupRequestSync(
confirmSync: Ref<boolean>,
requestForSync: Ref<HoppRESTRequest | null>
) {
const route = useRoute()
// Subscription to request sync
let sub: Subscription | null = null
// Load request on login resolve and start sync
onLoggedIn(async () => {
if (
Object.keys(route.query).length === 0 &&
!(route.query.code || route.query.error)
) {
const request = await loadRequestFromSync()
if (request) {
if (!isEqualHoppRESTRequest(request, getRESTRequest())) {
requestForSync.value = request
confirmSync.value = true
}
}
}
sub = startRequestSync()
})
// Stop subscription to stop syncing
onBeforeUnmount(() => {
sub?.unsubscribe()
})
}
export default defineComponent({
setup() {
const requestForSync = ref<HoppRESTRequest | null>(null)
const confirmSync = ref(false)
const toast = useToast()
const t = useI18n()
watch(confirmSync, (newValue) => {
if (newValue) {
toast.show(`${t("confirm.sync")}`, {
duration: 0,
action: [
{
text: `${t("action.yes")}`,
onClick: (_, toastObject) => {
syncRequest()
toastObject.goAway(0)
},
},
{
text: `${t("action.no")}`,
onClick: (_, toastObject) => {
toastObject.goAway(0)
},
},
],
})
}
})
const syncRequest = () => {
setRESTRequest(
safelyExtractRESTRequest(requestForSync.value!, getDefaultRESTRequest())
)
}
setupRequestSync(confirmSync, requestForSync)
bindRequestToURLParams()
oAuthURL()
return {
confirmSync,
syncRequest,
oAuthURL,
requestForSync,
}
},
})
</script>

View File

@@ -0,0 +1,279 @@
<template>
<div class="flex flex-col items-center justify-between min-h-screen">
<div
v-if="invalidLink"
class="flex flex-col items-center justify-center flex-1"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<h1 class="text-center heading">
{{ t("team.invalid_invite_link") }}
</h1>
<p class="mt-2 text-center">
{{ t("team.invalid_invite_link_description") }}
</p>
</div>
<div
v-else-if="loadingCurrentUser"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner />
</div>
<div
v-else-if="currentUser === null"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<h1 class="heading">{{ t("team.login_to_continue") }}</h1>
<p class="mt-2">{{ t("team.login_to_continue_description") }}</p>
<ButtonPrimary
:label="t('auth.login_to_hoppscotch')"
class="mt-8"
@click="showLogin = true"
/>
</div>
<div v-else class="flex flex-col items-center justify-center flex-1 p-4">
<div
v-if="inviteDetails.loading"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner />
</div>
<div v-else>
<div
v-if="!inviteDetails.loading && E.isLeft(inviteDetails.data)"
class="flex flex-col items-center p-4"
>
<i class="mb-4 material-icons">error_outline</i>
<p>
{{ getErrorMessage(inviteDetails.data.left) }}
</p>
<p
class="flex flex-col items-center p-4 mt-8 border rounded border-dividerLight"
>
<span class="mb-4">
{{ t("team.logout_and_try_again") }}
</span>
<span class="flex">
<FirebaseLogout
v-if="inviteDetails.data.left.type === 'gql_error'"
outline
/>
</span>
</p>
</div>
<div
v-if="
!inviteDetails.loading &&
E.isRight(inviteDetails.data) &&
!joinTeamSuccess
"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<h1 class="heading">
{{
t("team.join_team", {
team: inviteDetails.data.right.teamInvitation.team.name,
})
}}
</h1>
<p class="mt-2 text-secondaryLight">
{{
t("team.invited_to_team", {
owner:
inviteDetails.data.right.teamInvitation.creator.displayName ??
inviteDetails.data.right.teamInvitation.creator.email,
team: inviteDetails.data.right.teamInvitation.team.name,
})
}}
</p>
<div class="mt-8">
<ButtonPrimary
:label="
t('team.join_team', {
team: inviteDetails.data.right.teamInvitation.team.name,
})
"
:loading="loading"
:disabled="revokedLink"
@click="joinTeam"
/>
</div>
</div>
<div
v-if="
!inviteDetails.loading &&
E.isRight(inviteDetails.data) &&
joinTeamSuccess
"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<h1 class="heading">
{{
t("team.joined_team", {
team: inviteDetails.data.right.teamInvitation.team.name,
})
}}
</h1>
<p class="mt-2 text-secondaryLight">
{{
t("team.joined_team_description", {
team: inviteDetails.data.right.teamInvitation.team.name,
})
}}
</p>
<div class="mt-8">
<ButtonSecondary
to="/"
:icon="IconHome"
filled
:label="t('app.home')"
/>
</div>
</div>
</div>
</div>
<div class="p-4">
<ButtonSecondary
class="tracking-wide !font-bold !text-secondaryDark"
label="HOPPSCOTCH"
to="/"
/>
</div>
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue"
import { useRoute } from "vue-router"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { GQLError } from "~/helpers/backend/GQLClient"
import { useGQLQuery } from "@composables/graphql"
import {
GetInviteDetailsDocument,
GetInviteDetailsQuery,
GetInviteDetailsQueryVariables,
} from "~/helpers/backend/graphql"
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
import { initializeFirebase } from "~/helpers/fb"
import { currentUser$, probableUser$ } from "~/helpers/fb/auth"
import { onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { useI18n } from "~/composables/i18n"
import IconHome from "~icons/lucide/home"
type GetInviteDetailsError =
| "team_invite/not_valid_viewer"
| "team_invite/not_found"
| "team_invite/no_invite_found"
| "team_invite/email_do_not_match"
| "team_invite/already_member"
export default defineComponent({
layout: "empty",
setup() {
const route = useRoute()
const inviteDetails = useGQLQuery<
GetInviteDetailsQuery,
GetInviteDetailsQueryVariables,
GetInviteDetailsError
>({
query: GetInviteDetailsDocument,
variables: {
inviteID: route.query.id as string,
},
defer: true,
})
onLoggedIn(() => {
if (typeof route.query.id === "string") {
inviteDetails.execute({
inviteID: route.query.id,
})
}
})
const probableUser = useReadonlyStream(probableUser$, null)
const currentUser = useReadonlyStream(currentUser$, null)
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false
else if (!currentUser.value) return true
else return false
})
return {
E,
inviteDetails,
loadingCurrentUser,
currentUser,
toast: useToast(),
t: useI18n(),
IconHome,
}
},
data() {
return {
invalidLink: false,
showLogin: false,
loading: false,
revokedLink: false,
inviteID: "",
joinTeamSuccess: false,
}
},
beforeMount() {
initializeFirebase()
},
mounted() {
if (typeof this.$route.query.id === "string") {
this.inviteID = this.$route.query.id
}
this.invalidLink = !this.inviteID
// TODO: check revokeTeamInvitation
// TODO: check login user already a member
},
methods: {
joinTeam() {
this.loading = true
pipe(
acceptTeamInvitation(this.inviteID),
TE.matchW(
() => {
this.loading = false
this.toast.error(`${this.t("error.something_went_wrong")}`)
},
() => {
this.joinTeamSuccess = true
this.loading = false
}
)
)()
},
getErrorMessage(error: GQLError<GetInviteDetailsError>) {
if (error.type === "network_error") {
return this.t("error.network_error")
} else {
switch (error.error) {
case "team_invite/not_valid_viewer":
return this.t("team.not_valid_viewer")
case "team_invite/not_found":
return this.t("team.not_found")
case "team_invite/no_invite_found":
return this.t("team.no_invite_found")
case "team_invite/already_member":
return this.t("team.already_member")
case "team_invite/email_do_not_match":
return this.t("team.email_do_not_match")
default:
return this.t("error.something_went_wrong")
}
}
},
},
})
</script>

View File

@@ -0,0 +1,456 @@
<template>
<div>
<div class="container">
<div class="p-4">
<div
v-if="loadingCurrentUser"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner class="mb-4" />
</div>
<div
v-else-if="currentUser === null"
class="flex flex-col items-center justify-center"
>
<img
:src="`/images/states/${colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-24 h-24 my-4"
:alt="`${t('empty.parameters')}`"
/>
<p class="pb-4 text-center text-secondaryLight">
{{ t("empty.profile") }}
</p>
<ButtonPrimary
:label="t('auth.login')"
class="mb-4"
@click="showLogin = true"
/>
</div>
<div v-else class="space-y-8">
<div
class="h-24 rounded bg-primaryLight -mb-11 md:h-32"
style="background-image: url('/images/cover.svg')"
></div>
<div class="flex flex-col justify-between px-4 space-y-8 md:flex-row">
<div class="flex items-end">
<ProfilePicture
v-if="currentUser.photoURL"
:url="currentUser.photoURL"
:alt="
currentUser.displayName || t('profile.default_displayname')
"
class="ring-primary ring-4"
size="16"
rounded="lg"
/>
<ProfilePicture
v-else
:initial="currentUser.displayName || currentUser.email"
rounded="lg"
size="16"
class="ring-primary ring-4"
/>
<div class="ml-4">
<label class="heading">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</label>
<p class="flex items-center text-secondaryLight">
{{ currentUser.email }}
<icon-lucide-verified
v-if="currentUser.emailVerified"
v-tippy="{ theme: 'tooltip' }"
:title="t('settings.verified_email')"
class="ml-2 text-green-500 svg-icons cursor-help"
/>
<ButtonSecondary
v-else
:label="t('settings.verify_email')"
:icon="IconVerified"
class="px-1 py-0 ml-2"
:loading="verifyingEmailAddress"
@click="sendEmailVerification"
/>
</p>
</div>
</div>
<div class="flex items-end space-x-2">
<div>
<SmartItem
to="/settings"
:icon="IconSettings"
:label="t('profile.app_settings')"
outline
/>
</div>
<FirebaseLogout outline />
</div>
</div>
<SmartTabs
v-model="selectedProfileTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
render-inactive-tabs
>
<SmartTab :id="'sync'" :label="t('settings.account')">
<div class="grid grid-cols-1">
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.profile") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.profile_description") }}
</div>
<div class="py-4">
<label for="displayName">
{{ t("settings.profile_name") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateDisplayName"
>
<input
id="displayName"
v-model="displayName"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="text"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingDisplayName"
/>
</form>
</div>
<div class="py-4">
<label for="emailAddress">
{{ t("settings.profile_email") }}
</label>
<form
class="flex mt-2 md:max-w-sm"
@submit.prevent="updateEmailAddress"
>
<input
id="emailAddress"
v-model="emailAddress"
class="input"
:placeholder="`${t('settings.profile_name')}`"
type="email"
autocomplete="off"
required
/>
<ButtonSecondary
filled
outline
:label="t('action.save')"
class="ml-2 min-w-16"
type="submit"
:loading="updatingEmailAddress"
/>
</form>
</div>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.sync_description") }}
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle
:on="SYNC_COLLECTIONS"
@change="toggleSetting('syncCollections')"
>
{{ t("settings.sync_collections") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_ENVIRONMENTS"
@change="toggleSetting('syncEnvironments')"
>
{{ t("settings.sync_environments") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="SYNC_HISTORY"
@change="toggleSetting('syncHistory')"
>
{{ t("settings.sync_history") }}
</SmartToggle>
</div>
</div>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.short_codes") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.short_codes_description") }}
</div>
<div class="relative flex-shrink-0 py-4 overflow-x-auto">
<div
v-if="loading"
class="flex flex-col items-center justify-center"
>
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{
t("state.loading")
}}</span>
</div>
<div
v-if="!loading && myShortcodes.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/add_files.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.shortcodes')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.shortcodes") }}
</span>
</div>
<div v-else-if="!loading">
<div
class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight"
>
<div class="flex w-full overflow-y-scroll">
<div class="table-box">
{{ t("shortcodes.short_code") }}
</div>
<div class="table-box">
{{ t("shortcodes.method") }}
</div>
<div class="table-box">
{{ t("shortcodes.url") }}
</div>
<div class="table-box">
{{ t("shortcodes.created_on") }}
</div>
<div class="justify-center table-box">
{{ t("shortcodes.actions") }}
</div>
</div>
</div>
<div
class="flex flex-col items-center justify-between w-full overflow-y-scroll border rounded max-h-sm lg:rounded-t-none lg:divide-y border-dividerLight divide-dividerLight"
>
<ProfileShortcode
v-for="(shortcode, shortcodeIndex) in myShortcodes"
:key="`shortcode-${shortcodeIndex}`"
:shortcode="shortcode"
@delete-shortcode="deleteShortcode"
/>
<SmartIntersection
v-if="hasMoreShortcodes && myShortcodes.length > 0"
@intersecting="loadMoreShortcodes()"
>
<div
v-if="adapterLoading"
class="flex flex-col items-center py-3"
>
<SmartSpinner />
</div>
</SmartIntersection>
</div>
</div>
<div
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<component :is="IconHelpCircle" class="mb-4 svg-icons" />
{{ getErrorMessage(adapterError) }}
</div>
</div>
</section>
</div>
</SmartTab>
<SmartTab :id="'teams'" :label="t('team.title')">
<Teams :modal="false" class="p-4" />
</SmartTab>
</SmartTabs>
</div>
</div>
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watchEffect, computed } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
currentUser$,
probableUser$,
setDisplayName,
setEmailAddress,
verifyEmailAddress,
} from "~/helpers/fb/auth"
import { onAuthEvent, onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useSetting } from "@composables/settings"
import { useColorMode } from "@composables/theming"
import { usePageHead } from "@composables/head"
import { toggleSetting } from "~/newstore/settings"
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
import IconVerified from "~icons/lucide/verified"
import IconSettings from "~icons/lucide/settings"
import IconHelpCircle from "~icons/lucide/help-circle"
type ProfileTabs = "sync" | "teams"
const selectedProfileTab = ref<ProfileTabs>("sync")
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
usePageHead({
title: computed(() => t("navigation.profile")),
})
const showLogin = ref(false)
const SYNC_COLLECTIONS = useSetting("syncCollections")
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
const SYNC_HISTORY = useSetting("syncHistory")
const currentUser = useReadonlyStream(currentUser$, null)
const probableUser = useReadonlyStream(probableUser$, null)
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false
else if (!currentUser.value) return true
else return false
})
const displayName = ref(currentUser.value?.displayName)
const updatingDisplayName = ref(false)
watchEffect(() => (displayName.value = currentUser.value?.displayName))
const updateDisplayName = () => {
updatingDisplayName.value = true
setDisplayName(displayName.value as string)
.then(() => {
toast.success(`${t("profile.updated")}`)
})
.catch(() => {
toast.error(`${t("error.something_went_wrong")}`)
})
.finally(() => {
updatingDisplayName.value = false
})
}
const emailAddress = ref(currentUser.value?.email)
const updatingEmailAddress = ref(false)
watchEffect(() => (emailAddress.value = currentUser.value?.email))
const updateEmailAddress = () => {
updatingEmailAddress.value = true
setEmailAddress(emailAddress.value as string)
.then(() => {
toast.success(`${t("profile.updated")}`)
})
.catch(() => {
toast.error(`${t("error.something_went_wrong")}`)
})
.finally(() => {
updatingEmailAddress.value = false
})
}
const verifyingEmailAddress = ref(false)
const sendEmailVerification = () => {
verifyingEmailAddress.value = true
verifyEmailAddress()
.then(() => {
toast.success(`${t("profile.email_verification_mail")}`)
})
.catch(() => {
toast.error(`${t("error.something_went_wrong")}`)
})
.finally(() => {
verifyingEmailAddress.value = false
})
}
const adapter = new ShortcodeListAdapter(true)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
const adapterError = useReadonlyStream(adapter.error$, null)
const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
const loading = computed(
() => adapterLoading.value && myShortcodes.value.length === 0
)
onLoggedIn(() => {
adapter.initialize()
})
onAuthEvent((ev) => {
if (ev.event === "logout" && adapter.isInitialized()) {
adapter.dispose()
return
}
})
const deleteShortcode = (codeID: string) => {
pipe(
backendDeleteShortcode(codeID),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(`${t("shortcodes.deleted")}`)
}
)
)()
}
const loadMoreShortcodes = () => {
adapter.loadMore()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script>
<style lang="scss" scoped>
.table-box {
@apply flex flex-1 items-center px-4 py-2 truncate;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div class="flex flex-col items-center justify-between">
<div
v-if="invalidLink"
class="flex flex-col items-center justify-center flex-1"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<h1 class="text-center heading">
{{ t("error.invalid_link") }}
</h1>
<p class="mt-2 text-center">
{{ t("error.invalid_link_description") }}
</p>
</div>
<div v-else class="flex flex-col items-center justify-center flex-1 p-4">
<div
v-if="shortcodeDetails.loading"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner />
</div>
<div v-else>
<div
v-if="!shortcodeDetails.loading && E.isLeft(shortcodeDetails.data)"
class="flex flex-col items-center p-4"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<h1 class="text-center heading">
{{ t("error.invalid_link") }}
</h1>
<p class="mt-2 text-center">
{{ t("error.invalid_link_description") }}
</p>
<p class="mt-4">
<ButtonSecondary
to="/"
:icon="IconHome"
filled
:label="t('app.home')"
/>
<ButtonSecondary
:icon="IconRefreshCW"
:label="t('app.reload')"
filled
@click="reloadApplication"
/>
</p>
</div>
<div
v-if="!shortcodeDetails.loading && E.isRight(shortcodeDetails.data)"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<h1 class="heading">
{{ t("state.loading") }}
</h1>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import * as E from "fp-ts/Either"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { useGQLQuery } from "@composables/graphql"
import { useI18n } from "@composables/i18n"
import {
ResolveShortcodeDocument,
ResolveShortcodeQuery,
ResolveShortcodeQueryVariables,
} from "~/helpers/backend/graphql"
import { getDefaultRESTRequest, setRESTRequest } from "~/newstore/RESTSession"
import IconHome from "~icons/lucide/home"
import IconRefreshCW from "~icons/lucide/refresh-cw"
export default defineComponent({
setup() {
const route = useRoute()
const router = useRouter()
const t = useI18n()
const shortcodeDetails = useGQLQuery<
ResolveShortcodeQuery,
ResolveShortcodeQueryVariables,
""
>({
query: ResolveShortcodeDocument,
variables: {
code: route.params.id as string,
},
})
watch(
() => shortcodeDetails.data,
() => {
if (shortcodeDetails.loading) return
const data = shortcodeDetails.data
if (E.isRight(data)) {
const request: unknown = JSON.parse(
data.right.shortcode?.request as string
)
setRESTRequest(
safelyExtractRESTRequest(request, getDefaultRESTRequest())
)
router.push({ path: "/" })
}
}
)
const reloadApplication = () => {
window.location.reload()
}
return {
E,
shortcodeDetails,
reloadApplication,
t,
route,
IconHome,
IconRefreshCW,
}
},
data() {
return {
invalidLink: false,
shortcodeID: "",
}
},
mounted() {
if (typeof this.route.params.id === "string") {
this.shortcodeID = this.route.params.id
}
this.invalidLink = !this.shortcodeID
},
})
</script>

View File

@@ -0,0 +1,81 @@
<template>
<SmartTabs
v-model="currentTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
content-styles="h-[calc(100%-var(--sidebar-primary-sticky-fold)-1px)] !flex"
>
<SmartTab
v-for="{ target, title } in REALTIME_NAVIGATION"
:id="target"
:key="target"
:label="title"
>
<RouterView />
</SmartTab>
</SmartTabs>
</template>
<script setup lang="ts">
import { watch, ref, computed } from "vue"
import { RouterView, useRouter, useRoute } from "vue-router"
import { usePageHead } from "~/composables/head"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const router = useRouter()
const route = useRoute()
const REALTIME_NAVIGATION = [
{
target: "websocket",
title: t("tab.websocket"),
},
{
target: "sse",
title: t("tab.sse"),
},
{
target: "socketio",
title: t("tab.socketio"),
},
{
target: "mqtt",
title: t("tab.mqtt"),
},
] as const
type RealtimeNavTab = typeof REALTIME_NAVIGATION[number]["target"]
const currentTab = ref<RealtimeNavTab>("websocket")
usePageHead({
title: computed(() => t(`tab.${currentTab.value}`)),
})
// Update the router when the tab is updated
watch(currentTab, (newTab) => {
router.push(`/realtime/${newTab}`)
})
// Update the tab when router is upgrad
watch(
route,
(updateRoute) => {
const path = updateRoute.path
if (updateRoute.name?.toString() === "realtime") {
router.replace(`/realtime/websocket`)
return
}
const destination: string | undefined = path.split("realtime/")[1]
const target = REALTIME_NAVIGATION.find(
({ target }) => target === destination
)?.target
if (target) currentTab.value = target
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,476 @@
<template>
<AppPaneLayout layout-id="mqtt">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<input
id="mqtt-url"
v-model="url"
type="url"
autocomplete="off"
:class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
:placeholder="`${t('mqtt.url')}`"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<label
for="client-id"
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
>
{{ t("mqtt.client_id") }}
</label>
<input
id="client-id"
v-model="clientID"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
</div>
<ButtonPrimary
id="connect"
:disabled="!isUrlValid"
class="w-32"
:label="
connectionState === 'CONNECTING'
? t('action.connecting')
: connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
"
:loading="connectionState === 'CONNECTING'"
@click="toggleConnection"
/>
</div>
</div>
<div
class="flex flex-col flex-1"
:class="{ '!hidden': connectionState === 'CONNECTED' }"
>
<RealtimeConnectionConfig @change="onChangeConfig" />
</div>
<RealtimeCommunication
v-if="connectionState === 'CONNECTED'"
:show-event-field="currentTabId === 'all'"
:is-connected="connectionState === 'CONNECTED'"
event-field-styles="top-upperPrimaryStickyFold"
:sticky-header-styles="
currentTabId === 'all'
? 'top-upperSecondaryStickyFold'
: 'top-upperPrimaryStickyFold'
"
@send-message="
publish(
currentTabId === 'all'
? $event
: {
message: $event.message,
eventName: currentTabId,
}
)
"
/>
</template>
<template #secondary>
<SmartWindows
:id="'communication_tab'"
v-model="currentTabId"
:can-add-new-tab="false"
@remove-tab="removeTab"
@sort="sortTabs"
>
<SmartWindow
v-for="tab in tabs"
:id="tab.id"
:key="'removable_tab_' + tab.id"
:label="tab.name"
:is-removable="tab.removable"
>
<template #icon>
<icon-lucide-rss
:style="{
color: tab.color,
}"
class="w-4 h-4 svg-icons"
/>
</template>
<RealtimeLog
:title="t('mqtt.log')"
:log="((tab.id === 'all' ? logs : tab.logs) as LogEntryData[])"
@delete="clearLogEntries()"
/>
</SmartWindow>
</SmartWindows>
</template>
<template #sidebar>
<div
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y rounded-t divide-dividerLight bg-primary border-dividerLight"
>
<div class="flex justify-between flex-1">
<ButtonSecondary
:icon="IconPlus"
:label="t('mqtt.new')"
class="!rounded-none"
@click="showSubscriptionModal(true)"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/mqtt"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</span>
</div>
</div>
<div
v-if="topics.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.subscription')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.subscription") }}
</span>
<ButtonSecondary
:label="t('mqtt.new')"
filled
outline
@click="showSubscriptionModal(true)"
/>
</div>
<div v-else>
<div
v-for="(topic, index) in topics"
:key="`subscription-${index}`"
class="flex flex-col"
>
<div class="flex items-stretch group">
<span class="flex items-center justify-center px-4 cursor-pointer">
<icon-lucide-rss
:style="{
color: topic.color,
}"
class="w-4 h-4 svg-icons"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="openTopicAsTab(topic)"
>
<span class="truncate">
{{ topic.name }}
</span>
</span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
color="red"
:title="t('mqtt.unsubscribe')"
class="hidden group-hover:inline-flex"
data-testid="unsubscribe_mqtt_subscription"
@click="unsubscribeFromTopic(topic.name)"
/>
</div>
</div>
</div>
<RealtimeSubscription
:show="subscriptionModalShown"
:loading-state="subscribing"
@submit="subscribeToTopic"
@hide-modal="showSubscriptionModal(false)"
/>
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import IconPlus from "~icons/lucide/plus"
import IconTrash from "~icons/lucide/trash"
import IconHelpCircle from "~icons/lucide/help-circle"
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
import debounce from "lodash-es/debounce"
import {
MQTTConnection,
MQTTConnectionConfig,
MQTTError,
MQTTTopic,
} from "~/helpers/realtime/MQTTConnection"
import { HoppRealtimeLogLine } from "~/helpers/types/HoppRealtimeLog"
import { useColorMode } from "@composables/theming"
import {
useReadonlyStream,
useStream,
useStreamSubscriber,
} from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
addMQTTLogLine,
MQTTConn$,
MQTTEndpoint$,
MQTTClientID$,
MQTTLog$,
setMQTTConn,
setMQTTEndpoint,
setMQTTClientID,
setMQTTLog,
MQTTTabs$,
setMQTTTabs,
MQTTCurrentTab$,
setCurrentTab,
addMQTTCurrentTabLogLine,
} from "~/newstore/MQTTSession"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
const { subscribeToStream } = useStreamSubscriber()
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
const clientID = useStream(MQTTClientID$, "", setMQTTClientID)
const config = ref<MQTTConnectionConfig>({
username: "",
password: "",
keepAlive: "60",
cleanSession: true,
lwTopic: "",
lwMessage: "",
lwQos: 0,
lwRetain: false,
})
const logs = useStream(MQTTLog$, [], setMQTTLog)
const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
const connectionState = useReadonlyStream(
socket.value.connectionState$,
"DISCONNECTED"
)
const subscriptionState = useReadonlyStream(
socket.value.subscriptionState$,
false
)
const subscribing = useReadonlyStream(socket.value.subscribing$, false)
const isUrlValid = ref(true)
const subTopic = ref("")
let worker: Worker
const subscriptionModalShown = ref(false)
const canSubscribe = computed(() => connectionState.value === "CONNECTED")
const topics = useReadonlyStream(socket.value.subscribedTopics$, [])
const currentTabId = useStream(MQTTCurrentTab$, "", setCurrentTab)
const tabs = useStream(MQTTTabs$, [], setMQTTTabs)
const onChangeConfig = (e: MQTTConnectionConfig) => {
config.value = e
}
const showSubscriptionModal = (show: boolean) => {
subscriptionModalShown.value = show
}
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === url.value) isUrlValid.value = data.result
}
onMounted(() => {
worker = new RegexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(socket.value.event$, (event) => {
switch (event?.type) {
case "CONNECTING":
logs.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "CONNECTED":
logs.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addLog(
{
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "client",
ts: Date.now(),
},
event.message.topic
)
break
case "MESSAGE_RECEIVED":
addLog(
{
prefix: `${event.message.topic}`,
payload: event.message.message,
source: "server",
ts: event.time,
},
event.message.topic
)
break
case "SUBSCRIBED":
showSubscriptionModal(false)
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_success", { topic: event.topic })}`
: `${t("state.unsubscribed_success", { topic: event.topic })}`,
source: "server",
ts: event.time,
})
break
case "SUBSCRIPTION_FAILED":
addMQTTLogLine({
payload: subscriptionState.value
? `${t("state.subscribed_failed", { topic: subTopic.value })}`
: `${t("state.unsubscribed_failed", { topic: subTopic.value })}`,
source: "server",
ts: event.time,
})
break
case "ERROR":
addMQTTLogLine({
payload: getI18nError(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addMQTTLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
const addLog = (line: HoppRealtimeLogLine, topic: string | undefined) => {
if (topic) addMQTTCurrentTabLogLine(topic, line)
addMQTTLogLine(line)
}
const debouncer = debounce(function () {
worker.postMessage({ type: "ws", url: url.value })
}, 1000)
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
onUnmounted(() => {
worker.terminate()
})
// METHODS
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.value.connect(url.value, clientID.value, config.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const publish = (event: { message: string; eventName: string }) => {
socket.value?.publish(event.eventName, event.message)
}
const subscribeToTopic = (topic: MQTTTopic) => {
if (canSubscribe.value) {
if (topics.value.some((t) => t.name === topic.name)) {
return toast.error(t("mqtt.already_subscribed").toString())
}
socket.value.subscribe(topic)
} else {
subscriptionModalShown.value = false
toast.error(t("mqtt.not_connected").toString())
}
}
const unsubscribeFromTopic = (topic: string) => {
socket.value.unsubscribe(topic)
removeTab(topic)
}
const getI18nError = (error: MQTTError): string => {
if (typeof error === "string") return error
switch (error.type) {
case "CONNECTION_NOT_ESTABLISHED":
return t("state.connection_lost").toString()
case "SUBSCRIPTION_FAILED":
return t("state.mqtt_subscription_failed", {
topic: error.topic,
}).toString()
case "PUBLISH_ERROR":
return t("state.publish_error", { topic: error.topic }).toString()
case "CONNECTION_LOST":
return t("state.connection_lost").toString()
case "CONNECTION_FAILED":
return t("state.connection_failed").toString()
default:
return t("state.disconnected_from", { name: url.value }).toString()
}
}
const clearLogEntries = () => {
logs.value = []
}
const openTopicAsTab = (topic: MQTTTopic) => {
const { name, color } = topic
if (tabs.value.some((tab) => tab.id === topic.name)) {
return (currentTabId.value = topic.name)
}
tabs.value = [
...tabs.value,
{
id: name,
name,
color,
removable: true,
logs: [],
},
]
currentTabId.value = name
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
const newTabs = [...tabs.value]
newTabs.splice(e.newIndex, 0, newTabs.splice(e.oldIndex, 1)[0])
tabs.value = newTabs
}
const removeTab = (tabID: string) => {
tabs.value = tabs.value.filter((tab) => tab.id !== tabID)
}
</script>

View File

@@ -0,0 +1,460 @@
<template>
<AppPaneLayout layout-id="socketio">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<label for="client-version">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<span class="select-wrapper">
<input
id="client-version"
v-tippy="{ theme: 'tooltip' }"
title="socket.io-client version"
class="flex px-4 py-2 font-semibold border rounded-l cursor-pointer bg-primaryLight border-divider text-secondaryDark w-26"
:value="`Client ${clientVersion}`"
readonly
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="version in SIOVersions"
:key="`client-${version}`"
:label="`Client ${version}`"
@click="
() => {
onSelectVersion(version as SIOClientVersion)
hide()
}
"
/>
</div>
</template>
</tippy>
</label>
<input
id="socketio-url"
v-model="url"
type="url"
autocomplete="off"
spellcheck="false"
:class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border bg-primaryLight border-divider text-secondaryDark"
:placeholder="`${t('socketio.url')}`"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<input
id="socketio-path"
v-model="path"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
</div>
<ButtonPrimary
id="connect"
:disabled="!isUrlValid"
name="connect"
class="w-32"
:label="
connectionState === 'CONNECTING'
? t('action.connecting')
: connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
"
:loading="connectionState === 'CONNECTING'"
@click="toggleConnection"
/>
</div>
</div>
<SmartTabs
v-model="selectedTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
<SmartTab
:id="'communication'"
:label="`${t('websocket.communication')}`"
render-inactive-tabs
>
<RealtimeCommunication
:show-event-field="true"
:is-connected="connectionState === 'CONNECTED'"
event-field-styles="top-upperSecondaryStickyFold"
sticky-header-styles="top-upperTertiaryStickyFold"
@send-message="sendMessage($event)"
/>
</SmartTab>
<SmartTab :id="'protocols'" :label="`${t('request.authorization')}`">
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">
{{ t("authorization.type") }}
</label>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => authTippyActions.focus()"
>
<span class="select-wrapper">
<ButtonSecondary
class="pr-8 ml-2 rounded-none"
:label="authType"
/>
</span>
<template #content="{ hide }">
<div
ref="authTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
label="None"
:icon="authType === 'None' ? IconCircleDot : IconCircle"
:active="authType === 'None'"
@click="
() => {
authType = 'None'
hide()
}
"
/>
<SmartItem
label="Bearer Token"
:icon="authType === 'Bearer' ? IconCircleDot : IconCircle"
:active="authType === 'Bearer'"
@click="
() => {
authType = 'Bearer'
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
<div class="flex">
<SmartCheckbox
:on="authActive"
class="px-2"
@change="authActive = !authActive"
>
{{ t("state.enabled") }}
</SmartCheckbox>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/authorization"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear')"
:icon="IconTrash2"
@click="clearContent"
/>
</div>
</div>
<div
v-if="authType === 'None'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.authorization')}`"
/>
<span class="pb-4 text-center">
{{ t("socketio.connection_not_authorized") }}
</span>
<ButtonSecondary
outline
:label="t('app.documentation')"
to="https://docs.hoppscotch.io/features/authorization"
blank
:icon="IconExternalLink"
reverse
class="mb-4"
/>
</div>
<div
v-if="authType === 'Bearer'"
class="flex flex-1 border-b border-dividerLight"
>
<div class="w-2/3 border-r border-dividerLight">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
</div>
</div>
<div
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
>
<div class="p-2">
<div class="pb-2 text-secondaryLight">
{{ t("helpers.authorization") }}
</div>
<SmartAnchor
class="link"
:label="t('authorization.learn')"
:icon="IconExternalLink"
to="https://docs.hoppscotch.io/features/authorization"
blank
reverse
/>
</div>
</div>
</div>
</SmartTab>
</SmartTabs>
</template>
<template #secondary>
<RealtimeLog
:title="t('socketio.log')"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from "vue"
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconExternalLink from "~icons/lucide/external-link"
import { debounce } from "lodash-es"
import {
SIOConnection,
SIOError,
SIOMessage,
SOCKET_CLIENTS,
} from "~/helpers/realtime/SIOConnection"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
useReadonlyStream,
useStream,
useStreamSubscriber,
} from "@composables/stream"
import {
addSIOLogLine,
setSIOEndpoint,
setSIOLog,
setSIOPath,
setSIOVersion,
SIOClientVersion,
SIOEndpoint$,
SIOLog$,
SIOPath$,
SIOVersion$,
} from "~/newstore/SocketIOSession"
import { useColorMode } from "@composables/theming"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const colorMode = useColorMode()
const toast = useToast()
const { subscribeToStream } = useStreamSubscriber()
type SIOTab = "communication" | "protocols"
const selectedTab = ref<SIOTab>("communication")
const SIOVersions = Object.keys(SOCKET_CLIENTS)
const url = useStream(SIOEndpoint$, "", setSIOEndpoint)
const clientVersion = useStream(SIOVersion$, "v4", setSIOVersion)
const path = useStream(SIOPath$, "", setSIOPath)
const socket = new SIOConnection()
const connectionState = useReadonlyStream(
socket.connectionState$,
"DISCONNECTED"
)
const log = useStream(SIOLog$, [], setSIOLog)
// Template refs
const tippyActions = ref<any | null>(null)
const authTippyActions = ref<any | null>(null)
const isUrlValid = ref(true)
const authType = ref<"None" | "Bearer">("None")
const bearerToken = ref("")
const authActive = ref(true)
let worker: Worker
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === url.value) isUrlValid.value = data.result
}
const getMessagePayload = (data: SIOMessage): string =>
typeof data.value === "object" ? JSON.stringify(data.value) : `${data.value}`
const getErrorPayload = (error: SIOError): string => {
switch (error.type) {
case "CONNECTION":
return t("state.connection_error").toString()
case "RECONNECT_ERROR":
return t("state.reconnection_error").toString()
default:
return t("state.disconnected_from", { name: url.value }).toString()
}
}
onMounted(() => {
worker = new RegexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(socket.event$, (event) => {
switch (event?.type) {
case "CONNECTING":
log.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "CONNECTED":
log.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: event.time,
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addSIOLogLine({
prefix: `[${event.message.eventName}]`,
payload: getMessagePayload(event.message),
source: "client",
ts: event.time,
})
break
case "MESSAGE_RECEIVED":
addSIOLogLine({
prefix: `[${event.message.eventName}]`,
payload: getMessagePayload(event.message),
source: "server",
ts: event.time,
})
break
case "ERROR":
addSIOLogLine({
payload: getErrorPayload(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addSIOLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
// TODO: How important is this ?
// watch(connectionState, (connected) => {
// if (connected) versionOptions.value.tippy().disable()
// else versionOptions.value.tippy().enable()
// })
onUnmounted(() => {
worker.terminate()
})
const debouncer = debounce(function () {
worker.postMessage({ type: "socketio", url: url.value })
}, 1000)
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.connect({
url: url.value,
path: path.value || "/socket.io",
clientVersion: clientVersion.value,
auth: authActive.value
? {
type: authType.value,
token: bearerToken.value,
}
: undefined,
})
}
// Otherwise, it's disconnecting.
socket.disconnect()
}
const sendMessage = (event: { message: string; eventName: string }) => {
socket.sendMessage(event)
}
const onSelectVersion = (version: SIOClientVersion) => {
clientVersion.value = version
}
const clearLogEntries = () => {
log.value = []
}
const clearContent = () => {
// TODO: Implementation ?
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<AppPaneLayout layout-id="sse">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<input
id="server"
v-model="server"
type="url"
autocomplete="off"
:class="{ error: !isUrlValid }"
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
:placeholder="t('sse.url')"
:disabled="
connectionState === 'STARTED' || connectionState === 'STARTING'
"
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
/>
<label
for="event-type"
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
>
{{ t("sse.event_type") }}
</label>
<input
id="event-type"
v-model="eventType"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="
connectionState === 'STARTED' || connectionState === 'STARTING'
"
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
/>
</div>
<ButtonPrimary
id="start"
:disabled="!isUrlValid"
name="start"
class="w-32"
:label="
connectionState === 'STARTING'
? t('action.starting')
: connectionState === 'STOPPED'
? t('action.start')
: t('action.stop')
"
:loading="connectionState === 'STARTING'"
@click="toggleSSEConnection"
/>
</div>
</div>
</template>
<template #secondary>
<RealtimeLog
:title="t('sse.log')"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, onMounted } from "vue"
import "splitpanes/dist/splitpanes.css"
import { debounce } from "lodash-es"
import {
SSEEndpoint$,
setSSEEndpoint,
SSEEventType$,
setSSEEventType,
SSESocket$,
setSSESocket,
SSELog$,
setSSELog,
addSSELogLine,
} from "~/newstore/SSESession"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import {
useStream,
useStreamSubscriber,
useReadonlyStream,
} from "@composables/stream"
import { SSEConnection } from "@helpers/realtime/SSEConnection"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
const { subscribeToStream } = useStreamSubscriber()
const sse = useStream(SSESocket$, new SSEConnection(), setSSESocket)
const connectionState = useReadonlyStream(sse.value.connectionState$, "STOPPED")
const server = useStream(SSEEndpoint$, "", setSSEEndpoint)
const eventType = useStream(SSEEventType$, "", setSSEEventType)
const log = useStream(SSELog$, [], setSSELog)
const isUrlValid = ref(true)
let worker: Worker
const debouncer = debounce(function () {
worker.postMessage({ type: "sse", url: server.value })
}, 1000)
watch(server, (url) => {
if (url) debouncer()
})
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === server.value) isUrlValid.value = data.result
}
onMounted(() => {
worker = new RegexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(sse.value.event$, (event) => {
switch (event?.type) {
case "STARTING":
log.value = [
{
payload: `${t("state.connecting_to", { name: server.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "STARTED":
log.value = [
{
payload: `${t("state.connected_to", { name: server.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_RECEIVED":
addSSELogLine({
payload: event.message,
source: "server",
ts: event.time,
})
break
case "ERROR":
addSSELogLine({
payload: t("error.browser_support_sse").toString(),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "STOPPED":
addSSELogLine({
payload: t("state.disconnected_from", {
name: server.value,
}).toString(),
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
// METHODS
const toggleSSEConnection = () => {
// If it is connecting:
if (connectionState.value === "STOPPED") {
return sse.value.start(server.value, eventType.value)
}
// Otherwise, it's disconnecting.
sse.value.stop()
}
onUnmounted(() => {
worker.terminate()
})
const clearLogEntries = () => {
log.value = []
}
</script>

View File

@@ -0,0 +1,409 @@
<template>
<AppPaneLayout layout-id="websocket">
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
>
<div class="inline-flex flex-1 space-x-2">
<input
id="websocket-url"
v-model="url"
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
type="url"
autocomplete="off"
spellcheck="false"
:class="{ error: !isUrlValid }"
:placeholder="`${t('websocket.url')}`"
:disabled="
connectionState === 'CONNECTED' ||
connectionState === 'CONNECTING'
"
@keyup.enter="isUrlValid ? toggleConnection() : null"
/>
<ButtonPrimary
id="connect"
:disabled="!isUrlValid"
class="w-32"
name="connect"
:label="
connectionState === 'CONNECTING'
? t('action.connecting')
: connectionState === 'DISCONNECTED'
? t('action.connect')
: t('action.disconnect')
"
:loading="connectionState === 'CONNECTING'"
@click="toggleConnection"
/>
</div>
</div>
<SmartTabs
v-model="selectedTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
<SmartTab
:id="'communication'"
:label="`${t('websocket.communication')}`"
>
<RealtimeCommunication
:is-connected="connectionState === 'CONNECTED'"
sticky-header-styles="top-upperSecondaryStickyFold"
@send-message="sendMessage($event)"
/>
</SmartTab>
<SmartTab :id="'protocols'" :label="`${t('websocket.protocols')}`">
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<label class="font-semibold truncate text-secondaryLight">
{{ t("websocket.protocols") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
@click="addProtocol"
/>
</div>
</div>
<draggable
v-model="protocolsWithID"
item-key="id"
animation="250"
handle=".draggable-handle"
draggable=".draggable-content"
ghost-class="cursor-move"
chosen-class="bg-primaryLight"
drag-class="cursor-grabbing"
>
<template #item="{ element: { protocol }, index }">
<div
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<ButtonSecondary
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
content:
index !== protocols?.length - 1
? t('action.drag_to_reorder')
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
:class="{
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
index !== protocols?.length - 1,
}"
tabindex="-1"
/>
</span>
<input
v-model="protocol.value"
class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="`${t('count.protocol', { count: index + 1 })}`"
name="message"
type="text"
autocomplete="off"
@change="
updateProtocol(index, {
value: ($event.target as HTMLInputElement).value,
active: protocol.active,
})
"
/>
<span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
protocol.hasOwnProperty('active')
? protocol.active
? t('action.turn_off')
: t('action.turn_on')
: t('action.turn_off')
"
:icon="
protocol.hasOwnProperty('active')
? protocol.active
? IconCheckCircle
: IconCircle
: IconCheckCircle
"
color="green"
@click="
updateProtocol(index, {
value: protocol.value,
active: !protocol.active,
})
"
/>
</span>
<span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteProtocol(index)"
/>
</span>
</div>
</template>
</draggable>
<div
v-if="protocols.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/add_category.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="`${t('empty.protocols')}`"
/>
<span class="mb-4 text-center">
{{ t("empty.protocols") }}
</span>
</div>
</SmartTab>
</SmartTabs>
</template>
<template #secondary>
<RealtimeLog
:title="t('websocket.log')"
:log="(log as LogEntryData[])"
@delete="clearLogEntries()"
/>
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, onMounted, computed } from "vue"
import { debounce } from "lodash-es"
import IconTrash2 from "~icons/lucide/trash-2"
import IconPlus from "~icons/lucide/plus"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconGripVertical from "~icons/lucide/grip-vertical"
import IconTrash from "~icons/lucide/trash"
import draggable from "vuedraggable"
import {
setWSEndpoint,
WSEndpoint$,
WSProtocols$,
setWSProtocols,
addWSProtocol,
deleteWSProtocol,
updateWSProtocol,
deleteAllWSProtocols,
addWSLogLine,
WSLog$,
setWSLog,
HoppWSProtocol,
setWSSocket,
WSSocket$,
} from "~/newstore/WebSocketSession"
import { useI18n } from "@composables/i18n"
import {
useStream,
useStreamSubscriber,
useReadonlyStream,
} from "@composables/stream"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { WSConnection, WSErrorMessage } from "@helpers/realtime/WSConnection"
import RegexWorker from "@workers/regex?worker"
import { LogEntryData } from "~/components/realtime/Log.vue"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
const { subscribeToStream } = useStreamSubscriber()
const selectedTab = ref<"communication" | "protocols">("communication")
const url = useStream(WSEndpoint$, "", setWSEndpoint)
const protocols = useStream(WSProtocols$, [], setWSProtocols)
/**
* Protocols but with ID inbuilt
*/
const protocolsWithID = computed({
get() {
return protocols.value.map((protocol, index) => ({
id: `protocol-${index}-${protocol.value}`,
protocol,
}))
},
set(newData) {
protocols.value = newData.map(({ protocol }) => protocol)
},
})
const socket = useStream(WSSocket$, new WSConnection(), setWSSocket)
const connectionState = useReadonlyStream(
socket.value.connectionState$,
"DISCONNECTED"
)
const log = useStream(WSLog$, [], setWSLog)
// DATA
const isUrlValid = ref(true)
const activeProtocols = ref<string[]>([])
let worker: Worker
watch(url, (newUrl) => {
if (newUrl) debouncer()
})
watch(
protocols,
(newProtocols) => {
activeProtocols.value = newProtocols
.filter((item) =>
Object.prototype.hasOwnProperty.call(item, "active")
? item.active === true
: true
)
.map(({ value }) => value)
},
{ deep: true }
)
const workerResponseHandler = ({
data,
}: {
data: { url: string; result: boolean }
}) => {
if (data.url === url.value) isUrlValid.value = data.result
}
const getErrorPayload = (error: WSErrorMessage): string => {
if (error instanceof SyntaxError) {
return error.message
}
return t("error.something_went_wrong").toString()
}
onMounted(() => {
worker = new RegexWorker()
worker.addEventListener("message", workerResponseHandler)
subscribeToStream(socket.value.event$, (event) => {
switch (event?.type) {
case "CONNECTING":
log.value = [
{
payload: `${t("state.connecting_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: undefined,
},
]
break
case "CONNECTED":
log.value = [
{
payload: `${t("state.connected_to", { name: url.value })}`,
source: "info",
color: "var(--accent-color)",
ts: Date.now(),
},
]
toast.success(`${t("state.connected")}`)
break
case "MESSAGE_SENT":
addWSLogLine({
payload: event.message,
source: "client",
ts: Date.now(),
})
break
case "MESSAGE_RECEIVED":
addWSLogLine({
payload: event.message,
source: "server",
ts: event.time,
})
break
case "ERROR":
addWSLogLine({
payload: getErrorPayload(event.error),
source: "info",
color: "#ff5555",
ts: event.time,
})
break
case "DISCONNECTED":
addWSLogLine({
payload: t("state.disconnected_from", { name: url.value }).toString(),
source: "disconnected",
color: "#ff5555",
ts: event.time,
})
toast.error(`${t("state.disconnected")}`)
break
}
})
})
onUnmounted(() => {
if (worker) worker.terminate()
})
const clearContent = () => {
deleteAllWSProtocols()
}
const debouncer = debounce(function () {
worker.postMessage({ type: "ws", url: url.value })
}, 1000)
const toggleConnection = () => {
// If it is connecting:
if (connectionState.value === "DISCONNECTED") {
return socket.value.connect(url.value, activeProtocols.value)
}
// Otherwise, it's disconnecting.
socket.value.disconnect()
}
const sendMessage = (event: { message: string; eventName: string }) => {
socket.value.sendMessage(event)
}
const addProtocol = () => {
addWSProtocol({ value: "", active: true })
}
const deleteProtocol = (index: number) => {
const oldProtocols = protocols.value.slice()
deleteWSProtocol(index)
toast.success(`${t("state.deleted")}`, {
duration: 4000,
action: {
text: `${t("action.undo")}`,
onClick: (_, toastObject) => {
protocols.value = oldProtocols
toastObject.goAway()
},
},
})
}
const updateProtocol = (index: number, updated: HoppWSProtocol) => {
updateWSProtocol(index, updated)
}
const clearLogEntries = () => {
log.value = []
}
</script>

View File

@@ -0,0 +1,350 @@
<template>
<div>
<div class="container space-y-8 divide-y divide-dividerLight">
<div class="md:grid md:gap-4 md:grid-cols-3">
<div class="p-8 md:col-span-1">
<h3 class="heading">
{{ t("settings.theme") }}
</h3>
<p class="my-1 text-secondaryLight">
{{ t("settings.theme_description") }}
</p>
</div>
<div class="p-8 space-y-8 md:col-span-2">
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.background") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t(getColorModeName(colorMode.preference)) }}
<span v-if="colorMode.preference === 'system'">
({{ t(getColorModeName(colorMode.value)) }})
</span>
</div>
<div class="mt-4">
<SmartColorModePicker />
</div>
</section>
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.accent_color") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ ACCENT_COLOR.charAt(0).toUpperCase() + ACCENT_COLOR.slice(1) }}
</div>
<div class="mt-4">
<SmartAccentModePicker />
</div>
</section>
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.font_size") }}
</h4>
<div class="mt-4">
<SmartFontSizePicker />
</div>
</section>
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.language") }}
</h4>
<div class="mt-4">
<SmartChangeLanguage />
</div>
</section>
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.experiments") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.experiments_notice") }}
<SmartAnchor
class="link"
to="https://github.com/hoppscotch/hoppscotch/issues/new/choose"
blank
:label="t('app.contact_us')"
/>.
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle :on="TELEMETRY_ENABLED" @change="showConfirmModal">
{{ t("settings.telemetry") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="EXPAND_NAVIGATION"
@change="toggleSetting('EXPAND_NAVIGATION')"
>
{{ t("settings.expand_navigation") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle
:on="SIDEBAR_ON_LEFT"
@change="toggleSetting('SIDEBAR_ON_LEFT')"
>
{{ t("settings.sidebar_on_left") }}
</SmartToggle>
</div>
<div class="flex items-center">
<SmartToggle :on="ZEN_MODE" @change="toggleSetting('ZEN_MODE')">
{{ t("layout.zen_mode") }}
</SmartToggle>
</div>
</div>
</section>
</div>
</div>
<div class="md:grid md:gap-4 md:grid-cols-3">
<div class="p-8 md:col-span-1">
<h3 class="heading">
{{ t("settings.interceptor") }}
</h3>
<p class="my-1 text-secondaryLight">
{{ t("settings.interceptor_description") }}
</p>
</div>
<div class="p-8 space-y-8 md:col-span-2">
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.extensions") }}
</h4>
<div class="my-1 text-secondaryLight">
<span v-if="extensionVersion != null">
{{
`${t("settings.extension_version")}: v${
extensionVersion.major
}.${extensionVersion.minor}`
}}
</span>
<span v-else>
{{ t("settings.extension_version") }}:
{{ t("settings.extension_ver_not_reported") }}
</span>
</div>
<div class="flex flex-col py-4 space-y-2">
<span>
<SmartItem
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
blank
:icon="IconChrome"
label="Chrome"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasChromeExtInstalled"
outline
/>
</span>
<span>
<SmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank
:icon="IconFirefox"
label="Firefox"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
:active-info-icon="hasFirefoxExtInstalled"
outline
/>
</span>
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle
:on="EXTENSIONS_ENABLED"
@change="toggleInterceptor('extension')"
>
{{ t("settings.extensions_use_toggle") }}
</SmartToggle>
</div>
</div>
</section>
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.proxy") }}
</h4>
<div class="my-1 text-secondaryLight">
{{
`${t("settings.official_proxy_hosting")} ${t(
"settings.read_the"
)}`
}}
<SmartAnchor
class="link"
to="https://docs.hoppscotch.io/privacy"
blank
:label="t('app.proxy_privacy_policy')"
/>.
</div>
<div class="py-4 space-y-4">
<div class="flex items-center">
<SmartToggle
:on="PROXY_ENABLED"
@change="toggleInterceptor('proxy')"
>
{{ t("settings.proxy_use_toggle") }}
</SmartToggle>
</div>
</div>
<div class="flex items-center py-4 space-x-2">
<div class="relative flex flex-col flex-1">
<input
id="url"
v-model="PROXY_URL"
class="input floating-input"
placeholder=" "
type="url"
autocomplete="off"
:disabled="!PROXY_ENABLED"
/>
<label for="url">
{{ t("settings.proxy_url") }}
</label>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('settings.reset_default')"
:icon="clearIcon"
outline
class="rounded"
@click="resetProxy"
/>
</div>
</section>
</div>
</div>
</div>
<SmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_telemetry')} ${t(
'settings.telemetry_helps_us'
)}`"
@hide-modal="confirmRemove = false"
@resolve="
() => {
toggleSetting('TELEMETRY_ENABLED')
confirmRemove = false
}
"
/>
</div>
</template>
<script setup lang="ts">
import IconChrome from "~icons/brands/chrome"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFirefox from "~icons/brands/firefox"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconCheck from "~icons/lucide/check"
import { ref, computed, watch } from "vue"
import { refAutoReset } from "@vueuse/core"
import { applySetting, toggleSetting } from "~/newstore/settings"
import { useSetting } from "@composables/settings"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useReadonlyStream } from "@composables/stream"
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
import { extensionStatus$ } from "~/newstore/HoppExtension"
import { usePageHead } from "@composables/head"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
usePageHead({
title: computed(() => t("navigation.settings")),
})
const ACCENT_COLOR = useSetting("THEME_COLOR")
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
const PROXY_URL = useSetting("PROXY_URL")
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const ZEN_MODE = useSetting("ZEN_MODE")
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
const extensionVersion = computed(() => {
return currentExtensionStatus.value === "available"
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
: null
})
const hasChromeExtInstalled = computed(
() => browserIsChrome() && currentExtensionStatus.value === "available"
)
const hasFirefoxExtInstalled = computed(
() => browserIsFirefox() && currentExtensionStatus.value === "available"
)
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
IconRotateCCW,
1000
)
const confirmRemove = ref(false)
const proxySettings = computed(() => ({
url: PROXY_URL.value,
}))
watch(ZEN_MODE, (mode) => {
applySetting("EXPAND_NAVIGATION", !mode)
})
watch(
proxySettings,
({ url }) => {
applySetting("PROXY_URL", url)
},
{ deep: true }
)
// Extensions and proxy should not be enabled at the same time
const toggleInterceptor = (interceptor: "extension" | "proxy") => {
if (interceptor === "extension") {
EXTENSIONS_ENABLED.value = !EXTENSIONS_ENABLED.value
if (EXTENSIONS_ENABLED.value) {
PROXY_ENABLED.value = false
}
} else {
PROXY_ENABLED.value = !PROXY_ENABLED.value
if (PROXY_ENABLED.value) {
EXTENSIONS_ENABLED.value = false
}
}
}
const showConfirmModal = () => {
if (TELEMETRY_ENABLED.value) confirmRemove.value = true
else toggleSetting("TELEMETRY_ENABLED")
}
const resetProxy = () => {
applySetting("PROXY_URL", `https://proxy.hoppscotch.io/`)
clearIcon.value = IconCheck
toast.success(`${t("state.cleared")}`)
}
const getColorModeName = (colorMode: string) => {
switch (colorMode) {
case "system":
return "settings.system_mode"
case "light":
return "settings.light_mode"
case "dark":
return "settings.dark_mode"
case "black":
return "settings.black_mode"
default:
return "settings.system_mode"
}
}
</script>