wip bsky client for the web & android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: strongly type AppLink params

willow 4823b272 01354b53

+131 -137
+9 -7
src/components/Navigation/AppLink.vue
··· 1 - <script lang="ts" setup> 1 + <script setup lang="ts" generic="N extends PageNames"> 2 2 import { computed } from 'vue' 3 - import type { PageNames } from '@/router' 3 + import type { PageNames, RouteParams } from '@/router' 4 4 import { useNavigationStore } from '@/stores/navigation' 5 5 import { getPageByName, compileUrl } from '@/router' 6 6 7 - const props = defineProps<{ 8 - name: PageNames 9 - params?: Record<string, string> 7 + type LinkProps<N extends PageNames> = { 8 + name: N 10 9 ariaLabel?: string 11 - }>() 10 + params?: RouteParams<N> 11 + } 12 + 13 + const props = defineProps<LinkProps<N>>() 12 14 13 15 const nav = useNavigationStore() 14 16 ··· 45 47 </script> 46 48 47 49 <template> 48 - <a class="app-link" :href="href" @click="handleClick" :aria-label="ariaLabel"> 50 + <a class="app-link" :href="href" @click.stop="handleClick" :aria-label="ariaLabel"> 49 51 <slot /> 50 52 </a> 51 53 </template>
+122 -130
src/stores/navigation.ts
··· 1 - import { defineStore } from "pinia"; 2 - import { ref } from "vue"; 1 + import { defineStore } from 'pinia' 2 + import { ref } from 'vue' 3 3 import { 4 4 compileUrl, 5 5 getPageByName, ··· 7 7 type PageNames, 8 8 type StackRootNames, 9 9 stackRoots, 10 - } from "@/router"; 10 + } from '@/router' 11 11 12 - type TabKey = StackRootNames; 13 - type PageKey = PageNames; 12 + export type TabKey = StackRootNames 13 + export type PageKey = PageNames 14 14 15 15 export type StackEntry = { 16 - id: string; 17 - page: PageKey; 18 - title?: string; 19 - props?: Record<string, unknown>; 20 - }; 16 + id: string 17 + page: PageKey 18 + title?: string 19 + props?: Record<string, unknown> 20 + } 21 21 22 22 function generateId(page: string) { 23 - return `${page}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; 23 + return `${page}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` 24 24 } 25 25 26 26 function serializeProps(props?: Record<string, unknown>): string { 27 - if (!props || Object.keys(props).length === 0) return ""; 28 - const params = new URLSearchParams(); 27 + if (!props || Object.keys(props).length === 0) return '' 28 + const params = new URLSearchParams() 29 29 Object.entries(props).forEach(([key, value]) => { 30 - if (typeof value === "object") params.set(key, JSON.stringify(value)); 31 - else params.set(key, String(value)); 32 - }); 33 - return `?${params.toString()}`; 30 + if (typeof value === 'object') params.set(key, JSON.stringify(value)) 31 + else params.set(key, String(value)) 32 + }) 33 + return `?${params.toString()}` 34 34 } 35 35 36 - export const useNavigationStore = defineStore("navigation", () => { 37 - const isInitialized = ref(false); 38 - const activeTab = ref<TabKey>("home"); 39 - const pendingPop = ref<{ tab: TabKey; count: number } | null>(null); 36 + export const useNavigationStore = defineStore('navigation', () => { 37 + const isInitialized = ref(false) 38 + const activeTab = ref<TabKey>('home') 39 + const pendingPop = ref<{ tab: TabKey; count: number } | null>(null) 40 40 41 41 const stacks = ref<Record<TabKey, StackEntry[]>>( 42 42 stackRoots.reduce( 43 43 (acc, page) => { 44 44 acc[page.name as TabKey] = [ 45 45 { id: generateId(page.name), page: page.name, title: page.label }, 46 - ]; 47 - return acc; 46 + ] 47 + return acc 48 48 }, 49 49 {} as Record<TabKey, StackEntry[]>, 50 50 ), 51 - ); 51 + ) 52 52 53 - function updateHistory( 54 - method: "push" | "replace", 55 - tab: TabKey, 56 - entry: StackEntry, 57 - ) { 58 - const pageConfig = getPageByName(entry.page); 59 - if (!pageConfig) return; 53 + function updateHistory(method: 'push' | 'replace', tab: TabKey, entry: StackEntry) { 54 + const pageConfig = getPageByName(entry.page) 55 + if (!pageConfig) return 60 56 61 - const { url, remainingProps } = compileUrl( 62 - pageConfig.path, 63 - entry.props || {}, 64 - ); 65 - const fullPath = url + serializeProps(remainingProps); 57 + const { url, remainingProps } = compileUrl(pageConfig.path, entry.props || {}) 58 + const fullPath = url + serializeProps(remainingProps) 66 59 67 60 const state = { 68 61 tab, 69 62 page: entry.page, 70 63 stackId: entry.id, 71 - }; 64 + } 72 65 73 - if (method === "replace") window.history.replaceState(state, "", fullPath); 74 - else window.history.pushState(state, "", fullPath); 66 + if (method === 'replace') window.history.replaceState(state, '', fullPath) 67 + else window.history.pushState(state, '', fullPath) 75 68 } 76 69 77 70 function push( 78 71 page: PageKey, 79 72 opts?: { 80 - tab?: TabKey; 81 - title?: string; 82 - props?: Record<string, unknown>; 73 + tab?: TabKey 74 + title?: string 75 + props?: Record<string, unknown> 83 76 }, 84 77 ) { 85 - const targetTab = opts?.tab ?? activeTab.value; 78 + const targetTab = opts?.tab ?? activeTab.value 86 79 87 80 if (targetTab !== activeTab.value) { 88 - switchTab(targetTab); 81 + switchTab(targetTab) 89 82 } 90 83 91 - const stack = stacks.value[targetTab]; 92 - if (!stack) return; 84 + const stack = stacks.value[targetTab] 85 + if (!stack) return 93 86 94 87 const entry: StackEntry = { 95 88 id: generateId(page), 96 89 page, 97 90 props: opts?.props, 98 91 title: opts?.title, 99 - }; 92 + } 100 93 101 - stack.push(entry); 102 - updateHistory("push", targetTab, entry); 94 + stack.push(entry) 95 + updateHistory('push', targetTab, entry) 103 96 104 - return entry; 97 + return entry 105 98 } 106 99 107 100 function switchTab(tab: PageNames) { 108 - if (tab === activeTab.value) return; 101 + if (tab === activeTab.value) return 109 102 110 - const targetTab = tab as TabKey; 111 - const stack = stacks.value[targetTab]; 103 + const targetTab = tab as TabKey 104 + const stack = stacks.value[targetTab] 112 105 113 - if (!stack || stack.length === 0) return; 106 + if (!stack || stack.length === 0) return 114 107 115 - activeTab.value = targetTab; 108 + activeTab.value = targetTab 116 109 117 - const topEntry = stack[stack.length - 1]; 118 - if (!topEntry) return; 119 - updateHistory("push", targetTab, topEntry); 110 + const topEntry = stack[stack.length - 1] 111 + if (!topEntry) return 112 + updateHistory('push', targetTab, topEntry) 120 113 } 121 114 122 115 function resetTab(tab: TabKey) { 123 - const stack = stacks.value[tab]; 124 - if (!stack || stack.length <= 1) return; 116 + const stack = stacks.value[tab] 117 + if (!stack || stack.length <= 1) return 125 118 126 - const itemsToRemove = stack.length - 1; 127 - pendingPop.value = { tab, count: itemsToRemove }; 119 + const itemsToRemove = stack.length - 1 120 + pendingPop.value = { tab, count: itemsToRemove } 128 121 129 - const rootEntry = stack[0]; 130 - if (rootEntry) updateHistory("push", tab, rootEntry); 122 + const rootEntry = stack[0] 123 + if (rootEntry) updateHistory('push', tab, rootEntry) 131 124 } 132 125 133 126 function pop() { 134 - window.history.back(); 127 + window.history.back() 135 128 } 136 129 137 130 function popStack() { 138 - const stack = stacks.value[activeTab.value]; 139 - if (!stack || stack.length <= 1) return; 131 + const stack = stacks.value[activeTab.value] 132 + if (!stack || stack.length <= 1) return 140 133 141 - pendingPop.value = { tab: activeTab.value, count: 1 }; 134 + pendingPop.value = { tab: activeTab.value, count: 1 } 142 135 143 - const previousEntry = stack[stack.length - 2]; 144 - if (!previousEntry) return; 145 - updateHistory("push", activeTab.value, previousEntry); 136 + const previousEntry = stack[stack.length - 2] 137 + if (!previousEntry) return 138 + updateHistory('push', activeTab.value, previousEntry) 146 139 } 147 140 148 141 function popTo(index: number, tab: TabKey = activeTab.value) { 149 - const stack = stacks.value[tab]; 150 - if (!stack || index >= stack.length) return; 142 + const stack = stacks.value[tab] 143 + if (!stack || index >= stack.length) return 151 144 152 - const newStack = stack.slice(0, index + 1); 153 - stacks.value[tab] = newStack; 145 + const newStack = stack.slice(0, index + 1) 146 + stacks.value[tab] = newStack 154 147 155 - const topEntry = newStack[newStack.length - 1]; 156 - if (!topEntry) return; 157 - updateHistory("push", tab, topEntry); 148 + const topEntry = newStack[newStack.length - 1] 149 + if (!topEntry) return 150 + updateHistory('push', tab, topEntry) 158 151 } 159 152 160 153 function completePop() { 161 - if (!pendingPop.value) return; 154 + if (!pendingPop.value) return 162 155 163 - const { tab, count } = pendingPop.value; 164 - const stack = stacks.value[tab]; 156 + const { tab, count } = pendingPop.value 157 + const stack = stacks.value[tab] 165 158 166 - if (stack && stack.length > count) 167 - stack.splice(stack.length - count, count); 159 + if (stack && stack.length > count) stack.splice(stack.length - count, count) 168 160 169 - pendingPop.value = null; 161 + pendingPop.value = null 170 162 } 171 163 172 164 async function handlePopState(event: PopStateEvent) { 173 - const state = event.state; 165 + const state = event.state 174 166 175 167 if (!state || !state.stackId) { 176 - const { page, props } = parseUrl(); 177 - const pageConfig = getPageByName(page); 168 + const { page, props } = parseUrl() 169 + const pageConfig = getPageByName(page) 178 170 if (pageConfig) { 179 - const stack = stacks.value[activeTab.value]; 171 + const stack = stacks.value[activeTab.value] 180 172 if (stack) { 181 173 stack.push({ 182 174 id: generateId(page), 183 175 page: page as PageKey, 184 176 props, 185 - }); 177 + }) 186 178 187 - const top = stack[0]; 188 - if (!top) return; 189 - updateHistory("replace", activeTab.value, top); 179 + const top = stack[0] 180 + if (!top) return 181 + updateHistory('replace', activeTab.value, top) 190 182 } 191 183 } 192 - return; 184 + return 193 185 } 194 186 195 - const { tab, stackId, page } = state; 187 + const { tab, stackId, page } = state 196 188 197 189 if (tab !== activeTab.value) { 198 - activeTab.value = tab; 199 - return; 190 + activeTab.value = tab 191 + return 200 192 } 201 193 202 194 // @ts-expect-error: PopStateEvent's `state` is literally just `any` which 203 195 // is annoying 204 - const stack = stacks.value[tab]; 196 + const stack = stacks.value[tab] 205 197 // @ts-expect-error: see above 206 - const currentIndex = stack.findIndex((entry) => entry.id === stackId); 198 + const currentIndex = stack.findIndex((entry) => entry.id === stackId) 207 199 208 200 if (currentIndex !== -1) { 209 201 if (currentIndex < stack.length - 1) { 210 - const itemsToRemove = stack.length - 1 - currentIndex; 211 - pendingPop.value = { tab, count: itemsToRemove }; 202 + const itemsToRemove = stack.length - 1 - currentIndex 203 + pendingPop.value = { tab, count: itemsToRemove } 212 204 } 213 205 } else { 214 - const { props } = parseUrl(); 206 + const { props } = parseUrl() 215 207 216 208 stack.push({ 217 209 id: stackId, 218 210 page: page, 219 211 props: props, 220 - }); 212 + }) 221 213 } 222 214 } 223 215 224 216 function parseUrl() { 225 - const path = window.location.pathname; 226 - const searchParams = new URLSearchParams(window.location.search); 217 + const path = window.location.pathname 218 + const searchParams = new URLSearchParams(window.location.search) 227 219 228 - const match = matchPageByPath(path); 229 - const props: Record<string, unknown> = {}; 230 - if (match?.params) Object.assign(props, match.params); 220 + const match = matchPageByPath(path) 221 + const props: Record<string, unknown> = {} 222 + if (match?.params) Object.assign(props, match.params) 231 223 232 224 for (const [key, value] of searchParams.entries()) { 233 225 try { 234 - props[key] = JSON.parse(value); 226 + props[key] = JSON.parse(value) 235 227 } catch { 236 - props[key] = value; 228 + props[key] = value 237 229 } 238 230 } 239 231 240 232 return { 241 - page: match?.page.name || "home", 233 + page: match?.page.name || 'home', 242 234 root: match?.page.root || false, 243 235 props, 244 - }; 236 + } 245 237 } 246 238 247 239 function init() { 248 - if (isInitialized.value) return; 240 + if (isInitialized.value) return 249 241 250 - const { page, root, props } = parseUrl(); 242 + const { page, root, props } = parseUrl() 251 243 252 244 if (root) { 253 - activeTab.value = page as TabKey; 254 - const stack = stacks.value[activeTab.value]; 255 - if (!stack[0]) return; 245 + activeTab.value = page as TabKey 246 + const stack = stacks.value[activeTab.value] 247 + if (!stack[0]) return 256 248 257 - stack[0].props = props; 258 - updateHistory("replace", activeTab.value, stack[0]); 249 + stack[0].props = props 250 + updateHistory('replace', activeTab.value, stack[0]) 259 251 } else { 260 252 // deep links - we open these in the home tab. 261 - activeTab.value = "home"; 253 + activeTab.value = 'home' 262 254 263 - const stack = stacks.value["home"]; 264 - const rootEntry = stack[0]; 265 - if (!rootEntry) return; 255 + const stack = stacks.value['home'] 256 + const rootEntry = stack[0] 257 + if (!rootEntry) return 266 258 267 259 const subPageEntry: StackEntry = { 268 260 id: generateId(page), 269 261 page: page as PageKey, 270 262 props, 271 - }; 263 + } 272 264 273 - stacks.value["home"] = [rootEntry, subPageEntry]; 274 - updateHistory("replace", "home", rootEntry); 275 - updateHistory("push", "home", subPageEntry); 265 + stacks.value['home'] = [rootEntry, subPageEntry] 266 + updateHistory('replace', 'home', rootEntry) 267 + updateHistory('push', 'home', subPageEntry) 276 268 } 277 269 278 - window.addEventListener("popstate", handlePopState); 279 - isInitialized.value = true; 270 + window.addEventListener('popstate', handlePopState) 271 + isInitialized.value = true 280 272 } 281 273 282 274 return { ··· 292 284 popTo, 293 285 completePop, 294 286 isInitialized, 295 - }; 296 - }); 287 + } 288 + })