Skip to content

Commit

Permalink
Merge pull request #110 from nrako/add_bsky_reactions
Browse files Browse the repository at this point in the history
feat: Add support for BlueSky reactions on post
  • Loading branch information
nrako authored Dec 8, 2024
2 parents 1bbde48 + 85c8754 commit b153469
Show file tree
Hide file tree
Showing 15 changed files with 685 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.0.0
deno-version: v2.1.3

- name: Check types
run: deno task check
Expand Down
19 changes: 19 additions & 0 deletions components/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { TbBrandBluesky } from '@preact-icons/tb'

export function Avatar(
{ src, name }: { src?: string; name?: string },
) {
return src
? (
<img
src={src}
alt={`${name}'s avatar`}
class='w-8 h-8 rounded-full'
/>
)
: (
<div class='rounded-full w-8 h-8 bg-blue-100 flex items-center justify-center text-blue-600'>
{name?.charAt(0).toUpperCase() || <TbBrandBluesky />}
</div>
)
}
35 changes: 35 additions & 0 deletions components/BskyAuthor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { formatDateTime, getTimeAgo } from '../utils/intl.ts'

export function BskyAuthor({ author, indexedAt, postUri }: {
author: { handle: string; displayName?: string }
indexedAt: string
postUri?: string
}) {
return (
<div class='flex items-center space-x-2'>
<a
href={`https://bsky.app/profile/${author.handle}`}
target='_blank'
rel='noopener noreferrer'
class='hover:underline'
>
<span class='font-medium'>{author.displayName}</span>{' '}
<span class='text-gray-500 dark:text-gray-400'>
@{author.handle}
</span>
</a>
<span class='text-gray-500 dark:text-gray-400'>·</span>
{postUri && (
<a
href={postUri}
target='_blank'
rel='noopener noreferrer'
class='text-gray-500 dark:text-gray-400 hover:underline text-sm'
title={formatDateTime(new Date(indexedAt))}
>
{getTimeAgo(new Date(indexedAt))}
</a>
)}
</div>
)
}
124 changes: 124 additions & 0 deletions components/PostEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type {
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyEmbedVideo,
} from '@atproto/api'
import { Avatar } from './Avatar.tsx'
import { BskyAuthor } from './BskyAuthor.tsx'
import { ReactionBar } from './ReactionBar.tsx'
import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb'

// Type guard to check if the record has a text property
const hasText = (value: unknown): value is { text: string } =>
typeof value === 'object' && value !== null && 'text' in value

function EmbedContent({ postUri }: { postUri: string }) {
// Extract the post ID from the URI
const postId = postUri.split('/').pop()

// For AT protocol URIs, we need to extract the DID
const match = postUri.match(/at:\/\/([^/]+)\//)
const did = match ? match[1] : null

if (!postId || !did) {
console.warn('Invalid post URI:', postUri)
return null
}

return (
<a
href={`https://bsky.app/profile/${did}/post/${postId}`}
target='_blank'
rel='noopener noreferrer'
class='flex items-center gap-x-2 mt-2 p-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors'
>
<TbBrandBluesky class='inline-block' />View content on
Bluesky<TbArrowUpRight class='inline-block' />
</a>
)
}

function EmbedBskyPost({ embed }: { embed: AppBskyEmbedRecord.View }) {
const record = embed.record as AppBskyEmbedRecord.ViewRecord

return (
<div class='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 mb-2'>
<div class='flex items-center space-x-2 mb-2'>
<Avatar
src={record.author.avatar}
name={record.author.displayName ?? record.author.handle}
/>
<BskyAuthor
author={record.author}
indexedAt={record.indexedAt}
postUri={`https://bsky.app/profile/${record.author.handle}/post/${
record.uri.split('/').pop()
}`}
/>
</div>
{hasText(record.value) && (
<p class='mt-2 text-gray-600 dark:text-gray-300'>{record.value.text}</p>
)}

{typeof record.value === 'object' && record.value !== null &&
'embed' in record.value && <EmbedContent postUri={record.uri} />}

<div class='mt-2'>
<ReactionBar
likes={record.likeCount ?? 0}
reposts={(record.repostCount ?? 0) + (record.quoteCount ?? 0)}
replies={record.replyCount ?? 0}
postUri={`https://bsky.app/profile/${record.author.handle}/post/${
record.uri.split('/').pop()
}`}
/>
</div>
</div>
)
}

export function PostEmbed({ embed, postUri }: {
embed:
| AppBskyEmbedImages.View
| AppBskyEmbedVideo.View
| AppBskyEmbedExternal.View
| AppBskyEmbedRecord.View
| AppBskyEmbedRecordWithMedia.View
| { $type: string }
postUri: string
}) {
if (!embed || typeof embed.$type !== 'string') return null

const type = embed.$type.replace('#view', '')

// Handle BlueSky post embeds
if (type === 'app.bsky.embed.record' && 'record' in embed) {
// Handle not found records
if (
typeof embed.record === 'object' && embed.record !== null &&
'$type' in embed.record &&
embed.record.$type === 'app.bsky.embed.record#viewNotFound'
) {
return (
<div class='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 mb-2 text-gray-500 dark:text-gray-400 text-sm italic'>
Post not found
</div>
)
}

// Handle list view
if (
typeof embed.record === 'object' && embed.record !== null &&
'$type' in embed.record &&
embed.record.$type === 'app.bsky.graph.defs#listView'
) {
return <EmbedContent postUri={postUri} />
}
return <EmbedBskyPost embed={embed as AppBskyEmbedRecord.View} />
}

// For all other types of embeds, show a link to view on BlueSky
return <EmbedContent postUri={postUri} />
}
24 changes: 24 additions & 0 deletions components/ReactionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ReactionCount } from './ReactionCount.tsx'

export function ReactionBar(
{ likes, reposts, replies, postUri }: {
likes: number
reposts: number
replies: number
postUri: string
},
) {
return (
<a
href={postUri}
target='_blank'
rel='noopener noreferrer'
class='flex gap-4 opacity-90 hover:opacity-100 transition-opacity'
title='Interact with this post on Bluesky'
>
<ReactionCount count={likes} label='like' />
<ReactionCount count={reposts} label='repost' />
<ReactionCount count={replies} label='reply' />
</a>
)
}
28 changes: 28 additions & 0 deletions components/ReactionCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import TbHeart from '@preact-icons/tb/TbHeart'
import TbMessageCircle from '@preact-icons/tb/TbMessageCircle'
import TbRepeat from '@preact-icons/tb/TbRepeat'
import { formatCount } from '../utils/intl.ts'

export function ReactionCount(
{ count, label }: { count: number; label: string },
) {
const Icon = () => {
switch (label) {
case 'like':
return <TbHeart />
case 'repost':
return <TbRepeat />
case 'reply':
return <TbMessageCircle />
default:
return null
}
}

return (
<div class='flex items-center gap-1 text-gray-500'>
<Icon />
<span>{formatCount(count)}</span>
</div>
)
}
91 changes: 91 additions & 0 deletions components/Reply.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { AppBskyFeedDefs } from '@atproto/api'
import { AppBskyFeedPost } from '@atproto/api'
import { Avatar } from './Avatar.tsx'
import { BskyAuthor } from './BskyAuthor.tsx'
import { PostEmbed } from './PostEmbed.tsx'
import { ReactionBar } from './ReactionBar.tsx'
import { TbArrowUpRight, TbBrandBluesky } from '@preact-icons/tb'

export function Reply(
{ thread }: {
thread: AppBskyFeedDefs.ThreadViewPost
},
) {
const post = thread.post
const replies =
thread.replies?.filter((r): r is AppBskyFeedDefs.ThreadViewPost =>
!('notFound' in r) && !('blocked' in r)
).sort((a, b) =>
new Date(a.post.indexedAt).getTime() -
new Date(b.post.indexedAt).getTime()
) ?? []

const hasMoreReplies = (post.replyCount ?? 0) > (replies?.length ?? 0)
const showThread = replies.length > 0 || hasMoreReplies

return (
<div class='relative'>
<div class='flex space-x-3'>
<div class='flex-shrink-0 relative w-8'>
<a
href={`https://bsky.app/profile/${post.author.handle}`}
target='_blank'
rel='noopener noreferrer'
>
<Avatar
src={post.author.avatar}
name={post.author.displayName}
/>
</a>
{showThread && (
<div class='absolute left-1/2 top-8 -bottom-4 w-0.5 bg-gradient-to-b from-gray-200 to-transparent dark:from-gray-700' />
)}
</div>
<div class='flex-grow min-w-0'>
<BskyAuthor
author={post.author}
indexedAt={post.indexedAt}
postUri={`https://bsky.app/profile/${post.author.handle}/post/${
post.uri.split('/').pop()
}`}
/>
<p class='mt-1 text-gray-600 dark:text-gray-300'>
{AppBskyFeedPost.isRecord(post.record) ? post.record.text : ''}
</p>
{post.embed && <PostEmbed embed={post.embed} postUri={post.uri} />}
<div class='mt-2'>
<ReactionBar
likes={post.likeCount ?? 0}
reposts={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
replies={post.replyCount ?? 0}
postUri={`https://bsky.app/profile/${post.author.handle}/post/${
post.uri.split('/').pop()
}`}
/>
</div>
</div>
</div>

{showThread && (
<div class='mt-4 space-y-4 ml-4 pl-[15px]'>
{replies.map((reply) => (
<Reply key={reply.post.uri} thread={reply} />
))}
{hasMoreReplies && (
<a
href={`https://bsky.app/profile/${post.author.handle}/post/${
post.uri.split('/').pop()
}`}
target='_blank'
rel='noopener noreferrer'
class='flex items-center gap-x-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300'
>
<TbBrandBluesky class='inline-block' />Continue thread on
BlueSky<TbArrowUpRight class='inline-block' />
</a>
)}
</div>
)}
</div>
)
}
9 changes: 8 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"dev": "deno run -A --watch=static/,routes/ dev.ts --env-file .env ",
"build": "deno run -A dev.ts build",
"start": "deno run -A main.ts",
"update": "deno run -A -r jsr:@fresh/update ."
"update": "deno run -A -r jsr:@fresh/update .",
"bsky-uri": {
"description": "Utility to retrieve the `at:` URI for a given bsky post URL. Useful to set the `site.bskyUri` in blog post's frontmatter.",
"command": "deno run -A utils/bsky-uri.ts"
}
},
"lint": {
"rules": {
Expand All @@ -18,6 +22,7 @@
"**/_fresh/*"
],
"imports": {
"@atproto/api": "npm:@atproto/api@^0.13.19",
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.48",
"@fresh/plugin-tailwind": "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7",
"@preact-icons/gr": "jsr:@preact-icons/gr@^1.0.12",
Expand All @@ -29,6 +34,8 @@
"@std/fs": "jsr:@std/fs@^1.0.6",
"@std/path": "jsr:@std/path@^1.0.8",
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.15",
"@jridgewell/gen-mapping": "npm:@jridgewell/gen-mapping@^0.3.5",
"@jridgewell/trace-mapping": "npm:@jridgewell/trace-mapping@^0.3.25",
"fresh": "jsr:@fresh/core@^2.0.0-alpha.22",
"myst-frontmatter": "npm:myst-frontmatter@^1.7.5",
"preact": "npm:preact@10.24.2",
Expand Down
Loading

0 comments on commit b153469

Please sign in to comment.