wip bsky client for the web & android
0
fork

Configure Feed

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

feat: posts store

willow 7de6641d 67345438

+221 -5
+50 -4
src/components/Feed/FeedItem.vue
··· 5 5 IconChatBubbleOutlineRounded, 6 6 IconRepeatRounded, 7 7 IconFavoriteOutlineRounded, 8 + IconFavoriteRounded, 8 9 } from '@iconify-prerendered/vue-material-symbols' 10 + import { usePostStore } from '@/stores/posts' 9 11 10 - defineProps<{ 12 + const props = defineProps<{ 11 13 item: AppBskyFeedDefs.FeedViewPost 12 14 }>() 15 + 16 + const postStore = usePostStore() 13 17 14 18 const formatTime = (dateString: string) => { 15 19 const date = new Date(dateString) ··· 27 31 if (count < 1000) return count.toString() 28 32 if (count < 1000000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k` 29 33 return `${(count / 1000000).toFixed(1).replace(/\.0$/, '')}M` 34 + } 35 + 36 + const handleLike = () => { 37 + postStore.toggleLike(props.item.post) 38 + } 39 + 40 + const handleRepost = () => { 41 + postStore.toggleRepost(props.item.post) 30 42 } 31 43 </script> 32 44 ··· 73 85 </span> 74 86 </button> 75 87 76 - <button class="action-button repost" aria-label="Repost"> 88 + <button 89 + class="action-button repost" 90 + :class="{ 'is-active': !!item.post.viewer?.repost }" 91 + @click="handleRepost" 92 + aria-label="Repost" 93 + > 77 94 <div class="icon-wrapper"> 78 95 <IconRepeatRounded /> 79 96 </div> ··· 82 99 </span> 83 100 </button> 84 101 85 - <button class="action-button like" aria-label="Like"> 102 + <button 103 + class="action-button like" 104 + :class="{ 'is-active': !!item.post.viewer?.like }" 105 + @click="handleLike" 106 + aria-label="Like" 107 + > 86 108 <div class="icon-wrapper"> 87 - <IconFavoriteOutlineRounded /> 109 + <IconFavoriteRounded v-if="!!item.post.viewer?.like" /> 110 + <IconFavoriteOutlineRounded v-else /> 88 111 </div> 89 112 <span class="count" v-if="item.post.likeCount && item.post.likeCount > 0"> 90 113 {{ formatCount(item.post.likeCount) }} ··· 243 266 --hover-colour: var(--pink); 244 267 } 245 268 269 + &.repost.is-active { 270 + color: hsl(var(--green)); 271 + } 272 + &.like.is-active { 273 + color: hsl(var(--pink)); 274 + 275 + .icon-wrapper svg { 276 + animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); 277 + } 278 + } 279 + 246 280 .icon-wrapper { 247 281 display: flex; 248 282 align-items: center; ··· 268 302 &:active { 269 303 background-color: hsla(var(--hover-colour) / 0.075); 270 304 } 305 + } 306 + } 307 + 308 + @keyframes pop { 309 + 0% { 310 + transform: scale(1); 311 + } 312 + 50% { 313 + transform: scale(1.3); 314 + } 315 + 100% { 316 + transform: scale(1); 271 317 } 272 318 } 273 319 </style>
+18 -1
src/components/Feed/FeedList.vue
··· 6 6 7 7 import { useNavigationStore } from '@/stores/navigation' 8 8 import { useAuthStore } from '@/stores/auth' 9 + import { usePostStore } from '@/stores/posts' 10 + 9 11 import Button from '@/components/UI/BaseButton.vue' 10 12 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 11 13 import FeedThread from './FeedThread.vue' ··· 19 21 20 22 const auth = useAuthStore() 21 23 const nav = useNavigationStore() 24 + const postStore = usePostStore() 22 25 23 26 const threadedFeed = ref<ThreadNode[]>([]) 24 27 const totalPostCount = ref(0) ··· 62 65 }), 63 66 ) 64 67 65 - const builder = new ThreadBuilder(data.feed) 68 + const processedFeed = data.feed.map((item) => { 69 + item.post = postStore.mergePost(item.post) 70 + 71 + if (item.reply) { 72 + if (item.reply.parent && item.reply.parent.$type === 'app.bsky.feed.defs#postView') { 73 + item.reply.parent = postStore.mergePost(item.reply.parent) 74 + } 75 + if (item.reply.root && item.reply.root.$type === 'app.bsky.feed.defs#postView') { 76 + item.reply.root = postStore.mergePost(item.reply.root) 77 + } 78 + } 79 + return item 80 + }) 81 + 82 + const builder = new ThreadBuilder(processedFeed) 66 83 const newThreadedItems = builder.build() 67 84 68 85 if (!cursor.value) {
+153
src/stores/posts.ts
··· 1 + import { defineStore } from 'pinia' 2 + import { shallowRef, reactive } from 'vue' 3 + import { AppBskyFeedDefs } from '@atcute/bluesky' 4 + import { useAuthStore } from './auth' 5 + import { ok } from '@atcute/client' 6 + 7 + type PostView = AppBskyFeedDefs.PostView 8 + 9 + export const usePostStore = defineStore('posts', () => { 10 + const auth = useAuthStore() 11 + 12 + const posts = shallowRef(new Map<string, PostView>()) 13 + 14 + function mergePost(post: PostView): PostView { 15 + const existing = posts.value.get(post.uri) 16 + 17 + if (existing) { 18 + Object.assign(existing, post) 19 + return existing 20 + } else { 21 + const reactivePost = reactive(post) 22 + posts.value.set(post.uri, reactivePost) 23 + return reactivePost 24 + } 25 + } 26 + 27 + async function toggleLike(post: PostView) { 28 + if (!auth.isAuthenticated) return 29 + if (!auth.session) return 30 + 31 + const uri = post.uri 32 + const cid = post.cid 33 + 34 + const originalLike = post.viewer?.like 35 + const originalCount = post.likeCount || 0 36 + 37 + if (!post.viewer) post.viewer = {} 38 + 39 + if (originalLike) { 40 + post.viewer.like = undefined 41 + post.likeCount = originalCount - 1 42 + } else { 43 + post.viewer.like = 'at://did:plc:pending/like' 44 + post.likeCount = originalCount + 1 45 + } 46 + 47 + try { 48 + const rpc = auth.getRpc() 49 + if (originalLike) { 50 + await rpc.post('com.atproto.repo.deleteRecord', { 51 + input: { 52 + collection: 'app.bsky.feed.like', 53 + repo: auth.session?.info.sub, 54 + rkey: originalLike.split('/').pop()!, 55 + }, 56 + }) 57 + } else { 58 + const data = ok( 59 + await rpc.post('com.atproto.repo.createRecord', { 60 + input: { 61 + collection: 'app.bsky.feed.like', 62 + repo: auth.session?.info.sub, 63 + record: { 64 + $type: 'app.bsky.feed.like', 65 + subject: { uri, cid }, 66 + createdAt: new Date().toISOString(), 67 + }, 68 + }, 69 + }), 70 + ) 71 + 72 + const storedPost = posts.value.get(uri) 73 + if (storedPost && storedPost.viewer) { 74 + storedPost.viewer.like = data.uri 75 + } 76 + } 77 + } catch (err) { 78 + console.error('Failed to toggle like', err) 79 + const storedPost = posts.value.get(uri) 80 + if (storedPost && storedPost.viewer) { 81 + storedPost.viewer.like = originalLike 82 + storedPost.likeCount = originalCount 83 + } 84 + } 85 + } 86 + 87 + async function toggleRepost(post: PostView) { 88 + if (!auth.isAuthenticated) return 89 + 90 + const uri = post.uri 91 + const cid = post.cid 92 + 93 + const originalRepost = post.viewer?.repost 94 + const originalCount = post.repostCount || 0 95 + 96 + if (!post.viewer) post.viewer = {} 97 + 98 + if (originalRepost) { 99 + post.viewer.repost = undefined 100 + post.repostCount = originalCount - 1 101 + } else { 102 + post.viewer.repost = 'at://did:plc:pending/repost' 103 + post.repostCount = originalCount + 1 104 + } 105 + 106 + try { 107 + const rpc = auth.getRpc() 108 + if (originalRepost) { 109 + await ok( 110 + rpc.post('com.atproto.repo.deleteRecord', { 111 + input: { 112 + collection: 'app.bsky.feed.repost', 113 + repo: auth.session!.info.sub, 114 + rkey: originalRepost.split('/').pop()!, 115 + }, 116 + }), 117 + ) 118 + } else { 119 + const data = ok( 120 + await rpc.post('com.atproto.repo.createRecord', { 121 + input: { 122 + collection: 'app.bsky.feed.repost', 123 + repo: auth.session!.info.sub, 124 + record: { 125 + $type: 'app.bsky.feed.repost', 126 + subject: { uri, cid }, 127 + createdAt: new Date().toISOString(), 128 + }, 129 + }, 130 + }), 131 + ) 132 + const storedPost = posts.value.get(uri) 133 + if (storedPost && storedPost.viewer) { 134 + storedPost.viewer.repost = data.uri 135 + } 136 + } 137 + } catch (err) { 138 + console.error('Failed to toggle repost', err) 139 + const storedPost = posts.value.get(uri) 140 + if (storedPost && storedPost.viewer) { 141 + storedPost.viewer.repost = originalRepost 142 + storedPost.repostCount = originalCount 143 + } 144 + } 145 + } 146 + 147 + return { 148 + posts, 149 + mergePost, 150 + toggleLike, 151 + toggleRepost, 152 + } 153 + })