feat: render app shell with ssr to improve loading experience (#448)

This commit is contained in:
Daniel Roe 2022-12-17 16:55:29 +00:00 committed by GitHub
parent b545efeacc
commit 9395b7031e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 169 additions and 127 deletions

View file

@ -1,3 +1,4 @@
*.css
*.png
*.ico
*.toml

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
setupI18n()
setupLogging()
setupPageHeader()
await setupI18n()
provideGlobalCommands()
// We want to trigger rerendering the page when account changes
@ -11,7 +11,6 @@ const key = computed(() => `${currentServer.value}:${currentUser.value?.account.
<template>
<NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" />
<NuxtLayout :key="key">
<NuxtPage />
<NuxtPage v-if="isMastoInitialised" />
</NuxtLayout>
<TeleportTarget id="teleport-end" />
</template>

View file

@ -22,7 +22,7 @@ defineProps<{
</div>
<div flex items-center flex-shrink-0 gap-x-2>
<slot name="actions" />
<NavUser v-if="isMediumScreen" />
<NavUser v-if="isHydrated && isMediumScreen" />
</div>
</div>
<slot name="header" />

View file

@ -29,6 +29,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
</script>
<template>
<template v-if="isMastoInitialised">
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
<UserSignIn />
</ModalDialog>
@ -53,4 +54,5 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
<CommandPanel @close="closeCommandPanel()" />
</ModalDialog>
</template>
</template>

View file

@ -137,7 +137,7 @@ export default {
</script>
<template>
<SafeTeleport to="#teleport-end" @transitionend="trapFocusDialog">
<Teleport to="body" @transitionend="trapFocusDialog">
<!-- Dialog component -->
<Transition name="dialog-visible">
<div
@ -173,7 +173,7 @@ export default {
</div>
</div>
</Transition>
</SafeTeleport>
</Teleport>
</template>
<style lang="postcss" scoped>

View file

@ -10,7 +10,7 @@ const moreMenuVisible = ref(false)
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
>
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
<template v-if="currentUser">
<template v-if="isMastoInitialised && currentUser">
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:home-5-line />
</NuxtLink>
@ -24,12 +24,12 @@ const moreMenuVisible = ref(false)
<NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:group-2-line />
</NuxtLink>
<template v-if="!currentUser">
<template v-if="!isMastoInitialised || !currentUser">
<NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:earth-line />
</NuxtLink>
</template>
<template v-if="currentUser">
<template v-if="isMastoInitialised && currentUser">
<NuxtLink to="/conversations" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:at-line />
</NuxtLink>

View file

@ -96,7 +96,7 @@ onBeforeUnmount(() => {
</button>
</NavSelectLanguage>
<!-- Toggle Feature Flags -->
<NavSelectFeatureFlags v-if="currentUser">
<NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<button
flex flex-row items-center
block px-5 py-2 focus-blue w-full

View file

@ -30,7 +30,7 @@ const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions)
</button>
</CommonTooltip>
</NavSelectLanguage>
<NavSelectFeatureFlags v-if="currentUser">
<NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<CommonTooltip :content="$t('nav_footer.select_feature_flags')">
<button flex :aria-label="$t('nav_footer.select_feature_flags')">
<div i-ri:flag-line text-lg />
@ -44,7 +44,7 @@ const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions)
</button>
</div>
<div>{{ $t('app_desc_short') }}</div>
<div>
<div v-if="isMastoInitialised">
<i18n-t keypath="nav_footer.built_at">
<time :datetime="buildTime" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
</i18n-t>

View file

@ -4,7 +4,7 @@ const { notifications } = useNotifications()
<template>
<nav md:px3 md:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
<template v-if="currentUser">
<template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.home')" to="/home" icon="i-ri:home-5-line" />
<NavSideItem :text="$t('nav_side.notifications')" to="/notifications" icon="i-ri:notification-4-line">
<template #icon>
@ -20,12 +20,12 @@ const { notifications } = useNotifications()
<NavSideItem :text="$t('nav_side.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" />
<NavSideItem :text="$t('nav_side.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " />
<NavSideItem :text="$t('nav_side.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" />
<template v-if="currentUser">
<template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" />
<NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" />
<NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " />
<NavSideItem
v-if="isMediumScreen"
v-if="isHydrated && isMediumScreen"
:text="currentUser.account.displayName"
:to="getAccountRoute(currentUser.account)"
icon="i-ri:account-circle-line"

View file

@ -25,7 +25,7 @@ useCommand({
</script>
<template>
<NuxtLink :to="to" active-class="text-primary" group focus:outline-none @click="$scrollToTop">
<NuxtLink :to="to" :active-class="isMastoInitialised ? 'text-primary' : ''" group focus:outline-none @click="$scrollToTop">
<div flex w-fit px5 py2 md:gap2 gap4 items-center transition-100 rounded-full group-hover:bg-active group-focus-visible:ring="2 current">
<slot name="icon">
<div :class="icon" md:text-size-inherit text-xl />

View file

@ -1,5 +1,5 @@
<template>
<VDropdown v-if="currentUser">
<VDropdown v-if="isMastoInitialised && currentUser">
<div style="-webkit-touch-callout: none;">
<AccountAvatar
ref="avatar"

View file

@ -27,11 +27,11 @@ const description = ref(props.attachment.description ?? '')
v-if="removable"
aria-label="Remove attachment"
hover:bg="gray/40" transition-100 p-1 rounded-5 cursor-pointer
:class="[isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']"
:class="[isHydrated && isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']"
mix-blend-difference
@click="$emit('remove')"
>
<div i-ri:close-line text-3 :class="[isSmallScreen ? 'text-6' : 'text-3']" />
<div i-ri:close-line text-3 :class="[isHydrated && isSmallScreen ? 'text-6' : 'text-3']" />
</div>
</div>
<div absolute right-2 bottom-2>

View file

@ -167,7 +167,7 @@ defineExpose({
</script>
<template>
<div v-if="currentUser" flex="~ col gap-4" py4 px2 sm:px4>
<div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py4 px2 sm:px4>
<template v-if="draft.editingStatus">
<div flex="~ col gap-1">
<div id="state-editing" text-secondary self-center>

View file

@ -162,7 +162,7 @@ async function editStatus() {
@click="toggleTranslation"
/>
<template v-if="currentUser">
<template v-if="isMastoInitialised && currentUser">
<template v-if="isAuthor">
<CommonDropdownItem
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"

View file

@ -1,6 +1,6 @@
<template>
<div p8 flex="~ col gap4">
<p text-sm>
<p v-if="isMastoInitialised" text-sm>
Viewing <strong>{{ currentServer }}</strong> public data
</p>
<p text-sm text-secondary>

View file

@ -44,7 +44,7 @@ const switchUser = (user: UserLogin) => {
@click="openSigninDialog"
/>
<CommonDropdownItem
v-if="currentUser"
v-if="isMastoInitialised && currentUser"
:text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])"
icon="i-ri:logout-box-line"
@click="signout"

View file

@ -5,7 +5,7 @@ const cache = new LRU<string, any>({
max: 1000,
})
if (process.dev)
if (process.dev && process.client)
// eslint-disable-next-line no-console
console.log({ cache })

View file

@ -2,7 +2,7 @@ import type { Emoji } from 'masto'
import type { Node } from 'ultrahtml'
import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml'
const decoder = document.createElement('textarea')
const decoder = process.client ? document.createElement('textarea') : null as any as HTMLTextAreaElement
function decode(text: string) {
decoder.innerHTML = text
return decoder.value

View file

@ -13,7 +13,7 @@ export function getDefaultFeatureFlags(): FeatureFlags {
}
}
export const currentUserFeatureFlags = useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags)
export const currentUserFeatureFlags = process.server ? computed(getDefaultFeatureFlags) : useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags)
export function useFeatureFlags() {
const featureFlags = currentUserFeatureFlags.value

14
composables/hydration.ts Normal file
View file

@ -0,0 +1,14 @@
export const isHydrated = computed(() => {
if (process.server)
return false
const nuxtApp = useNuxtApp()
if (!nuxtApp.isHydrating)
return false
const hydrated = ref(false)
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
hydrated.value = true
})
return hydrated
})

View file

@ -1,12 +1,11 @@
import type { Ref } from 'vue'
import type { Account, MastoClient, Relationship, Status } from 'masto'
import type { Account, Relationship, Status } from 'masto'
import { withoutProtocol } from 'ufo'
import type { ElkMasto } from '~/types'
export const useMasto = () => useNuxtApp().$masto.api as MastoClient
export const useMasto = () => useNuxtApp().$masto as ElkMasto
export const setMasto = (masto: MastoClient) => {
useNuxtApp().$masto?.replace(masto)
}
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
// @unocss-include
export const STATUS_VISIBILITIES = [

View file

@ -56,6 +56,7 @@ export function usePaginator<T>(paginator: Paginator<any, T[]>, stream?: WsEvent
bound.update()
}
if (process.client) {
useIntervalFn(() => {
bound.update()
}, 1000)
@ -73,6 +74,7 @@ export function usePaginator<T>(paginator: Paginator<any, T[]>, stream?: WsEvent
},
{ immediate: true },
)
}
return {
items,

View file

@ -19,8 +19,10 @@ export function setupPageHeader() {
export async function setupI18n() {
const { locale, setLocale, locales } = useI18n()
const isFirstVisit = !window.localStorage.getItem(STORAGE_KEY_LANG)
const localeStorage = useLocalStorage(STORAGE_KEY_LANG, locale.value)
const nuxtApp = useNuxtApp()
nuxtApp.hook('app:suspense:resolve', async () => {
const isFirstVisit = process.server ? false : !window.localStorage.getItem(STORAGE_KEY_LANG)
const localeStorage = process.server ? ref('en-US') : useLocalStorage(STORAGE_KEY_LANG, locale.value)
if (isFirstVisit) {
const userLang = (navigator.language || 'en-US').toLowerCase()
@ -37,4 +39,5 @@ export async function setupI18n() {
watchEffect(() => {
localeStorage.value = locale.value
})
})
}

View file

@ -2,7 +2,7 @@ import type { Account, Status } from 'masto'
import { STORAGE_KEY_DRAFTS } from '~/constants'
import type { Draft, DraftMap } from '~/types'
export const currentUserDrafts = useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
export function getDefaultDraft(options: Partial<Draft['params'] & Omit<Draft, 'params'>> = {}): Draft {
const {

View file

@ -8,10 +8,9 @@ export interface TranslationResponse {
}
}
const config = useRuntimeConfig()
export const languageCode = process.server ? 'en' : navigator.language.replace(/-.*$/, '')
export async function translateText(text: string, from?: string | null, to?: string) {
const config = useRuntimeConfig()
const { translatedText } = await $fetch<TranslationResponse>(config.public.translateApi, {
method: 'POST',
body: {
@ -41,7 +40,7 @@ export function useTranslation(status: Status) {
}
return {
enabled: !!config.public.translateApi,
enabled: !!useRuntimeConfig().public.translateApi,
toggle,
translation,
}

View file

@ -73,8 +73,6 @@ export async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: Ac
}
}
setMasto(masto)
if ('server' in route.params && user?.token) {
await router.push({
...route,
@ -117,6 +115,7 @@ const notifications = reactive<Record<string, undefined | [Promise<WsEvents>, nu
export const useNotifications = () => {
const id = currentUser.value?.account.id
const masto = useMasto()
const clearNotifications = () => {
if (!id || !notifications[id])
@ -125,10 +124,9 @@ export const useNotifications = () => {
}
async function connect(): Promise<void> {
if (!id || notifications[id] || !currentUser.value?.token)
if (!isMastoInitialised.value || !id || notifications[id] || !currentUser.value?.token)
return
const masto = useMasto()
const stream = masto.stream.streamUser()
notifications[id] = [stream, 0]
;(await stream).on('notification', () => {

View file

@ -7,7 +7,7 @@
<NavTitle mx3 mt4 mb2 self-start />
<div flex="~ col" overflow-y-auto>
<NavSide />
<PublishButton v-if="currentUser" m5 />
<PublishButton v-if="isMastoInitialised && currentUser" m5 />
<div flex-auto />
</div>
</slot>
@ -18,15 +18,15 @@
<slot />
</div>
<div sticky left-0 right-0 bottom-0 z-10 bg-base pb="[env(safe-area-inset-bottom)]" transition="padding 20">
<CommonOfflineChecker :small-screen="isSmallScreen" />
<NavBottom v-if="isSmallScreen" />
<CommonOfflineChecker :small-screen="isHydrated && isSmallScreen" />
<NavBottom v-if="isHydrated && isSmallScreen" />
</div>
</div>
<aside class="hidden md:none lg:block w-1/4 zen-hide">
<div sticky top-0 h-screen flex="~ col">
<slot name="right">
<UserSignInEntry v-if="!currentUser" />
<div v-if="currentUser" py6 px4 w-full flex="~" items-center justify-between>
<UserSignInEntry v-if="isMastoInitialised && !currentUser" />
<div v-if="isMastoInitialised && currentUser" py6 px4 w-full flex="~" items-center justify-between>
<NuxtLink
p2 rounded-full text-start w-full
hover:bg-active cursor-pointer transition-100

View file

@ -1,4 +1,6 @@
export default defineNuxtRouteMiddleware((to) => {
if (process.server)
return
if (!currentUser.value)
return navigateTo(`/${currentServer.value}/public`)
if (to.path === '/')

View file

@ -6,7 +6,6 @@ import { i18n } from './config/i18n'
const isPreview = process.env.PULL_REQUEST === 'true'
export default defineNuxtConfig({
ssr: false,
modules: [
'@vueuse/nuxt',
'@unocss/nuxt',

View file

@ -83,7 +83,6 @@
"unplugin-auto-import": "^0.12.0",
"vite-plugin-inspect": "^0.7.9",
"vitest": "^0.25.3",
"vue-safe-teleport": "^0.1.1",
"vue-tsc": "^1.0.11",
"vue-virtual-scroller": "2.0.0-beta.4"
},

View file

@ -1,31 +1,60 @@
import type { MastoClient } from 'masto'
import { currentUser } from '../composables/users'
import type { ElkMasto } from '~/types'
export default defineNuxtPlugin(async () => {
let masto!: MastoClient
try {
export default defineNuxtPlugin(async (nuxtApp) => {
const api = shallowRef<MastoClient | null>(null)
const apiPromise = ref<Promise<MastoClient> | null>(null)
const initialised = computed(() => !!api.value)
const masto = new Proxy({} as ElkMasto, {
get(_, key: keyof ElkMasto) {
if (key === 'loggedIn')
return initialised
if (key === 'loginTo') {
return (...args: any[]) => {
apiPromise.value = loginTo(...args).then((r) => {
api.value = r
return masto
}).catch(() => {
// Show error page when Mastodon server is down
throw createError({
fatal: true,
statusMessage: 'Could not log into account.',
})
})
return apiPromise
}
}
if (api.value && key in api.value)
return api.value[key as keyof MastoClient]
if (!api) {
return new Proxy({}, {
get(_, subkey) {
return (...args: any[]) => apiPromise.value?.then((r: any) => r[key][subkey](...args))
},
})
}
},
})
if (process.client) {
const { query } = useRoute()
const user = typeof query.server === 'string' && typeof query.token === 'string'
? { server: query.server, token: query.token }
: currentUser.value
nuxtApp.hook('app:suspense:resolve', () => {
// TODO: improve upstream to make this synchronous (delayed auth)
masto = await loginTo(user)
}
catch {
// Show error page when Mastodon server is down
showError({
fatal: true,
statusMessage: 'Could not log into account.',
masto.loginTo(user)
})
}
return {
provide: {
masto: shallowReactive({
replace(api: MastoClient) { this.api = api },
api: masto,
}),
masto,
},
}
})

View file

@ -1,6 +0,0 @@
import VueSafeTeleport from 'vue-safe-teleport'
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueSafeTeleport)
})

View file

@ -63,7 +63,6 @@ specifiers:
unplugin-auto-import: ^0.12.0
vite-plugin-inspect: ^0.7.9
vitest: ^0.25.3
vue-safe-teleport: ^0.1.1
vue-tsc: ^1.0.11
vue-virtual-scroller: 2.0.0-beta.4
@ -132,7 +131,6 @@ devDependencies:
unplugin-auto-import: 0.12.0
vite-plugin-inspect: 0.7.9
vitest: 0.25.3_jsdom@20.0.3
vue-safe-teleport: 0.1.1
vue-tsc: 1.0.11_typescript@4.9.3
vue-virtual-scroller: 2.0.0-beta.4
@ -8710,12 +8708,6 @@ packages:
vue: 3.2.45
dev: true
/vue-safe-teleport/0.1.1:
resolution: {integrity: sha512-fHA4mod2oF7am2yEUtT0CsxAwfNBt6hWuYTVWzGxrY8vzxxgHMFnPjdZTKl01qGcKEMYYO38LmWizL7oGMVPGw==}
peerDependencies:
vue: ^3.2.0
dev: true
/vue-template-compiler/2.7.14:
resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==}
dependencies:

View file

@ -1,4 +1,5 @@
import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, Notification, Status } from 'masto'
import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, MastoClient, Notification, Status } from 'masto'
import type { Ref } from 'vue'
import type { Mutable } from './utils'
export interface AppInfo {
@ -17,6 +18,11 @@ export interface UserLogin {
account: AccountCredentials
}
export interface ElkMasto extends MastoClient {
loginTo (user?: Omit<UserLogin, 'account'> & { account?: AccountCredentials }): Promise<MastoClient>
loggedIn: Ref<boolean>
}
export type PaginatorState = 'idle' | 'loading' | 'done' | 'error'
export interface ServerInfo extends Instance {

View file

@ -9,6 +9,10 @@ export default defineConfig({
'~/': `${resolve(__dirname)}/`,
},
},
define: {
'process.server': 'false',
'process.client': 'true',
},
plugins: [
Vue(),
AutoImport({