wip bsky client for the web & android bbell.vt3e.cat

feat: split out app.vue into shell & splash; pronouns prompt

vt3e.cat 9ea4d2de cbf5059a

verified
+476 -179
+193 -178
src/App.vue
··· 1 1 <script setup lang="ts"> 2 - import { onMounted, computed, ref, onUnmounted } from 'vue' 2 + import { ref, onMounted, watch } from 'vue' 3 3 import { App, type URLOpenListenerEvent } from '@capacitor/app' 4 4 5 5 import { useNavigationStore } from './stores/navigation' ··· 7 7 import { useThemeStore } from './stores/theme' 8 8 import { useAuthStore } from './stores/auth' 9 9 10 - import TabStack from '@/components/Navigation/TabStack.vue' 11 - import NavigationBar from '@/components/Navigation/NavigationBar.vue' 10 + import SplashScreen from '@/components/Layout/SplashScreen.vue' 12 11 import OAuthCallback from '@/views/Auth/OAuthCallback.vue' 13 12 import OnboardingFlow from '@/views/Onboarding/OnboardingFlow.vue' 14 - import PostComposer from '@/components/Composer/PostComposer.vue' 13 + import AppShell from '@/components/Layout/AppShell.vue' 15 14 16 - import { stackRoots, type StackRootNames } from './router' 17 15 import BaseModal from './components/UI/BaseModal.vue' 16 + import TextInput from './components/UI/TextInput.vue' 17 + import BaseButton from './components/UI/BaseButton.vue' 18 + import KEYS from './utils/keys' 19 + import type { AppBskyActorProfile } from '@atcute/bluesky' 20 + import { ok } from '@atcute/client' 21 + 22 + type AppPhase = 'loading' | 'callback' | 'intro' | 'shell' 23 + const currentPhase = ref<AppPhase>('loading') 18 24 19 25 const nav = useNavigationStore() 20 26 const env = useEnvironmentStore() 21 27 const theme = useThemeStore() 22 28 const auth = useAuthStore() 23 29 24 - const activeTab = computed(() => nav.activeTab) 25 - const tabs: StackRootNames[] = stackRoots.map((p) => p.name) 30 + // init stuff 31 + // ======================================================== 32 + async function initializeApp() { 33 + theme.init() 34 + env.init() 35 + auth.init() 36 + 37 + const path = window.location.pathname 38 + if (path.includes('/oauth/callback')) { 39 + currentPhase.value = 'callback' 40 + return 41 + } 42 + 43 + const wait = () => new Promise((resolve) => setTimeout(resolve, 750)) 44 + 45 + // waiting for auth 46 + // we then either determine the Next Phase - either the onboarding flow or to the shell 47 + if (auth.isLoading) { 48 + const unwatch = watch( 49 + () => auth.isLoading, 50 + async (loading) => { 51 + if (!loading) { 52 + await wait() 53 + unwatch() 54 + determineNextPhase() 55 + } 56 + }, 57 + ) 58 + } else { 59 + await wait() 60 + determineNextPhase() 61 + } 62 + } 26 63 27 - const isCallback = ref(window.location.pathname.includes('/oauth/callback')) 64 + function determineNextPhase() { 65 + const hasSeenIntro = localStorage.getItem(KEYS.STATE.INTRO_COMPLETE) === 'true' 28 66 29 - const hasSeenIntro = localStorage.getItem('bluebell-intro-complete') === 'true' 30 - const showIntro = ref(!hasSeenIntro && !isCallback.value) 67 + // onboarding flow! 68 + if (!hasSeenIntro) { 69 + currentPhase.value = 'intro' 70 + } 71 + // shell/main app! 72 + else { 73 + finishStartup() 74 + } 75 + } 31 76 32 - const modalOpen = computed(() => showPostComposerDialog.value) 33 - const showPostComposerDialog = ref(false) 77 + function finishStartup() { 78 + nav.init() 79 + currentPhase.value = 'shell' 34 80 35 - auth.init() 81 + const profile = auth.profile 82 + if (!profile?.pronouns) showWokeModal.value = !localStorage.getItem(KEYS.STATE.WOKE_DISMISSED) 83 + } 36 84 37 - const onAuthComplete = () => { 85 + // event handlers 86 + // ======================================================== 87 + const onAuthCallbackComplete = () => { 38 88 window.history.replaceState(null, '', '/') 39 89 theme.init() 40 90 env.init() 41 - auth.init() 42 - isCallback.value = false 91 + finishStartup() 43 92 } 44 93 45 94 const onIntroComplete = (action: 'stay' | 'login') => { 46 - localStorage.setItem('bluebell-intro-complete', 'true') 47 - showIntro.value = false 48 - nav.init() 95 + localStorage.setItem(KEYS.STATE.INTRO_COMPLETE, 'true') 96 + finishStartup() 49 97 50 98 if (action === 'login') { 51 99 setTimeout(() => { ··· 54 102 } 55 103 } 56 104 57 - const handleBackNavigation = () => { 58 - if (!nav.canGoBack) { 59 - if (activeTab.value !== 'home') { 60 - nav.switchTab('home') 61 - return 62 - } 63 - App.exitApp().catch(() => {}) 64 - } 65 - nav.pop() 66 - } 67 - 68 - function isTypingInInput(): boolean { 69 - const active = document.activeElement as HTMLElement | null 70 - if (!active) return false 71 - return ( 72 - active.tagName === 'INPUT' || 73 - active.tagName === 'TEXTAREA' || 74 - active.tagName === 'SELECT' || 75 - active.isContentEditable 76 - ) 77 - } 78 - function handleKeybings(e: KeyboardEvent) { 79 - if (isTypingInInput()) return 80 - 81 - if (!e.ctrlKey) { 82 - switch (e.key) { 83 - case 'c': 84 - showPostComposerDialog.value = true 85 - break 86 - case 'Escape': 87 - showPostComposerDialog.value = false 88 - break 89 - } 90 - } 91 - } 92 - 93 - onMounted(async () => { 94 - App.addListener('backButton', handleBackNavigation) 95 - document.addEventListener('keyup', (e) => { 96 - if (e.key === 'e' && e.altKey) handleBackNavigation() 97 - }) 105 + // ======================================================== 106 + onMounted(() => { 107 + initializeApp() 98 108 99 109 App.addListener('appUrlOpen', function (event: URLOpenListenerEvent) { 100 110 const url = new URL(event.url) ··· 102 112 const hash = url.hash 103 113 104 114 if (!path) return 115 + 105 116 if (path.startsWith('/oauth/callback')) { 106 117 auth._hash = hash 107 - isCallback.value = true 118 + currentPhase.value = 'callback' 108 119 } else { 109 120 nav.navigateToUrl(path) 110 121 } 111 122 }) 123 + }) 112 124 113 - window.addEventListener('keyup', handleKeybings) 125 + // woke helpers 126 + // ======================================================== 127 + const pronounsError = ref('') 128 + const pronouns = ref('') 129 + const showWokeModal = ref(false) 130 + const updatingPronouns = ref(false) 131 + const suggestedPronouns = ['she/her', 'they/them', 'he/him', 'any'] 132 + 133 + function handlePronounsChange(value: string) { 134 + pronouns.value = value 135 + } 114 136 115 - theme.init() 116 - env.init() 137 + async function savePronouns() { 138 + try { 139 + updatingPronouns.value = true 140 + 141 + const rpc = auth.getRpc() 142 + const did = auth.userDid 143 + if (!did) return 117 144 118 - if (!showIntro.value && !isCallback.value) { 119 - nav.init() 145 + const profile = ok( 146 + await rpc.get('com.atproto.repo.getRecord', { 147 + params: { 148 + collection: 'app.bsky.actor.profile', 149 + repo: did, 150 + rkey: 'self', 151 + }, 152 + }), 153 + ) 154 + 155 + await rpc.post('com.atproto.repo.putRecord', { 156 + input: { 157 + collection: 'app.bsky.actor.profile', 158 + repo: auth.userDid, 159 + rkey: 'self', 160 + record: { 161 + ...(profile.value as AppBskyActorProfile.Main), 162 + pronouns: pronouns.value, 163 + } as AppBskyActorProfile.Main, 164 + }, 165 + }) 166 + 167 + localStorage.removeItem(KEYS.STATE.WOKE_DISMISSED) 168 + showWokeModal.value = false 169 + } catch (err) { 170 + if (err instanceof Error) pronounsError.value = err.message 171 + } finally { 172 + updatingPronouns.value = false 120 173 } 121 - }) 174 + } 122 175 123 - onUnmounted(() => { 124 - App.removeAllListeners() 125 - window.removeEventListener('keyup', handleKeybings) 126 - }) 176 + function remindLater() { 177 + localStorage.setItem(KEYS.STATE.WOKE_DISMISSED, new Date().toISOString()) 178 + showWokeModal.value = false 179 + } 127 180 </script> 128 181 129 182 <template> 130 - <Transition name="intro-fade"> 131 - <OnboardingFlow v-if="showIntro" @complete="onIntroComplete('stay')" /> 132 - </Transition> 183 + <div class="app-root"> 184 + <Transition name="fade" mode="out-in"> 185 + <SplashScreen v-if="currentPhase === 'loading'" key="loading" /> 186 + 187 + <OAuthCallback 188 + v-else-if="currentPhase === 'callback'" 189 + key="callback" 190 + class="view-layer" 191 + @complete="onAuthCallbackComplete" 192 + /> 193 + 194 + <OnboardingFlow 195 + v-else-if="currentPhase === 'intro'" 196 + key="intro" 197 + @complete="onIntroComplete('stay')" 198 + /> 199 + 200 + <div 201 + v-else-if="currentPhase === 'shell'" 202 + key="shell" 203 + class="shell-wrapper" 204 + :inert="showWokeModal" 205 + > 206 + <AppShell /> 207 + </div> 208 + </Transition> 133 209 134 - <Transition name="app-fade" mode="in-out"> 135 - <div v-if="!showIntro" class="app-root" :inert="modalOpen"> 136 - <Transition name="app-fade" mode="out-in"> 137 - <OAuthCallback 138 - v-if="isCallback" 139 - @complete="onAuthComplete" 140 - class="view-layer" 141 - :key="'callback'" 210 + <BaseModal title="Add your pronouns" :open="showWokeModal" width="600px"> 211 + <div class="woke-modal"> 212 + <p>Let people know how to refer to you!</p> 213 + <TextInput 214 + placeholder="e.g. she/her, they/them, he/him, any" 215 + v-model="pronouns" 216 + class="input-pronouns" 142 217 /> 143 - <div v-else class="app-shell view-layer" :key="'shell'"> 144 - <div class="skip-links"> 145 - <a href="#main-content" id="skip-to-content" class="skip-link"> 146 - skip to main content 147 - </a> 148 - <a href="#navigation-bar" class="skip-link"> skip to navigation </a> 149 - </div> 150 - 151 - <div class="viewport" id="main-content"> 152 - <TabStack 153 - v-for="t in tabs" 154 - :key="t" 155 - :tab="t" 156 - v-show="activeTab === t" 157 - :class="{ active: activeTab === t }" 158 - /> 159 - </div> 160 - <NavigationBar ref="navBar" /> 218 + <div class="suggested-pronouns"> 219 + <BaseButton 220 + variant="subtle-alt" 221 + size="sm" 222 + pill 223 + v-for="pronoun in suggestedPronouns" 224 + :key="pronoun" 225 + @click="handlePronounsChange(pronoun)" 226 + > 227 + {{ pronoun }} 228 + </BaseButton> 229 + </div> 230 + <div class="pronouns-error"> 231 + <p v-if="pronounsError">{{ pronounsError }}</p> 161 232 </div> 162 - </Transition> 163 - </div> 164 - </Transition> 165 - 166 - <BaseModal 167 - title="New Post" 168 - :open="showPostComposerDialog" 169 - width="600px" 170 - @close="showPostComposerDialog = false" 171 - > 172 - <PostComposer @close="showPostComposerDialog = false" /> 173 - </BaseModal> 233 + </div> 234 + <template #footer> 235 + <BaseButton variant="subtle-alt" size="md" @click="remindLater">Maybe later</BaseButton> 236 + <BaseButton :loading="updatingPronouns" :disabled="!pronouns" @click="savePronouns" 237 + >Save</BaseButton 238 + > 239 + </template> 240 + </BaseModal> 241 + </div> 174 242 </template> 175 243 176 244 <style scoped> ··· 186 254 height: 100vh; 187 255 } 188 256 189 - .intro-fade-leave-active { 190 - transition: opacity 0.8s ease; 191 - } 192 - .intro-fade-leave-to { 193 - opacity: 0; 257 + .shell-wrapper { 258 + height: 100%; 259 + width: 100%; 194 260 } 195 261 196 - .app-fade-enter-active { 197 - transition: opacity 1s ease 0.2s; 198 - } 199 - .app-fade-leave-active { 200 - transition: opacity 0.5s ease; 262 + .fade-enter-active, 263 + .fade-leave-active { 264 + transition: opacity 0.3s ease; 201 265 } 202 266 203 - .app-fade-enter-from { 267 + .fade-enter-from, 268 + .fade-leave-to { 204 269 opacity: 0; 205 - transform: scale(0.98); 206 270 } 207 271 208 - .app-fade-leave-to { 209 - opacity: 0; 272 + .woke-modal .input-pronouns { 273 + margin-top: 0.5rem; 210 274 } 211 - 212 - .app-shell { 213 - position: relative; 214 - height: 100vh; 215 - width: 100vw; 216 - overflow: hidden; 217 - background-color: hsla(var(--mantle) / 1); 275 + .woke-modal .suggested-pronouns { 276 + margin-top: -0.75rem; 218 277 display: flex; 219 - flex-direction: column; 220 - } 221 - 222 - .viewport { 223 - position: relative; 224 - flex: 1; 225 - overflow: hidden; 226 - z-index: 0; 227 - } 228 - 229 - @media (min-width: 512px) { 230 - .app-shell { 231 - flex-direction: row; 232 - justify-content: center; 233 - } 234 - 235 - .viewport { 236 - flex: 1; 237 - order: 2; 238 - max-width: 768px; 239 - } 240 - } 241 - 242 - .skip-links { 243 - position: absolute; 244 - top: -100px; 245 - left: 0; 246 - z-index: 1000; 247 - } 248 - 249 - .skip-link { 250 - position: absolute; 251 - top: -100px; 252 - left: 8px; 253 - background: hsl(var(--overlay0)); 254 - color: hsl(var(--blue)); 255 - padding: 8px 16px; 256 - text-decoration: none; 257 - border-radius: 4px; 258 - font-weight: 600; 259 - z-index: 11000; 260 - &:focus { 261 - top: 128px; 262 - width: fit-content; 263 - } 278 + gap: 0.25rem; 264 279 } 265 280 </style>
+153
src/components/Layout/AppShell.vue
··· 1 + <script setup lang="ts"> 2 + import { computed, ref, onMounted, onUnmounted } from 'vue' 3 + import { App } from '@capacitor/app' 4 + import { useNavigationStore } from '@/stores/navigation' 5 + import { stackRoots, type StackRootNames } from '@/router' 6 + 7 + import TabStack from '@/components/Navigation/TabStack.vue' 8 + import NavigationBar from '@/components/Navigation/NavigationBar.vue' 9 + import PostComposer from '@/components/Composer/PostComposer.vue' 10 + import BaseModal from '@/components/UI/BaseModal.vue' 11 + 12 + const nav = useNavigationStore() 13 + const activeTab = computed(() => nav.activeTab) 14 + const tabs: StackRootNames[] = stackRoots.map((p) => p.name) 15 + 16 + const showPostComposerDialog = ref(false) 17 + 18 + const handleBackNavigation = () => { 19 + if (!nav.canGoBack) { 20 + if (activeTab.value !== 'home') { 21 + nav.switchTab('home') 22 + return 23 + } 24 + 25 + // exit if we are at root of home 26 + App.exitApp().catch(() => {}) 27 + } 28 + nav.pop() 29 + } 30 + 31 + function isTypingInInput(): boolean { 32 + const active = document.activeElement as HTMLElement | null 33 + if (!active) return false 34 + return ( 35 + active.tagName === 'INPUT' || 36 + active.tagName === 'TEXTAREA' || 37 + active.tagName === 'SELECT' || 38 + active.isContentEditable 39 + ) 40 + } 41 + 42 + function handleKeybings(e: KeyboardEvent) { 43 + if (isTypingInInput()) return 44 + if (!e.ctrlKey) { 45 + switch (e.key) { 46 + case 'c': 47 + showPostComposerDialog.value = true 48 + break 49 + case 'Escape': 50 + showPostComposerDialog.value = false 51 + break 52 + } 53 + } 54 + } 55 + 56 + onMounted(() => { 57 + App.addListener('backButton', handleBackNavigation) 58 + document.addEventListener('keyup', (e) => { 59 + if (e.key === 'e' && e.altKey) handleBackNavigation() 60 + }) 61 + window.addEventListener('keyup', handleKeybings) 62 + }) 63 + 64 + onUnmounted(() => { 65 + App.removeAllListeners() 66 + window.removeEventListener('keyup', handleKeybings) 67 + }) 68 + </script> 69 + 70 + <template> 71 + <div class="app-shell"> 72 + <div class="skip-links"> 73 + <a href="#main-content" id="skip-to-content" class="skip-link"> skip to main content </a> 74 + <a href="#navigation-bar" class="skip-link"> skip to navigation </a> 75 + </div> 76 + 77 + <div class="viewport" id="main-content"> 78 + <TabStack 79 + v-for="t in tabs" 80 + :key="t" 81 + :tab="t" 82 + v-show="activeTab === t" 83 + :class="{ active: activeTab === t }" 84 + /> 85 + </div> 86 + <NavigationBar ref="navBar" /> 87 + 88 + <BaseModal 89 + title="New Post" 90 + :open="showPostComposerDialog" 91 + width="600px" 92 + @close="showPostComposerDialog = false" 93 + > 94 + <PostComposer @close="showPostComposerDialog = false" /> 95 + </BaseModal> 96 + </div> 97 + </template> 98 + 99 + <style scoped> 100 + .app-shell { 101 + position: relative; 102 + height: 100vh; 103 + width: 100vw; 104 + overflow: hidden; 105 + background-color: hsla(var(--mantle) / 1); 106 + display: flex; 107 + flex-direction: column; 108 + } 109 + 110 + .viewport { 111 + position: relative; 112 + flex: 1; 113 + overflow: hidden; 114 + z-index: 0; 115 + } 116 + 117 + @media (min-width: 512px) { 118 + .app-shell { 119 + flex-direction: row; 120 + justify-content: center; 121 + } 122 + 123 + .viewport { 124 + flex: 1; 125 + order: 2; 126 + max-width: 668px; 127 + } 128 + } 129 + 130 + .skip-links { 131 + position: absolute; 132 + top: -100px; 133 + left: 0; 134 + z-index: 1000; 135 + } 136 + 137 + .skip-link { 138 + position: absolute; 139 + top: -100px; 140 + left: 8px; 141 + background: hsl(var(--overlay0)); 142 + color: hsl(var(--blue)); 143 + padding: 8px 16px; 144 + text-decoration: none; 145 + border-radius: 4px; 146 + font-weight: 600; 147 + z-index: 11000; 148 + &:focus { 149 + top: 128px; 150 + width: fit-content; 151 + } 152 + } 153 + </style>
+128
src/components/Layout/SplashScreen.vue
··· 1 + <script setup lang="ts"> 2 + import SVG from '@/components/UI/SVG.vue' 3 + import BluebellLogo from '@/assets/icons/bluebell.svg?raw' 4 + </script> 5 + 6 + <template> 7 + <div class="splash-screen"> 8 + <div class="splash-content"> 9 + <div class="logo-wrapper"> 10 + <SVG :icon="BluebellLogo" class="logo" /> 11 + </div> 12 + 13 + <div class="brand-text"> 14 + <h1 class="title">Bluebell</h1> 15 + <div class="loader-track"> 16 + <div class="loader-bar"></div> 17 + </div> 18 + </div> 19 + </div> 20 + </div> 21 + </template> 22 + 23 + <style scoped lang="scss"> 24 + .splash-screen { 25 + display: flex; 26 + flex-direction: column; 27 + align-items: center; 28 + justify-content: center; 29 + height: 100vh; 30 + width: 100vw; 31 + background-color: hsl(var(--base)); 32 + color: hsl(var(--text)); 33 + position: fixed; 34 + inset: 0; 35 + z-index: 9999; 36 + } 37 + 38 + .splash-content { 39 + display: flex; 40 + flex-direction: column; 41 + align-items: center; 42 + gap: 2rem; 43 + margin-bottom: 10vh; 44 + } 45 + 46 + .logo-wrapper { 47 + width: 6rem; 48 + height: 6rem; 49 + color: hsl(var(--accent)); 50 + 51 + :deep(svg) { 52 + width: 100%; 53 + height: 100%; 54 + } 55 + } 56 + 57 + .brand-text { 58 + display: flex; 59 + flex-direction: column; 60 + align-items: center; 61 + gap: 1rem; 62 + } 63 + 64 + .title { 65 + font-size: 2.5rem; 66 + font-weight: 800; 67 + letter-spacing: -0.03em; 68 + background: linear-gradient(135deg, hsl(var(--text)) 0%, hsl(var(--subtext0)) 100%); 69 + background-clip: text; 70 + -webkit-text-fill-color: transparent; 71 + margin: 0; 72 + } 73 + 74 + .loader-track { 75 + width: 120px; 76 + height: 4px; 77 + background-color: hsla(var(--surface2) / 0.3); 78 + border-radius: 99px; 79 + overflow: hidden; 80 + position: relative; 81 + } 82 + 83 + .loader-bar { 84 + position: absolute; 85 + top: 0; 86 + left: 0; 87 + height: 100%; 88 + width: 100%; 89 + background-color: hsl(var(--accent)); 90 + border-radius: 99px; 91 + transform-origin: left; 92 + animation: loading 1.5s cubic-bezier(0.65, 0, 0.35, 1) infinite; 93 + } 94 + 95 + .footer { 96 + position: absolute; 97 + bottom: 2rem; 98 + p { 99 + font-size: 0.875rem; 100 + color: hsl(var(--subtext0)); 101 + opacity: 0.6; 102 + font-weight: 500; 103 + letter-spacing: 0.02em; 104 + } 105 + } 106 + 107 + @keyframes float { 108 + 0%, 109 + 100% { 110 + transform: translateY(0); 111 + } 112 + 50% { 113 + transform: translateY(-10px); 114 + } 115 + } 116 + 117 + @keyframes loading { 118 + 0% { 119 + transform: translateX(-100%); 120 + } 121 + 50% { 122 + transform: translateX(0); 123 + } 124 + 100% { 125 + transform: translateX(100%); 126 + } 127 + } 128 + </style>
+1
src/stores/auth.ts
··· 83 83 84 84 async function fetchProfile() { 85 85 if (!session.value) return 86 + 86 87 try { 87 88 const rpc = getRpc() 88 89 const data = await ok(
+1 -1
src/utils/keys.ts
··· 20 20 'ACCENT_COLOUR', 21 21 ]), 22 22 23 - STATE: defineScope('state', ['ACTIVE_FEED_URI']), 23 + STATE: defineScope('state', ['ACTIVE_FEED_URI', 'WOKE_DISMISSED', 'INTRO_COMPLETE']), 24 24 SETTINGS: defineScope('settings', ['BASE']), 25 25 AUTH: defineScope('auth', ['SESSION', 'ACTIVE_DID']), 26 26 }