Skip to content

Commit

Permalink
self-built app banner (#9696)
Browse files Browse the repository at this point in the history
* wip self-built app banner

* wip almost complete

* complete, final tests pending

* fixed URL generation, works!

* clean up

* added changelog

* fixed pnpm lock

* linting

* fixed test

* changes per request in pr

* added tests, moved component to web pkg

* minor cosmetic changes

* changes per request from PO

* added custom translation hack

* fixed after rebase

* removed console log
  • Loading branch information
grimmoc authored Sep 28, 2023
1 parent 8a380a4 commit baef9d6
Show file tree
Hide file tree
Showing 18 changed files with 344 additions and 46 deletions.
5 changes: 5 additions & 0 deletions changelog/unreleased/enhancement-app-banner
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Enhancement: Added app banner for mobile devices

We've added an app banner at the top of the web view for mobile devices asking the user whether they want to continue working in the app. By dismissing it, it will not show again until a new session is started, e.g. by opening a new tab.

https://github.com/owncloud/web/pull/9696
2 changes: 1 addition & 1 deletion packages/web-app-files/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"sanitize-html": "^2.7.0",
"uuid": "^9.0.0",
"vue-concurrency": "4.0.1",
"vue3-gettext": "^2.3.3",
"vue-router": "4.2.0",
"vue3-gettext": "^2.3.3",
"vuex": "4.1.0",
"web-app-files": "workspace:*",
"web-app-search": "workspace:*",
Expand Down
6 changes: 5 additions & 1 deletion packages/web-app-files/src/views/spaces/DriveResolver.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<app-loading-spinner v-if="isLoading" />
<template v-else>
<app-banner :file-id="fileId"></app-banner>
<drive-redirect
v-if="!space"
:drive-alias-and-item="driveAliasAndItem"
Expand Down Expand Up @@ -40,9 +41,11 @@ import { linkRoleUploaderFolder } from 'web-client/src/helpers/share'
import { createFileRouteOptions } from 'web-pkg/src/helpers/router'
import AppLoadingSpinner from 'web-pkg/src/components/AppLoadingSpinner.vue'
import { dirname } from 'path'
import AppBanner from 'web-pkg/src/components/AppBanner.vue'
export default defineComponent({
components: {
AppBanner,
DriveRedirect,
GenericSpace,
GenericTrash,
Expand Down Expand Up @@ -142,7 +145,8 @@ export default defineComponent({
driveAliasAndItem,
isSpaceRoute,
isTrashRoute,
isLoading
isLoading,
fileId
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ function getMountedWrapper({
plugins: [...defaultPlugins(), store],
mocks: defaultMocks,
provide: defaultMocks,
stubs: defaultStubs
stubs: { ...defaultStubs, 'app-banner': true }
}
})
}
Expand Down
8 changes: 7 additions & 1 deletion packages/web-app-preview/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<template>
<app-banner :file-id="fileId"></app-banner>
<main
id="preview"
ref="preview"
Expand Down Expand Up @@ -87,6 +88,7 @@ import MediaAudio from './components/Sources/MediaAudio.vue'
import MediaImage from './components/Sources/MediaImage.vue'
import MediaVideo from './components/Sources/MediaVideo.vue'
import { CachedFile } from './helpers/types'
import AppBanner from 'web-pkg/src/components/AppBanner.vue'
import { watch } from 'vue'
import { getCurrentInstance } from 'vue'
Expand All @@ -112,6 +114,7 @@ export const mimeTypes = () => {
export default defineComponent({
name: 'Preview',
components: {
AppBanner,
AppTopBar,
MediaControls,
MediaAudio,
Expand Down Expand Up @@ -229,6 +232,8 @@ export default defineComponent({
{ immediate: true }
)
const fileId = computed(() => unref(unref(currentFileContext).itemId))
return {
...appDefaults,
activeFilteredFile,
Expand All @@ -240,7 +245,8 @@ export default defineComponent({
isFileContentLoading,
isFullScreenModeActivated,
toggleFullscreenMode,
updateLocalHistory
updateLocalHistory,
fileId: fileId
}
},
data() {
Expand Down
153 changes: 153 additions & 0 deletions packages/web-pkg/src/components/AppBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<template>
<portal to="app.app-banner">
<div class="app-banner hide-desktop" :hidden="isVisible === false">
<oc-button
variation="brand"
appearance="raw"
class="app-banner-exit"
aria-label="Close"
@click="close"
>
<oc-icon name="close" size="small" />
</oc-button>
<div
class="app-banner-icon"
:style="{ 'background-image': `url('${appBannerSettings.icon}')` }"
></div>
<div class="info-container">
<div>
<div class="app-title">{{ appBannerSettings.title }}</div>
<div class="app-publisher">{{ appBannerSettings.publisher }}</div>
<div v-if="appBannerSettings.additionalInformation !== ''" class="app-additional-info">
{{ $gettext(appBannerSettings.additionalInformation) }}
</div>
</div>
</div>
<a
:href="appUrl"
target="_blank"
class="app-banner-cta"
rel="noopener"
aria-label="{{ $gettext(appBannerSettings.ctaText) }}"
>{{ $gettext(appBannerSettings.ctaText) }}</a
>
</div>
</portal>
</template>

<script lang="ts">
import { computed, defineComponent, ref, unref } from 'vue'
import { useRouter, useStore } from 'web-pkg'
import { buildUrl } from 'web-pkg/src/helpers/router'
import { useSessionStorage } from '@vueuse/core'
export default defineComponent({
components: {},
props: {
fileId: {
type: String,
required: true
}
},
setup(props) {
const appBannerWasClosed = useSessionStorage('app_banner_closed', null)
const isVisible = ref<boolean>(unref(appBannerWasClosed) === null)
const store = useStore()
const router = useRouter()
const appBannerSettings = unref(store.getters.configuration.currentTheme.appBanner)
const appUrl = computed(() => {
return buildUrl(router, `/f/${props.fileId}`)
.toString()
.replace('https', appBannerSettings.appScheme)
})
const close = () => {
isVisible.value = false
useSessionStorage('app_banner_closed', 1)
}
return {
appUrl,
close,
isVisible,
appBannerSettings
}
}
})
</script>

<style scoped lang="scss">
.hide-desktop {
@media (min-width: 768px) {
display: none;
}
}
.app-banner {
overflow-x: hidden;
width: 100%;
height: 84px;
background: #f3f3f3;
font-family: Helvetica, sans, sans-serif;
z-index: 5;
}
.info-container {
position: absolute;
top: 10px;
left: 104px;
display: flex;
overflow-y: hidden;
width: 60%;
height: 64px;
align-items: center;
color: #000;
}
.app-banner-icon {
position: absolute;
top: 10px;
left: 30px;
width: 64px;
height: 64px;
border-radius: 15px;
background-size: 64px 64px;
}
.app-banner-cta {
position: absolute;
top: 32px;
right: 10px;
z-index: 1;
display: block;
padding: 0 10px;
min-width: 10%;
border-radius: 5px;
background: #f3f3f3;
color: #1474fc;
font-size: 18px;
text-align: center;
text-decoration: none;
}
.app-title {
font-size: 14px;
}
.app-publisher,
.app-additional-info {
font-size: 12px;
}
.app-banner-exit {
position: absolute;
top: 34px;
left: 9px;
margin: 0;
width: 12px;
height: 12px;
border: 0;
text-align: center;
display: inline;
}
</style>
2 changes: 1 addition & 1 deletion packages/web-pkg/src/components/QuotaSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
</template>

<script lang="ts">
import { formatFileSize } from 'web-pkg'
import { formatFileSize } from 'web-pkg/src/helpers/filesize'
export default {
name: 'QuotaSelect',
Expand Down
33 changes: 33 additions & 0 deletions packages/web-pkg/src/helpers/router/buildUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Router } from 'vue-router'

export const buildUrl = (router: Router, pathname) => {
const base = document.querySelector('base')
const isHistoryMode = !!base
const baseUrl = new URL(window.location.href.split('#')[0])
baseUrl.search = ''

if (isHistoryMode) {
// in history mode we can't determine the base path, it must be provided by the document
baseUrl.pathname = new URL(base.href).pathname
} else {
// in hash mode, auto-determine the base path by removing `/index.html`
if (baseUrl.pathname.endsWith('/index.html')) {
baseUrl.pathname = baseUrl.pathname.split('/').slice(0, -1).filter(Boolean).join('/')
}
}

/**
* build full url by either
* - concatenating baseUrl and pathname (for unknown/non-router urls, e.g. `oidc-callback.html`) or
* - resolving via router (for known routes)
*/
if (/\.(html?)$/i.test(pathname)) {
baseUrl.pathname = [...baseUrl.pathname.split('/'), ...pathname.split('/')]
.filter(Boolean)
.join('/')
} else {
baseUrl[isHistoryMode ? 'pathname' : 'hash'] = router.resolve(pathname).href
}

return baseUrl.href
}
1 change: 1 addition & 0 deletions packages/web-pkg/src/helpers/router/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './routeOptions'
export * from './buildUrl'
96 changes: 96 additions & 0 deletions packages/web-pkg/tests/unit/components/AppBanner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
createStore,
defaultComponentMocks,
defaultPlugins,
defaultStoreMockOptions,
shallowMount
} from 'web-test-helpers'
import AppBanner from 'web-pkg/src/components/AppBanner.vue'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { useSessionStorage } from '@vueuse/core'
import { ref } from 'vue'

jest.mock('@vueuse/core')

describe('AppBanner', () => {
it('generates app url with correct app scheme', () => {
const baseElement = document.createElement('base')
baseElement.href = '/'
document.getElementsByTagName('head')[0].appendChild(baseElement)
delete window.location
window.location = new URL('https://localhost') as any

const { wrapper } = getWrapper({
fileId: '1337',
appScheme: 'owncloud',
sessionStorageReturnValue: null
})
expect(wrapper.find('.app-banner-cta').attributes().href).toBe('owncloud://localhost/f/1337')
})
it('does not show when banner was closed', () => {
const { wrapper } = getWrapper({
fileId: '1337',
appScheme: 'owncloud',
sessionStorageReturnValue: '1'
})
expect(wrapper.find('.app-banner').attributes().hidden).toBe('')
})

it('shows when banner was not yet closed', () => {
const { wrapper } = getWrapper({
fileId: '1337',
appScheme: 'owncloud',
sessionStorageReturnValue: null
})
expect(wrapper.find('.app-banner').attributes().hidden).toBe(undefined)
})
})

function getWrapper({ fileId, appScheme, sessionStorageReturnValue }) {
const storeOptions = {
...defaultStoreMockOptions
}

storeOptions.getters.configuration.mockReturnValue({
currentTheme: {
appBanner: {
title: 'ownCloud',
publisher: 'ownCloud GmbH',
additionalInformation: 'FREE',
ctaText: 'VIEW',
icon: 'themes/owncloud/assets/owncloud-app-icon.png',
appScheme
}
}
})

const router = createRouter({
routes: [
{
path: '/f',
component: {}
}
],
history: ('/' && createWebHistory('/')) || createWebHashHistory()
})

jest.mocked(useSessionStorage).mockImplementation(() => {
return ref<string>(sessionStorageReturnValue)
})

const mocks = { ...defaultComponentMocks(), $router: router }
const store = createStore(storeOptions)

return {
wrapper: shallowMount(AppBanner, {
props: {
fileId
},
global: {
plugins: [...defaultPlugins(), store],
mocks,
provide: mocks
}
})
}
}
1 change: 1 addition & 0 deletions packages/web-runtime/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<template>
<portal-target name="app.app-banner" multiple />
<div id="web">
<oc-hidden-announcer :announcement="announcement" level="polite" />
<skip-to target="web-content-main">
Expand Down
Loading

0 comments on commit baef9d6

Please sign in to comment.