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

feat(profile): show fronters in profile

vt3e.cat c3a91649 b24a441e

verified
+375 -8
+197
src/components/Modals/Plurality/PluralHelp.vue
··· 1 + <script lang="ts" setup> 2 + import { 3 + IconGroupsRounded, 4 + IconInfoRounded, 5 + IconOpenInNewRounded, 6 + } from '@iconify-prerendered/vue-material-symbols' 7 + 8 + import Modal from '@/components/UI/BaseModal.vue' 9 + import Button from '@/components/UI/BaseButton.vue' 10 + 11 + const serviceLink = 'https://plural.host' 12 + const infoLink = 'https://morethanone.info' 13 + </script> 14 + 15 + <template> 16 + <Modal title="Plurality & Fronting" width="480px"> 17 + <div class="plural-modal"> 18 + <div class="modal-header"> 19 + <div class="header-icon">&</div> 20 + <p class="desc"> 21 + Plurality is when multiple self-aware entities exist together in one head. Similarly to 22 + lifelong roommates, but who share a body rather than an apartment. 23 + </p> 24 + </div> 25 + 26 + <div class="info-grid"> 27 + <div class="info-item primary"> 28 + <IconInfoRounded class="info-icon" /> 29 + <div class="info-text"> 30 + <h3>What is "Fronting"?</h3> 31 + <p> 32 + Fronting refers to which member(s) of a system are currently present and active, if 33 + any. 34 + </p> 35 + </div> 36 + </div> 37 + 38 + <a :href="infoLink" target="_blank" class="info-item"> 39 + <IconOpenInNewRounded class="info-icon" /> 40 + <span>Learn More</span> 41 + </a> 42 + <a :href="serviceLink" target="_blank" class="info-item"> 43 + <IconGroupsRounded class="info-icon" /> 44 + <span>Set Up Profile</span> 45 + </a> 46 + </div> 47 + </div> 48 + <template #footer> 49 + <Button variant="primary" @click="$emit('close')">okay!!!</Button> 50 + </template> 51 + </Modal> 52 + </template> 53 + 54 + <style lang="scss" scoped> 55 + .plural-modal { 56 + display: flex; 57 + flex-direction: column; 58 + gap: 1rem; 59 + padding: 0.5rem 0; 60 + text-align: center; 61 + 62 + .modal-header { 63 + display: flex; 64 + flex-direction: column; 65 + align-items: center; 66 + gap: 0.75rem; 67 + 68 + .header-icon { 69 + font-size: 4rem; 70 + font-weight: 900; 71 + color: hsl(var(--accent)); 72 + } 73 + .desc { 74 + color: hsl(var(--subtext0)); 75 + } 76 + } 77 + 78 + .info-grid { 79 + display: grid; 80 + gap: 0.5rem; 81 + 82 + .info-item { 83 + display: flex; 84 + gap: 1rem; 85 + align-items: flex-start; 86 + 87 + padding: 1rem; 88 + background: hsla(var(--accent) / 0.05); 89 + border-radius: var(--radius-md); 90 + text-decoration: none; 91 + color: hsl(var(--text)); 92 + user-select: none; 93 + 94 + .info-icon { 95 + font-size: 1.5rem; 96 + color: hsl(var(--accent)); 97 + flex-shrink: 0; 98 + } 99 + 100 + .info-text { 101 + h3 { 102 + font-size: 0.95rem; 103 + margin-bottom: 0.25rem; 104 + } 105 + p { 106 + font-size: 0.85rem; 107 + color: hsl(var(--subtext0)); 108 + line-height: 1.4; 109 + } 110 + } 111 + 112 + &.primary { 113 + grid-column: span 2; 114 + text-align: left; 115 + gap: 0.5rem; 116 + } 117 + 118 + &:hover { 119 + background: hsla(var(--accent) / 0.1); 120 + } 121 + } 122 + } 123 + 124 + /* .info-section { 125 + background: hsla(var(--surface1) / 0.1); 126 + border-radius: 1rem; 127 + padding: 1rem; 128 + text-align: left; 129 + 130 + .info-item { 131 + display: flex; 132 + gap: 1rem; 133 + align-items: flex-start; 134 + 135 + .info-icon { 136 + font-size: 1.5rem; 137 + color: hsl(var(--accent)); 138 + flex-shrink: 0; 139 + } 140 + 141 + .info-text { 142 + strong { 143 + display: block; 144 + margin-bottom: 0.25rem; 145 + font-size: 0.9rem; 146 + } 147 + p { 148 + font-size: 0.85rem; 149 + color: hsl(var(--subtext0)); 150 + line-height: 1.4; 151 + } 152 + } 153 + } 154 + } 155 + 156 + .modal-actions { 157 + display: flex; 158 + justify-content: center; 159 + gap: 0.75rem; 160 + 161 + .action-card { 162 + display: flex; 163 + flex-direction: column; 164 + align-items: center; 165 + justify-content: center; 166 + gap: 0.5rem; 167 + padding: 1rem; 168 + flex: 1; 169 + background: hsla(var(--surface1) / 0.1); 170 + border-radius: var(--radius-md); 171 + text-decoration: none; 172 + color: hsl(var(--text)); 173 + 174 + .action-icon { 175 + font-size: 1.25rem; 176 + color: hsl(var(--accent)); 177 + } 178 + 179 + span { 180 + font-size: 0.8rem; 181 + font-weight: 600; 182 + } 183 + 184 + &:hover { 185 + background: hsla(var(--surface1) / 0.2); 186 + } 187 + } 188 + } */ 189 + 190 + .service-credit { 191 + font-size: 0.8rem; 192 + color: hsl(var(--subtext0)); 193 + opacity: 0.8; 194 + line-height: 1.4; 195 + } 196 + } 197 + </style>
+11
src/utils/atproto.ts
··· 1 1 import type { AppBskyFeedDefs } from '@atcute/bluesky' 2 2 import type { ComAtprotoRepoStrongRef } from '@atcute/atproto' 3 + import type { ActorIdentifier, Blob, LegacyBlob } from '@atcute/lexicons' 4 + import { isLegacyBlob } from '@atcute/lexicons/interfaces' 3 5 4 6 export function createStrongRef(post: AppBskyFeedDefs.PostView): ComAtprotoRepoStrongRef.Main { 5 7 return { ··· 8 10 uri: post.uri, 9 11 } 10 12 } 13 + 14 + export function blobUrl(pdsUrl: string, did: ActorIdentifier, blob: LegacyBlob | Blob): string { 15 + let cid: string 16 + 17 + if (isLegacyBlob(blob)) cid = blob.cid 18 + else cid = blob.ref.$link 19 + 20 + return `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}` 21 + }
+167 -8
src/views/Profile/ProfileView.vue
··· 1 1 <script setup lang="ts"> 2 2 import { ref, computed, watch, onMounted, onUnmounted } from 'vue' 3 3 import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky' 4 - import { ok } from '@atcute/client' 4 + import { Client, ok, simpleFetchHandler } from '@atcute/client' 5 5 import { 6 6 IconAddRounded, 7 7 IconRemoveRounded, 8 8 IconMoreVert, 9 9 IconGlobe, 10 + IconInfoRounded, 10 11 IconCalendarMonthRounded, 11 12 IconBombRounded, 12 13 IconPets, ··· 17 18 import { useAuthStore } from '@/stores/auth' 18 19 import { usePostStore } from '@/stores/posts' 19 20 import { useToastStore } from '@/stores/toast' 21 + import { useModalStore } from '@/stores/modal' 22 + 20 23 import { useEnvironmentStore } from '@/stores/environment' 21 24 22 25 import { useDraggableScroll } from '@/composables/useDraggableScroll' ··· 29 32 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 30 33 import RichText from '@/components/RichText.vue' 31 34 35 + import PluralModal from '@/components/Modals/Plurality/PluralHelp.vue' 36 + 32 37 import type { ActorIdentifier } from '@atcute/lexicons' 33 38 import AppLink from '@/components/Navigation/AppLink.vue' 34 39 import type { CollectionString } from '@/types/atproto' 40 + import { getIdentity } from '@/utils/identity' 41 + import { HostPluralSystemMember, type HostPluralFrontLog } from '@/lex' 42 + import { blobUrl } from '@/utils/atproto' 35 43 36 44 const props = defineProps<{ id: string }>() 37 45 ··· 39 47 const postStore = usePostStore() 40 48 const toast = useToastStore() 41 49 const env = useEnvironmentStore() 50 + const modal = useModalStore() 42 51 43 52 const { events: statsDragEvents, isDragging } = useDraggableScroll() 44 53 54 + const getPdsClient = async () => { 55 + if (!profile.value) throw new Error('profile not loaded') 56 + const { pds } = await getIdentity(profile.value.did) 57 + if (!pds) throw new Error('Failed to resolve user PDS') 58 + return new Client({ handler: simpleFetchHandler({ service: pds }) }) 59 + } 60 + 61 + const pdsUrl = ref<string | null>(null) 45 62 const profile = ref<AppBskyActorDefs.ProfileViewDetailed | null>(null) 46 63 const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([]) 47 64 const cursor = ref<string | undefined>(undefined) ··· 55 72 type Tab = { 56 73 label: string 57 74 value: TabType 75 + isFeed: boolean 58 76 } 59 77 60 - type TabType = 'posts_no_replies' | 'posts_with_replies' | 'posts_with_media' 78 + type TabType = 'posts_no_replies' | 'posts_with_replies' | 'posts_with_media' | 'pluralhost' 61 79 const tabs: Tab[] = [ 62 - { label: 'Posts', value: 'posts_no_replies' }, 63 - { label: 'Replies', value: 'posts_with_replies' }, 64 - { label: 'Media', value: 'posts_with_media' }, 80 + { label: 'Posts', value: 'posts_no_replies', isFeed: true }, 81 + { label: 'Replies', value: 'posts_with_replies', isFeed: true }, 82 + { label: 'Media', value: 'posts_with_media', isFeed: true }, 65 83 ] 66 84 const activeTab = ref<TabType>('posts_no_replies') 67 85 const tabRefs = ref<HTMLElement[]>([]) ··· 75 93 transform: `translateX(${el.offsetLeft}px)`, 76 94 } 77 95 }) 96 + 97 + const isSystem = ref(false) 98 + const fronters = ref<HostPluralSystemMember.Main[]>([]) 78 99 79 100 const formatCount = (num: number | undefined) => { 80 101 if (!num) return '0' ··· 112 133 () => profile.value?.viewer?.following && profile.value?.viewer?.followedBy, 113 134 ) 114 135 136 + const memberAvatar = (member: HostPluralSystemMember.Main) => { 137 + return blobUrl(pdsUrl.value!, profile.value!.did, member.avatar!) 138 + } 139 + 115 140 const followTerm = computed(() => { 116 141 if (!profile.value) return 'Follow' 117 142 const viewer = profile.value.viewer ··· 151 176 loadingProfile.value = true 152 177 try { 153 178 const rpc = auth.getRpc() 179 + 154 180 const data = await ok( 155 181 rpc.get('app.bsky.actor.getProfile', { params: { actor: props.id as ActorIdentifier } }), 156 182 ) 157 183 profile.value = data 184 + 185 + const { pds } = await getIdentity(profile.value.did) 186 + if (!pds) throw new Error('Failed to resolve user PDS') 187 + pdsUrl.value = pds 158 188 } catch (e) { 159 189 if (e instanceof Error) error.value = e.message 160 190 else error.value = 'An unknown error occurred :c' ··· 162 192 } finally { 163 193 loadingProfile.value = false 164 194 } 195 + 196 + await fetchSystemStatus() 197 + } 198 + 199 + const fetchSystemStatus = async () => { 200 + isSystem.value = false 201 + fronters.value = [] 202 + 203 + if (!profile.value) { 204 + console.error('profile not loaded, cannot fetch system status') 205 + return 206 + } 207 + 208 + try { 209 + const client = await getPdsClient() 210 + if (!client) { 211 + console.error('PDS client not initialized') 212 + return 213 + } 214 + 215 + const frontLog = await ok( 216 + client.get('com.atproto.repo.listRecords', { 217 + params: { 218 + repo: profile.value.did, 219 + collection: 'host.plural.front.log', 220 + }, 221 + }), 222 + ) 223 + 224 + if (frontLog.records.length > 0) { 225 + isSystem.value = true 226 + } 227 + 228 + const lastLog = frontLog.records[0]?.value as HostPluralFrontLog.Main 229 + if (!lastLog) return 230 + 231 + const fronterIds = lastLog.fronters 232 + if (!fronterIds || fronterIds.length === 0) return 233 + 234 + const fronterRes = await Promise.all( 235 + fronterIds.map((fronterDid) => 236 + ok( 237 + client.get('com.atproto.repo.getRecord', { 238 + params: { 239 + repo: profile.value!.did, 240 + collection: 'host.plural.system.member', 241 + rkey: fronterDid, 242 + }, 243 + }), 244 + ), 245 + ), 246 + ) 247 + 248 + fronters.value = fronterRes.map((res) => res.value as HostPluralSystemMember.Main) 249 + } catch {} 165 250 } 166 251 167 252 const fetchFeed = async (reset = false) => { ··· 516 601 </div> 517 602 </div> 518 603 604 + <div v-if="isSystem && fronters?.length" class="system"> 605 + <div class="system__label"> 606 + <span>Fronting</span> 607 + <Button variant="text" size="sm" icon @click="modal.open(PluralModal)"> 608 + <IconInfoRounded /> 609 + </Button> 610 + </div> 611 + 612 + <div class="fronters"> 613 + <div 614 + class="fronter" 615 + v-for="fronter in fronters" 616 + :key="fronter.createdAt || fronter.displayName" 617 + > 618 + <div v-if="memberAvatar(fronter)" class="avatar"> 619 + <img :src="memberAvatar(fronter)" alt="" /> 620 + </div> 621 + <div class="name">{{ fronter.displayName }}</div> 622 + </div> 623 + </div> 624 + </div> 625 + 519 626 <RichText v-if="profile.description" :text="profile.description" class="description" /> 520 627 521 628 <div class="additional"> ··· 728 835 align-items: center; 729 836 gap: 0.25rem; 730 837 } 731 - 732 - &:not(.mobile) { 733 - } 734 838 } 735 839 736 840 .profile { ··· 738 842 display: flex; 739 843 flex-direction: column; 740 844 gap: 0.5rem; 845 + align-items: flex-start; 741 846 742 847 .stats { 743 848 display: flex; ··· 844 949 text-decoration: underline; 845 950 } 846 951 } 952 + } 953 + } 954 + 955 + .system { 956 + display: inline-flex; 957 + align-items: center; 958 + gap: 0.25rem; 959 + border-radius: 2rem; 960 + 961 + &__label { 962 + font-size: 0.75rem; 963 + color: hsl(var(--subtext0)); 964 + text-transform: uppercase; 965 + display: flex; 966 + align-items: center; 967 + } 968 + 969 + .fronters { 970 + display: flex; 971 + gap: 0.5rem; 972 + 973 + .fronter { 974 + display: flex; 975 + align-items: center; 976 + border-radius: 2rem; 977 + background: hsl(var(--surface0)); 978 + padding: 0.1rem; 979 + 980 + .avatar { 981 + width: 1.5rem; 982 + height: 1.5rem; 983 + border-radius: 50%; 984 + overflow: hidden; 985 + } 986 + 987 + img { 988 + width: 100%; 989 + height: 100%; 990 + object-fit: cover; 991 + } 992 + 993 + .name { 994 + font-size: 0.75rem; 995 + color: hsl(var(--subtext0)); 996 + margin: 0 0.5rem 0 0.25rem; 997 + } 998 + } 999 + } 1000 + 1001 + scrollbar-width: none; 1002 + -ms-overflow-style: none; 1003 + 1004 + &::-webkit-scrollbar { 1005 + display: none; 847 1006 } 848 1007 } 849 1008 }