-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #110 from nrako/add_bsky_reactions
feat: Add support for BlueSky reactions on post
- Loading branch information
Showing
15 changed files
with
685 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.