wip bsky client for the web & android
0
fork

Configure Feed

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

feat: threaded view for feed list

willow 5ef2b0cb dabdb11a

+221 -116
+1 -1
src/components/Feed/FeedItem.vue
··· 57 57 <style lang="scss" scoped> 58 58 .feed-item { 59 59 padding: 1rem; 60 - border-bottom: 1px solid hsla(var(--surface2) / 0.3); 60 + border-bottom: 1px solid hsla(var(--surface2) / 1); 61 61 display: flex; 62 62 flex-direction: column; 63 63 gap: 0.5rem;
+23 -115
src/components/Feed/FeedList.vue
··· 1 1 <script setup lang="ts"> 2 2 import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' 3 3 import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' 4 - import { AppBskyFeedDefs } from '@atcute/bluesky' 5 4 import type { ResourceUri } from '@atcute/lexicons' 6 5 import { ok } from '@atcute/client' 7 6 ··· 9 8 import { useAuthStore } from '@/stores/auth' 10 9 import Button from '@/components/UI/BaseButton.vue' 11 10 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 12 - import FeedItem from './FeedItem.vue' 11 + import FeedThread from './FeedThread.vue' 12 + import { ThreadBuilder, type ThreadNode } from '@/utils/threading' 13 13 14 14 const props = defineProps<{ 15 15 type: 'timeline' | 'userFeed' | 'listFeed' ··· 20 20 const auth = useAuthStore() 21 21 const nav = useNavigationStore() 22 22 23 - const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([]) 23 + const threadedFeed = ref<ThreadNode[]>([]) 24 + const totalPostCount = ref(0) 24 25 const cursor = ref<string | undefined>(undefined) 25 26 26 27 const loading = ref(false) ··· 61 62 }), 62 63 ) 63 64 64 - if (!cursor.value) feed.value = data.feed 65 - else feed.value.push(...data.feed) 65 + const builder = new ThreadBuilder(data.feed) 66 + const newThreadedItems = builder.build() 67 + 68 + if (!cursor.value) { 69 + threadedFeed.value = newThreadedItems 70 + totalPostCount.value = data.feed.length 71 + } else { 72 + threadedFeed.value.push(...newThreadedItems) 73 + totalPostCount.value += data.feed.length 74 + } 75 + 66 76 cursor.value = data.cursor 67 77 } catch (err) { 68 78 console.error('failed to fetch feed', err) ··· 135 145 <Button @click="fetchTimeline" variant="secondary"> <IconRefreshRounded /> Retry </Button> 136 146 </div> 137 147 138 - <div v-else-if="loading && feed.length === 0" class="loading-stack"> 148 + <div v-else-if="loading && threadedFeed.length === 0" class="loading-stack"> 139 149 <div v-for="i in 15" :key="i" class="feed-item skeleton-item"> 140 150 <div class="post-header"> 141 151 <SkeletonLoader circle width="2.5rem" height="2.5rem" /> ··· 152 162 </div> 153 163 154 164 <div v-else class="feed-list"> 155 - <FeedItem v-for="item in feed" :key="item.post.uri" :item="item" /> 165 + <FeedThread 166 + v-for="node in threadedFeed" 167 + :key="'post' in node.data ? node.data.post.uri : node.data.uri" 168 + :node="node" 169 + /> 156 170 157 171 <div class="feed-end" ref="loadMoreTrigger"> 158 172 <div v-if="loading" class="spinner-container"> ··· 161 175 <template v-else-if="cursor"> 162 176 <Button variant="subtle-alt" @click="fetchTimeline"> Load More </Button> 163 177 <p> 164 - You've scrolled past <span class="post-count">{{ feed.length }} posts</span> so far. 178 + You've scrolled past <span class="post-count">{{ totalPostCount }} posts</span> so far. 165 179 </p> 166 180 </template> 167 181 <p v-else class="end-text">You've reached the end!</p> ··· 201 215 display: flex; 202 216 flex-direction: column; 203 217 gap: 1rem; 218 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 204 219 205 220 .post-header { 206 221 display: flex; ··· 218 233 flex-direction: column; 219 234 gap: 0.5rem; 220 235 padding-left: 3.5rem; 221 - } 222 - } 223 - } 224 - } 225 - 226 - .feed-item { 227 - padding: 1rem; 228 - border-bottom: 1px solid hsla(var(--surface2) / 0.3); 229 - display: flex; 230 - flex-direction: column; 231 - gap: 0.5rem; 232 - 233 - &:hover { 234 - background-color: hsla(var(--surface0) / 0.3); 235 - cursor: pointer; 236 - } 237 - 238 - .repost-indicator { 239 - display: flex; 240 - align-items: center; 241 - gap: 0.5rem; 242 - font-size: 0.8rem; 243 - color: hsl(var(--subtext0)); 244 - font-weight: 600; 245 - margin-left: 2.5rem; 246 - margin-bottom: -0.25rem; 247 - 248 - .repost-icon { 249 - font-size: 1rem; 250 - color: hsl(var(--green)); 251 - } 252 - } 253 - 254 - .post-layout { 255 - display: flex; 256 - gap: 0.75rem; 257 - 258 - .post-avatar { 259 - flex-shrink: 0; 260 - width: 2.5rem; 261 - height: 2.5rem; 262 - 263 - img { 264 - width: 100%; 265 - height: 100%; 266 - border-radius: 50%; 267 - object-fit: cover; 268 - background-color: hsl(var(--surface1)); 269 - } 270 - 271 - .avatar-fallback { 272 - width: 100%; 273 - height: 100%; 274 - border-radius: 50%; 275 - background-color: hsl(var(--surface2)); 276 - } 277 - } 278 - 279 - .post-content { 280 - flex: 1; 281 - min-width: 0; 282 - display: flex; 283 - flex-direction: column; 284 - gap: 0.25rem; 285 - 286 - .post-header { 287 - display: flex; 288 - align-items: baseline; 289 - gap: 0.35rem; 290 - font-size: 0.95rem; 291 - line-height: 1.3; 292 - 293 - * { 294 - min-width: 0; 295 - text-wrap: nowrap; 296 - text-overflow: ellipsis; 297 - overflow: hidden; 298 - } 299 - 300 - .display-name { 301 - font-weight: 700; 302 - color: hsl(var(--text)); 303 - text-emphasis: none; 304 - } 305 - 306 - .handle { 307 - color: hsl(var(--subtext0)); 308 - font-weight: 500; 309 - } 310 - 311 - .dot { 312 - user-select: none; 313 - color: hsl(var(--surface2)); 314 - } 315 - 316 - .time { 317 - color: hsl(var(--subtext0)); 318 - font-size: 0.85rem; 319 - } 320 - } 321 - 322 - .post-text { 323 - color: hsl(var(--text)); 324 - font-size: 1rem; 325 - line-height: 1.5; 326 - white-space: pre-wrap; 327 - word-wrap: break-word; 328 236 } 329 237 } 330 238 }
+85
src/components/Feed/FeedThread.vue
··· 1 + <script setup lang="ts"> 2 + import { computed } from 'vue' 3 + import { AppBskyFeedDefs } from '@atcute/bluesky' 4 + import type { ThreadNode } from '@/utils/threading' 5 + import FeedItem from './FeedItem.vue' 6 + 7 + const props = defineProps<{ 8 + node: ThreadNode 9 + depth?: number 10 + }>() 11 + 12 + const currentDepth = props.depth || 0 13 + 14 + const displayItem = computed<AppBskyFeedDefs.FeedViewPost>(() => { 15 + if ('post' in props.node.data) { 16 + return props.node.data as AppBskyFeedDefs.FeedViewPost 17 + } 18 + return { 19 + post: props.node.data as AppBskyFeedDefs.PostView, 20 + } 21 + }) 22 + 23 + const sortedChildren = computed(() => { 24 + return [...props.node.children].sort((a, b) => { 25 + const dateA = 'post' in a.data ? a.data.post.indexedAt : a.data.indexedAt 26 + const dateB = 'post' in b.data ? b.data.post.indexedAt : b.data.indexedAt 27 + return new Date(dateA).getTime() - new Date(dateB).getTime() 28 + }) 29 + }) 30 + </script> 31 + 32 + <template> 33 + <div class="thread-node" :class="{ 'is-child': currentDepth > 0 }"> 34 + <div class="thread-content"> 35 + <FeedItem :item="displayItem" :class="{ 'virtual-node': node.isVirtual }" /> 36 + 37 + <!-- Thread line connecting this post to its children --> 38 + <div v-if="sortedChildren.length > 0" class="thread-line"></div> 39 + </div> 40 + 41 + <div v-if="sortedChildren.length > 0" class="thread-children"> 42 + <FeedThread 43 + v-for="child in sortedChildren" 44 + :key="'post' in child.data ? child.data.post.uri : child.data.uri" 45 + :node="child" 46 + :depth="currentDepth + 1" 47 + /> 48 + </div> 49 + </div> 50 + </template> 51 + 52 + <style scoped lang="scss"> 53 + .thread-node { 54 + display: flex; 55 + flex-direction: column; 56 + position: relative; 57 + } 58 + 59 + .thread-content { 60 + position: relative; 61 + z-index: 2; 62 + 63 + :deep(.feed-item) { 64 + border-bottom: none; 65 + } 66 + 67 + :deep(.virtual-node) { 68 + } 69 + 70 + .thread-line { 71 + position: absolute; 72 + left: 2.25rem; 73 + top: calc(1rem + 2.5rem + 4px); 74 + bottom: calc(-1rem + 4px); 75 + border-radius: 1rem; 76 + width: 2px; 77 + background-color: hsl(var(--surface0)); 78 + z-index: 5; 79 + } 80 + } 81 + 82 + .thread-node:not(.is-child) { 83 + border-bottom: 1px solid hsla(var(--surface2) / 0.3); 84 + } 85 + </style>
+112
src/utils/threading.ts
··· 1 + /** 2 + * ThreadBuilder 3 + * builds threaded conversations from a flat feed of posts 4 + */ 5 + 6 + /** 7 + * i initially based this off of @aylac.top's implementation for supercoolclient.pages.dev, but later 8 + * completely pivoted, thank you ayla for the inspiration :D 9 + * @see https://raw.githubusercontent.com/ayla6/supercoolclient/refs/heads/main/src/elements/ui/feed.ts 10 + */ 11 + 12 + import { AppBskyFeedDefs } from '@atcute/bluesky' 13 + import { is } from '@atcute/lexicons' 14 + 15 + export type FeedItem = AppBskyFeedDefs.FeedViewPost 16 + export type PostView = AppBskyFeedDefs.PostView 17 + 18 + export interface ThreadNode { 19 + data: FeedItem | PostView 20 + children: ThreadNode[] 21 + /** if the node was created from `reply.parent` and is not in the main feed */ 22 + isVirtual: boolean 23 + } 24 + 25 + export class ThreadBuilder { 26 + private nodeMap = new Map<string, ThreadNode>() 27 + private rootUris = new Set<string>() 28 + 29 + constructor(private feed: FeedItem[]) {} 30 + 31 + public build(): ThreadNode[] { 32 + this.nodeMap.clear() 33 + this.rootUris.clear() 34 + 35 + this.createNodesFromFeed() 36 + this.linkRepliesToParents() 37 + return this.getSortedRoots() 38 + } 39 + 40 + // create nodes for each feed item 41 + private createNodesFromFeed() { 42 + for (const item of this.feed) { 43 + const uri = item.post.uri 44 + 45 + if (this.nodeMap.has(uri)) { 46 + const existing = this.nodeMap.get(uri)! 47 + existing.data = item 48 + existing.isVirtual = false 49 + } else { 50 + this.nodeMap.set(uri, { 51 + data: item, 52 + children: [], 53 + isVirtual: false, 54 + }) 55 + } 56 + this.rootUris.add(uri) 57 + } 58 + } 59 + 60 + /** link replies to parent nodes, will create virtual parents if missing */ 61 + private linkRepliesToParents() { 62 + for (const item of this.feed) { 63 + if (!item.reply?.parent) continue 64 + 65 + const parent = item.reply.parent 66 + 67 + // check that the parent is a PostView 68 + if (!is(AppBskyFeedDefs.postViewSchema, parent)) { 69 + continue 70 + } 71 + 72 + const currentUri = item.post.uri 73 + const parentUri = parent.uri 74 + const currentNode = this.nodeMap.get(currentUri)! 75 + 76 + if (this.nodeMap.has(parentUri)) { 77 + const parentNode = this.nodeMap.get(parentUri)! 78 + if (!parentNode.children.includes(currentNode)) parentNode.children.push(currentNode) 79 + this.rootUris.delete(currentUri) 80 + } else { 81 + // parent missing from feed; create virtual parent 82 + const virtualParentNode: ThreadNode = { 83 + data: parent, 84 + children: [currentNode], 85 + isVirtual: true, 86 + } 87 + this.nodeMap.set(parentUri, virtualParentNode) 88 + this.rootUris.add(parentUri) 89 + this.rootUris.delete(currentUri) 90 + } 91 + } 92 + } 93 + 94 + /** sort roots by newest activity */ 95 + private getSortedRoots(): ThreadNode[] { 96 + const roots = Array.from(this.rootUris).map((uri) => this.nodeMap.get(uri)!) 97 + 98 + return roots.sort((a, b) => { 99 + const dateA = getIndexedAt(a) 100 + const dateB = getIndexedAt(b) 101 + return new Date(dateB).getTime() - new Date(dateA).getTime() 102 + }) 103 + } 104 + } 105 + 106 + function getIndexedAt(node: ThreadNode): string { 107 + return isFeedItem(node.data) ? node.data.post.indexedAt : node.data.indexedAt 108 + } 109 + 110 + function isFeedItem(item: FeedItem | PostView): item is FeedItem { 111 + return 'post' in item 112 + }