wip bsky client for the web & android
0
fork

Configure Feed

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

feat: post view (wip); feed selector in top bar + with fancy drag animation thing

willow 50dcb0cf 9e7e4477

+598 -3
+182
src/composables/useDraggableScroll.ts
··· 1 + import { ref, onUnmounted } from 'vue' 2 + 3 + export function useDraggableScroll() { 4 + const element = ref<HTMLElement | null>(null) 5 + 6 + const isDown = ref(false) 7 + const isDragging = ref(false) 8 + 9 + let startX = 0 10 + let velocity = 0 11 + let lastPageX = 0 12 + let lastTime = 0 13 + let animationFrameId: number | null = null 14 + 15 + let overscrollX = 0 16 + 17 + const stopAnimation = () => { 18 + if (animationFrameId !== null) { 19 + cancelAnimationFrame(animationFrameId) 20 + animationFrameId = null 21 + } 22 + } 23 + 24 + const updateRendering = () => { 25 + if (!element.value) return 26 + if (Math.abs(overscrollX) > 0.1) 27 + element.value.style.transform = `translate3d(${overscrollX}px, 0, 0)` 28 + else element.value.style.transform = '' 29 + } 30 + 31 + const onMouseDown = (e: MouseEvent) => { 32 + if (!element.value) return 33 + stopAnimation() 34 + 35 + isDown.value = true 36 + isDragging.value = false 37 + 38 + startX = e.pageX 39 + lastPageX = e.pageX 40 + lastTime = performance.now() 41 + velocity = 0 42 + } 43 + 44 + const onMouseLeave = () => { 45 + if (isDown.value) { 46 + isDown.value = false 47 + isDragging.value = false 48 + beginPhysicsLoop() 49 + } 50 + } 51 + 52 + const onMouseUp = () => { 53 + if (!isDown.value) return 54 + isDown.value = false 55 + setTimeout(() => { 56 + isDragging.value = false 57 + }, 0) 58 + beginPhysicsLoop() 59 + } 60 + 61 + const onMouseMove = (e: MouseEvent) => { 62 + if (!isDown.value || !element.value) return 63 + e.preventDefault() 64 + 65 + const x = e.pageX 66 + const now = performance.now() 67 + const deltaX = x - lastPageX 68 + const dt = now - lastTime 69 + 70 + if (dt > 0) { 71 + const currentVel = deltaX / dt 72 + velocity = 0.6 * currentVel + 0.4 * velocity 73 + } 74 + 75 + lastPageX = x 76 + lastTime = now 77 + 78 + const maxScroll = element.value.scrollWidth - element.value.clientWidth 79 + const currentScroll = element.value.scrollLeft 80 + 81 + const isAtLeftEdge = currentScroll <= 0 && (overscrollX > 0 || deltaX > 0) 82 + const isAtRightEdge = currentScroll >= maxScroll && (overscrollX < 0 || deltaX < 0) 83 + 84 + if (isAtLeftEdge || isAtRightEdge) { 85 + const resistance = 1 / (1 + Math.abs(overscrollX) / 150) 86 + overscrollX += deltaX * resistance 87 + } else { 88 + element.value.scrollLeft -= deltaX 89 + overscrollX = 0 90 + } 91 + 92 + updateRendering() 93 + 94 + if (Math.abs(x - startX) > 5) { 95 + isDragging.value = true 96 + } 97 + } 98 + 99 + const beginPhysicsLoop = () => { 100 + if (!element.value) return 101 + 102 + const friction = 0.95 103 + 104 + const step = () => { 105 + if (!element.value) return 106 + 107 + const maxScroll = element.value.scrollWidth - element.value.clientWidth 108 + let keepAnimating = false 109 + 110 + // overscroll - if overscrolled, spring back to the edge. 111 + if (Math.abs(overscrollX) > 0.1) { 112 + const returnSpeed = 0.15 113 + const diff = -overscrollX 114 + velocity += diff * returnSpeed 115 + velocity *= 0.7 116 + 117 + overscrollX += velocity 118 + keepAnimating = true 119 + 120 + // snap to zero if very close & moving slowly 121 + if (Math.abs(overscrollX) < 0.5 && Math.abs(velocity) < 0.5) { 122 + overscrollX = 0 123 + velocity = 0 124 + keepAnimating = false 125 + } 126 + } 127 + // inertia - if within bounds, continue scrolling. 128 + else { 129 + velocity *= friction 130 + 131 + const move = velocity * 16 132 + const potentialScroll = element.value.scrollLeft - move 133 + 134 + // if beyond left edge 135 + if (potentialScroll <= 0) { 136 + element.value.scrollLeft = 0 137 + overscrollX = -potentialScroll * 0.1 138 + keepAnimating = true 139 + } 140 + // if beyond right edge 141 + else if (potentialScroll >= maxScroll) { 142 + element.value.scrollLeft = maxScroll 143 + overscrollX = (maxScroll - potentialScroll) * 0.1 144 + keepAnimating = true 145 + } 146 + // standard scrolling 147 + else { 148 + element.value.scrollLeft = potentialScroll 149 + if (Math.abs(velocity) > 0.1) keepAnimating = true 150 + } 151 + } 152 + 153 + updateRendering() 154 + 155 + if (keepAnimating) { 156 + animationFrameId = requestAnimationFrame(step) 157 + } else { 158 + stopAnimation() 159 + overscrollX = 0 160 + updateRendering() 161 + } 162 + } 163 + 164 + animationFrameId = requestAnimationFrame(step) 165 + } 166 + 167 + onUnmounted(() => { 168 + stopAnimation() 169 + }) 170 + 171 + return { 172 + element, 173 + isDown, 174 + isDragging, 175 + events: { 176 + mousedown: onMouseDown, 177 + mouseleave: onMouseLeave, 178 + mouseup: onMouseUp, 179 + mousemove: onMouseMove, 180 + }, 181 + } 182 + }
+265
src/views/Post/PostView.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, onMounted, watch } from 'vue' 3 + import { AppBskyFeedDefs } from '@atcute/bluesky' 4 + import { ok } from '@atcute/client' 5 + import { is, type ResourceUri } from '@atcute/lexicons' 6 + 7 + import { useAuthStore } from '@/stores/auth' 8 + import { usePostStore } from '@/stores/posts' 9 + import type { ThreadNode } from '@/utils/threading' 10 + 11 + import PageLayout from '@/components/Navigation/PageLayout.vue' 12 + import FeedItem from '@/components/Feed/FeedItem.vue' 13 + import FeedThread from '@/components/Feed/FeedThread.vue' 14 + import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 15 + import Button from '@/components/UI/BaseButton.vue' 16 + import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' 17 + import type { HandleString } from '@/types/atproto' 18 + 19 + const props = defineProps<{ 20 + identifier: string 21 + rkey: string 22 + }>() 23 + 24 + const auth = useAuthStore() 25 + const postStore = usePostStore() 26 + 27 + const loading = ref(true) 28 + const error = ref<string | null>(null) 29 + const thread = ref<AppBskyFeedDefs.ThreadViewPost | null>(null) 30 + const ancestors = ref<AppBskyFeedDefs.ThreadViewPost[]>([]) 31 + const replyNodes = ref<ThreadNode[]>([]) 32 + 33 + const resolveUri = async (): Promise<string> => { 34 + let did = props.identifier 35 + 36 + if (!did.startsWith('did:')) { 37 + const rpc = auth.getRpc() 38 + const data = await ok( 39 + rpc.get('com.atproto.identity.resolveHandle', { 40 + params: { handle: props.identifier as HandleString }, 41 + }), 42 + ) 43 + did = data.did 44 + } 45 + 46 + return `at://${did}/app.bsky.feed.post/${props.rkey}` 47 + } 48 + 49 + const fetchThread = async () => { 50 + loading.value = true 51 + error.value = null 52 + ancestors.value = [] 53 + replyNodes.value = [] 54 + thread.value = null 55 + 56 + try { 57 + const rpc = auth.getRpc() 58 + const uri = await resolveUri() 59 + 60 + const data = await ok( 61 + rpc.get('app.bsky.feed.getPostThread', { 62 + params: { 63 + uri: uri as ResourceUri, 64 + depth: 10, 65 + parentHeight: 10, 66 + }, 67 + }), 68 + ) 69 + 70 + if (!is(AppBskyFeedDefs.threadViewPostSchema, data.thread)) { 71 + if (data.thread.$type === 'app.bsky.feed.defs#notFoundPost') { 72 + throw new Error('Post not found') 73 + } else if (data.thread.$type === 'app.bsky.feed.defs#blockedPost') { 74 + throw new Error('Either you have blocked this user or they have blocked you') 75 + } else { 76 + throw new Error( 77 + 'Could not load thread - thread is not of type app.bsky.feed.defs#threadViewPost', 78 + ) 79 + } 80 + } 81 + 82 + const threadData = data.thread as AppBskyFeedDefs.ThreadViewPost 83 + 84 + threadData.post = postStore.mergePost(threadData.post) 85 + thread.value = threadData 86 + 87 + const parents: AppBskyFeedDefs.ThreadViewPost[] = [] 88 + let curr = threadData.parent 89 + while (curr) { 90 + if (!is(AppBskyFeedDefs.threadViewPostSchema, curr)) break 91 + curr.post = postStore.mergePost(curr.post) 92 + parents.unshift(curr) 93 + curr = curr.parent 94 + } 95 + ancestors.value = parents 96 + 97 + if (threadData.replies) { 98 + replyNodes.value = (threadData.replies as AppBskyFeedDefs.ThreadViewPost[]) 99 + .filter((r) => is(AppBskyFeedDefs.threadViewPostSchema, r)) 100 + .map((r) => convertToThreadNode(r)) 101 + .sort((a, b) => { 102 + const postA = a.data as AppBskyFeedDefs.PostView 103 + const postB = b.data as AppBskyFeedDefs.PostView 104 + return (postB.likeCount || 0) - (postA.likeCount || 0) 105 + }) 106 + } 107 + 108 + setTimeout(() => { 109 + const el = document.getElementById(threadData.post.uri) 110 + if (el) { 111 + el.scrollIntoView({ behavior: 'instant', block: 'start' }) 112 + } 113 + }, 100) 114 + } catch (err) { 115 + console.error('Failed to fetch thread', err) 116 + if (err instanceof Error) error.value = err.message 117 + else error.value = 'An unknown error occurred' 118 + } finally { 119 + loading.value = false 120 + } 121 + } 122 + 123 + function convertToThreadNode(view: AppBskyFeedDefs.ThreadViewPost): ThreadNode { 124 + view.post = postStore.mergePost(view.post) 125 + const children: ThreadNode[] = [] 126 + 127 + if (view.replies) { 128 + for (const reply of view.replies) { 129 + if (is(AppBskyFeedDefs.threadViewPostSchema, reply)) { 130 + children.push(convertToThreadNode(reply)) 131 + } 132 + } 133 + } 134 + 135 + return { 136 + data: view.post, 137 + children, 138 + isVirtual: false, 139 + } 140 + } 141 + 142 + onMounted(() => { 143 + if (auth.isAuthenticated) fetchThread() 144 + }) 145 + 146 + watch( 147 + () => [props.identifier, props.rkey], 148 + () => { 149 + if (auth.isAuthenticated) fetchThread() 150 + }, 151 + ) 152 + </script> 153 + 154 + <template> 155 + <PageLayout noPadding title="Thread"> 156 + <div class="thread-view"> 157 + <div v-if="loading && !thread" class="loading-state"> 158 + <div class="skeleton-group"> 159 + <SkeletonLoader circle width="3rem" height="3rem" /> 160 + <div class="skeleton-lines"> 161 + <SkeletonLoader width="40%" height="1rem" /> 162 + <SkeletonLoader width="100%" height="4rem" /> 163 + </div> 164 + </div> 165 + </div> 166 + 167 + <div v-else-if="error" class="error-state"> 168 + <p>{{ error }}</p> 169 + <Button variant="secondary" @click="fetchThread"> <IconRefreshRounded /> Retry </Button> 170 + </div> 171 + 172 + <div v-else-if="thread" class="thread-content"> 173 + <div class="ancestors-chain" v-if="ancestors.length > 0"> 174 + <div v-for="parent in ancestors" :key="parent.post.uri" class="ancestor-node"> 175 + <FeedItem :post="parent.post" /> 176 + <div class="connector-line"></div> 177 + </div> 178 + </div> 179 + 180 + <div class="main-post-wrapper"> 181 + <FeedItem :post="thread.post" class="main-post" :rootPost="true" /> 182 + </div> 183 + 184 + <div class="replies-section"> 185 + <div class="replies-header" v-if="replyNodes.length > 0">Replies</div> 186 + <FeedThread 187 + v-for="node in replyNodes" 188 + :key="(node.data as AppBskyFeedDefs.PostView).uri" 189 + :node="node" 190 + /> 191 + <div v-if="replyNodes.length === 0" class="no-replies">No replies yet.</div> 192 + </div> 193 + </div> 194 + </div> 195 + </PageLayout> 196 + </template> 197 + 198 + <style scoped lang="scss"> 199 + .thread-view { 200 + padding-bottom: 4rem; 201 + } 202 + 203 + .loading-state { 204 + padding: 2rem 1rem; 205 + .skeleton-group { 206 + display: flex; 207 + gap: 1rem; 208 + .skeleton-lines { 209 + flex: 1; 210 + display: flex; 211 + flex-direction: column; 212 + gap: 0.5rem; 213 + } 214 + } 215 + } 216 + 217 + .error-state { 218 + padding: 4rem 1rem; 219 + text-align: center; 220 + display: flex; 221 + flex-direction: column; 222 + align-items: center; 223 + gap: 1rem; 224 + color: hsl(var(--red)); 225 + } 226 + 227 + /* TODO)) this sucks */ 228 + .ancestors-chain { 229 + .ancestor-node { 230 + position: relative; 231 + :deep(.feed-item) { 232 + border-bottom: none; 233 + } 234 + .connector-line { 235 + position: absolute; 236 + left: 2.375rem; 237 + top: 0; 238 + bottom: 0; 239 + width: 2px; 240 + background-color: hsl(var(--surface2)); 241 + z-index: 0; 242 + pointer-events: none; 243 + } 244 + &:first-child .connector-line { 245 + top: 3.5rem; 246 + } 247 + } 248 + } 249 + 250 + .replies-section { 251 + .replies-header { 252 + padding: 1rem; 253 + font-weight: 700; 254 + color: hsl(var(--subtext0)); 255 + font-size: 0.9rem; 256 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 257 + } 258 + .no-replies { 259 + padding: 2rem; 260 + text-align: center; 261 + color: hsl(var(--subtext0)); 262 + font-size: 0.9rem; 263 + } 264 + } 265 + </style>
+151 -3
src/views/Root/HomeView.vue
··· 1 1 <script lang="ts" setup> 2 + import { onMounted, ref } from 'vue' 3 + import { type AppBskyActorGetPreferences, AppBskyFeedDefs } from '@atcute/bluesky' 4 + import type { ResourceUri } from '@atcute/lexicons' 5 + 2 6 import PageLayout from '@/components/Navigation/PageLayout.vue' 7 + import FeedList from '@/components/Feed/FeedList.vue' 8 + import { useAuthStore } from '@/stores/auth' 9 + 10 + import { useDraggableScroll } from '@/composables/useDraggableScroll' 11 + 12 + import KEYS from '@/utils/keys' 13 + 14 + const auth = useAuthStore() 15 + const pinnedFeeds = ref<AppBskyFeedDefs.GeneratorView[]>([]) 16 + const feedList = ref<InstanceType<typeof FeedList> | null>(null) 17 + const pageLayout = ref<InstanceType<typeof PageLayout> | null>(null) 18 + 19 + const { element: feedsBarRef, events: dragEvents, isDragging } = useDraggableScroll() 20 + 21 + onMounted(async () => { 22 + if (!auth.isAuthenticated) return 3 23 4 - import FeedList from '@/components/Feed/FeedList.vue' 24 + const rpc = auth.getRpc() 25 + const { data, ok } = await rpc.get('app.bsky.actor.getPreferences', { 26 + params: {}, 27 + }) 28 + 29 + if (!ok) return 30 + const savedFeedsPref = (data as AppBskyActorGetPreferences.$output).preferences.find( 31 + (pref) => pref.$type === 'app.bsky.actor.defs#savedFeedsPrefV2', 32 + ) 33 + 34 + if (!savedFeedsPref) return 35 + 36 + // TODO)) handle lists & the following feed too 37 + const feedGenerators = await rpc.get('app.bsky.feed.getFeedGenerators', { 38 + params: { 39 + feeds: savedFeedsPref.items 40 + .map((feed) => feed.value) 41 + .filter((value) => value !== 'following') as ResourceUri[], 42 + }, 43 + }) 44 + 45 + if (!feedGenerators.ok) return 46 + pinnedFeeds.value = feedGenerators.data.feeds.filter((feed) => 47 + savedFeedsPref.items.some((item) => item.value === feed.uri), 48 + ) 49 + 50 + const activeFeedUri = localStorage.getItem(KEYS.STATE.ACTIVE_FEED_URI) 51 + if (activeFeedUri) { 52 + const matchedFeed = pinnedFeeds.value.find((feed) => feed.uri === activeFeedUri) 53 + if (matchedFeed) { 54 + activeFeed.value = matchedFeed 55 + } 56 + } else if (pinnedFeeds.value.length > 0) { 57 + activeFeed.value = pinnedFeeds.value[0] as AppBskyFeedDefs.GeneratorView 58 + } 59 + }) 60 + 61 + const switchFeed = async (feed: AppBskyFeedDefs.GeneratorView) => { 62 + if (activeFeed.value === feed) { 63 + await feedList.value?.refresh() 64 + pageLayout.value?.scrollToTop(true) 65 + return 66 + } 67 + 68 + activeFeed.value = feed 69 + localStorage.setItem(KEYS.STATE.ACTIVE_FEED_URI, feed ? feed.uri : '') 70 + pageLayout.value?.scrollToTop(false) 71 + } 72 + 73 + const activeFeed = ref<AppBskyFeedDefs.GeneratorView | null>(null) 5 74 </script> 6 75 7 76 <template> 8 - <PageLayout no-padding title="Home"> 9 - <FeedList type="timeline" /> 77 + <PageLayout no-padding title="Home" ref="pageLayout"> 78 + <template #app-bar v-if="pinnedFeeds.length > 0"> 79 + <div 80 + class="feeds-bar" 81 + ref="feedsBarRef" 82 + v-on="dragEvents" 83 + :class="{ 'is-dragging': isDragging }" 84 + > 85 + <button 86 + v-for="feed in pinnedFeeds" 87 + :key="feed.uri" 88 + :class="['feed-button', { active: activeFeed && activeFeed.uri === feed.uri }]" 89 + @click="switchFeed(feed)" 90 + > 91 + {{ feed.displayName || 'Unnamed Feed' }} 92 + </button> 93 + </div> 94 + </template> 95 + <FeedList 96 + :type="activeFeed ? 'feed' : 'timeline'" 97 + :uri="activeFeed ? activeFeed.uri : null" 98 + ref="feedList" 99 + /> 10 100 </PageLayout> 11 101 </template> 102 + 103 + <style lang="scss" scoped> 104 + .feeds-bar { 105 + display: flex; 106 + gap: 0.5rem; 107 + overflow-x: auto; 108 + margin: -1rem; 109 + padding: 0.5rem; 110 + transition: none; 111 + 112 + scrollbar-width: none; 113 + -ms-overflow-style: none; 114 + &::-webkit-scrollbar { 115 + display: none; 116 + } 117 + 118 + cursor: grab; 119 + &:active { 120 + cursor: grabbing; 121 + } 122 + 123 + &.is-dragging { 124 + .feed-button { 125 + pointer-events: none; 126 + } 127 + } 128 + } 129 + 130 + .feed-button { 131 + appearance: none; 132 + background: transparent; 133 + border: none; 134 + border-radius: 10rem; 135 + padding: 0.5rem 1rem; 136 + 137 + font-size: 0.9rem; 138 + font-weight: 600; 139 + color: hsl(var(--subtext0)); 140 + white-space: nowrap; 141 + cursor: pointer; 142 + 143 + flex-shrink: 0; 144 + 145 + &:hover { 146 + background-color: hsla(var(--surface2) / 0.3); 147 + color: hsl(var(--text)); 148 + } 149 + 150 + &:active { 151 + transform: scale(0.95); 152 + } 153 + 154 + &.active { 155 + color: hsl(var(--text)); 156 + background-color: hsla(var(--accent) / 0.2); 157 + } 158 + } 159 + </style>