wip bsky client for the web & android
0
fork

Configure Feed

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

at main 823 lines 19 kB view raw
1<script setup lang="ts"> 2import { computed, ref, onMounted, onUnmounted } from 'vue' 3import { AppBskyFeedDefs, AppBskyEmbedRecord } from '@atcute/bluesky' 4import { 5 IconRefreshRounded, 6 IconChatBubbleOutlineRounded, 7 IconRepeatRounded, 8 IconFavoriteOutlineRounded, 9 IconFavoriteRounded, 10 IconMoreVert, 11 IconBookmarkRounded, 12 IconBookmarkAddedRounded, 13 IconFormatQuoteRounded, 14 IconContentCopyRounded, 15 IconLinkRounded, 16 IconOpenInNewRounded, 17 IconForwardRounded, 18 IconPets, 19 IconBombRounded, 20} from '@iconify-prerendered/vue-material-symbols' 21 22import { useNavigationStore } from '@/stores/navigation' 23import { usePostStore } from '@/stores/posts' 24import { useToastStore } from '@/stores/toast' 25 26import AppLink from '@/components/Navigation/AppLink.vue' 27import BasePopover from '@/components/UI/BasePopover.vue' 28import ImageEmbed from './Embeds/ImageEmbed.vue' 29import EmbedRecord from './Embeds/EmbedRecord.vue' 30import ExternalEmbed from './Embeds/ExternalEmbed.vue' 31import VideoEmbed from './Embeds/VideoEmbed.vue' 32import { tap } from '@/utils/haptics' 33import { createRecord } from '@/utils/atproto' 34 35type PostInput = AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord 36 37const props = defineProps<{ 38 item?: AppBskyFeedDefs.FeedViewPost 39 post?: PostInput 40 embedded?: boolean 41 rootPost?: boolean 42 context?: 'post' | 'feed' | 'replies' 43}>() 44 45const postStore = usePostStore() 46const navigationStore = useNavigationStore() 47const toastStore = useToastStore() 48const rootEl = ref<HTMLElement | null>(null) 49const isVisible = ref(false) 50 51const displayPost = computed(() => { 52 if (props.item) return props.item.post 53 54 if (props.post) { 55 const p = props.post 56 57 if ('value' in p && 'embeds' in p) { 58 return { 59 ...p, 60 record: p.value, 61 embed: p.embeds?.[0], 62 } as AppBskyFeedDefs.PostView 63 } 64 65 return p as AppBskyFeedDefs.PostView 66 } 67 68 return null 69}) 70 71const embed = computed(() => displayPost.value?.embed) 72 73const rkey = computed(() => { 74 if (!displayPost.value) return null 75 const uriParts = displayPost.value.uri.split('/') 76 return uriParts.pop() || null 77}) 78 79const formatTime = (dateString?: string) => { 80 if (!dateString) return '' 81 const date = new Date(dateString) 82 const now = new Date() 83 const diff = (now.getTime() - date.getTime()) / 1000 84 85 if (diff < 60) return 'now' 86 if (diff < 3600) return `${Math.floor(diff / 60)}m` 87 if (diff < 86400) return `${Math.floor(diff / 3600)}h` 88 return `${Math.floor(diff / 86400)}d` 89} 90 91const formatCount = (count?: number) => { 92 if (!count) return '' 93 if (count < 1000) return count.toString() 94 if (count < 1000000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k` 95 return `${(count / 1000000).toFixed(1).replace(/\.0$/, '')}M` 96} 97 98const handleLike = () => { 99 if (displayPost.value && !props.embedded) { 100 postStore.toggleLike(displayPost.value) 101 tap() 102 } 103} 104 105const handleRepost = () => { 106 if (displayPost.value && !props.embedded) { 107 postStore.toggleRepost(displayPost.value) 108 tap() 109 } 110} 111 112const handleBookmark = () => { 113 if (displayPost.value && !props.embedded) { 114 postStore.toggleBookmark(displayPost.value) 115 toastStore.success( 116 displayPost.value.viewer?.bookmarked ? 'Bookmarked post' : 'Removed bookmark', 117 ) 118 tap() 119 } 120} 121 122const actions = { 123 bite: async () => { 124 await createRecord( 125 'net.wafrn.feed.bite', 126 { 127 $type: 'net.wafrn.feed.bite', 128 subject: displayPost.value?.uri, 129 createdAt: new Date().toISOString(), 130 }, 131 { 132 error: `failed to bite the post :c`, 133 success: `bit the post!`, 134 }, 135 ) 136 }, 137 explode: async () => { 138 await createRecord( 139 'net.wafrn.feed.explode', 140 { 141 $type: 'net.wafrn.feed.explode', 142 subject: displayPost.value?.uri, 143 createdAt: new Date().toISOString(), 144 }, 145 { 146 error: `failed to explode the post :c`, 147 success: `exploded the post!`, 148 }, 149 ) 150 }, 151} 152 153const handleShare = () => { 154 if (displayPost.value) { 155 const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 156 if (navigator.share) { 157 navigator 158 .share({ 159 title: 'Check out this post on Bluesky', 160 url, 161 }) 162 .then(() => tap()) 163 .catch((error) => console.error('Error sharing', error)) 164 return 165 } 166 tap() 167 } 168} 169 170const share = { 171 systemShare: () => { 172 if (displayPost.value) { 173 const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 174 if (navigator.share) { 175 navigator 176 .share({ 177 title: 'Check out this post on Bluesky', 178 url, 179 }) 180 .then(() => tap()) 181 .catch((error) => console.error('Error sharing', error)) 182 } 183 } 184 }, 185 copyLink: () => { 186 if (displayPost.value) { 187 const url = `${window.location.origin}/profile/${displayPost.value.author.handle}/post/${rkey.value}` 188 navigator.clipboard.writeText(url) 189 toastStore.success('Link copied to clipboard') 190 tap() 191 } 192 }, 193 copyBlueskyLink: () => { 194 if (displayPost.value) { 195 const bskyUrl = `https://bsky.app/profile/${displayPost.value.author.did}/post/${rkey.value}` 196 navigator.clipboard.writeText(bskyUrl) 197 toastStore.success('Link copied to clipboard') 198 tap() 199 } 200 }, 201 copyATUri: () => { 202 if (displayPost.value) { 203 navigator.clipboard.writeText(displayPost.value.uri) 204 toastStore.success('URI copied to clipboard') 205 tap() 206 } 207 }, 208 copyDid: () => { 209 if (displayPost.value) { 210 navigator.clipboard.writeText(displayPost.value.author.did) 211 toastStore.success('DID copied to clipboard') 212 tap() 213 } 214 }, 215 openInPDSls: () => { 216 if (displayPost.value) { 217 const url = `https://pds.ls/${displayPost.value.uri}` 218 window.open(url, '_blank') 219 } 220 }, 221} 222 223const handleClick = (e: MouseEvent) => { 224 if (window.getSelection()?.toString().length) return 225 if (props.rootPost) return 226 227 if (props.embedded) { 228 e.stopPropagation() 229 if (displayPost.value) navigateToPost(displayPost.value) 230 return 231 } 232 233 if (displayPost.value) { 234 navigateToPost(displayPost.value) 235 } 236} 237 238const navigateToPost = (post: AppBskyFeedDefs.PostView, event?: MouseEvent | KeyboardEvent) => { 239 const uriParts = post.uri.split('/') 240 const rkey = uriParts.pop() 241 const identifier = post.author.handle || post.author.did 242 243 if (!rkey || !identifier) return 244 245 const isModifierKey = event && (event.ctrlKey || event.metaKey || event.shiftKey) 246 247 if (isModifierKey) { 248 const url = `${window.location.origin}/profile/${identifier}/post/${rkey}` 249 window.open(url, '_blank') 250 } else { 251 navigationStore.push('post-thread', { 252 props: { identifier, rkey: rkey }, 253 }) 254 } 255} 256 257const handleMiddleClick = () => { 258 if (!displayPost.value) return 259 const uriParts = displayPost.value.uri.split('/') 260 const rkey = uriParts.pop() 261 const identifier = displayPost.value.author.handle || displayPost.value.author.did 262 263 if (rkey && identifier) { 264 const url = `${window.location.origin}/profile/${identifier}/post/${rkey}` 265 window.open(url, '_blank') 266 } 267} 268 269let observer: IntersectionObserver | null = null 270 271onMounted(() => { 272 if (props.embedded || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 273 isVisible.value = true 274 return 275 } 276 277 observer = new IntersectionObserver( 278 (entries) => { 279 if (entries[0]?.isIntersecting) { 280 isVisible.value = true 281 observer?.disconnect() 282 observer = null 283 } 284 }, 285 { 286 threshold: 0.1, 287 rootMargin: '50px', 288 }, 289 ) 290 291 if (rootEl.value) observer.observe(rootEl.value) 292}) 293 294onUnmounted(() => { 295 if (observer) observer.disconnect() 296}) 297</script> 298 299<template> 300 <article 301 v-if="displayPost" 302 ref="rootEl" 303 :key="displayPost.uri" 304 :id="displayPost.uri" 305 class="feed-item" 306 :class="{ 307 'is-embedded': embedded, 308 'is-root-post': rootPost, 309 'is-visible': isVisible, 310 'is-post-view': context === 'post', 311 }" 312 @click="handleClick" 313 @click.middle="handleMiddleClick" 314 > 315 <div v-if="item?.reason?.$type === 'app.bsky.feed.defs#reasonRepost'" class="repost-indicator"> 316 <IconRefreshRounded class="repost-icon" /> 317 <span>Reposted by {{ item.reason.by.displayName || item.reason.by.handle }}</span> 318 <span class="repost-indicator__time"> · {{ formatTime(item.reason.indexedAt) }} </span> 319 </div> 320 321 <div class="post-layout"> 322 <div class="post-top"> 323 <AppLink name="user-profile" :params="{ id: displayPost.author.did }" class="post-avatar"> 324 <img 325 v-if="displayPost.author.avatar" 326 :src="displayPost.author.avatar" 327 alt="avatar" 328 loading="lazy" 329 /> 330 <div v-else class="avatar-fallback"></div> 331 </AppLink> 332 333 <div class="not-gutter"> 334 <div class="post-header"> 335 <div class="post-header__part"> 336 <AppLink 337 class="display-name" 338 name="user-profile" 339 :params="{ id: displayPost.author.did }" 340 > 341 {{ displayPost.author.displayName || displayPost.author.handle }} 342 </AppLink> 343 344 <p v-if="displayPost.author.pronouns" class="pronouns"> 345 {{ displayPost.author.pronouns }} 346 </p> 347 </div> 348 <div class="post-header__part"> 349 <p class="time" :title="new Date(displayPost.indexedAt).toLocaleString()"> 350 {{ formatTime(displayPost.indexedAt) }} 351 </p> 352 </div> 353 </div> 354 355 <div class="post-content"> 356 <div class="post-text" v-if="displayPost.record?.text"> 357 {{ displayPost.record.text }} 358 </div> 359 </div> 360 </div> 361 </div> 362 363 <div class="post-embeds" v-if="embed"> 364 <ImageEmbed v-if="embed.$type === 'app.bsky.embed.images#view'" :embed="embed" /> 365 <ExternalEmbed 366 v-else-if="embed.$type === 'app.bsky.embed.external#view'" 367 :embed="embed.external" 368 /> 369 <VideoEmbed v-else-if="embed.$type === 'app.bsky.embed.video#view'" :embed="embed" /> 370 <template v-else-if="embed.$type === 'app.bsky.embed.record#view'"> 371 <EmbedRecord v-if="!embedded" :embed="embed" /> 372 <div v-else class="embedded-record">Post has nested quote.</div> 373 </template> 374 <template v-else-if="embed.$type === 'app.bsky.embed.recordWithMedia#view'"> 375 <ImageEmbed 376 v-if="embed.media.$type === 'app.bsky.embed.images#view'" 377 :embed="embed.media" 378 /> 379 <EmbedRecord 380 v-if="embed.record.$type === 'app.bsky.embed.record#view'" 381 :embed="embed.record" 382 /> 383 </template> 384 </div> 385 386 <div class="post-footer" v-if="!embedded"> 387 <div class="metrics row"> 388 <AppLink 389 name="post-thread" 390 :params="{ identifier: displayPost.author.handle, rkey: rkey! }" 391 class="action-button reply" 392 aria-label="Reply" 393 @click.stop 394 > 395 <div class="icon-wrapper"><IconChatBubbleOutlineRounded /></div> 396 <span class="count" v-if="displayPost.replyCount && displayPost.replyCount > 0"> 397 {{ formatCount(displayPost.replyCount) }} 398 </span> 399 </AppLink> 400 401 <BasePopover 402 :actions="[ 403 { 404 label: !!displayPost.viewer?.repost ? 'Undo Repost' : 'Repost', 405 icon: IconRepeatRounded, 406 onClick: handleRepost, 407 }, 408 { 409 label: 'Quote Post', 410 icon: IconFormatQuoteRounded, 411 onClick: () => {}, 412 }, 413 ]" 414 align="right" 415 > 416 <template #trigger="{ triggerProps }"> 417 <button 418 class="action-button repost more" 419 :class="{ 'is-active': !!displayPost.viewer?.repost }" 420 v-bind="triggerProps as any" 421 > 422 <div class="icon-wrapper"><IconRepeatRounded /></div> 423 <span class="count" v-if="displayPost.repostCount && displayPost.repostCount > 0"> 424 {{ formatCount(displayPost.repostCount) }} 425 </span> 426 </button> 427 </template> 428 </BasePopover> 429 430 <button 431 class="action-button like" 432 :class="{ 'is-active': !!displayPost.viewer?.like }" 433 @click.stop="handleLike" 434 aria-label="Like" 435 > 436 <div class="icon-wrapper"> 437 <IconFavoriteRounded v-if="!!displayPost.viewer?.like" /> 438 <IconFavoriteOutlineRounded v-else /> 439 </div> 440 <span class="count" v-if="displayPost.likeCount && displayPost.likeCount > 0"> 441 {{ formatCount(displayPost.likeCount) }} 442 </span> 443 </button> 444 </div> 445 446 <div class="footer-content row"> 447 <BasePopover 448 :actions="[ 449 { 450 actions: [ 451 { label: 'System share', icon: IconForwardRounded, onClick: handleShare }, 452 ], 453 }, 454 { 455 actions: [ 456 { 457 label: 'Copy Link', 458 icon: IconLinkRounded, 459 onClick: share.copyLink, 460 }, 461 { 462 label: 'Copy Bluesky link', 463 icon: IconContentCopyRounded, 464 onClick: share.copyBlueskyLink, 465 }, 466 ], 467 }, 468 { 469 actions: [ 470 { 471 label: 'Copy AT URI', 472 icon: IconContentCopyRounded, 473 onClick: share.copyBlueskyLink, 474 }, 475 { 476 label: 'Copy author DID', 477 icon: IconLinkRounded, 478 onClick: share.copyDid, 479 }, 480 { 481 label: 'Open in PDSls', 482 icon: IconOpenInNewRounded, 483 onClick: share.openInPDSls, 484 }, 485 ], 486 }, 487 ]" 488 align="right" 489 > 490 <template #trigger="{ triggerProps }"> 491 <button class="action-button more" v-bind="triggerProps as any"> 492 <div class="icon-wrapper"> 493 <IconForwardRounded /> 494 </div> 495 </button> 496 </template> 497 </BasePopover> 498 499 <BasePopover 500 :actions="[ 501 { 502 actions: [ 503 { 504 label: displayPost.viewer?.bookmarked ? 'Remove Bookmark' : 'Bookmark', 505 icon: displayPost.viewer?.bookmarked 506 ? IconBookmarkAddedRounded 507 : IconBookmarkRounded, 508 onClick: handleBookmark, 509 }, 510 { 511 label: 'Bite post', 512 icon: IconPets, 513 onClick: actions.bite, 514 }, 515 { 516 label: 'Explode post', 517 icon: IconBombRounded, 518 onClick: actions.explode, 519 }, 520 ], 521 }, 522 ]" 523 align="right" 524 > 525 <template #trigger="{ triggerProps }"> 526 <button class="action-button more" v-bind="triggerProps as any"> 527 <div class="icon-wrapper"> 528 <IconMoreVert /> 529 </div> 530 </button> 531 </template> 532 </BasePopover> 533 </div> 534 </div> 535 </div> 536 </article> 537</template> 538 539<style lang="scss" scoped> 540.feed-item { 541 display: flex; 542 flex-direction: column; 543 gap: 0.25rem; 544 padding-top: 0.5rem; 545 padding-bottom: 0.5rem; 546 547 opacity: 0; 548 filter: blur(4px); 549 transform: translateY(12px); 550 user-select: none; 551 transition: 552 background-color 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), 553 filter 0.4s ease-out, 554 opacity 0.4s ease-out, 555 transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 556 557 &.is-post-view { 558 .post-text { 559 user-select: text; 560 } 561 } 562 563 &.is-visible { 564 opacity: 1; 565 transform: translateY(0); 566 filter: blur(0); 567 } 568 569 &:not(.is-root-post) { 570 &:hover { 571 background-color: hsla(var(--surface0) / 0.3); 572 cursor: default; 573 } 574 &:active { 575 background-color: hsla(var(--surface0) / 0.2); 576 } 577 } 578 579 &.is-embedded { 580 border: 1px solid hsla(var(--surface2) / 0.5); 581 border-radius: var(--radius-md); 582 padding: 0.5rem; 583 margin-top: 0.5rem; 584 background-color: transparent; 585 586 opacity: 1; 587 transform: none; 588 transition: none; 589 590 &:hover { 591 background-color: hsla(var(--surface0) / 0.5); 592 border-color: hsla(var(--surface2) / 0.8); 593 } 594 595 .post-layout { 596 gap: 0.5rem; 597 } 598 599 .post-avatar { 600 width: 1.5rem; 601 height: 1.5rem; 602 margin-top: 0; 603 } 604 605 .post-header { 606 font-size: 0.85rem; 607 } 608 609 .post-text { 610 font-size: 0.9rem; 611 } 612 } 613} 614 615.repost-indicator { 616 display: flex; 617 align-items: center; 618 gap: 0.35rem; 619 font-size: 0.8rem; 620 color: hsl(var(--subtext0)); 621 font-weight: 600; 622 margin-left: 3.25rem; 623 margin-bottom: 0.125rem; 624 625 .repost-icon { 626 font-size: 1rem; 627 color: hsl(var(--green)); 628 } 629} 630 631.post-layout { 632 --gutter-width: calc(2.75rem + 1rem); 633 display: flex; 634 flex-direction: column; 635 636 .post-top { 637 padding: 0 0.75rem; 638 display: flex; 639 gap: 0.75rem; 640 .not-gutter { 641 width: 100%; 642 } 643 } 644 645 .post-avatar { 646 flex-shrink: 0; 647 width: 2.75rem; 648 height: 2.75rem; 649 margin-top: 0.25rem; 650 651 img { 652 width: 100%; 653 height: 100%; 654 border-radius: 50%; 655 object-fit: cover; 656 background-color: hsl(var(--surface1)); 657 } 658 659 .avatar-fallback { 660 width: 100%; 661 height: 100%; 662 border-radius: 50%; 663 background-color: hsl(var(--surface2)); 664 } 665 } 666 667 .post-header { 668 display: flex; 669 align-items: last baseline; 670 flex-direction: row; 671 justify-content: space-between; 672 673 gap: 0.5rem; 674 font-size: 1rem; 675 line-height: 1.3; 676 677 &__part { 678 display: flex; 679 flex-direction: row; 680 align-items: last baseline; 681 gap: 0.5rem; 682 } 683 684 * { 685 min-width: 0; 686 text-wrap: nowrap; 687 text-overflow: ellipsis; 688 overflow: hidden; 689 } 690 691 .display-name { 692 font-weight: 700; 693 color: hsl(var(--text)); 694 } 695 696 p { 697 font-size: 0.85rem; 698 color: hsla(var(--overlay0) / 1); 699 font-weight: 700; 700 } 701 } 702 703 .post-text { 704 color: hsl(var(--text)); 705 font-size: 0.95rem; 706 line-height: 1.4; 707 white-space: pre-wrap; 708 word-wrap: break-word; 709 } 710 711 .post-content { 712 flex: 1; 713 min-width: 0; 714 display: flex; 715 flex-direction: column; 716 717 .embedded-record { 718 padding: 0.5rem; 719 border: 1px solid hsla(var(--surface2) / 0.5); 720 border-radius: var(--radius-sm); 721 color: hsl(var(--subtext0)); 722 font-size: 0.9rem; 723 text-align: center; 724 margin-top: 0.5rem; 725 } 726 } 727} 728 729.post-embeds { 730 &:not(:has(.image-embed__wrapper)) { 731 padding: 0 0.75rem; 732 padding-left: calc(var(--gutter-width)); 733 } 734} 735 736.post-footer { 737 display: flex; 738 align-items: center; 739 justify-content: space-between; 740 margin-top: 0.5rem; 741 padding: 0 0.75rem; 742 margin-left: calc(-0.75rem + var(--gutter-width)); 743 744 .row { 745 display: flex; 746 align-items: center; 747 gap: 0.25rem; 748 } 749 750 .action-button { 751 display: flex; 752 align-items: center; 753 gap: 0.25rem; 754 padding: 0.35rem 0.5rem; 755 background: transparent; 756 border: none; 757 color: hsl(var(--subtext0)); 758 font-size: 0.8rem; 759 font-weight: 500; 760 border-radius: 99px; 761 --hover-colour: var(--subtext0); 762 763 &.reply { 764 --hover-colour: var(--blue); 765 } 766 &.repost { 767 --hover-colour: var(--green); 768 } 769 &.like { 770 --hover-colour: var(--pink); 771 } 772 773 &.repost.is-active { 774 color: hsl(var(--green)); 775 } 776 &.like.is-active { 777 color: hsl(var(--pink)); 778 779 .icon-wrapper svg { 780 animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); 781 } 782 } 783 784 .icon-wrapper { 785 display: flex; 786 align-items: center; 787 justify-content: center; 788 789 svg { 790 width: 1.15rem; 791 height: 1.15rem; 792 } 793 } 794 795 .count { 796 font-variant-numeric: tabular-nums; 797 line-height: 1; 798 } 799 800 &:hover, 801 &:focus-visible { 802 color: hsl(var(--hover-colour)); 803 background-color: hsla(var(--hover-colour) / 0.1); 804 } 805 806 &:active { 807 background-color: hsla(var(--hover-colour) / 0.075); 808 } 809 } 810} 811 812@keyframes pop { 813 0% { 814 transform: scale(1); 815 } 816 50% { 817 transform: scale(1.3); 818 } 819 100% { 820 transform: scale(1); 821 } 822} 823</style>