Add Bluesky post helper with facet rendering and external link support

- Add BlueskyPostHelper for rendering Bluesky post facets (mentions, links, hashtags)
- Implement facet parsing and rendering with proper styling
- Add external link partial for non-Bluesky URLs
- Update DisplayedFile and PostFiles components to handle Bluesky posts
- Add comprehensive test coverage for helper methods
- Update scan user job to handle Bluesky-specific data
This commit is contained in:
Dylan Knutson
2025-08-12 20:43:08 +00:00
parent d08c896d97
commit ad0675a9aa
13 changed files with 808 additions and 6 deletions

View File

@@ -0,0 +1,183 @@
# typed: strict
# frozen_string_literal: true
module Domain::BlueskyPostHelper
extend T::Sig
include ActionView::Helpers::UrlHelper
include HelpersInterface
sig do
params(text: String, facets: T.nilable(T::Array[T.untyped])).returns(
T.nilable(String),
)
end
def render_bsky_post_facets(text, facets = nil)
return text if facets.blank?
facets =
begin
facets.map { |facet| Bluesky::Text::Facet.from_hash(facet) }
rescue => e
Rails.logger.error("error parsing Bluesky facets: #{e.message}")
return text
end
result_parts = []
last_end = 0
# Sort facets by start position to handle them in order
sorted_facets = facets.sort_by(&:byteStart)
sorted_facets.each do |facet|
if facet.byteStart < 0 || facet.byteEnd <= facet.byteStart ||
facet.byteEnd > text.bytesize
next
end
# Skip overlapping facets
next if facet.byteStart < last_end
# Add text before this facet
if facet.byteStart > last_end
before_text = text.byteslice(last_end, facet.byteStart - last_end)
result_parts << before_text if before_text
end
# Extract the facet text using byteslice for accurate character extraction
facet_text =
text.byteslice(facet.byteStart, facet.byteEnd - facet.byteStart)
next unless facet_text # Skip if byteslice returns nil
# Process the facet
rendered_facet = render_facet(facet, facet_text)
result_parts << rendered_facet
last_end = facet.byteEnd
end
# Add remaining text after the last facet
if last_end < text.bytesize
remaining_text = text.byteslice(last_end, text.bytesize - last_end)
result_parts << remaining_text if remaining_text
end
result_parts.join.html_safe
end
private
sig do
params(facet: Bluesky::Text::Facet, facet_text: String).returns(String)
end
def render_facet(facet, facet_text)
return facet_text unless facet.features.any?
# Process the first feature (Bluesky facets typically have one feature per facet)
feature = facet.features.first
return facet_text unless feature.is_a?(Bluesky::Text::FacetFeature)
case feature
when Bluesky::Text::FacetFeatureMention
render_mention_facet(feature, facet_text)
when Bluesky::Text::FacetFeatureURI
render_link_facet(feature, facet_text)
when Bluesky::Text::FacetFeatureTag
render_tag_facet(feature, facet_text)
else
# Unknown facet type, return original text
facet_text
end
end
sig do
params(
feature: Bluesky::Text::FacetFeatureMention,
facet_text: String,
).returns(String)
end
def render_mention_facet(feature, facet_text)
did = feature.did
return facet_text unless did.present?
# Try to find the user in the database
user = Domain::User::BlueskyUser.find_by(did: did)
if user
# Render the inline user partial
render(
partial: "domain/has_description_html/inline_link_domain_user",
locals: {
user: user,
link_text: facet_text,
visual_style: "description-section-link-light",
},
)
else
# Render external link to Bluesky profile
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: "https://bsky.app/profile/#{did}",
},
)
end
end
sig do
params(feature: Bluesky::Text::FacetFeatureURI, facet_text: String).returns(
String,
)
end
def render_link_facet(feature, facet_text)
uri = feature.uri
return facet_text unless uri.present?
# Check if this is a Bluesky post link
if uri.match(%r{https://bsky\.app/profile/[^/]+/post/([^/?]+)})
rkey = $1
# Try to find the post in the database
post = Domain::Post::BlueskyPost.find_by(rkey: rkey)
if post
# Render the inline post partial
return(
render(
partial: "domain/has_description_html/inline_link_domain_post",
locals: {
post: post,
link_text: facet_text,
visual_style: "description-section-link-light",
},
)
)
end
end
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: uri,
},
)
end
sig do
params(feature: Bluesky::Text::FacetFeatureTag, facet_text: String).returns(
String,
)
end
def render_tag_facet(feature, facet_text)
tag = feature.tag
return facet_text unless tag.present?
render(
partial: "domain/has_description_html/external_link",
locals: {
link_text: facet_text,
url: "https://bsky.app/hashtag/#{tag}",
},
)
end
end

View File

@@ -259,6 +259,13 @@ module Domain::DescriptionsHelper
"rounded-md px-1 transition-all",
"inline-flex items-center align-bottom",
].join(" ")
when "description-section-link-light"
[
"text-sky-600 border-slate-300",
"border border-transparent hover:border-slate-500 hover:text-sky-800 hover:bg-slate-200",
"rounded-md px-1 transition-all",
"inline-flex items-center align-bottom",
].join(" ")
else
"blue-link"
end

View File

@@ -33,6 +33,8 @@ function fileStateContent(fileState: FileData['fileState']) {
switch (fileState) {
case 'pending':
return 'File pending download';
case 'terminal_error':
return 'File download failed';
}
return 'No file content available';

View File

@@ -19,7 +19,6 @@ export interface FileData {
hasContent: boolean;
index: number;
contentHtml?: string;
fileDetailsHtml?: string;
fileDetails?: FileDetailsProps;
}
@@ -93,6 +92,14 @@ export const PostFiles: React.FC<PostFilesProps> = ({
return (
<section id="file-display-section">
{files.length == 0 && (
<div className="flex grow justify-center text-slate-500">
<div className="flex items-center gap-2">
<i className="fa-solid fa-file-circle-exclamation"></i>
No files
</div>
</div>
)}
{files.length > 1 && (
<FileCarousel
files={files}

View File

@@ -115,20 +115,31 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
logger.info(
format_tags(
"avatar url changed, creating new avatar",
make_tags(avatar:),
make_arg_tag(avatar),
),
)
defer_job(Domain::UserAvatarJob, { avatar: avatar })
defer_job(
Domain::UserAvatarJob,
{ avatar: avatar },
{ queue: "bluesky" },
)
elsif existing_avatar.state_pending?
defer_job(Domain::UserAvatarJob, { avatar: existing_avatar })
defer_job(
Domain::UserAvatarJob,
{ avatar: existing_avatar },
{ queue: "bluesky" },
)
logger.info(format_tags("re-enqueued pending avatar download"))
end
else
# Create new avatar and enqueue download
avatar = user.avatars.create!(url_str: avatar_url)
defer_job(Domain::UserAvatarJob, { avatar: })
defer_job(Domain::UserAvatarJob, { avatar: }, { queue: "bluesky" })
logger.info(
format_tags("created avatar and enqueued download", make_tags(avatar:)),
format_tags(
"created avatar and enqueued download",
make_arg_tag(avatar),
),
)
end
end

5
app/lib/bluesky.rb Normal file
View File

@@ -0,0 +1,5 @@
# typed: strict
# frozen_string_literal: true
module Bluesky
end

5
app/lib/bluesky/text.rb Normal file
View File

@@ -0,0 +1,5 @@
# typed: strict
# frozen_string_literal: true
module Bluesky::Text
end

View File

@@ -0,0 +1,22 @@
# typed: strict
# frozen_string_literal: true
class Bluesky::Text::Facet < T::ImmutableStruct
extend T::Sig
const :byteStart, Integer
const :byteEnd, Integer
const :features, T::Array[Bluesky::Text::FacetFeature]
sig { params(hash: T::Hash[String, T.untyped]).returns(Bluesky::Text::Facet) }
def self.from_hash(hash)
new(
byteStart: hash["index"]["byteStart"],
byteEnd: hash["index"]["byteEnd"],
features:
hash["features"].map do |feature|
Bluesky::Text::FacetFeature.from_hash(feature)
end,
)
end
end

View File

@@ -0,0 +1,56 @@
# typed: strict
# frozen_string_literal: true
module Bluesky::Text
class FacetFeature
extend T::Sig
extend T::Helpers
abstract!
sig(:final) do
params(hash: T::Hash[String, T.untyped]).returns(FacetFeature)
end
def self.from_hash(hash)
case hash["$type"]
when "app.bsky.richtext.facet#mention"
FacetFeatureMention.new(hash)
when "app.bsky.richtext.facet#link"
FacetFeatureURI.new(hash)
when "app.bsky.richtext.facet#tag"
FacetFeatureTag.new(hash)
else
raise "Unknown facet feature type: #{hash["$type"]}"
end
end
end
class FacetFeatureURI < FacetFeature
sig { returns(String) }
attr_reader :uri
sig { params(hash: T::Hash[String, T.untyped]).void }
def initialize(hash)
@uri = T.let(hash["uri"], String)
end
end
class FacetFeatureMention < FacetFeature
sig { returns(String) }
attr_reader :did
sig { params(hash: T::Hash[String, T.untyped]).void }
def initialize(hash)
@did = T.let(hash["did"], String)
end
end
class FacetFeatureTag < FacetFeature
sig { returns(String) }
attr_reader :tag
sig { params(hash: T::Hash[String, T.untyped]).void }
def initialize(hash)
@tag = T.let(hash["tag"], String)
end
end
end

View File

@@ -0,0 +1,13 @@
<span class="flex items-center gap-1 text-slate-500 hover:text-slate-700">
<%= link_to(
local_assigns[:url],
target: "_blank",
rel: "noopener noreferrer nofollow",
class: "hover:underline decoration-dotted",
) do %>
<i class="fa-solid fa-external-link-alt"></i>
<span class="text-blue-500">
<%= local_assigns[:link_text] %>
</span>
<% end %>
</span>

View File

@@ -0,0 +1,11 @@
<% description_html = render_bsky_post_facets(
post.text,
post.post_raw&.dig("value", "facets")
) %>
<%= sky_section_tag("Description") do %>
<% if description_html %>
<%= description_html %>
<% else %>
<div class="p-4 text-center text-slate-500">No description available</div>
<% end %>
<% end %>