forked from
did:plc:2hcnfmbfr4ucfbjpnvjqvt3e/bbell
wip bsky client for the web & android
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>