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:
183
app/helpers/domain/bluesky_post_helper.rb
Normal file
183
app/helpers/domain/bluesky_post_helper.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
5
app/lib/bluesky.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Bluesky
|
||||
end
|
||||
5
app/lib/bluesky/text.rb
Normal file
5
app/lib/bluesky/text.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Bluesky::Text
|
||||
end
|
||||
22
app/lib/bluesky/text/facet.rb
Normal file
22
app/lib/bluesky/text/facet.rb
Normal 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
|
||||
56
app/lib/bluesky/text/facet_feature.rb
Normal file
56
app/lib/bluesky/text/facet_feature.rb
Normal 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
|
||||
@@ -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>
|
||||
11
app/views/domain/posts/bsky/_section_description.html.erb
Normal file
11
app/views/domain/posts/bsky/_section_description.html.erb
Normal 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 %>
|
||||
Reference in New Issue
Block a user