Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

self-built app banner #9696

Merged
merged 16 commits into from
Sep 28, 2023
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 @@ -36,9 +37,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 @@ -138,7 +141,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: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite sure if this should be always required, as a generic component this could be shown even if not public link / file context

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)
grimmoc marked this conversation as resolved.
Show resolved Hide resolved
})

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