wip bsky client for the web & android
0
fork

Configure Feed

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

feat(profile): followers/following modal

vi 5be34a64 ed8c6a34

+149 -21
-1
src/components/Composer/ReplyComposer.vue
··· 69 69 function collapse() { 70 70 if (!composer.text.value && !composer.hasMedia.value) { 71 71 reset() 72 - console.log(lastFocusedElement.value) 73 72 if (lastFocusedElement.value) lastFocusedElement.value.focus() 74 73 } 75 74 }
-2
src/components/Profile/ProfileRow.vue
··· 50 50 <div class="info"> 51 51 <div class="top"> 52 52 <div class="display-name">{{ profile.displayName || profile.handle }}</div> 53 - <span class="handle">@{{ profile.handle }}</span> 54 - <span v-if="profile.pronouns" class="dot" aria-hidden="true">·</span> 55 53 <span v-if="profile.pronouns" class="pronouns">{{ profile.pronouns }}</span> 56 54 </div> 57 55 <div class="meta">
+16 -3
src/components/UI/BaseModal.vue
··· 8 8 title?: string 9 9 width?: string 10 10 zIndex?: number 11 + edgeToEdge?: boolean 11 12 }>() 12 13 13 14 const emit = defineEmits<{ ··· 111 112 <div 112 113 ref="modalContainerRef" 113 114 class="modal-container" 114 - :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile }" 115 + :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile, 'edge-to-edge': edgeToEdge }" 115 116 role="dialog" 116 117 aria-modal="true" 117 118 :aria-labelledby="title ? 'modal-title-id' : undefined" ··· 192 193 flex-direction: column; 193 194 outline-color: transparent; 194 195 pointer-events: none; 196 + 197 + &.edge-to-edge { 198 + .modal-content { 199 + width: 100%; 200 + max-width: none; 201 + } 202 + 203 + .modal-body { 204 + padding: 0; 205 + } 206 + } 195 207 } 196 208 197 209 .modal-content { ··· 243 255 display: flex; 244 256 align-items: center; 245 257 justify-content: space-between; 246 - padding: 0.5rem 1.5rem 0.5rem; 258 + padding: 1rem; 247 259 flex-shrink: 0; 260 + border-bottom: 1px solid hsl(var(--surface0)); 248 261 249 262 .modal-title { 250 263 font-size: 1.25rem; ··· 298 311 } 299 312 300 313 .modal-body { 301 - padding: 0 1.5rem 1.5rem; 314 + padding: 1rem; 302 315 overflow-y: auto; 303 316 flex: 1; 304 317 color: hsl(var(--text));
+8 -5
src/components/UI/SkeletonLoader.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ 3 - width?: string; 4 - height?: string; 5 - circle?: boolean; 6 - }>(); 2 + withDefaults( 3 + defineProps<{ 4 + width?: string 5 + height?: string 6 + circle?: boolean 7 + }>(), 8 + { width: '100%', height: '60px', circle: false }, 9 + ) 7 10 </script> 8 11 9 12 <template>
+125 -10
src/views/Profile/ProfileView.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, computed, watch, onMounted, onUnmounted } from 'vue' 2 + import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue' 3 3 import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky' 4 4 import { Client, ok, simpleFetchHandler } from '@atcute/client' 5 5 import { ··· 15 15 IconArrowDownwardRounded, 16 16 } from '@iconify-prerendered/vue-material-symbols' 17 17 18 - import { useAuthStore } from '@/stores/auth' 18 + import { useAuthStore, BSKY_APPVIEW } from '@/stores/auth' 19 19 import { usePostStore } from '@/stores/posts' 20 20 import { useModalStore } from '@/stores/modal' 21 21 import { useToastStore } from '@/stores/toast' ··· 34 34 35 35 import PluralModal from '@/components/Modals/Plurality/PluralHelp.vue' 36 36 import PluralMemberModal from '@/components/Modals/Plurality/SystemMember.vue' 37 + import UserListModal from '@/components/Modals/UserListModal.vue' 37 38 38 39 import type { ActorIdentifier } from '@atcute/lexicons' 39 - import AppLink from '@/components/Navigation/AppLink.vue' 40 40 import { getIdentity } from '@/utils/identity' 41 41 import { HostPluralSystemMember, type HostPluralFrontLog } from '@/lex' 42 42 import { blobUrl, createRecord } from '@/utils/atproto' ··· 172 172 scrollY.value = target.scrollTop 173 173 } 174 174 175 + const followersPreview = ref<AppBskyActorDefs.ProfileView[]>([]) 176 + const followsPreview = ref<AppBskyActorDefs.ProfileView[]>([]) 177 + const followersPreviewCursor = ref<string | undefined>(undefined) 178 + const followsPreviewCursor = ref<string | undefined>(undefined) 179 + 180 + const fetchFollowsPage = async ( 181 + rpc: ReturnType<typeof auth.getRpc>, 182 + endpoint: 'app.bsky.graph.getFollowers' | 'app.bsky.graph.getFollows', 183 + actorDid: ActorIdentifier, 184 + limit = 50, 185 + cursorParam?: string | undefined, 186 + ) => { 187 + const { data, ok: resOk } = await rpc.get(endpoint, { 188 + params: { actor: actorDid, limit, cursor: cursorParam }, 189 + headers: { 'atproto-proxy': BSKY_APPVIEW }, 190 + }) 191 + if (!resOk) throw new Error('Failed to fetch') 192 + const users: AppBskyActorDefs.ProfileView[] = 193 + 'followers' in data ? data.followers || [] : data.follows || [] 194 + return { users, cursor: data.cursor as string | undefined } 195 + } 196 + 197 + const fetchGraphPreview = async (kind: 'followers' | 'follows') => { 198 + if (!profile.value) return false 199 + const rpc = auth.getRpc() 200 + const endpoint = 201 + kind === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows' 202 + 203 + try { 204 + const { users, cursor } = await fetchFollowsPage(rpc, endpoint, profile.value.did, 12) 205 + if (kind === 'followers') { 206 + followersPreview.value = users 207 + followersPreviewCursor.value = cursor 208 + } else { 209 + followsPreview.value = users 210 + followsPreviewCursor.value = cursor 211 + } 212 + return true 213 + } catch (e) { 214 + console.error(`failed to fetch ${kind} preview`, e) 215 + if (kind === 'followers') { 216 + followersPreview.value = [] 217 + followersPreviewCursor.value = undefined 218 + } else { 219 + followsPreview.value = [] 220 + followsPreviewCursor.value = undefined 221 + } 222 + return false 223 + } 224 + } 225 + 175 226 const fetchProfile = async () => { 176 227 loadingProfile.value = true 177 228 try { ··· 232 283 console.error('PDS client not initialized') 233 284 return 234 285 } 286 + 287 + Promise.all([fetchGraphPreview('followers'), fetchGraphPreview('follows')]) 235 288 236 289 const frontLog = await ok( 237 290 client.get('com.atproto.repo.listRecords', { ··· 420 473 modal.open(PluralMemberModal, { member, avatarUrl: memberAvatar(member) }) 421 474 } 422 475 476 + const openFollowsModal = async (mode: 'followers' | 'follows') => { 477 + if (!profile.value) return 478 + 479 + const rpc = auth.getRpc() 480 + const endpoint = 481 + mode === 'followers' ? 'app.bsky.graph.getFollowers' : 'app.bsky.graph.getFollows' 482 + 483 + const initialUsers = 484 + mode === 'followers' ? followersPreview.value.slice() : followsPreview.value.slice() 485 + const initialCursor = 486 + mode === 'followers' ? followersPreviewCursor.value : followsPreviewCursor.value 487 + 488 + const listState = reactive({ 489 + title: mode === 'followers' ? 'Followers' : 'Following', 490 + users: initialUsers as AppBskyActorDefs.ProfileView[], 491 + loading: false, 492 + cursor: initialCursor as string | undefined, 493 + hasMore: Boolean(initialCursor), 494 + onReachedBottom: async () => { 495 + if (listState.loading || !listState.cursor) return 496 + await fetchPage() 497 + }, 498 + }) 499 + 500 + const fetchPage = async () => { 501 + if (!profile.value) return 502 + listState.loading = true 503 + try { 504 + const { users, cursor: newCursor } = await fetchFollowsPage( 505 + rpc, 506 + endpoint, 507 + profile.value.did, 508 + 50, 509 + listState.cursor, 510 + ) 511 + listState.users.push(...users) 512 + listState.cursor = newCursor 513 + listState.hasMore = Boolean(newCursor) 514 + } catch (e) { 515 + console.error(e) 516 + } finally { 517 + listState.loading = false 518 + } 519 + } 520 + 521 + if (!initialUsers || initialUsers.length === 0) await fetchPage() 522 + modal.open(UserListModal, listState) 523 + } 524 + 423 525 watch( 424 526 () => props.id, 425 527 () => { ··· 591 693 <div class="stat alt" v-if="isSelf"> 592 694 <span class="stat-count"> it's you! </span> 593 695 </div> 594 - <AppLink class="stat" name="user-followers" :params="{ id: profile.did }"> 696 + <div 697 + class="stat link-stat" 698 + role="button" 699 + tabindex="0" 700 + @click="openFollowsModal('followers')" 701 + > 595 702 <span class="stat-count">{{ formatCount(profile?.followersCount) }}</span> 596 703 <span class="stat-label">Followers</span> 597 - </AppLink> 598 - <AppLink class="stat" name="user-follows" :params="{ id: profile.did }"> 704 + </div> 705 + <div 706 + class="stat link-stat" 707 + role="button" 708 + tabindex="0" 709 + @click="openFollowsModal('follows')" 710 + > 599 711 <span class="stat-count">{{ formatCount(profile?.followsCount) }}</span> 600 712 <span class="stat-label">Following</span> 601 - </AppLink> 713 + </div> 602 714 <div class="stat"> 603 715 <span class="stat-count">{{ formatCount(profile?.postsCount) }}</span> 604 716 <span class="stat-label">Posts</span> ··· 893 1005 color: inherit; 894 1006 } 895 1007 896 - &:not(a) { 1008 + &:not(.link-stat) { 897 1009 cursor: default; 1010 + } 1011 + &.link-stat { 1012 + cursor: pointer; 898 1013 } 899 1014 900 1015 &.alt { ··· 923 1038 } 924 1039 } 925 1040 926 - &:hover { 1041 + &.link-stat:hover { 927 1042 color: hsl(var(--text)); 928 1043 background: hsla(var(--surface2) / 0.25); 929 1044 } 930 - &:active { 1045 + &.link-stat:active { 931 1046 color: hsl(var(--subtext0)); 932 1047 background: hsla(var(--surface2) / 0.15); 933 1048 }