DEV Community

Cover image for How to add comment from BlueSky to static/vue/nuxt project
Ismael Garcia
Ismael Garcia

Posted on

How to add comment from BlueSky to static/vue/nuxt project

Using Plain HTML

For static HTML site you can use the web component provided by Matt Kane

<!-- The webcomponent provided with the default styles -->
<bluesky-comments url="https://bsky.app/profile/mk.gg/post/3lbtkg6m7ys2v"></bluesky-comments>

<script type="module">
  import "https://esm.sh/bluesky-comments-tag/load";
</script>
Enter fullscreen mode Exit fullscreen mode

You can customize the styles in the styles' playground:

Theme Playground

Using Vue & Nuxt Custom implementation

Note: **This project is using Shadcn-vue with prefix Ui

Requirements:
- Tailwind CSS
- Nuxt(Not really needed, but the auto import is done so if you're using Vue you will need to do the manual imports)
- Vue Use
- Shadcn vue (Can use your own components just need adjustment)

You don’t want to add another dependencies to your project you can copy the code below:

BlueSkyTypes.ts

export type Thread = {
    thread: ThreadViewPost;
};

export type ThreadViewPost = {
    $type: string;
    post: Post;
    replies: ThreadViewPost[];
};
export type DisplayThread = {
    isReply: boolean;
    post: Post;
    replies: DisplayThread[];
}

export type Post = {
    uri: string;
    cid: string;
    author: Author;
    record: Record;
    embed?: EmbedView;
    replyCount: number;
    repostCount: number;
    likeCount: number;
    quoteCount: number;
    indexedAt: string;
    labels: Label[];
};

export type Author = {
    did: string;
    handle: string;
    displayName: string;
    avatar?: string;
    labels: Label[];
    createdAt: string;
};

export type Record = {
    $type: string;
    createdAt: string;
    embed?: EmbedImages;
    facets?: Facet[];
    text: string;
    langs?: string[];
    reply?: Reply;
};

export type EmbedImages = {
    $type: string;
    images: Image[];
};

export type EmbedView = {
    $type: string;
    images: ImageView[];
};

export type Image = {
    alt: string;
    image: Blob;
};

export type ImageView = {
    thumb: string;
    fullsize: string;
    alt: string;
};

export type Blob = {
    $type: string;
    ref: Link;
    mimeType: string;
    size: number;
};

export type Link = {
    $link: string;
};

export type Facet = {
    $type?: string;
    features: Feature[];
    index: Index;
};

export type Feature = {
    $type: string;
    uri?: string;
    did?: string;
};

export type Index = {
    byteStart: number;
    byteEnd: number;
};

export type Reply = {
    parent: ReplyReference;
    root: ReplyReference;
};

export type ReplyReference = {
    cid: string;
    uri: string;
};

export type Label = {};
Enter fullscreen mode Exit fullscreen mode

BlueSkyComments.vue

<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
/**
 *
 * Component Description:Desc
 *
 * @author Ismael Garcia <github@leamsigc.com>
 * @version 0.0.1
 *
 * @todo [ ] Test the component
 * @todo [ ] Integration test.
 * @todo [βœ”] Update the typescript.
 */
//Import types if you are using typescript 
import type { DisplayThread, Post, Thread, ThreadViewPost } from '~~/types/BlueSkyTypes'

interface Props {
    url: string
}

const storage = useStorage<Record<string, Record<string, unknown>>>('comments', {})
const props = defineProps<Props>();

const replies = ref<DisplayThread[]>([])
const post = ref<DisplayThread | null>(null)
const error = ref(false)

const sortedReplies = computed(() => {
    if (!replies.value.length) return []

    return replies.value.sort((a, b) => {
        return new Date(a.post.record.createdAt).getTime() -
            new Date(b.post.record.createdAt).getTime()
    })
})

const loadComments = async () => {
    if (!props.url) return

    try {
        const atUri = await resolvePostUrl(props.url)
        if (!atUri) {
            throw new Error("Failed to resolve AT URI")
        }
        const { thread } = await fetchReplies(atUri)
        if (thread?.replies) {
            replies.value = processReplies(thread.replies)
        }

        if (thread?.post) {
            const baseTreat = {
                ...thread,
                isReply: false,
                replies: []
            };
            post.value = baseTreat as unknown as DisplayThread;
        }
    } catch (e) {
        console.error('Error loading comments:', e)
        error.value = true
    }
}

const resolvePostUrl = async (postUrl: string) => {
    if (postUrl.startsWith("at:")) {
        return postUrl
    }

    if (!postUrl.startsWith("https://bsky.app/")) {
        return undefined
    }

    const urlParts = new URL(postUrl).pathname.split("/")
    let did = urlParts[2]
    const postId = urlParts[4]

    if (!did || !postId) {
        return undefined
    }

    if (!did.startsWith("did:")) {
        const cachedDid = getCache(`handle:${did}`)
        if (cachedDid) {
            did = cachedDid
        } else {
            try {
                const handleResolutionUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(did)}`
                const handleResponse = await fetch(handleResolutionUrl)

                if (!handleResponse.ok) {
                    throw new Error("Failed to resolve handle")
                }

                const handleData = await handleResponse.json()

                if (!handleData.did) {
                    return undefined
                }


                setCache(`handle:${did}`, handleData.did, 86400)
                did = handleData.did
            } catch (e) {
                const error = e as Error
                console.error(`Failed to resolve handle: ${error.message || error}`)
                return undefined
            }
        }
    }

    return `at://${did}/app.bsky.feed.post/${postId}`
}

const fetchReplies = async (atUri: string) => {
    const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}`
    const response = await fetch(apiUrl)
    if (!response.ok) {
        throw new Error("Failed to fetch replies")
    }
    return await response.json() as Thread
}

const processReplies = (replyThreads: ThreadViewPost[], isReply = false) => {
    return replyThreads.map(reply => {

        if (reply.post.record.text.trim() === "πŸ“Œ") {
            return null
        }

        (reply as unknown as DisplayThread).isReply = isReply
        if (reply.replies) {
            (reply as unknown as DisplayThread).replies = processReplies(reply.replies, true)
        }
        return reply as unknown as DisplayThread;
    }).filter(Boolean) as DisplayThread[];
}


const setCache = (key: string, value: unknown, ttl = 86400) => {
    const expiry = Date.now() + ttl * 1000
    const cacheData = { value, expiry }
    storage.value[key] = cacheData;
}

const getCache = (key: string) => {
    const cachedItem = storage.value[key];
    if (!cachedItem || !cachedItem.expiry) return null

    const { value, expiry } = cachedItem
    if (Date.now() > (expiry as number)) {
        storage.value[key] = {}
        return null
    }
    return value as string
}
watch(() => props.url, (newUrl) => {
    if (newUrl) {
        error.value = false
        replies.value = []
        loadComments()
    }
}, { immediate: true })

</script>

<template>
    <section class="container  border-x">
       <!--Remove this if you dont need it -->
        <UiSeparator class="my-4" show-buckle />
        <div class="text-center mb-20">
            <h2 class="text-lg text-primary text-center mb-2 tracking-wider">
                <slot name="title"> Comments </slot>
            </h2>

            <h3 class="text-3xl md:text-4xl text-center font-bold">
                <slot name="subtitle">
                    Be part of the conversation
                    <Icon name="logos:bluesky" />
                </slot>
            </h3>
        </div>
        <div v-if="url" class=" font-sans text-base text-left">
            <div v-if="error" class="p-4 text-red-600">Error loading comments.</div>
            <template v-else>
                <section class="border-t border-primary/10">
                    <BlueSkyComment v-if="post" :thread="post" />
                </section>
                <BlueSkyComment v-for="(thread, index) in sortedReplies" :key="index" :thread="thread" />
            </template>
        </div>

       <!--Remove this if you dont need it -->
        <UiSeparator class="my-4" show-buckle />
    </section>
</template>
<style scoped></style>
Enter fullscreen mode Exit fullscreen mode

BlueSkyComment.vue

<script lang="ts" setup>
import type { DisplayThread } from '~~/types/BlueSkyTypes';

/**
 *
 * Single comment component.
 *
 * @author Ismael Garcia <github@leamsigc.com>
 * @version 0.0.1
 *
 * @todo [ ] Test the component
 * @todo [ ] Integration test.
 * @todo [βœ”] Update the typescript.
 */
interface Props {
    thread: DisplayThread;
}


const props = defineProps<Props>();
const { thread } = toRefs(props);


const getAvatarUrl = (avatarUrl: string) => {
    return avatarUrl.replace("/img/avatar/", "/img/avatar_thumbnail/")
}

const getAuthorProfileUrl = (did: string) => {
    return `https://bsky.app/profile/${did}`
}

const getPostUrl = (did: string, uri: string) => {
    const postId = uri.split("/").pop()
    return `https://bsky.app/profile/${did}/post/${postId}`
}

const formatFullDate = (dateString: string) => {
    return new Date(dateString).toLocaleString()
}

const getAbbreviatedTime = (dateString: string) => {
    const date = new Date(dateString)
    const now = new Date()
    const diffMs = now.getTime() - date.getTime()
    const diffSeconds = Math.floor(diffMs / 1000)
    const diffMinutes = Math.floor(diffSeconds / 60)
    const diffHours = Math.floor(diffMinutes / 60)
    const diffDays = Math.floor(diffHours / 24)

    if (diffDays > 0) return `${diffDays}d`
    if (diffHours > 0) return `${diffHours}h`
    if (diffMinutes > 0) return `${diffMinutes}m`
    return `${diffSeconds}s`
}


</script>

<template>
    <div class="border-b border-primary/10 dark:border-primary/50 pt-4 last:border-b-0"
        :class="{ 'border-l ml-4 pt-1': thread.isReply, 'border-l border-gray-200 pb-10 ': !thread.isReply }">
        <section class="hover:opacity-60 cursor-pointer transition-all">
            <div class="flex items-center gap-3 px-4">

               <!--Remove this if you dont need it  or use regular image-->
                <UiAvatar>
                    <UiAvatarImage v-if="thread.post.author.avatar" :src="getAvatarUrl(thread.post.author.avatar)"
                        :alt="`${thread.post.author.handle}'s avatar`" />
                    <UiAvatarFallback>
                        {{ thread.post.author.displayName || thread.post.author.handle }}
                    </UiAvatarFallback>
                </UiAvatar>

                <NuxtLink :href="getAuthorProfileUrl(thread.post.author.did)" class="grid gap-1">
                    <p class="text-sm font-medium leading-none">
                        {{ thread.post.author.displayName || thread.post.author.handle }}
                    </p>
                    <p class="text-sm text-muted-foreground">
                        @{{ thread.post.author.handle }}
                    </p>
                </NuxtLink>
                <NuxtLink :href="getPostUrl(thread.post.author.did, thread.post.uri)" target="_blank" rel="ugc"
                    :title="formatFullDate(thread.post.record.createdAt)"
                    class="ml-auto text-xs font-light opacity-30 hover:underline">
                    {{ getAbbreviatedTime(thread.post.record.createdAt) }}
                </NuxtLink>
            </div>

            <div class="comment-body">
                <a :href="getPostUrl(thread.post.author.did, thread.post.uri)" target="_blank" rel="nofollow noopener"
                    class="block px-4">
                    <div class="py-1">{{ thread.post.record.text }}</div>
                    <div
                        class="flex justify-between items-center py-1 text-sm text-muted-foreground dark:text-primary-foreground/50 max-w-xs">
                        <div class="flex items-center gap-1">
                            <Icon name="lucide:reply" />
                            <span>{{ thread.post.replyCount || 0 }}</span>
                        </div>
                        <div class="flex items-center gap-1">
                            <Icon name="lucide:repeat" />
                            <span>{{ thread.post.repostCount || 0 }}</span>
                        </div>
                        <div class="flex items-center gap-1">
                            <Icon name="lucide:heart" />
                            <span>{{ thread.post.likeCount || 0 }}</span>
                        </div>
                    </div>
                </a>
            </div>
        </section>
        <BlueSkyComment v-for="reply in thread.replies" :key="reply.post.indexedAt" :thread="reply" />
    </div>
</template>
<style scoped></style>
Enter fullscreen mode Exit fullscreen mode

Comments.vue

<script lang="ts" setup>
/**
 *
 * Comments component for the application
 *
 * @author Ismael Garcia <github@leamsigc.com>
 * @version 0.0.1
 *
 * @todo [ ] Test the component
 * @todo [ ] Integration test.
 * @todo [βœ”] Update the typescript.
 */
const props = defineProps<{
    url: string;
}>();
const target = ref(null)
const targetIsVisible = ref(false)

const { stop } = useIntersectionObserver(
    target,
    ([entry], observerElement) => {
        if (entry?.isIntersecting) {
            targetIsVisible.value = entry?.isIntersecting
            stop()
        }
    },
)

</script>

<template>
    <section class="text-center" ref="target">

       <!--Use async component if  you need to use with out Nuxt  -->
        <LazyClientOnly fallback="Loading Comments..." fallback-tag="section">
            <LazyBlueSkyComments :url v-if="targetIsVisible" />
        </LazyClientOnly>
    </section>
</template>
<style scoped></style>
Enter fullscreen mode Exit fullscreen mode

How to use

<LazyComments url="https://bsky.app/profile/leamsigc.bsky.social/post/3ldp3y3irus2k" />

Enter fullscreen mode Exit fullscreen mode

You can view the example in

https://must-know-resources-for-programmers.giessen.dev/ all the way after the FAQ


Personal Recommendation of the week:
(postiz-app)[https://github.com/gitroomhq/postiz-app]

GitHub logo gitroomhq / postiz-app

πŸ“¨ The ultimate social media scheduling tool, with a bunch of AI πŸ€–

Postiz Logo

License

Your ultimate AI social media scheduling tool



Postiz: An alternative to: Buffer.com, Hypefury, Twitter Hunter, Etc...



Postiz offers everything you need to manage your social media posts,
build an audience, capture leads, and grow your business.

Instagram Youtube Dribbble Linkedin Reddit TikTok Facebook Pinterest Threads X X X X X


Explore the docs Β»

Register Β· Join Our Discord Β· X Β· Gitroom


hero.1.mp4

✨ Features

Image 1 Image 2
Image 3 Image 4

Intro

  • Schedule all your social media posts (many AI features)
  • Measure your work with analytics.
  • Collaborate with other team members to exchange or buy posts.
  • Invite your team members to collaborate, comment, and schedule posts.
  • At the moment there is no difference between the hosted version to the self-hosted version

Tech Stack

  • NX (Monorepo)
  • NextJS (React)
  • NestJS
  • Prisma (Default to PostgreSQL)
  • Redis (BullMQ)
  • Resend (email notifications)

Quick Start

To have the project up and running, please follow the Quick Start Guide

License

This repository's source code is available under the Apache 2.0 License.

g2







If you're looking for a manage version of Postiz you can sign up for the service, that way we help this amazing open source:

Signup for Postiz


Happy hacking!

Working on the audio version

The Loop VueJs Podcast

Podcast Episode

Top comments (0)