Skip to content

Commit fd95e0e

Browse files
committed
Refine onboarding based on maintainer review conventions
1 parent b7c733c commit fd95e0e

13 files changed

Lines changed: 207 additions & 134 deletions

File tree

‎frontend/src/common/appdownload.ts‎

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
export function isMobile(): boolean {
2-
return /android|iphone|ipad|ipod/i.test(navigator.userAgent);
2+
return /android|iphone|ipad|ipod/i.test(navigator.userAgent) || isIpadOs();
3+
}
4+
5+
// iPadOS reports itself as a Mac in the user agent; the touch screen gives it away
6+
function isIpadOs(): boolean {
7+
return /mac/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1;
38
}
49

510
export function appDownloadUrl(): string {
611
const ua = navigator.userAgent;
712
if (/android/i.test(ua)) {
813
return 'https://play.google.com/store/apps/details?id=org.cryptomator.freemium';
9-
} else if (/iphone|ipad|ipod/i.test(ua)) {
14+
} else if (/iphone|ipad|ipod/i.test(ua) || isIpadOs()) {
1015
return 'https://apps.apple.com/app/cryptomator/id1560822163';
1116
} else if (/windows/i.test(ua)) {
1217
return 'https://cryptomator.org/downloads/win/thanks/';
@@ -24,13 +29,14 @@ let cachedMacDownloadUrl: string | undefined;
2429
const macFallbackUrl = 'https://cryptomator.org/downloads/#mac';
2530

2631
// same WebGL renderer sniffing as cryptomator.org uses to tell Apple Silicon and Intel Macs apart;
27-
// cached because every sniff allocates a WebGL context that counts against the browser's context cap
32+
// the context is released right away and the result cached, as browsers cap concurrent WebGL contexts
2833
function macDownloadUrl(): string {
2934
try {
3035
const gl = document.createElement('canvas').getContext('webgl');
3136
if (gl) {
3237
const info = gl.getExtension('WEBGL_debug_renderer_info');
3338
const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)).toLowerCase() : '';
39+
gl.getExtension('WEBGL_lose_context')?.loseContext();
3440
cachedMacDownloadUrl = macUrlForRenderer(renderer);
3541
return cachedMacDownloadUrl;
3642
}
@@ -49,24 +55,3 @@ function macUrlForRenderer(renderer: string): string {
4955
return macFallbackUrl;
5056
}
5157
}
52-
53-
function appHintKey(userId: string): string {
54-
return `hub.appHintDismissed.${userId}`;
55-
}
56-
57-
export function isAppHintDismissed(userId: string): boolean {
58-
try {
59-
return localStorage.getItem(appHintKey(userId)) !== null;
60-
} catch {
61-
// without localStorage we cannot remember a dismissal, so rather hide the hint than repeat it on every visit
62-
return true;
63-
}
64-
}
65-
66-
export function dismissAppHint(userId: string) {
67-
try {
68-
localStorage.setItem(appHintKey(userId), new Date().toISOString());
69-
} catch {
70-
// if localStorage is unavailable, the hint may show again next time
71-
}
72-
}

‎frontend/src/common/onboarding.ts‎

Lines changed: 58 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DriveStep, driver } from 'driver.js';
2-
import { createApp } from 'vue';
2+
import { createApp, ref } from 'vue';
33
import type { App, Component } from 'vue';
44
import GetAppStep from '../components/onboarding/GetAppStep.vue';
55
import ProfileVignette from '../components/onboarding/ProfileVignette.vue';
@@ -8,30 +8,43 @@ import VaultListVignette from '../components/onboarding/VaultListVignette.vue';
88
import WelcomeVignette from '../components/onboarding/WelcomeVignette.vue';
99
import i18n from '../i18n';
1010
import auth from './auth';
11+
import { isFlagSet, setFlag } from './util';
1112
import 'driver.js/dist/driver.css';
1213

1314
type TourStep = {
1415
target?: string;
16+
showWithoutTarget?: boolean;
1517
titleKey: string;
1618
descriptionKey?: string;
1719
vignette?: Component;
1820
};
1921

2022
const vaultListTarget = '[data-tour="vaultList"]';
21-
const adminNavTarget = '[data-tour="adminNav"]';
23+
24+
let activeTour: ReturnType<typeof driver> | undefined;
25+
26+
// driver.js rebuilds the popover per step, so each render unmounts the previous step's content app;
27+
// module scope lets a replayed tour clean up a predecessor whose onDestroyed hook driver.js skipped
28+
let contentApp: App | undefined;
29+
30+
function unmountContent() {
31+
contentApp?.unmount();
32+
contentApp = undefined;
33+
}
2234

2335
// addVault must be gated explicitly because the button is always rendered, merely disabled;
2436
// the admin step needs no role check: its target only exists for admins and invisible steps are skipped
2537
/** exported for testing */
2638
export function tourSteps(canCreateVaults: boolean): TourStep[] {
2739
return [
2840
{ titleKey: 'onboarding.welcome.title', descriptionKey: 'onboarding.welcome.description', vignette: WelcomeVignette },
29-
{ target: vaultListTarget, titleKey: 'onboarding.vaultList.title', descriptionKey: canCreateVaults ? 'onboarding.vaultList.description' : 'onboarding.vaultList.description.shared', vignette: VaultListVignette },
41+
{ target: vaultListTarget, titleKey: 'onboarding.vaultList.title', descriptionKey: canCreateVaults ? 'onboarding.vaultList.owner.description' : 'onboarding.vaultList.member.description', vignette: VaultListVignette },
3042
canCreateVaults
3143
? { target: '[data-tour="addVault"]', titleKey: 'onboarding.addVault.title', descriptionKey: 'onboarding.addVault.description' }
32-
: { target: vaultListTarget, titleKey: 'onboarding.useVault.title', descriptionKey: 'onboarding.useVault.description' },
33-
{ target: adminNavTarget, titleKey: 'onboarding.adminNav.title', descriptionKey: 'onboarding.adminNav.description' },
34-
{ target: '[data-tour="profile"]', titleKey: 'onboarding.profile.title', descriptionKey: 'onboarding.profile.description', vignette: ProfileVignette },
44+
: { titleKey: 'onboarding.useVault.title', descriptionKey: 'onboarding.useVault.description' },
45+
{ target: '[data-tour="adminNav"]', titleKey: 'onboarding.adminNav.title', descriptionKey: 'onboarding.adminNav.description' },
46+
// the account step must reach everyone, so it falls back to a floating card when the sidebar is hidden
47+
{ target: '[data-tour="profile"]', showWithoutTarget: true, titleKey: 'onboarding.profile.title', descriptionKey: 'onboarding.profile.description', vignette: ProfileVignette },
3548
{ titleKey: 'onboarding.getApp.title', vignette: GetAppStep }
3649
];
3750
}
@@ -40,27 +53,26 @@ function onboardingKey(userId: string): string {
4053
return `hub.onboardingCompleted.${userId}`;
4154
}
4255

43-
function isOnboardingCompleted(userId: string): boolean {
44-
try {
45-
return localStorage.getItem(onboardingKey(userId)) !== null;
46-
} catch {
47-
// without localStorage we cannot remember a completion, so rather skip the tour than repeat it on every visit
48-
return true;
49-
}
56+
// bumped on completion, so long-lived components like the sidebar react without a reload
57+
const completionVersion = ref(0);
58+
59+
/** also gates UI that should only appear once the tour is over, e.g. the sidebar app hint */
60+
export function isOnboardingCompleted(userId: string): boolean {
61+
void completionVersion.value; // subscribes reactive callers to tour completions
62+
return isFlagSet(onboardingKey(userId));
5063
}
5164

52-
function setOnboardingCompleted(userId: string) {
53-
try {
54-
localStorage.setItem(onboardingKey(userId), new Date().toISOString());
55-
} catch {
56-
// if localStorage is unavailable, the tour may show again next time
57-
}
65+
function completeOnboarding(userId: string) {
66+
setFlag(onboardingKey(userId));
67+
completionVersion.value++;
5868
}
5969

6070
function findVisibleElement(selector: string): Element | undefined {
61-
return Array.from(document.querySelectorAll(selector)).find(element => element.checkVisibility());
71+
return Array.from(document.querySelectorAll(selector)).find(element => element.checkVisibility() && !element.matches(':disabled'));
6272
}
6373

74+
// driver.js' own waitForElement cannot be used here: the steps are filtered and their duplicate
75+
// anchors resolved to the visible instance before the tour is built
6476
async function waitForVisibleElement(selector: string): Promise<boolean> {
6577
for (let attempt = 0; attempt < 80 && !findVisibleElement(selector); attempt++) {
6678
await new Promise(resolve => setTimeout(resolve, 50));
@@ -85,7 +97,7 @@ export async function startOnboardingIfNeeded(userId: string) {
8597
* @param userId the id of the currently logged in user
8698
*/
8799
export async function startOnboarding(userId: string) {
88-
await driveTour(tourSteps((await auth).hasRole('create-vaults')), () => setOnboardingCompleted(userId));
100+
await driveTour(tourSteps((await auth).hasRole('create-vaults')), () => completeOnboarding(userId));
89101
}
90102

91103
/**
@@ -96,70 +108,66 @@ export function stopOnboarding() {
96108
const tour = activeTour;
97109
activeTour = undefined;
98110
tour?.destroy();
111+
// driver.js skips onDestroyed during its highlight transition, so do not rely on it for cleanup
112+
unmountContent();
99113
}
100114

101-
let activeTour: ReturnType<typeof driver> | undefined;
102-
103-
// driver.js rebuilds the popover per step, so each render unmounts the previous step's content app;
104-
// module scope lets a replayed tour clean up a predecessor whose onDestroyed hook driver.js skipped
105-
let contentApp: App | undefined;
106-
107-
function unmountContent() {
108-
contentApp?.unmount();
109-
contentApp = undefined;
110-
}
111-
112-
async function driveTour(includedSteps: TourStep[], onTourDestroyed?: () => void) {
115+
async function driveTour(includedSteps: TourStep[], onTourDestroyed: () => void) {
113116
const { t } = i18n.global;
114117
if (!await waitForVisibleElement(vaultListTarget)) {
115118
console.warn('Onboarding tour not started because the vault list did not become visible');
116119
return;
117120
}
121+
// concurrent starts (e.g. a replay while the auto-start is still waiting) converge on the last caller
118122
stopOnboarding();
119123

120124
const steps: DriveStep[] = [];
125+
const titles: string[] = [];
121126
const contents: TourPopoverProps[] = [];
122127
for (const step of includedSteps) {
123128
const element = step.target ? findVisibleElement(step.target) : undefined;
124-
if (step.target && !element) {
129+
if (step.target && !element && !step.showWithoutTarget) {
125130
continue;
126131
}
127-
// the blank placeholder keeps driver.js from hiding the description element it hands to onPopoverRender
128-
steps.push({ element, popover: { title: t(step.titleKey), description: ' ' } });
129-
contents.push({ content: step.vignette, text: step.descriptionKey ? t(step.descriptionKey) : undefined });
132+
// blank placeholders keep driver.js from hiding the title and description elements it hands to onPopoverRender
133+
steps.push({ element, popover: { title: ' ', description: ' ' } });
134+
titles.push(t(step.titleKey));
135+
contents.push({ vignette: step.vignette, text: step.descriptionKey ? t(step.descriptionKey) : undefined });
130136
}
131137

132-
activeTour = driver({
138+
const tour = driver({
133139
popoverClass: 'onboarding-popover',
134-
overlayColor: 'var(--color-gray-500)',
140+
overlayColor: 'var(--color-gray-500, #6b7280)',
135141
overlayOpacity: 0.75,
136142
stagePadding: 8,
137143
stageRadius: 8,
138144
showProgress: true,
139-
// driver.js substitutes {{current}} and {{total}} itself, vue-i18n only places them in the message
140-
progressText: t('onboarding.progress', ['{{current}}', '{{total}}']),
141-
nextBtnText: t('common.next'),
142-
prevBtnText: t('common.previous'),
143-
doneBtnText: t('onboarding.done'),
144145
onPopoverRender: (popover, { state }) => {
145-
popover.closeButton.setAttribute('aria-label', t('common.close'));
146146
unmountContent();
147-
const content = state.activeIndex !== undefined ? contents[state.activeIndex] : undefined;
148-
if (!content) {
147+
const index = state.activeIndex;
148+
const content = index !== undefined ? contents[index] : undefined;
149+
if (index === undefined || !content) {
149150
return;
150151
}
152+
// driver.js assigns all of these via innerHTML, so set them as text to keep translations inert
153+
popover.title.textContent = titles[index];
154+
popover.progress.textContent = t('onboarding.progress', [index + 1, steps.length]);
155+
popover.previousButton.textContent = t('common.previous');
156+
popover.nextButton.textContent = index === steps.length - 1 ? t('onboarding.done') : t('common.next');
157+
popover.closeButton.setAttribute('aria-label', t('common.close'));
151158
contentApp = createApp(TourPopoverContent, content);
152159
contentApp.mount(popover.description);
153160
},
154161
onDestroyed: () => {
155162
unmountContent();
156163
// a tour cancelled via stopOnboarding() has already been detached and must not count as completed
157-
if (activeTour !== undefined) {
164+
if (activeTour === tour) {
158165
activeTour = undefined;
159-
onTourDestroyed?.();
166+
onTourDestroyed();
160167
}
161168
},
162169
steps
163170
});
164-
activeTour.drive();
171+
activeTour = tour;
172+
tour.drive();
165173
}

‎frontend/src/common/util.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,26 @@ class WordEncoder {
176176
}
177177

178178
export const wordEncoder = new WordEncoder(dictionary);
179+
180+
/**
181+
* Checks whether a flag was set via {@link setFlag}.
182+
*/
183+
export function isFlagSet(key: string): boolean {
184+
try {
185+
return localStorage.getItem(key) !== null;
186+
} catch {
187+
// without localStorage the flag cannot be remembered, so report it as set rather than nag on every visit
188+
return true;
189+
}
190+
}
191+
192+
/**
193+
* Sets a persistent flag. The stored timestamp is informational; only the key's presence matters.
194+
*/
195+
export function setFlag(key: string) {
196+
try {
197+
localStorage.setItem(key, new Date().toISOString());
198+
} catch {
199+
// if localStorage is unavailable, the flag may be requested again next time
200+
}
201+
}

‎frontend/src/components/Sidebar.vue‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,25 +59,27 @@ const adminNav: NavigationItem[] = [
5959
];
6060
6161
const profileDropdown: ProfileDropdownItem[][] = [
62-
[{ icon: UserIcon, name: 'nav.profile.profile', to: '/app/profile' }],
63-
[{ icon: QuestionMarkCircleIcon, name: 'nav.profile.showTour', action: replayTour }],
62+
[
63+
{ icon: UserIcon, name: 'nav.profile.profile', to: '/app/profile' },
64+
{ icon: QuestionMarkCircleIcon, name: 'nav.profile.showTour', action: replayTour }
65+
],
6466
[{ icon: ArrowRightStartOnRectangleIcon, name: 'nav.profile.signOut', to: '/app/logout' }]
6567
];
6668
6769
const isAdmin = ref(false);
6870
const licenseStatus = ref<LicenseUserInfoDto>();
6971
70-
let drawerClosed: Deferred<void> | undefined;
72+
let drawerClosed: Deferred<'closed' | 'reopened'> | undefined;
7173
7274
function onDrawerClosed() {
73-
drawerClosed?.resolve();
75+
drawerClosed?.resolve('closed');
7476
drawerClosed = undefined;
7577
}
7678
7779
// a cancelled leave transition never emits after-leave, which would leave the replay hanging forever
7880
watch(() => props.mobileOpen, (open) => {
7981
if (open) {
80-
drawerClosed?.reject(new Error('Drawer reopened'));
82+
drawerClosed?.resolve('reopened');
8183
drawerClosed = undefined;
8284
}
8385
});
@@ -88,7 +90,9 @@ async function replayTour() {
8890
// otherwise the tour would highlight the drawer's detaching elements
8991
drawerClosed ??= new Deferred();
9092
emit('close');
91-
await drawerClosed.promise;
93+
if (await drawerClosed.promise === 'reopened') {
94+
return;
95+
}
9296
}
9397
await router.push('/app/vaults');
9498
await startOnboarding(props.me.id);

‎frontend/src/components/SidebarContent.vue‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
</li>
2121
</ul>
2222

23+
<!-- SidebarContent renders twice (drawer and desktop); the tour resolves data-tour anchors to the visible instance -->
2324
<div v-if="isAdmin" data-tour="adminNav">
2425
<hr class="border-white/10" />
2526
<ul role="list" class="mt-3 space-y-1">
@@ -35,7 +36,7 @@
3536

3637
<div v-if="showAppHint" class="relative rounded-lg bg-white/5 p-3 ring-1 ring-white/10">
3738
<div class="flex items-center gap-x-2.5 pr-6">
38-
<img src="/cryptomator.svg" alt="" class="h-7 w-auto shrink-0" />
39+
<img src="/app-logo.svg" alt="" class="h-7 w-auto shrink-0" />
3940
<h3 class="text-sm font-medium text-white">{{ t('appHint.title') }}</h3>
4041
</div>
4142
<p class="mt-1.5 text-xs/5 text-gray-400">{{ t('appHint.description') }}</p>
@@ -92,8 +93,10 @@ import { XMarkIcon } from '@heroicons/vue/24/outline';
9293
import { computed, FunctionalComponent, ref } from 'vue';
9394
import { useI18n } from 'vue-i18n';
9495
import { useRoute } from 'vue-router';
95-
import { appDownloadUrl, dismissAppHint, isAppHintDismissed } from '../common/appdownload';
96+
import { appDownloadUrl } from '../common/appdownload';
9697
import { UserDto } from '../common/backend';
98+
import { isOnboardingCompleted } from '../common/onboarding';
99+
import { isFlagSet, setFlag } from '../common/util';
97100
98101
export type NavigationItem = { icon: FunctionalComponent, name: string, to: string };
99102
export type ProfileDropdownItem = { icon: FunctionalComponent, name: string } & ({ to: string } | { action: () => void });
@@ -116,11 +119,14 @@ const emit = defineEmits<{
116119
}>();
117120
118121
const downloadUrl = appDownloadUrl();
119-
const appHintDismissed = ref(isAppHintDismissed(props.me.id));
120-
const showAppHint = computed(() => !appHintDismissed.value && props.me.devices.every(device => device.type === 'BROWSER'));
122+
const appHintKey = `hub.appHintDismissed.${props.me.id}`;
123+
const appHintDismissed = ref(isFlagSet(appHintKey));
124+
// only shown once the tour is over, so it does not compete with the tour's own download step;
125+
// legacy devices are ignored on purpose: their owners should switch to the current app anyway
126+
const showAppHint = computed(() => !appHintDismissed.value && isOnboardingCompleted(props.me.id) && props.me.devices.every(device => device.type === 'BROWSER'));
121127
122128
function dismissHint() {
123-
dismissAppHint(props.me.id);
129+
setFlag(appHintKey);
124130
appHintDismissed.value = true;
125131
}
126132

‎frontend/src/components/VaultList.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ const filteredVaults = computed(() =>
206206
207207
onMounted(async () => {
208208
await fetchData();
209-
if (me.value) {
209+
if (me.value && !onFetchError.value) {
210210
await nextTick();
211211
await startOnboardingIfNeeded(me.value.id);
212212
}

0 commit comments

Comments
 (0)