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

feat(profile): new profile and richtext component

vt3e.cat 6dfffa6e b476a82d

verified
+654 -291
+22 -19
bun.lock
··· 4 4 "": { 5 5 "name": "bluebell", 6 6 "dependencies": { 7 - "@atcute/atproto": "^3.1.9", 8 - "@atcute/bluesky": "^3.2.14", 9 - "@atcute/client": "^4.1.1", 10 - "@atcute/identity-resolver": "^1.2.0", 11 - "@atcute/lexicons": "^1.2.5", 7 + "@atcute/atproto": "^3.1.10", 8 + "@atcute/bluesky": "^3.2.18", 9 + "@atcute/bluesky-richtext-parser": "^2.1.1", 10 + "@atcute/client": "^4.2.1", 11 + "@atcute/identity-resolver": "^1.2.2", 12 + "@atcute/lexicons": "^1.2.9", 12 13 "@atcute/oauth-browser-client": "^2.0.3", 13 - "@capacitor/android": "^8.0.0", 14 - "@capacitor/app": "^8.0.0", 14 + "@capacitor/android": "^8.1.0", 15 + "@capacitor/app": "^8.0.1", 15 16 "@capacitor/assets": "^3.0.5", 16 - "@capacitor/core": "^8.0.0", 17 + "@capacitor/core": "^8.1.0", 17 18 "@capacitor/haptics": "^8.0.0", 18 19 "@iconify-prerendered/vue-material-symbols": "^0.28.1755063979", 19 20 "add": "^2.0.6", ··· 22 23 "hls.js": "^1.6.15", 23 24 "i": "^0.3.7", 24 25 "pinia": "^3.0.4", 25 - "vue": "^3.5.25", 26 + "vue": "^3.5.28", 26 27 }, 27 28 "devDependencies": { 28 - "@capacitor/cli": "^8.0.0", 29 + "@capacitor/cli": "^8.1.0", 29 30 "@prettier/plugin-oxc": "^0.0.5", 30 - "@tsconfig/node24": "^24.0.3", 31 - "@types/node": "^25.0.3", 32 - "@vitejs/plugin-vue": "^6.0.2", 31 + "@tsconfig/node24": "^24.0.4", 32 + "@types/node": "^25.3.0", 33 + "@vitejs/plugin-vue": "^6.0.4", 33 34 "@vue/eslint-config-prettier": "^10.2.0", 34 - "@vue/eslint-config-typescript": "^14.6.0", 35 + "@vue/eslint-config-typescript": "^14.7.0", 35 36 "@vue/tsconfig": "^0.8.1", 36 - "eslint": "^9.39.1", 37 + "eslint": "^9.39.3", 37 38 "eslint-plugin-oxlint": "~1.29.0", 38 39 "eslint-plugin-vue": "~10.5.1", 39 40 "jiti": "^2.6.1", 40 41 "npm-run-all2": "^8.0.4", 41 42 "oxlint": "~1.29.0", 42 43 "prettier": "3.6.2", 43 - "rollup-plugin-visualizer": "^6.0.5", 44 - "sass-embedded": "^1.97.0", 44 + "rollup-plugin-visualizer": "^6.0.8", 45 + "sass-embedded": "^1.97.3", 45 46 "typescript": "^5.9.3", 46 47 "vite": "npm:rolldown-vite@latest", 47 - "vite-plugin-vue-devtools": "^8.0.5", 48 - "vue-tsc": "^3.1.5", 48 + "vite-plugin-vue-devtools": "^8.0.6", 49 + "vue-tsc": "^3.2.4", 49 50 }, 50 51 }, 51 52 }, ··· 53 54 "@atcute/atproto": ["@atcute/atproto@3.1.10", "", { "dependencies": { "@atcute/lexicons": "^1.2.6" } }, "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ=="], 54 55 55 56 "@atcute/bluesky": ["@atcute/bluesky@3.2.18", "", { "dependencies": { "@atcute/atproto": "^3.1.10", "@atcute/lexicons": "^1.2.9" } }, "sha512-8S4D0YMUUtvZFchBpEEkvIk7luMu0Z3l50ppUa+EGDDNqF6P5gkgm8q0qfaqpULtDyInKHR+MqJ8fMm20xWgFg=="], 57 + 58 + "@atcute/bluesky-richtext-parser": ["@atcute/bluesky-richtext-parser@2.1.1", "", {}, "sha512-2CJiZ1oLAxQEz6BL5r1m/p+m89bb02959dFEvMvYI7CbHgIzbZsDOp3JB2XVu49DjPNtd9Mz5VnF5OBBpTgWdg=="], 56 59 57 60 "@atcute/client": ["@atcute/client@4.2.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.6" } }, "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw=="], 58 61
+1
package.json
··· 21 21 "dependencies": { 22 22 "@atcute/atproto": "^3.1.10", 23 23 "@atcute/bluesky": "^3.2.18", 24 + "@atcute/bluesky-richtext-parser": "^2.1.1", 24 25 "@atcute/client": "^4.2.1", 25 26 "@atcute/identity-resolver": "^1.2.2", 26 27 "@atcute/lexicons": "^1.2.9",
+131
src/components/RichText.vue
··· 1 + <script lang="ts" setup> 2 + import { h, type VNode } from 'vue' 3 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser' 4 + 5 + import AppLink from '@/components/Navigation/AppLink.vue' 6 + 7 + defineProps<{ 8 + text: string 9 + }>() 10 + 11 + const RenderToken = ({ token }: { token: Token }): VNode | string => { 12 + switch (token.type) { 13 + case 'text': 14 + return h('span', { class: 'rt-text' }, token.content) 15 + case 'escape': 16 + return token.escaped 17 + case 'code': 18 + return h('code', { class: 'rt-code' }, token.content) 19 + case 'autolink': 20 + return h( 21 + 'a', 22 + { href: token.url, target: '_blank', rel: 'noopener noreferrer', class: 'rt-link' }, 23 + token.raw, 24 + ) 25 + case 'mention': 26 + return h( 27 + AppLink, 28 + { name: 'user-profile', params: { id: token.handle }, class: 'rt-mention' }, 29 + { default: () => token.raw }, 30 + ) 31 + case 'topic': 32 + return h( 33 + 'a', 34 + { href: `/search?q=${encodeURIComponent(token.name)}`, class: 'rt-topic' }, 35 + token.raw, 36 + ) 37 + case 'emote': 38 + return h('span', { title: token.name, class: 'rt-emote' }, token.raw) 39 + case 'link': 40 + return h( 41 + 'a', 42 + { href: token.url, target: '_blank', rel: 'noopener noreferrer', class: 'rt-link' }, 43 + token.children.map((c) => RenderToken({ token: c })), 44 + ) 45 + case 'strong': 46 + return h( 47 + 'strong', 48 + { class: 'rt-strong' }, 49 + token.children.map((c) => RenderToken({ token: c })), 50 + ) 51 + case 'emphasis': 52 + return h( 53 + 'em', 54 + { class: 'rt-emphasis' }, 55 + token.children.map((c) => RenderToken({ token: c })), 56 + ) 57 + case 'underline': 58 + return h( 59 + 'u', 60 + { class: 'rt-underline' }, 61 + token.children.map((c) => RenderToken({ token: c })), 62 + ) 63 + case 'delete': 64 + return h( 65 + 'del', 66 + { class: 'rt-delete' }, 67 + token.children.map((c) => RenderToken({ token: c })), 68 + ) 69 + default: 70 + return token.raw 71 + } 72 + } 73 + </script> 74 + 75 + <template> 76 + <div class="rt-container"> 77 + <RenderToken v-for="(token, i) in tokenize(text)" :key="i" :token="token" /> 78 + </div> 79 + </template> 80 + 81 + <style scoped> 82 + .rt-container { 83 + white-space: pre-wrap; 84 + word-break: break-word; 85 + color: hsl(var(--text)); 86 + line-height: 1.5; 87 + } 88 + 89 + .rt-link, 90 + .rt-mention, 91 + .rt-topic { 92 + color: hsl(var(--accent)); 93 + text-decoration: none; 94 + &:hover { 95 + color: hsla(var(--accent) / 0.8); 96 + } 97 + } 98 + 99 + .rt-code { 100 + background-color: hsl(var(--surface0)); 101 + color: hsl(var(--subtext0)); 102 + padding: 0.2rem 0.4rem; 103 + border-radius: 4px; 104 + font-family: monospace; 105 + font-size: 0.9em; 106 + } 107 + 108 + .rt-strong { 109 + font-weight: 600; 110 + color: hsl(var(--text)); 111 + } 112 + 113 + .rt-emphasis { 114 + font-style: italic; 115 + } 116 + 117 + .rt-underline { 118 + text-decoration: underline; 119 + text-decoration-color: hsl(var(--overlay2)); 120 + } 121 + 122 + .rt-delete { 123 + text-decoration: line-through; 124 + color: hsl(var(--subtext0)); 125 + } 126 + 127 + .rt-emote { 128 + font-style: italic; 129 + color: hsl(var(--subtext0)); 130 + } 131 + </style>
+29 -2
src/components/UI/BasePopover.vue
··· 6 6 import { useReposition } from '@/composables/useReposition' 7 7 8 8 import type { Component } from 'vue' 9 + import { FOCUSABLE_SELECTOR, isNaturallyFocusable } from '@/utils/focusable' 9 10 10 11 interface PopoverAction { 11 12 label: string ··· 36 37 (e: 'open'): void 37 38 (e: 'close'): void 38 39 }>() 40 + 41 + const unwrapToElement = (maybeEl: unknown): HTMLElement | null => { 42 + if (!maybeEl) return null 43 + 44 + if (maybeEl instanceof HTMLElement) return maybeEl 45 + if (maybeEl.$el instanceof HTMLElement) return maybeEl.$el as HTMLElement 46 + if (maybeEl.$el?.value instanceof HTMLElement) return maybeEl.$el.value as HTMLElement 47 + if (maybeEl.value instanceof HTMLElement) return maybeEl.value as HTMLElement 48 + 49 + return null 50 + } 39 51 40 52 const env = useEnvironmentStore() 41 53 const isMobile = computed(() => env.isMobile) ··· 139 151 } 140 152 } 141 153 154 + const focusTriggerElement = (container: HTMLElement | null) => { 155 + if (!container) return 156 + 157 + const descendant = container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) 158 + const target = descendant ?? container 159 + 160 + if (!isNaturallyFocusable(target) && !target.hasAttribute('tabindex')) { 161 + target.setAttribute('tabindex', '-1') 162 + target.focus() 163 + target.removeAttribute('tabindex') 164 + } else { 165 + ;(target as HTMLElement).focus?.() 166 + } 167 + } 168 + 142 169 const close = () => { 143 170 isOpen.value = false 144 171 currentY.value = 0 145 172 backdropOpacity.value = 1 146 173 isDragging.value = false 147 174 emit('close') 148 - triggerRef.value?.querySelector('button')?.focus() 175 + focusTriggerElement(triggerRef.value) 149 176 } 150 177 151 178 const toggle = () => (isOpen.value ? close() : open()) ··· 165 192 defineExpose({ open, close, toggle }) 166 193 167 194 const setTriggerRef = (el: HTMLElement | null) => { 168 - triggerRef.value = el 195 + triggerRef.value = unwrapToElement(el) 169 196 } 170 197 </script> 171 198
+11
src/utils/focusable.ts
··· 1 + export const FOCUSABLE_SELECTOR = 2 + 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"]), [contenteditable]' 3 + 4 + export const isNaturallyFocusable = (el: HTMLElement) => { 5 + const node = el.nodeName.toLowerCase() 6 + if (node === 'a') return (el as HTMLAnchorElement).hasAttribute('href') 7 + if (['input', 'select', 'textarea', 'button', 'iframe'].includes(node)) { 8 + return !(el as HTMLButtonElement).hasAttribute('disabled') 9 + } 10 + return false 11 + }
+460 -270
src/views/Profile/ProfileView.vue
··· 5 5 import { 6 6 IconAddRounded, 7 7 IconRemoveRounded, 8 - IconMoreHoriz, 8 + IconMoreVert, 9 9 IconGlobe, 10 10 IconCalendarMonthRounded, 11 + IconBombRounded, 12 + IconPets, 13 + IconTouchAppRounded, 11 14 IconArrowDownwardRounded, 12 15 } from '@iconify-prerendered/vue-material-symbols' 13 16 14 17 import { useAuthStore } from '@/stores/auth' 15 18 import { usePostStore } from '@/stores/posts' 19 + import { useToastStore } from '@/stores/toast' 16 20 17 21 import BackButton from '@/components/Navigation/BackButton.vue' 18 22 import PageLayout from '@/components/Navigation/PageLayout.vue' 19 23 import FeedItem from '@/components/Feed/FeedItem.vue' 20 24 import Button from '@/components/UI/BaseButton.vue' 25 + import Popover from '@/components/UI/BasePopover.vue' 21 26 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 22 - import SVG from '@/components/UI/SVG.vue' 27 + import RichText from '@/components/RichText.vue' 23 28 24 - import BluebellLogo from '@/assets/icons/bluebell.svg?raw' 25 29 import type { ActorIdentifier } from '@atcute/lexicons' 26 30 import AppLink from '@/components/Navigation/AppLink.vue' 31 + import type { CollectionString } from '@/types/atproto' 27 32 28 33 const props = defineProps<{ id: string }>() 29 34 30 35 const auth = useAuthStore() 31 36 const postStore = usePostStore() 37 + const toast = useToastStore() 32 38 33 39 const profile = ref<AppBskyActorDefs.ProfileViewDetailed | null>(null) 34 40 const feed = ref<AppBskyFeedDefs.FeedViewPost[]>([]) ··· 81 87 } 82 88 } 83 89 90 + const isFollowingSelf = computed(() => { 91 + if (!profile.value || !auth.session?.info.sub) return false 92 + const followers = profile.value.viewer?.knownFollowers?.followers 93 + if (!followers || followers.length === 0) return false 94 + 95 + const dids = followers.map((f) => f.did) 96 + return dids.includes(auth.session.info.sub) 97 + }) 98 + 84 99 const isSelf = computed(() => auth.session?.info.sub === profile.value?.did) 85 - const isFollowing = computed(() => !!profile.value?.viewer?.following) 100 + const isFollowing = computed(() => { 101 + if (!profile.value) return false 102 + if (isSelf.value) return isFollowingSelf.value 103 + return Boolean(profile.value.viewer?.following) 104 + }) 105 + const isMutual = computed( 106 + () => profile.value?.viewer?.following && profile.value?.viewer?.followedBy, 107 + ) 86 108 87 - const bannerStyle = computed(() => { 88 - if (!profile.value?.banner) return {} 89 - return { backgroundImage: `url(${profile.value.banner})` } 109 + const followTerm = computed(() => { 110 + if (!profile.value) return 'Follow' 111 + const viewer = profile.value.viewer 112 + 113 + if (isFollowingSelf.value) { 114 + return 'Unfollow yourself' 115 + } 116 + 117 + if (!viewer) return 'Follow' 118 + const follows = Boolean(viewer.following) 119 + 120 + if (isMutual.value) return 'Unfollow mutual' 121 + if (follows) return 'Unfollow (2)' 122 + return 'Follow' 90 123 }) 91 124 92 125 const heroStyle = computed(() => { ··· 204 237 } 205 238 } 206 239 240 + async function createRecord( 241 + collection: CollectionString, 242 + record: Record<string, unknown>, 243 + message: { error: string; success: string } = { 244 + error: 'Failed to perform action', 245 + success: 'Yayyyyyy done!!', 246 + }, 247 + ) { 248 + const rpc = auth.getRpc() 249 + try { 250 + await ok( 251 + rpc.post('com.atproto.repo.createRecord', { 252 + input: { 253 + collection, 254 + repo: auth.session!.info.sub, 255 + record: { $type: collection, ...record }, 256 + }, 257 + }), 258 + ) 259 + toast.success(message.success) 260 + } catch (e) { 261 + console.error(message.error, e) 262 + toast.error(message.error) 263 + } 264 + } 265 + 266 + const actions = { 267 + bite: async () => { 268 + if (!profile.value) return 269 + await createRecord( 270 + 'net.wafrn.feed.bite', 271 + { 272 + $type: 'net.wafrn.feed.bite', 273 + subject: `at://${profile.value.did}/app.bsky.actor.profile/self`, 274 + createdAt: new Date().toISOString(), 275 + }, 276 + { 277 + error: `failed to bite ${profile.value.displayName || profile.value.handle} :c`, 278 + success: `bit ${profile.value.displayName || profile.value.handle}!`, 279 + }, 280 + ) 281 + }, 282 + explode: async () => { 283 + if (!profile.value) return 284 + await createRecord( 285 + 'net.wafrn.feed.explode', 286 + { 287 + $type: 'net.wafrn.feed.explode', 288 + subject: `at://${profile.value.did}/app.bsky.actor.profile/self`, 289 + createdAt: new Date().toISOString(), 290 + }, 291 + { 292 + error: `failed to explode ${profile.value.displayName || profile.value.handle} :c`, 293 + success: `exploded ${profile.value.displayName || profile.value.handle}!`, 294 + }, 295 + ) 296 + }, 297 + poke: async () => { 298 + if (!profile.value) return 299 + await createRecord( 300 + 'xyz.atpoke.graph.poke', 301 + { 302 + $type: 'xyz.atpoke.graph.poke', 303 + subject: profile.value.did, 304 + createdAt: new Date().toISOString(), 305 + }, 306 + { 307 + error: `failed to poke ${profile.value.displayName || profile.value.handle} :c`, 308 + success: `poked ${profile.value.displayName || profile.value.handle}!`, 309 + }, 310 + ) 311 + }, 312 + } 313 + 314 + const randomBanner = computed(() => { 315 + const randomInteger = Math.floor(Math.random() * 124) 316 + return `https://randomfox.ca/images/${randomInteger}.jpg` 317 + }) 318 + 319 + const handleLink = computed(() => { 320 + const noLink = ['bsky.social', 'blacksky.app'] 321 + const handleDomain = profile.value?.handle.split('.').slice(-1)[0] 322 + if (handleDomain && !noLink.includes(handleDomain)) { 323 + return `https://${profile.value?.handle}` 324 + } 325 + return null 326 + }) 327 + 207 328 watch( 208 329 () => props.id, 209 330 () => { ··· 223 344 if (pageContent.value) 224 345 pageContent.value.scrollContainer?.removeEventListener('scroll', handleScroll) 225 346 }) 347 + 348 + const scrollToTop = () => { 349 + if (pageContent.value) pageContent.value.scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }) 350 + } 226 351 </script> 227 352 228 353 <template> 229 354 <PageLayout :title="profile?.handle || 'Profile'" noPadding ref="pageContent"> 230 355 <template #app-bar> 231 - <div class="topbar-left"> 356 + <div class="topbar-left" @click="scrollToTop"> 232 357 <BackButton /> 233 358 <div class="header-fade-wrapper" :style="{ opacity: headerOpacity }"> 234 359 <div class="mini-avatar" v-if="profile?.avatar"> ··· 240 365 </div> 241 366 </div> 242 367 </div> 368 + <div class="topbar-right" :style="{ opacity: headerOpacity }"> 369 + <Button v-if="isSelf" variant="secondary" size="md" pill>Edit</Button> 370 + <Button 371 + :variant="isFollowing ? 'secondary' : 'primary'" 372 + size="md" 373 + pill 374 + flat 375 + @click="toggleFollow" 376 + > 377 + <component :is="isFollowing ? IconRemoveRounded : IconAddRounded" /> 378 + {{ isFollowing ? 'Following' : 'Follow' }} 379 + </Button> 380 + </div> 243 381 </template> 244 382 245 - <div class="profile-container"> 246 - <div class="hero-wrapper"> 247 - <div class="hero-bg" :style="[bannerStyle, heroStyle]"></div> 248 - <div class="hero-overlay"></div> 383 + <div class="profile-container" v-if="profile"> 384 + <div class="banner"> 385 + <img 386 + v-if="profile?.banner" 387 + :src="profile.banner" 388 + class="banner__image" 389 + :style="heroStyle" 390 + :alt="`${profile.displayName || profile.handle}'s banner`" 391 + aria-role="presentation" 392 + /> 393 + <img 394 + v-else 395 + class="banner__image banner__placeholder" 396 + :src="randomBanner" 397 + aria-role="presentation" 398 + alt="picture of a fox" 399 + /> 249 400 </div> 250 - 251 - <div class="content-stack"> 252 - <div class="identity-card-wrapper" v-if="profile"> 253 - <div class="identity-card"> 254 - <div class="card-top-row"> 255 - <div class="avatar-large"> 256 - <img v-if="profile.avatar" :src="profile.avatar" /> 257 - <div v-else class="fallback"><SVG :icon="BluebellLogo" /></div> 258 - </div> 259 - 260 - <div class="action-buttons"> 261 - <Button v-if="isSelf" variant="secondary" size="sm" flat>Edit Profile</Button> 262 - <Button 263 - :variant="isFollowing ? 'secondary' : 'primary'" 264 - size="sm" 265 - flat 266 - @click="toggleFollow" 267 - > 268 - <component :is="isFollowing ? IconRemoveRounded : IconAddRounded" /> 269 - {{ isFollowing ? 'Following' : 'Follow' }} 401 + <div class="identity"> 402 + <div class="avatar" v-if="profile?.avatar"> 403 + <img 404 + :src="profile.avatar" 405 + :alt="`${profile.displayName || profile.handle}'s avatar`" 406 + aria-role="presentation" 407 + /> 408 + </div> 409 + <div class="identity-row"> 410 + <div class="identity-row__names"> 411 + <span class="header-name">{{ profile?.displayName || profile.handle }}</span> 412 + <a v-if="handleLink" :href="handleLink" target="_blank" class="header-handle" 413 + >@{{ formatUrl(profile.handle) }}</a 414 + > 415 + <span class="header-handle" v-else>@{{ profile?.handle }}</span> 416 + </div> 417 + <div class="identity-row__actions"> 418 + <Button v-if="isSelf" variant="secondary" size="md" pill>Edit</Button> 419 + <Button 420 + v-else 421 + :variant="isFollowing ? 'secondary' : 'primary'" 422 + size="md" 423 + pill 424 + flat 425 + @click="toggleFollow" 426 + > 427 + <component :is="isFollowing ? IconRemoveRounded : IconAddRounded" /> 428 + {{ isFollowing ? 'Following' : 'Follow' }} 429 + </Button> 430 + <Popover 431 + :actions="[ 432 + { 433 + actions: [ 434 + { 435 + label: followTerm, 436 + icon: isFollowing ? IconRemoveRounded : IconAddRounded, 437 + onClick: toggleFollow, 438 + }, 439 + ], 440 + }, 441 + { 442 + label: 'Silly', 443 + actions: [ 444 + { 445 + label: 'Bite', 446 + icon: IconPets, 447 + onClick: actions.bite, 448 + }, 449 + { 450 + label: 'Explode', 451 + icon: IconBombRounded, 452 + onClick: actions.explode, 453 + }, 454 + { 455 + label: 'Poke', 456 + icon: IconTouchAppRounded, 457 + onClick: actions.poke, 458 + }, 459 + ], 460 + }, 461 + ]" 462 + align="right" 463 + > 464 + <template #trigger="{ triggerProps }"> 465 + <Button pill variant="secondary" icon v-bind="triggerProps as any"> 466 + <div class="icon-wrapper"> 467 + <IconMoreVert /> 468 + </div> 270 469 </Button> 271 - <Button variant="secondary" icon size="sm" flat> 272 - <IconMoreHoriz /> 273 - </Button> 274 - </div> 275 - </div> 276 - 277 - <div class="card-names"> 278 - <h1 class="display-name">{{ profile.displayName || profile.handle }}</h1> 279 - <div class="handle-row"> 280 - <span class="handle">@{{ profile.handle }}</span> 281 - <span class="badge" v-if="isSelf"> It's you!! </span> 282 - <span class="badge" v-if="profile.viewer?.followedBy && profile.viewer?.following"> 283 - Mutuals 284 - </span> 285 - <span class="badge" v-else-if="profile.viewer?.followedBy"> Follows you </span> 286 - </div> 287 - </div> 288 - 289 - <div class="stats-row"> 290 - <AppLink class="stat-item" name="user-followers" :params="{ id: profile.did }"> 291 - <span class="stat-val">{{ formatCount(profile.followersCount) }}</span> 292 - <span class="stat-label">Followers</span> 293 - </AppLink> 294 - <AppLink class="stat-item" name="user-follows" :params="{ id: profile.did }"> 295 - <span class="stat-val">{{ formatCount(profile.followsCount) }}</span> 296 - <span class="stat-label">Following</span> 297 - </AppLink> 298 - <div class="stat-item"> 299 - <span class="stat-val">{{ formatCount(profile.postsCount) }}</span> 300 - <span class="stat-label">Posts</span> 301 - </div> 302 - </div> 303 - 304 - <div class="bio-section" v-if="profile.description"> 305 - {{ profile.description }} 306 - </div> 307 - 308 - <div class="meta-details"> 309 - <div class="meta-pill" v-if="profile.indexedAt"> 310 - <IconCalendarMonthRounded /> 311 - Joined 312 - {{ 313 - new Date(profile.indexedAt).toLocaleDateString(undefined, { 314 - month: 'short', 315 - year: 'numeric', 316 - }) 317 - }} 318 - </div> 319 - <div class="meta-pill" v-if="profile.pronouns"> 320 - {{ profile.pronouns }} 321 - </div> 322 - <a 323 - class="meta-pill" 324 - v-if="profile.website" 325 - :href="profile.website" 326 - target="_blank" 327 - rel="noopener" 328 - > 329 - <IconGlobe /> 330 - {{ formatUrl(profile.website) }} 331 - </a> 332 - </div> 470 + </template> 471 + </Popover> 333 472 </div> 334 473 </div> 335 - 336 - <div v-else-if="loadingProfile" class="identity-card-wrapper"> 337 - <div class="identity-card loading"> 338 - <SkeletonLoader width="80px" height="80px" style="border-radius: 50%" /> 339 - <SkeletonLoader width="60%" height="2rem" style="margin-top: 1rem" /> 340 - <SkeletonLoader width="40%" height="1rem" style="margin-top: 0.5rem" /> 474 + </div> 475 + <div class="profile"> 476 + <div class="stats"> 477 + <div class="stat alt" v-if="profile.pronouns"> 478 + <span class="stat-count">{{ profile.pronouns }}</span> 479 + <span class="stat-label">Pronouns</span> 480 + </div> 481 + <div class="stat alt" v-if="isSelf"> 482 + <span class="stat-count"> it's you! </span> 483 + </div> 484 + <AppLink class="stat" name="user-followers" :params="{ id: profile.did }"> 485 + <span class="stat-count">{{ formatCount(profile?.followersCount) }}</span> 486 + <span class="stat-label">Followers</span> 487 + </AppLink> 488 + <AppLink class="stat" name="user-follows" :params="{ id: profile.did }"> 489 + <span class="stat-count">{{ formatCount(profile?.followsCount) }}</span> 490 + <span class="stat-label">Following</span> 491 + </AppLink> 492 + <div class="stat"> 493 + <span class="stat-count">{{ formatCount(profile?.postsCount) }}</span> 494 + <span class="stat-label">Posts</span> 341 495 </div> 342 496 </div> 343 497 344 - <div class="sticky-tabs"> 345 - <div class="tabs-inner"> 346 - <button 347 - v-for="(tab, index) in tabs" 348 - :key="tab.value" 349 - :ref=" 350 - (el) => { 351 - if (el) tabRefs[index] = el as HTMLElement 352 - } 353 - " 354 - class="tab-btn" 355 - :class="{ active: activeTab === tab.value }" 356 - @click="activeTab = tab.value" 357 - > 358 - {{ tab.label }} 359 - </button> 360 - <div class="active-indicator" :style="indicatorStyle"></div> 498 + <RichText v-if="profile.description" :text="profile.description" class="description" /> 499 + 500 + <div class="additional"> 501 + <div class="additional-item" v-if="profile.website"> 502 + <IconGlobe /> 503 + <a :href="profile.website" target="_blank">{{ formatUrl(profile.website) }}</a> 361 504 </div> 362 - </div> 363 - <div class="feed-container"> 364 - <div v-if="loadingFeed && feed.length === 0" class="loading-feed"> 365 - <SkeletonLoader 366 - width="100%" 367 - height="100px" 368 - v-for="n in 5" 369 - :key="n" 370 - style="margin-bottom: 1rem; border-radius: 1rem" 371 - /> 505 + <div class="additional-item" v-if="profile.createdAt"> 506 + <IconCalendarMonthRounded /> 507 + <span>Joined {{ new Date(profile.createdAt).toLocaleDateString() }}</span> 372 508 </div> 509 + </div> 510 + </div> 511 + </div> 373 512 374 - <div v-else-if="feed.length === 0" class="empty-feed"> 375 - <IconArrowDownwardRounded class="big-icon" /> 376 - <p>Nothing to see here yet.</p> 377 - </div> 513 + <div class="sticky-tabs"> 514 + <div class="tabs-inner"> 515 + <button 516 + v-for="(tab, index) in tabs" 517 + :key="tab.value" 518 + :ref=" 519 + (el) => { 520 + if (el) tabRefs[index] = el as HTMLElement 521 + } 522 + " 523 + class="tab-btn" 524 + :class="{ active: activeTab === tab.value }" 525 + @click="activeTab = tab.value" 526 + > 527 + {{ tab.label }} 528 + </button> 529 + <div class="active-indicator" :style="indicatorStyle"></div> 530 + </div> 531 + </div> 378 532 379 - <div v-else class="feed-list"> 380 - <FeedItem v-for="item in feed" :key="item.post.uri" :item="item" /> 381 - <div class="load-more" v-if="cursor"> 382 - <Button variant="ghost" @click="fetchFeed(false)" :loading="loadingFeed"> 383 - Load more posts 384 - </Button> 385 - </div> 386 - </div> 533 + <div class="feed-container"> 534 + <div v-if="loadingFeed && feed.length === 0" class="loading-feed"> 535 + <SkeletonLoader 536 + width="100%" 537 + height="100px" 538 + v-for="n in 5" 539 + :key="n" 540 + style="margin-bottom: 1rem; border-radius: 1rem" 541 + /> 542 + </div> 543 + 544 + <div v-else-if="feed.length === 0" class="empty-feed"> 545 + <IconArrowDownwardRounded class="big-icon" /> 546 + <p>Nothing to see here yet.</p> 547 + </div> 548 + 549 + <div v-else class="feed-list"> 550 + <FeedItem v-for="item in feed" :key="item.post.uri" :item="item" /> 551 + <div class="load-more" v-if="cursor"> 552 + <Button variant="ghost" @click="fetchFeed(false)" :loading="loadingFeed"> 553 + Load more posts 554 + </Button> 387 555 </div> 388 556 </div> 389 557 </div> ··· 391 559 </template> 392 560 393 561 <style scoped lang="scss"> 394 - .topbar-left { 562 + .topbar-left, 563 + .topbar-right { 395 564 display: flex; 396 565 flex-direction: row; 397 566 gap: 0.5rem; 398 567 align-items: center; 568 + transition: none; 399 569 } 400 570 401 571 .header-fade-wrapper { 402 572 display: flex; 573 + flex-direction: row; 574 + flex: 1; 575 + 403 576 align-items: center; 404 577 gap: 0.5rem; 405 578 transition: none; ··· 434 607 } 435 608 436 609 .profile-container { 437 - position: relative; 438 - min-height: 100vh; 439 - background-color: hsl(var(--base)); 610 + border-bottom: 1px solid hsl(var(--surface0) / 0.5); 440 611 } 441 612 442 - .hero-wrapper { 443 - position: absolute; 444 - left: 0; 445 - width: 100%; 446 - height: 22rem; 613 + .banner { 614 + line-height: 0; 615 + transition: none; 447 616 overflow: hidden; 448 - z-index: 0; 617 + border-radius: 1.5rem; 618 + background: hsla(var(--base) / 1); 619 + margin: 0.5rem; 620 + outline: 4px solid hsla(var(--surface0) / 0.5); 621 + outline-offset: -1px; 449 622 450 - .hero-bg { 451 - position: absolute; 452 - inset: -20px; 453 - background-color: hsl(var(--surface1)); 454 - background-size: cover; 455 - background-position: center; 456 - will-change: transform; 623 + &__image { 624 + width: 100%; 457 625 transition: none; 626 + object-fit: cover; 458 627 } 459 - 460 - .hero-overlay { 461 - position: absolute; 462 - inset: 0; 463 - background: linear-gradient(to bottom, hsla(var(--base) / 0) 0%, hsla(var(--base) / 0.6) 100%); 464 - backdrop-filter: blur(0px); 628 + &__placeholder { 629 + background: hsla(var(--surface1) / 0.25); 630 + height: 214px; 465 631 } 466 632 } 467 633 468 - .content-stack { 634 + .identity { 469 635 position: relative; 470 - z-index: 1; 471 - padding-top: 14rem; 636 + --avatar-size: 6rem; 637 + padding: 0.5rem; 638 + padding-bottom: 0; 472 639 display: flex; 473 - flex-direction: column; 640 + flex-direction: row; 641 + gap: 1rem; 474 642 475 - .identity-card-wrapper { 476 - padding: 0 1rem; 477 - margin-bottom: 1rem; 478 - } 643 + margin-top: calc((var(--avatar-size) / 2 + 0.75rem) * -1); 479 644 480 - .identity-card { 481 - background: hsla(var(--base) / 0.75); 482 - backdrop-filter: blur(1.5rem); 483 - -webkit-backdrop-filter: blur(1.5rem); 484 - border: 1px solid hsla(var(--surface2) / 0.5); 485 - border-radius: 1.5rem; 486 - padding: 1.5rem; 487 - 488 - display: flex; 489 - flex-direction: column; 490 - gap: 0.5rem; 491 - 492 - &.loading { 493 - min-height: 200px; 494 - align-items: center; 495 - justify-content: center; 496 - } 497 - } 498 - 499 - .card-top-row { 500 - display: flex; 501 - justify-content: space-between; 502 - align-items: flex-start; 503 - margin-top: -3rem; 504 - } 505 - 506 - .avatar-large { 507 - width: 5.5rem; 508 - height: 5.5rem; 645 + .avatar { 646 + flex: 0 0 var(--avatar-size); 647 + margin-left: 1rem; 648 + width: var(--avatar-size); 649 + height: var(--avatar-size); 509 650 border-radius: 50%; 510 651 overflow: hidden; 652 + outline: 4px solid hsla(var(--surface0) / 1); 653 + background: hsla(var(--base) / 1); 654 + outline-offset: -1px; 511 655 512 656 img { 513 657 width: 100%; 514 658 height: 100%; 515 659 object-fit: cover; 516 660 } 517 - .fallback { 518 - width: 100%; 519 - height: 100%; 520 - display: flex; 521 - align-items: center; 522 - justify-content: center; 523 - color: hsl(var(--accent)); 524 - } 525 661 } 526 662 527 - .action-buttons { 663 + .identity-row { 664 + position: relative; 665 + top: calc(var(--avatar-size) / 2); 666 + height: calc(var(--avatar-size) / 2); 528 667 display: flex; 668 + align-items: center; 669 + justify-content: space-between; 670 + flex: 1; 529 671 gap: 0.5rem; 530 - margin-top: 3rem; 531 - } 672 + min-width: 0; 532 673 533 - .card-names { 534 - .display-name { 535 - font-size: 1.75rem; 536 - font-weight: 800; 537 - color: hsl(var(--text)); 538 - line-height: 1; 539 - } 674 + &__names { 675 + flex: 1 1 0%; 676 + min-width: 0; 540 677 541 - .handle-row { 542 678 display: flex; 543 - align-items: center; 544 - gap: 0.5rem; 545 - margin-top: 0.25rem; 679 + flex-direction: column; 680 + 681 + span { 682 + display: block; 683 + white-space: nowrap; 684 + overflow: hidden; 685 + text-overflow: ellipsis; 686 + min-width: 0; 687 + } 546 688 547 - .handle { 689 + .header-name { 690 + font-weight: 700; 691 + font-size: 1.25rem; 692 + } 693 + .header-handle { 694 + font-size: 0.9rem; 548 695 color: hsl(var(--subtext0)); 549 - font-size: 0.95rem; 696 + text-decoration: none; 697 + 698 + display: flex; 699 + align-items: center; 700 + gap: 0.25rem; 550 701 } 702 + } 551 703 552 - .badge { 553 - font-size: 0.7rem; 554 - background: hsla(var(--surface2) / 0.5); 555 - color: hsl(var(--subtext1)); 556 - padding: 0.1rem 0.4rem; 557 - border-radius: 4px; 558 - font-weight: 600; 559 - } 704 + &__actions { 705 + flex: 0 0 auto; 706 + display: flex; 707 + gap: 0.5rem; 560 708 } 561 709 } 710 + } 711 + 712 + .profile { 713 + padding: 0 1.5rem 0.5rem 1.5rem; 714 + display: flex; 715 + flex-direction: column; 716 + gap: 0.5rem; 562 717 563 - .stats-row { 718 + .stats { 564 719 display: flex; 565 - gap: 1.5rem; 566 - padding: 0.5rem 0; 567 - border-bottom: 1px solid hsla(var(--surface2) / 0.5); 720 + flex-direction: row; 721 + gap: 0.25rem; 722 + margin-top: 0.75rem; 568 723 569 - .stat-item { 724 + .stat { 570 725 display: flex; 571 - align-items: baseline; 572 - gap: 0.3rem; 573 - cursor: pointer; 574 - transition: opacity 0.2s; 726 + flex-direction: row; 727 + align-items: center; 728 + gap: 0.25rem; 729 + padding: 0 0.5rem; 730 + border-radius: 1rem; 731 + color: hsl(var(--subtext1)); 575 732 576 - &:hover { 577 - opacity: 0.7; 733 + .stat-count { 734 + font-weight: 900; 735 + font-size: 1rem; 736 + color: inherit; 737 + } 738 + .stat-label { 739 + font-size: 0.85rem; 740 + color: inherit; 741 + } 742 + 743 + &:not(a) { 744 + cursor: default; 578 745 } 579 746 580 - .stat-val { 581 - font-weight: 700; 747 + &.alt { 748 + background: hsla(var(--surface0) / 0.5); 749 + color: hsl(var(--base)); 750 + padding: 0 0.5rem; 751 + border-radius: 1rem; 752 + 753 + .stat-label { 754 + display: none; 755 + } 756 + 757 + color: hsl(var(--subtext1)); 758 + 759 + &:first-child { 760 + background: hsla(var(--accent) / 0.75); 761 + color: hsl(var(--base)); 762 + &:hover { 763 + color: hsl(var(--base)); 764 + background: hsla(var(--accent) / 1); 765 + } 766 + &:active { 767 + color: hsl(var(--base)); 768 + background: hsla(var(--accent) / 0.6); 769 + } 770 + } 771 + } 772 + 773 + &:hover { 582 774 color: hsl(var(--text)); 775 + background: hsla(var(--surface2) / 0.25); 583 776 } 584 - .stat-label { 585 - font-size: 0.85rem; 777 + &:active { 586 778 color: hsl(var(--subtext0)); 779 + background: hsla(var(--surface2) / 0.15); 587 780 } 588 781 } 589 782 } 590 783 591 - .bio-section { 592 - white-space: pre-wrap; 593 - color: hsl(var(--text)); 594 - line-height: 1.5; 595 - font-size: 0.95rem; 596 - } 597 - 598 - .meta-details { 784 + .additional { 599 785 display: flex; 600 - flex-wrap: wrap; 786 + flex-direction: row; 601 787 gap: 0.5rem; 788 + color: hsl(var(--subtext0)); 789 + margin-left: -0.25rem; 602 790 603 - .meta-pill { 791 + .additional-item { 604 792 display: flex; 793 + flex-direction: row; 605 794 align-items: center; 795 + gap: 0.25rem; 796 + font-size: 0.9rem; 606 797 607 - gap: 0.3rem; 608 - font-size: 0.8rem; 609 - color: hsl(var(--subtext0)); 610 - background: hsla(var(--surface0) / 0.5); 611 - padding: 0.25rem 0.75rem; 612 - border-radius: 5rem; 613 - text-decoration: none; 614 - user-select: none; 798 + padding: 0.1rem 0.75rem; 799 + border-radius: 1rem; 800 + background: hsla(var(--surface2) / 0.25); 801 + 802 + a { 803 + color: inherit; 804 + text-decoration: none; 615 805 616 - &:hover, 617 - &:focus-visible { 618 - background: hsla(var(--surface0) / 0.6); 619 - } 620 - &:active { 621 - background: hsla(var(--surface0) / 0.45); 806 + &:hover { 807 + text-decoration: underline; 808 + } 622 809 } 623 810 } 624 811 } ··· 628 815 position: fixed; 629 816 bottom: calc(var(--inset-bottom) + 4.5rem); 630 817 z-index: 10; 818 + 631 819 left: 50%; 820 + max-width: 75%; 821 + overflow-x: auto; 632 822 transform: translateX(-50%); 633 823 634 824 padding: 0.25rem;