Browse Source

Feature: Mobile - Add popup to update service worker, add "Install" button

Vladimir Sheremet 2 years ago
parent
commit
11331005df

+ 77 - 0
app/frontend/apps/mobile/pages/account/__tests__/install-pwa.spec.ts

@@ -0,0 +1,77 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import type { Mock } from 'vitest'
+import { visitView } from '@tests/support/components/visitView'
+import * as utilsPWA from '@shared/utils/pwa'
+import * as utilsBrowser from '@shared/utils/browser'
+import { computed } from 'vue'
+
+const utilsPWAmock = vi.mocked(utilsPWA)
+const utilsBrowsermock = vi.mocked(utilsBrowser)
+
+vi.mock('@shared/utils/browser')
+vi.mock('@shared/utils/pwa')
+
+const mockPWA = ({
+  canInstallPWA = false,
+  isStandalone = false,
+  installPWA = vi.fn(),
+}: {
+  canInstallPWA?: boolean
+  isStandalone?: boolean
+  installPWA?: Mock
+}) => {
+  utilsPWAmock.usePWASupport.mockReturnValue({
+    canInstallPWA: computed(() => canInstallPWA),
+    installPWA,
+  })
+  vi.spyOn(utilsPWA, 'isStandalone', 'get').mockReturnValue(isStandalone)
+}
+
+describe('Installing Zammad as PWA', () => {
+  test("cannot install zammad as PWA, so user doesn't see a button", async () => {
+    mockPWA({ canInstallPWA: false, isStandalone: false })
+
+    const view = await visitView('/account')
+
+    expect(view.queryByText('Install')).not.toBeInTheDocument()
+  })
+  test("already opened as PWA, so user doesn't see a button", async () => {
+    mockPWA({ canInstallPWA: false, isStandalone: true })
+
+    const view = await visitView('/account')
+
+    expect(view.queryByText('Install')).not.toBeInTheDocument()
+  })
+
+  test('installing PWA, when prompt event is available', async () => {
+    const installPWA = vi.fn()
+    mockPWA({ canInstallPWA: true, isStandalone: false, installPWA })
+
+    const view = await visitView('/account')
+
+    const install = view.getByText('Install App')
+
+    await view.events.click(install)
+
+    expect(installPWA).toHaveBeenCalled()
+  })
+
+  test('installing PWA on iOS - show instructions', async () => {
+    utilsBrowsermock.browser.name = 'Safari'
+    utilsBrowsermock.os.name = 'iOS'
+    mockPWA({ canInstallPWA: false, isStandalone: false })
+
+    const view = await visitView('/account')
+
+    const install = view.getByText('Install App')
+
+    await view.events.click(install)
+
+    expect(
+      view.getByText(/To install Zammad as an app, press/),
+    ).toBeInTheDocument()
+    expect(view.getByIconName('mobile-ios-share')).toBeInTheDocument()
+    expect(view.getByIconName('mobile-add-square')).toBeInTheDocument()
+  })
+})

+ 74 - 1
app/frontend/apps/mobile/pages/account/views/AccountOverview.vue

@@ -1,17 +1,24 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+/* eslint-disable vue/no-v-html */
+
 import { computed, ref } from 'vue'
 import { useRouter } from 'vue-router'
 import { storeToRefs } from 'pinia'
 import { MutationHandler, QueryHandler } from '@shared/server/apollo/handler'
 import { useSessionStore } from '@shared/stores/session'
+import { usePWASupport, isStandalone } from '@shared/utils/pwa'
 import { useLocaleStore } from '@shared/stores/locale'
+import { browser, os } from '@shared/utils/browser'
 import FormGroup from '@shared/components/Form/FormGroup.vue'
 import CommonUserAvatar from '@shared/components/CommonUserAvatar/CommonUserAvatar.vue'
 import { useProductAboutQuery } from '@shared/graphql/queries/about.api'
 import CommonSectionMenu from '@mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
 import CommonSectionMenuLink from '@mobile/components/CommonSectionMenu/CommonSectionMenuLink.vue'
+import CommonSectionPopup from '@mobile/components/CommonSectionPopup/CommonSectionPopup.vue'
+import { useRawHTMLIcon } from '@shared/components/CommonIcon'
+import { i18n } from '@shared/i18n'
 import { useAccountLocaleMutation } from '../graphql/mutations/locale.api'
 
 const router = useRouter()
@@ -66,6 +73,55 @@ const productAboutQuery = new QueryHandler(
 )
 
 const productAbout = productAboutQuery.result()
+
+const isMobileIOS = browser.name?.includes('Safari') && os.name?.includes('iOS')
+const { canInstallPWA, installPWA } = usePWASupport()
+const showInstallButton = computed(
+  () => !isStandalone && (canInstallPWA.value || isMobileIOS),
+)
+
+const showInstallIOSPopup = ref(false)
+const installPWAMessage = computed(() => {
+  const iconShare = useRawHTMLIcon({
+    class: 'inline-flex text-blue',
+    decorative: true,
+    size: 'small',
+    name: 'mobile-ios-share',
+  })
+
+  const iconAdd = useRawHTMLIcon({
+    class: 'inline-flex',
+    decorative: true,
+    size: 'small',
+    name: 'mobile-add-square',
+  })
+
+  return i18n.t(
+    __(
+      'To install %s as an app, press the %s "Share" button and then the %s "Add to Home Screen" button.',
+    ),
+    __('Zammad'),
+    iconShare,
+    iconAdd,
+  )
+})
+
+const installZammadPWA = () => {
+  if (isStandalone) return
+
+  // on chromium this will show a chrome popup with native "install" button
+  if (canInstallPWA.value) {
+    installPWA()
+    return
+  }
+
+  // on iOS we cannot install it with native functionality, so we show
+  // instructions on how to install it
+  // let's pray Apple will add native functionality in the future
+  if (isMobileIOS) {
+    showInstallIOSPopup.value = true
+  }
+}
 </script>
 
 <template>
@@ -113,14 +169,23 @@ const productAbout = productAboutQuery.result()
       </template>
     </FormGroup>
 
-    <CommonSectionMenu v-if="hasVersionPermission">
+    <CommonSectionMenu v-if="hasVersionPermission || showInstallButton">
       <CommonSectionMenuLink
+        v-if="hasVersionPermission"
         :icon="{ name: 'mobile-info', size: 'base' }"
         :information="productAbout?.productAbout"
         icon-bg="bg-gray"
       >
         {{ $t('About') }}
       </CommonSectionMenuLink>
+      <CommonSectionMenuLink
+        v-if="showInstallButton"
+        :icon="{ name: 'mobile-install', size: 'small' }"
+        icon-bg="bg-blue"
+        @click="installZammadPWA"
+      >
+        {{ $t('Install App') }}
+      </CommonSectionMenuLink>
     </CommonSectionMenu>
 
     <div class="mb-4">
@@ -134,5 +199,13 @@ const productAbout = productAboutQuery.result()
         {{ $t('Sign out') }}
       </FormKit>
     </div>
+
+    <CommonSectionPopup v-model:state="showInstallIOSPopup" :items="[]">
+      <template #header>
+        <section class="inline-flex min-h-[54px] items-center p-3">
+          <span v-html="installPWAMessage" />
+        </section>
+      </template>
+    </CommonSectionPopup>
   </div>
 </template>

+ 1 - 1
app/frontend/apps/mobile/pages/online-notification/views/NotificationsList.vue

@@ -97,7 +97,7 @@ const haveUnread = computed(() => unseenCount.value > 0)
 </script>
 
 <template>
-  <CommonLoader :loading="!notifications.length && loading">
+  <CommonLoader center :loading="!notifications.length && loading">
     <div class="ltr:pr-4 ltr:pl-3 rtl:pl-4 rtl:pr-3">
       <NotificationItem
         v-for="notification of notifications"

+ 23 - 12
app/frontend/apps/mobile/sw/sw.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 /* eslint-disable no-restricted-globals */
+/* eslint-disable no-underscore-dangle */
 
 import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
 import { clientsClaim } from 'workbox-core'
@@ -14,15 +15,25 @@ self.addEventListener('message', (event) => {
 clientsClaim()
 cleanupOutdatedCaches()
 
-precacheAndRoute(
-  // eslint-disable-next-line no-underscore-dangle
-  self.__WB_MANIFEST.map((entry) => {
-    const base = import.meta.env.VITE_RUBY_PUBLIC_OUTPUT_DIR
-    // this is relative to service worker script, which is
-    // located in /mobile/sw.js
-    // assets are loaded as /vite/assets/...
-    // in the future we will probably have service worker in root
-    if (typeof entry === 'string') return `../${base}/${entry}`
-    return { ...entry, url: `../${base}/${entry.url}` }
-  }),
-)
+if (import.meta.env.MODE !== 'development') {
+  precacheAndRoute(
+    self.__WB_MANIFEST.map((entry) => {
+      const base = import.meta.env.VITE_RUBY_PUBLIC_OUTPUT_DIR
+      // this is relative to service worker script, which is
+      // located in /mobile/sw.js
+      // assets are loaded as /vite/assets/...
+      // in the future we will probably have service worker in root
+      if (typeof entry === 'string') return `../${base}/${entry}`
+      return { ...entry, url: `../${base}/${entry.url}` }
+    }),
+  )
+}
+
+if (import.meta.env.MODE === 'development') {
+  console.groupCollapsed("Service worker doesn't precache in development mode")
+  self.__WB_MANIFEST.forEach((entry) =>
+    console.log(typeof entry === 'string' ? entry : entry.url),
+  )
+  console.groupEnd()
+  precacheAndRoute([])
+}

+ 2 - 5
app/frontend/entrypoints/mobile.ts

@@ -1,10 +1,7 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
 import mountApp from '@mobile/main'
-import { registerSW } from '@shared/sw/register'
+import { registerPWAHooks } from '@shared/utils/pwa'
 
+registerPWAHooks()
 mountApp()
-registerSW({
-  path: '/mobile/sw.js',
-  scope: '/mobile/',
-})

+ 2 - 34
app/frontend/shared/components/CommonIcon/CommonIcon.vue

@@ -1,7 +1,7 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { usePrivateIcon } from './composable'
 import type { Animations, Sizes } from './types'
 
 export interface Props {
@@ -13,23 +13,6 @@ export interface Props {
   animation?: Animations
 }
 
-const animationClassMap: Record<Animations, string> = {
-  pulse: 'animate-pulse',
-  spin: 'animate-spin',
-  ping: 'animate-ping',
-  bounce: 'animate-bounce',
-}
-
-const sizeMap: Record<Sizes, number> = {
-  xs: 12,
-  tiny: 16,
-  small: 20,
-  base: 24,
-  medium: 32,
-  large: 48,
-  xl: 96,
-}
-
 const props = withDefaults(defineProps<Props>(), {
   size: 'medium',
   decorative: false,
@@ -43,22 +26,7 @@ const onClick = (event: MouseEvent) => {
   emit('click', event)
 }
 
-const iconClass = computed(() => {
-  let className = `icon-${props.name}`
-  if (props.animation) {
-    className += ` ${animationClassMap[props.animation]}`
-  }
-  return className
-})
-
-const finalSize = computed(() => {
-  if (props.fixedSize) return props.fixedSize
-
-  return {
-    width: sizeMap[props.size],
-    height: sizeMap[props.size],
-  }
-})
+const { iconClass, finalSize } = usePrivateIcon(props)
 </script>
 
 <template>

+ 3 - 0
app/frontend/shared/components/CommonIcon/assets/mobile/add-square.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M3,5.6C3,4.2,4.2,3,5.6,3h12.9C19.8,3,21,4.2,21,5.6v12.9c0,1.4-1.2,2.6-2.6,2.6H5.6C4.2,21,3,19.8,3,18.4V5.6z M5.6,4.3 c-0.7,0-1.3,0.6-1.3,1.3v12.9c0,0.7,0.6,1.3,1.3,1.3h12.9c0.7,0,1.3-0.6,1.3-1.3V5.6c0-0.7-0.6-1.3-1.3-1.3H5.6z M12.8,16.6v-3.8 h3.8c0.4,0,0.8-0.4,0.8-0.8s-0.4-0.8-0.8-0.8h-3.8V7.4c0-0.4-0.4-0.8-0.8-0.8S11.2,7,11.2,7.4v3.8H7.4c-0.4,0-0.8,0.4-0.8,0.8 s0.4,0.8,0.8,0.8h3.8v3.8c0,0.4,0.4,0.8,0.8,0.8S12.8,17,12.8,16.6z" />
+</svg>

+ 3 - 0
app/frontend/shared/components/CommonIcon/assets/mobile/install.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M20.6,5.9h-2.9c-0.4,0-0.7,0.3-0.7,0.7c0,0.4,0.3,0.7,0.7,0.7h3v13.3H3.3V7.2h2.9c0.4,0,0.7-0.3,0.7-0.7 c0-0.4-0.3-0.7-0.7-0.7H3.4C2.7,5.8,2,6.4,2,7.1c0,0,0,0,0,0.1v13.3c0,0.7,0.6,1.3,1.3,1.3c0,0,0,0,0.1,0h17.2 c0.7,0,1.4-0.5,1.4-1.3c0,0,0,0,0-0.1V7.2C22,6.5,21.4,5.9,20.6,5.9C20.6,5.9,20.6,5.9,20.6,5.9z M6.8,13l5.2,5.2l5.2-5.2 c0.3-0.3,0.3-0.7,0-1c-0.3-0.3-0.7-0.3-1,0l-3.6,3.6V3.2c0-0.4-0.3-0.7-0.7-0.7c-0.4,0-0.7,0.3-0.7,0.7v12.4L7.7,12 c-0.3-0.3-0.7-0.3-1,0C6.5,12.3,6.5,12.7,6.8,13z" />
+</svg>

+ 3 - 0
app/frontend/shared/components/CommonIcon/assets/mobile/ios-share.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M19.9,11.2c0.4,0,0.7,0.3,0.7,0.6l0,0.1v6.9c0,1.7-1.3,3.1-3,3.2l-0.2,0H6.6c-1.7,0-3.1-1.3-3.2-3l0-0.2v-6.9 c0-0.4,0.3-0.7,0.7-0.7c0.4,0,0.7,0.3,0.7,0.6l0,0.1v6.9c0,0.9,0.7,1.6,1.6,1.7l0.1,0h10.8c0.9,0,1.6-0.7,1.7-1.6l0-0.1v-6.9 C19.1,11.5,19.5,11.2,19.9,11.2L19.9,11.2z M17.7,7.7L12,2L6.3,7.7C6,8,6,8.4,6.3,8.7c0.3,0.3,0.8,0.3,1,0l3.9-3.9v11.5 c0,0.4,0.3,0.7,0.7,0.7s0.7-0.3,0.7-0.7V4.8l3.9,3.9c0.3,0.3,0.8,0.3,1,0C18,8.4,18,8,17.7,7.7z" />
+</svg>

+ 68 - 0
app/frontend/shared/components/CommonIcon/composable.ts

@@ -0,0 +1,68 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { i18n } from '@shared/i18n'
+import { computed } from 'vue'
+import type { Props } from './CommonIcon.vue'
+import type { Animations, Sizes } from './types'
+
+export const usePrivateIcon = (
+  props: Omit<Props, 'size'> & { size: Sizes },
+) => {
+  const animationClassMap: Record<Animations, string> = {
+    pulse: 'animate-pulse',
+    spin: 'animate-spin',
+    ping: 'animate-ping',
+    bounce: 'animate-bounce',
+  }
+
+  const sizeMap: Record<Sizes, number> = {
+    xs: 12,
+    tiny: 16,
+    small: 20,
+    base: 24,
+    medium: 32,
+    large: 48,
+    xl: 96,
+  }
+
+  const iconClass = computed(() => {
+    let className = `icon-${props.name}`
+    if (props.animation) {
+      className += ` ${animationClassMap[props.animation]}`
+    }
+    return className
+  })
+
+  const finalSize = computed(() => {
+    if (props.fixedSize) return props.fixedSize
+
+    return {
+      width: sizeMap[props.size],
+      height: sizeMap[props.size],
+    }
+  })
+
+  return {
+    iconClass,
+    finalSize,
+  }
+}
+
+export const useRawHTMLIcon = (props: Props & { class?: string }) => {
+  const { iconClass, finalSize } = usePrivateIcon({ size: 'medium', ...props })
+  const html = String.raw
+
+  return html`
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      class="icon ${iconClass.value} ${props.class || ''} fill-current"
+      width="${finalSize.value.width}"
+      height="${finalSize.value.height}"
+      ${!props.decorative &&
+      `aria-label=${i18n.t(props.label || props.name) || ''}`}
+      ${(props.decorative && 'aria-hidden="true"') || ''}
+    >
+      <use href="#icon-${props.name}" />
+    </svg>
+  `
+}

Some files were not shown because too many files changed in this diff