wip bsky client for the web & android bbell.vt3e.cat

fix: oh wait no theyre not meant to be there

vt3e.cat 44ee16ff ab334541

verified
-590
-222
src/views/Profile/FollowersView.vue
··· 1 - <script lang="ts" setup> 2 - import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue' 3 - import type { ActorIdentifier } from '@atcute/lexicons' 4 - import { AppBskyActorDefs } from '@atcute/bluesky' 5 - import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth' 6 - 7 - import PageLayout from '@/components/Navigation/PageLayout.vue' 8 - import AppLink from '@/components/Navigation/AppLink.vue' 9 - import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 10 - import Button from '@/components/UI/BaseButton.vue' 11 - 12 - const props = defineProps<{ id: ActorIdentifier }>() 13 - const auth = useAuthStore() 14 - 15 - const follows = ref<AppBskyActorDefs.ProfileView[]>([]) 16 - const cursor = ref<string | null>(null) 17 - const isLoading = ref(false) 18 - const isError = ref<string | null>(null) 19 - const loadMoreTrigger = ref<HTMLElement | null>(null) 20 - let obs: IntersectionObserver | null = null 21 - 22 - const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value) 23 - 24 - async function fetchFollows(reset = false) { 25 - if (isLoading.value) return 26 - isLoading.value = true 27 - isError.value = null 28 - 29 - if (reset) { 30 - follows.value = [] 31 - cursor.value = null 32 - } 33 - 34 - try { 35 - const rpc = auth.getRpc() 36 - const { data, ok } = await rpc.get('app.bsky.graph.getFollowers', { 37 - params: { 38 - actor: props.id, 39 - limit: 50, 40 - cursor: cursor.value || undefined, 41 - }, 42 - headers: { 'atproto-proxy': BSKY_APPVIEW }, 43 - }) 44 - 45 - if (!ok) { 46 - isError.value = (data && data.error) || 'Failed to fetch follows' 47 - return 48 - } 49 - 50 - follows.value.push(...(data.followers || [])) 51 - cursor.value = data.cursor || null 52 - } catch (e) { 53 - console.error('fetchFollows error', e) 54 - isError.value = e instanceof Error ? e.message : 'Unknown error' 55 - } finally { 56 - isLoading.value = false 57 - } 58 - } 59 - 60 - async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) { 61 - if (!auth.isAuthenticated || !auth.session) return 62 - const rpc = auth.getRpc() 63 - const original = profile.viewer?.following 64 - 65 - profile.viewer = profile.viewer || {} 66 - if (original) { 67 - profile.viewer.following = undefined 68 - } else { 69 - profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary` 70 - } 71 - 72 - try { 73 - if (original) { 74 - const rkey = original.split('/').pop()! 75 - await rpc.post('com.atproto.repo.deleteRecord', { 76 - input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey }, 77 - }) 78 - } else { 79 - const { data, ok } = await rpc.post('com.atproto.repo.createRecord', { 80 - input: { 81 - collection: 'app.bsky.graph.follow', 82 - repo: auth.session.info.sub, 83 - record: { 84 - $type: 'app.bsky.graph.follow', 85 - subject: profile.did, 86 - createdAt: new Date().toISOString(), 87 - }, 88 - }, 89 - }) 90 - if (ok) 91 - profile.viewer 92 - ? (profile.viewer.following = data.uri) 93 - : (profile.viewer = { following: data.uri }) 94 - } 95 - } catch (e) { 96 - console.error('toggleFollowRow failed', e) 97 - profile.viewer.following = original 98 - } 99 - } 100 - 101 - const isMutual = (p: AppBskyActorDefs.ProfileView) => 102 - !!(p.viewer?.following && p.viewer?.followedBy) 103 - 104 - const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following 105 - 106 - function followButtonLabel(p: AppBskyActorDefs.ProfileView) { 107 - if (isMutual(p)) return 'Mutuals' 108 - return isFollowingRow(p) ? 'Following' : 'Follow' 109 - } 110 - 111 - function setupObserver() { 112 - if (!loadMoreTrigger.value) return 113 - obs = new IntersectionObserver( 114 - (entries) => { 115 - for (const ent of entries) { 116 - if (ent.isIntersecting && cursor.value && !isLoading.value) { 117 - fetchFollows(false) 118 - } 119 - } 120 - }, 121 - { root: null, rootMargin: '200px', threshold: 0.1 }, 122 - ) 123 - obs.observe(loadMoreTrigger.value) 124 - } 125 - 126 - onMounted(async () => { 127 - await fetchFollows(true) 128 - setupObserver() 129 - }) 130 - 131 - onBeforeUnmount(() => { 132 - if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value) 133 - obs = null 134 - }) 135 - 136 - watch( 137 - () => props.id, 138 - () => { 139 - fetchFollows(true) 140 - }, 141 - ) 142 - </script> 143 - 144 - <template> 145 - <PageLayout title="Following" no-padding> 146 - <div class="follows-list" role="list"> 147 - <div v-if="isLoading && follows.length === 0" class="skeletons"> 148 - <SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" /> 149 - </div> 150 - 151 - <div v-else-if="isError" class="error-state"> 152 - <p>Failed to load follows - {{ isError }}</p> 153 - <Button @click="fetchFollows(true)">Retry</Button> 154 - </div> 155 - 156 - <div v-else-if="isEmpty" class="empty-state"> 157 - <p>This user isn't following anyone yet.</p> 158 - </div> 159 - 160 - <AppLink 161 - v-for="follow in follows" 162 - :key="follow.did" 163 - class="follow" 164 - name="user-profile" 165 - :params="{ id: follow.did }" 166 - role="listitem" 167 - > 168 - <img 169 - class="avatar" 170 - :src="follow.avatar" 171 - :alt="`${follow.displayName || follow.handle}'s avatar`" 172 - loading="lazy" 173 - /> 174 - <div class="info"> 175 - <div class="top"> 176 - <div class="display-name">{{ follow.displayName || follow.handle }}</div> 177 - <span class="handle">@{{ follow.handle }}</span> 178 - <span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span> 179 - <span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span> 180 - </div> 181 - <div class="meta"> 182 - <span v-if="follow.description" class="bio">{{ follow.description }}</span> 183 - </div> 184 - </div> 185 - 186 - <button 187 - class="follow-btn" 188 - :class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }" 189 - @click.stop.prevent="toggleFollowRow(follow)" 190 - :aria-pressed="!!follow.viewer?.following" 191 - :title=" 192 - isMutual(follow) 193 - ? 'Mutuals - click to unfollow' 194 - : follow.viewer?.following 195 - ? 'Unfollow' 196 - : 'Follow' 197 - " 198 - > 199 - {{ followButtonLabel(follow) }} 200 - </button> 201 - </AppLink> 202 - 203 - <div ref="loadMoreTrigger" class="load-more__sentinel" /> 204 - <div class="load-more__fallback" v-if="cursor && !isLoading"> 205 - <Button variant="ghost" @click="fetchFollows(false)">Load more</Button> 206 - </div> 207 - <div v-if="isLoading && follows.length > 0" class="loading-more"> 208 - <SkeletonLoader width="100%" height="72px" /> 209 - </div> 210 - </div> 211 - </PageLayout> 212 - </template> 213 - 214 - <style scoped lang="scss"> 215 - .follows-list { 216 - /* padding: 1rem; */ 217 - display: flex; 218 - flex-direction: column; 219 - /* gap: 0.5rem; */ 220 - min-height: 200px; 221 - } 222 - </style>
-368
src/views/Profile/FollowingView.vue
··· 1 - <script lang="ts" setup> 2 - import { ref, onMounted, watch, computed, onBeforeUnmount } from 'vue' 3 - import type { ActorIdentifier } from '@atcute/lexicons' 4 - import { AppBskyActorDefs } from '@atcute/bluesky' 5 - import { BSKY_APPVIEW, useAuthStore } from '@/stores/auth' 6 - 7 - import PageLayout from '@/components/Navigation/PageLayout.vue' 8 - import AppLink from '@/components/Navigation/AppLink.vue' 9 - import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 10 - import Button from '@/components/UI/BaseButton.vue' 11 - 12 - const props = defineProps<{ id: ActorIdentifier }>() 13 - const auth = useAuthStore() 14 - 15 - const follows = ref<AppBskyActorDefs.ProfileView[]>([]) 16 - const cursor = ref<string | null>(null) 17 - const isLoading = ref(false) 18 - const isError = ref<string | null>(null) 19 - const loadMoreTrigger = ref<HTMLElement | null>(null) 20 - let obs: IntersectionObserver | null = null 21 - 22 - const isEmpty = computed(() => !isLoading.value && follows.value.length === 0 && !isError.value) 23 - 24 - async function fetchFollows(reset = false) { 25 - if (isLoading.value) return 26 - isLoading.value = true 27 - isError.value = null 28 - 29 - if (reset) { 30 - follows.value = [] 31 - cursor.value = null 32 - } 33 - 34 - try { 35 - const rpc = auth.getRpc() 36 - const { data, ok } = await rpc.get('app.bsky.graph.getFollows', { 37 - params: { 38 - actor: props.id, 39 - limit: 50, 40 - cursor: cursor.value || undefined, 41 - }, 42 - headers: { 'atproto-proxy': BSKY_APPVIEW }, 43 - }) 44 - 45 - if (!ok) { 46 - isError.value = (data && data.error) || 'Failed to fetch follows' 47 - return 48 - } 49 - 50 - follows.value.push(...(data.follows || [])) 51 - cursor.value = data.cursor || null 52 - } catch (e) { 53 - console.error('fetchFollows error', e) 54 - isError.value = e instanceof Error ? e.message : 'Unknown error' 55 - } finally { 56 - isLoading.value = false 57 - } 58 - } 59 - 60 - async function toggleFollowRow(profile: AppBskyActorDefs.ProfileView) { 61 - if (!auth.isAuthenticated || !auth.session) return 62 - const rpc = auth.getRpc() 63 - const original = profile.viewer?.following 64 - 65 - profile.viewer = profile.viewer || {} 66 - if (original) { 67 - profile.viewer.following = undefined 68 - } else { 69 - profile.viewer.following = `at://${profile.did}/app.bsky.graph.follow/temporary` 70 - } 71 - 72 - try { 73 - if (original) { 74 - const rkey = original.split('/').pop()! 75 - await rpc.post('com.atproto.repo.deleteRecord', { 76 - input: { collection: 'app.bsky.graph.follow', repo: auth.session.info.sub, rkey }, 77 - }) 78 - } else { 79 - const { data, ok } = await rpc.post('com.atproto.repo.createRecord', { 80 - input: { 81 - collection: 'app.bsky.graph.follow', 82 - repo: auth.session.info.sub, 83 - record: { 84 - $type: 'app.bsky.graph.follow', 85 - subject: profile.did, 86 - createdAt: new Date().toISOString(), 87 - }, 88 - }, 89 - }) 90 - if (ok) 91 - profile.viewer 92 - ? (profile.viewer.following = data.uri) 93 - : (profile.viewer = { following: data.uri }) 94 - } 95 - } catch (e) { 96 - console.error('toggleFollowRow failed', e) 97 - profile.viewer!.following = original 98 - } 99 - } 100 - 101 - const isMutual = (p: AppBskyActorDefs.ProfileView) => 102 - !!(p.viewer?.following && p.viewer?.followedBy) 103 - 104 - const isFollowingRow = (p: AppBskyActorDefs.ProfileView) => !!p.viewer?.following 105 - 106 - function followButtonLabel(p: AppBskyActorDefs.ProfileView) { 107 - if (isMutual(p)) return 'Mutuals' 108 - return isFollowingRow(p) ? 'Following' : 'Follow' 109 - } 110 - 111 - function setupObserver() { 112 - if (!loadMoreTrigger.value) return 113 - obs = new IntersectionObserver( 114 - (entries) => { 115 - for (const ent of entries) { 116 - if (ent.isIntersecting && cursor.value && !isLoading.value) { 117 - fetchFollows(false) 118 - } 119 - } 120 - }, 121 - { root: null, rootMargin: '200px', threshold: 0.1 }, 122 - ) 123 - obs.observe(loadMoreTrigger.value) 124 - } 125 - 126 - onMounted(async () => { 127 - await fetchFollows(true) 128 - setupObserver() 129 - }) 130 - 131 - onBeforeUnmount(() => { 132 - if (obs && loadMoreTrigger.value) obs.unobserve(loadMoreTrigger.value) 133 - obs = null 134 - }) 135 - 136 - watch( 137 - () => props.id, 138 - () => { 139 - fetchFollows(true) 140 - }, 141 - ) 142 - </script> 143 - 144 - <template> 145 - <PageLayout title="Following" no-padding> 146 - <div class="follows-list" role="list"> 147 - <div v-if="isLoading && follows.length === 0" class="skeletons"> 148 - <SkeletonLoader v-for="n in 6" :key="n" width="100%" height="72px" /> 149 - </div> 150 - 151 - <div v-else-if="isError" class="error-state"> 152 - <p>Failed to load follows - {{ isError }}</p> 153 - <Button @click="fetchFollows(true)">Retry</Button> 154 - </div> 155 - 156 - <div v-else-if="isEmpty" class="empty-state"> 157 - <p>This user isn't following anyone yet.</p> 158 - </div> 159 - 160 - <AppLink 161 - v-for="follow in follows" 162 - :key="follow.did" 163 - class="follow" 164 - name="user-profile" 165 - :params="{ id: follow.did }" 166 - role="listitem" 167 - > 168 - <img 169 - class="avatar" 170 - :src="follow.avatar" 171 - :alt="`${follow.displayName || follow.handle}'s avatar`" 172 - loading="lazy" 173 - /> 174 - <div class="info"> 175 - <div class="top"> 176 - <div class="display-name">{{ follow.displayName || follow.handle }}</div> 177 - <span class="handle">@{{ follow.handle }}</span> 178 - <span v-if="follow.pronouns" class="dot" aria-hidden="true">·</span> 179 - <span v-if="follow.pronouns" class="pronouns">{{ follow.pronouns }}</span> 180 - </div> 181 - <div class="meta"> 182 - <span v-if="follow.description" class="bio">{{ follow.description }}</span> 183 - </div> 184 - </div> 185 - 186 - <button 187 - class="follow-btn" 188 - :class="{ mutual: isMutual(follow), following: isFollowingRow(follow) }" 189 - @click.stop.prevent="toggleFollowRow(follow)" 190 - :aria-pressed="!!follow.viewer?.following" 191 - :title=" 192 - isMutual(follow) 193 - ? 'Mutuals - click to unfollow' 194 - : follow.viewer?.following 195 - ? 'Unfollow' 196 - : 'Follow' 197 - " 198 - > 199 - {{ followButtonLabel(follow) }} 200 - </button> 201 - </AppLink> 202 - 203 - <div ref="loadMoreTrigger" class="load-more__sentinel" /> 204 - <div class="load-more__fallback" v-if="cursor && !isLoading"> 205 - <Button variant="ghost" @click="fetchFollows(false)">Load more</Button> 206 - </div> 207 - <div v-if="isLoading && follows.length > 0" class="loading-more"> 208 - <SkeletonLoader width="100%" height="72px" /> 209 - </div> 210 - </div> 211 - </PageLayout> 212 - </template> 213 - 214 - <style scoped lang="scss"> 215 - .follows-list { 216 - /* padding: 1rem; */ 217 - display: flex; 218 - flex-direction: column; 219 - /* gap: 0.5rem; */ 220 - min-height: 200px; 221 - } 222 - 223 - .follow { 224 - display: flex; 225 - align-items: center; 226 - gap: 0.75rem; 227 - padding: 0.75rem; 228 - /* border-radius: 0.75rem; */ 229 - /* background: hsla(var(--base) / 0.6); */ 230 - border-bottom: 1px solid hsla(var(--surface2) / 0.35); 231 - text-decoration: none; 232 - color: inherit; 233 - 234 - &:hover { 235 - background: hsla(var(--surface0) / 0.2); 236 - } 237 - 238 - .avatar { 239 - width: 3rem; 240 - height: 3rem; 241 - border-radius: 50%; 242 - object-fit: cover; 243 - flex: 0 0 auto; 244 - } 245 - 246 - .info { 247 - flex: 1; 248 - display: flex; 249 - flex-direction: column; 250 - overflow: hidden; 251 - 252 - .top { 253 - display: flex; 254 - align-items: center; 255 - gap: 0.5rem; 256 - min-width: 0; 257 - font-size: 0.9rem; 258 - 259 - .display-name { 260 - font-weight: 700; 261 - white-space: nowrap; 262 - text-overflow: ellipsis; 263 - overflow: hidden; 264 - } 265 - 266 - .handle { 267 - color: hsl(var(--subtext1)); 268 - font-size: 0.9rem; 269 - } 270 - 271 - .pronouns { 272 - color: hsl(var(--subtext0)); 273 - white-space: nowrap; 274 - } 275 - 276 - .badge { 277 - margin-left: auto; 278 - font-size: 0.72rem; 279 - padding: 0.15rem 0.4rem; 280 - border-radius: 999px; 281 - background: hsla(var(--surface2) / 0.45); 282 - color: hsl(var(--subtext1)); 283 - font-weight: 600; 284 - } 285 - 286 - .badge.mutual { 287 - background: hsla(var(--accent) / 0.12); 288 - color: hsl(var(--accent)); 289 - } 290 - } 291 - 292 - .meta { 293 - color: hsl(var(--subtext0)); 294 - font-size: 0.88rem; 295 - margin-top: 0.25rem; 296 - overflow: hidden; 297 - text-overflow: ellipsis; 298 - white-space: nowrap; 299 - } 300 - } 301 - 302 - .follow-btn { 303 - flex: 0 0 auto; 304 - padding: 0.35rem 0.75rem; 305 - border-radius: 999px; 306 - border: none; 307 - cursor: pointer; 308 - color: hsl(var(--crust)); 309 - font-weight: 700; 310 - 311 - background: hsla(var(--accent) / 0.85); 312 - 313 - &:hover { 314 - background: hsla(var(--accent) / 1); 315 - } 316 - &:active { 317 - background: hsla(var(--accent) / 0.75); 318 - } 319 - 320 - &.following { 321 - background: transparent; 322 - border: 1px solid hsla(var(--surface2) / 0.75); 323 - color: hsl(var(--text)); 324 - &:hover { 325 - background: hsla(var(--surface0) / 0.9); 326 - } 327 - &:active { 328 - background: hsla(var(--surface0) / 0.5); 329 - } 330 - } 331 - /* &.mutual { 332 - background: hsla(var(--accent) / 0.85); 333 - color: hsl(var(--base)); 334 - border: 1px solid hsla(var(--surface2) / 0.45); 335 - &:hover { 336 - background: hsla(var(--accent) / 1); 337 - } 338 - &:active { 339 - background: hsla(var(--accent) / 0.6); 340 - } 341 - } */ 342 - } 343 - } 344 - 345 - .empty-state, 346 - .error-state { 347 - padding: 2rem; 348 - display: flex; 349 - flex-direction: column; 350 - align-items: center; 351 - gap: 0.75rem; 352 - color: hsl(var(--subtext0)); 353 - } 354 - 355 - .load_more { 356 - &__sentinel { 357 - height: 1px; 358 - 359 - &__fallback { 360 - display: flex; 361 - justify-content: center; 362 - padding: 1rem; 363 - margin-top: 1rem; 364 - margin-bottom: 8rem; 365 - } 366 - } 367 - } 368 - </style>