wip bsky client for the web & android
0
fork

Configure Feed

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

feat(feed): expose refresh; watch for prop changes; random skeleton loader

willow 082e5cce efc47c87

+208 -122
+91 -25
src/components/Feed/FeedList.vue
··· 3 3 import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' 4 4 import type { ResourceUri } from '@atcute/lexicons' 5 5 import { ok } from '@atcute/client' 6 - import { AppBskyFeedDefs } from '@atcute/bluesky' 6 + import { AppBskyFeedDefs, AppBskyActorDefs } from '@atcute/bluesky' 7 7 8 8 import { useNavigationStore } from '@/stores/navigation' 9 9 import { useAuthStore } from '@/stores/auth' ··· 15 15 import { ThreadBuilder, type ThreadNode } from '@/utils/threading' 16 16 17 17 const props = defineProps<{ 18 - type: 'timeline' | 'userFeed' | 'listFeed' 18 + type: AppBskyActorDefs.SavedFeed['type'] 19 19 /** the AT URI of the feed or list; omit for timeline */ 20 20 uri?: ResourceUri | null 21 21 }>() ··· 35 35 const loadMoreTrigger = ref<HTMLElement | null>(null) 36 36 let observer: IntersectionObserver | null = null 37 37 38 + const skeletonItems = ref( 39 + Array.from({ length: 15 }, () => { 40 + const hasImage = Math.random() > 0.7 41 + const hasLine2 = Math.random() > 0.35 42 + const hasLine3 = hasLine2 && Math.random() > 0.5 43 + 44 + const line1Width = hasLine2 45 + ? `${Math.floor(Math.random() * 6) + 95}%` 46 + : `${Math.floor(Math.random() * 40) + 50}%` 47 + 48 + const line2Width = hasLine3 49 + ? `${Math.floor(Math.random() * 6) + 95}%` 50 + : `${Math.floor(Math.random() * 30) + 30}%` 51 + 52 + const line3Width = hasLine3 ? `${Math.floor(Math.random() * 40) + 20}%` : null 53 + 54 + return { 55 + hasImage, 56 + line1Width, 57 + line2Width, 58 + line3Width, 59 + imageHeight: `${Math.floor(Math.random() * 100) + 120}px`, 60 + } 61 + }), 62 + ) 63 + 38 64 const fetchTimeline = async () => { 39 65 if (!auth.isAuthenticated) return 40 66 if (loading.value) return ··· 52 78 const route = 53 79 props.type === 'timeline' 54 80 ? 'app.bsky.feed.getTimeline' 55 - : props.type === 'userFeed' 81 + : props.type === 'feed' 56 82 ? 'app.bsky.feed.getFeed' 57 83 : 'app.bsky.feed.getListFeed' 58 84 ··· 105 131 } 106 132 } 107 133 134 + const refresh = async () => { 135 + cursor.value = undefined 136 + threadedFeed.value = [] 137 + totalPostCount.value = 0 138 + await fetchTimeline() 139 + } 140 + 108 141 const setupObserver = () => { 109 142 if (observer) observer.disconnect() 110 143 if (!loadOnScroll.value) return ··· 147 180 }, 148 181 ) 149 182 183 + // watch for prop changes, e.g., when switching between feeds/lists 184 + watch( 185 + () => [props.type, props.uri], 186 + () => { 187 + if (auth.isAuthenticated) { 188 + cursor.value = undefined 189 + threadedFeed.value = [] 190 + fetchTimeline() 191 + } 192 + }, 193 + ) 150 194 watch(loadMoreTrigger, (el) => { 151 195 if (el && auth.isAuthenticated) setupObserver() 152 196 }) 153 197 watch(loadOnScroll, () => { 154 198 setupObserver() 199 + }) 200 + 201 + defineExpose({ 202 + refresh, 155 203 }) 156 204 </script> 157 205 ··· 168 216 </div> 169 217 170 218 <div v-else-if="loading && threadedFeed.length === 0" class="loading-stack"> 171 - <div v-for="i in 15" :key="i" class="feed-item skeleton-item"> 172 - <div class="post-header"> 173 - <SkeletonLoader circle width="2.5rem" height="2.5rem" /> 174 - <div class="header-text"> 175 - <SkeletonLoader width="30%" height="1rem" /> 176 - <SkeletonLoader width="20%" height="0.8rem" /> 219 + <div v-for="(skel, i) in skeletonItems" :key="i" class="feed-item skeleton-item"> 220 + <SkeletonLoader circle width="2.75rem" height="2.75rem" /> 221 + 222 + <div class="post-content"> 223 + <div class="post-header"> 224 + <div class="header-text"> 225 + <SkeletonLoader width="25%" height="1rem" /> 226 + <SkeletonLoader width="15%" height="1rem" /> 227 + <SkeletonLoader width="5%" height="1rem" /> 228 + </div> 177 229 </div> 178 - </div> 179 - <div class="post-body"> 180 - <SkeletonLoader width="90%" height="1rem" /> 181 - <SkeletonLoader width="60%" height="1rem" /> 230 + <div class="post-body"> 231 + <SkeletonLoader :width="skel.line1Width" height="1rem" /> 232 + <SkeletonLoader :width="skel.line2Width" height="1rem" /> 233 + <SkeletonLoader v-if="skel.line3Width" :width="skel.line3Width" height="1rem" /> 234 + 235 + <SkeletonLoader 236 + v-if="skel.hasImage" 237 + width="100%" 238 + :height="skel.imageHeight" 239 + style="margin-top: 0.5rem; border-radius: 8px" 240 + /> 241 + </div> 182 242 </div> 183 243 </div> 184 244 </div> ··· 235 295 .skeleton-item { 236 296 padding: 1rem; 237 297 display: flex; 238 - flex-direction: column; 239 - gap: 1rem; 298 + flex-direction: row; 240 299 border-bottom: 1px solid hsla(var(--surface2) / 0.3); 241 300 301 + .post-content { 302 + flex: 1; 303 + display: flex; 304 + flex-direction: column; 305 + gap: 0.5rem; 306 + margin-left: 0.75rem; 307 + } 308 + 242 309 .post-header { 243 310 display: flex; 244 - gap: 1rem; 245 - align-items: center; 311 + gap: 0.5rem; 246 312 247 313 .header-text { 248 314 flex: 1; 249 315 display: flex; 250 - flex-direction: column; 251 - gap: 0.5rem; 316 + flex-direction: row; 317 + gap: 0.25rem; 252 318 } 253 - .post-body { 254 - display: flex; 255 - flex-direction: column; 256 - gap: 0.5rem; 257 - padding-left: 3.5rem; 258 - } 319 + } 320 + 321 + .post-body { 322 + display: flex; 323 + flex-direction: column; 324 + gap: 0.5rem; 259 325 } 260 326 } 261 327 }
+117 -97
src/router/index.ts
··· 1 1 import { 2 - IconHomeRounded, 3 - IconSearchRounded, 4 - IconSettingsRounded, 2 + IconHomeRounded, 3 + IconSearchRounded, 4 + IconSettingsRounded, 5 5 } from '@iconify-prerendered/vue-material-symbols' 6 6 import type { Component } from 'vue' 7 7 8 8 export type Page = { 9 - root: boolean 10 - label: string 11 - name: string 12 - path: string 13 - component: Component | (() => Promise<Component>) 14 - icon?: Component | (() => Promise<Component>) 9 + root: boolean 10 + label: string 11 + name: string 12 + path: string 13 + component: Component | (() => Promise<Component>) 14 + icon?: Component | (() => Promise<Component>) 15 15 } 16 16 17 17 export const pages = [ 18 - { 19 - root: true, 20 - label: 'Home', 21 - name: 'home', 22 - path: '/', 23 - component: async () => import('@/views/Root/HomeView.vue'), 24 - icon: IconHomeRounded, 25 - }, 26 - { 27 - root: true, 28 - label: 'Search', 29 - name: 'search', 30 - path: '/search', 31 - component: () => import('@/views/Root/SearchView.vue'), 32 - icon: IconSearchRounded, 33 - }, 34 - { 35 - root: false, 36 - label: 'Subpage', 37 - name: 'subpage', 38 - path: '/subpage', 39 - component: () => import('@/views/SubPage.vue'), 40 - }, 41 - { 42 - root: false, 43 - label: 'User Profile', 44 - name: 'user-profile', 45 - path: '/user/:id', 46 - component: () => import('@/views/UserProfile.vue'), 47 - }, 48 - { 49 - root: false, 50 - label: 'Login', 51 - name: 'login', 52 - path: '/login', 53 - component: () => import('@/views/Auth/LoginPage.vue'), 54 - }, 55 - { 56 - root: false, 57 - label: 'Authenticating...', 58 - name: 'oauth-callback', 59 - path: '/oauth/callback', 60 - component: () => import('@/views/Auth/OAuthCallback.vue'), 61 - }, 62 - { 63 - root: true, 64 - label: 'Settings', 65 - name: 'settings', 66 - path: '/settings', 67 - icon: IconSettingsRounded, 68 - component: () => import('@/views/SettingsPage.vue'), 69 - }, 18 + { 19 + root: true, 20 + label: 'Home', 21 + name: 'home', 22 + path: '/', 23 + component: async () => import('@/views/Root/HomeView.vue'), 24 + icon: IconHomeRounded, 25 + }, 26 + { 27 + root: true, 28 + label: 'Search', 29 + name: 'search', 30 + path: '/search', 31 + component: () => import('@/views/Root/SearchView.vue'), 32 + icon: IconSearchRounded, 33 + }, 34 + { 35 + root: false, 36 + label: 'Subpage', 37 + name: 'subpage', 38 + path: '/subpage', 39 + component: () => import('@/views/SubPage.vue'), 40 + }, 41 + { 42 + root: false, 43 + label: 'User Profile', 44 + name: 'user-profile', 45 + path: '/user/:id', 46 + component: () => import('@/views/UserProfile.vue'), 47 + }, 48 + { 49 + root: false, 50 + label: 'Login', 51 + name: 'login', 52 + path: '/login', 53 + component: () => import('@/views/Auth/LoginPage.vue'), 54 + }, 55 + { 56 + root: false, 57 + label: 'Authenticating...', 58 + name: 'oauth-callback', 59 + path: '/oauth/callback', 60 + component: () => import('@/views/Auth/OAuthCallback.vue'), 61 + }, 62 + { 63 + root: true, 64 + label: 'Settings', 65 + name: 'settings', 66 + path: '/settings', 67 + icon: IconSettingsRounded, 68 + component: () => import('@/views/SettingsPage.vue'), 69 + }, 70 + { 71 + root: false, 72 + label: 'Thread', 73 + name: 'post-thread', 74 + path: '/profile/:identifier/post/:rkey', 75 + component: () => import('@/views/Post/PostView.vue'), 76 + }, 70 77 ] as const satisfies readonly Page[] 71 78 72 79 export const stackRoots = pages.filter((page) => page.root) 73 80 export type PageNames = (typeof pages)[number]['name'] 74 81 export type StackRootNames = (typeof stackRoots)[number]['name'] 75 82 83 + type ExtractRouteParams<Path> = Path extends `${string}:${infer Param}/${infer Rest}` 84 + ? { [K in Param]: string | number } & ExtractRouteParams<`/${Rest}`> 85 + : Path extends `${string}:${infer Param}` 86 + ? { [K in Param]: string | number } 87 + : // eslint-disable-next-line @typescript-eslint/no-empty-object-type 88 + {} 89 + 90 + export type RouteParamsMap = { 91 + [P in (typeof pages)[number] as P['name']]: ExtractRouteParams<P['path']> 92 + } 93 + 94 + export type RouteParams<N extends PageNames> = RouteParamsMap[N] 95 + 76 96 export const getPageByName = (name: string) => pages.find((p) => p.name === name) 77 97 78 98 function compilePath(path: string) { 79 - const paramNames: string[] = [] 80 - const regexString = 81 - '^' + 82 - path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { 83 - paramNames.push(key) 84 - return '([^/]+)' 85 - }) + 86 - '$' 87 - return { regex: new RegExp(regexString), paramNames } 99 + const paramNames: string[] = [] 100 + const regexString = 101 + '^' + 102 + path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { 103 + paramNames.push(key) 104 + return '([^/]+)' 105 + }) + 106 + '$' 107 + return { regex: new RegExp(regexString), paramNames } 88 108 } 89 109 90 110 export const matchPageByPath = (currentPath: string) => { 91 - for (const page of pages) { 92 - const { regex, paramNames } = compilePath(page.path) 93 - const match = currentPath.match(regex) 111 + for (const page of pages) { 112 + const { regex, paramNames } = compilePath(page.path) 113 + const match = currentPath.match(regex) 94 114 95 - if (match) { 96 - const params: Record<string, string> = {} 97 - match.slice(1).forEach((value, index) => { 98 - const param = paramNames[index] 99 - if (param) params[param] = decodeURIComponent(value) 100 - }) 101 - return { page, params } 102 - } 103 - } 104 - return null 115 + if (match) { 116 + const params: Record<string, string> = {} 117 + match.slice(1).forEach((value, index) => { 118 + const param = paramNames[index] 119 + if (param) params[param] = decodeURIComponent(value) 120 + }) 121 + return { page, params } 122 + } 123 + } 124 + return null 105 125 } 106 126 107 127 export const compileUrl = (path: string, props: Record<string, unknown> = {}) => { 108 - let url = path 109 - const usedKeys = new Set<string>() 128 + let url = path 129 + const usedKeys = new Set<string>() 110 130 111 - url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { 112 - if (props[key] !== undefined) { 113 - usedKeys.add(key) 114 - return encodeURIComponent(String(props[key])) 115 - } 116 - return ':' + key 117 - }) 131 + url = url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => { 132 + if (props[key] !== undefined) { 133 + usedKeys.add(key) 134 + return encodeURIComponent(String(props[key])) 135 + } 136 + return ':' + key 137 + }) 118 138 119 - const remainingProps: Record<string, unknown> = {} 120 - Object.keys(props).forEach((key) => { 121 - if (!usedKeys.has(key)) remainingProps[key] = props[key] 122 - }) 139 + const remainingProps: Record<string, unknown> = {} 140 + Object.keys(props).forEach((key) => { 141 + if (!usedKeys.has(key)) remainingProps[key] = props[key] 142 + }) 123 143 124 - return { url, remainingProps } 144 + return { url, remainingProps } 125 145 }