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,479 @@
# typed: false
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Domain::BlueskyPostHelper, type: :helper do
# Mock policies to avoid Devise authentication errors
before do
allow(helper).to receive(:policy).and_return(
double("PostPolicy", view_file?: true),
)
allow(helper).to receive(:policy).and_return(
double("UserPolicy", view_file?: true),
)
end
describe "#render_bsky_post_facets" do
let(:post_text) do
"Hello @alice.bsky.social! Check out https://bsky.app/profile/bob.bsky.social/post/abc123 and #hashtag"
end
context "when facets is nil" do
it "returns the original text unchanged" do
result =
helper.render_bsky_post_facets("Simple text without facets", nil)
expect(result).to eq("Simple text without facets")
end
end
context "when facets is empty array" do
it "returns the original text unchanged" do
result = helper.render_bsky_post_facets(post_text, [])
expect(result).to eq(post_text)
end
end
context "with mention facets" do
let(:mentioned_user) do
create(
:domain_user_bluesky_user,
handle: "alice.bsky.social",
did: "did:plc:alice123",
)
end
let(:text) { "Hello @alice.bsky.social!" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 6,
"byteEnd" => 24,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
]
end
context "when mentioned user exists in database" do
before { mentioned_user } # Ensure user exists
it "renders inline_link_domain_user partial" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/inline_link_domain_user",
locals:
hash_including(
user: mentioned_user,
link_text: "@alice.bsky.social",
),
).and_return("[external user link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Hello [external user link]!")
end
end
context "when mentioned user does not exist in database" do
it "renders external link" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "@alice.bsky.social",
url: "https://bsky.app/profile/did:plc:alice123",
),
).and_return("[external user link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Hello [external user link]!")
end
end
end
context "with link facets" do
let(:linked_post) do
create(
:domain_post_bluesky_post,
rkey: "abc123",
at_uri: "at://did:plc:bob123/app.bsky.feed.post/abc123",
)
end
let(:text) do
"Check out https://bsky.app/profile/bob.bsky.social/post/abc123"
end
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 10,
"byteEnd" => 62,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#link",
"uri" => "https://bsky.app/profile/bob.bsky.social/post/abc123",
},
],
},
]
end
context "when linked post exists in database" do
before { linked_post } # Ensure post exists
it "renders inline_link_domain_post partial" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/inline_link_domain_post",
locals:
hash_including(
post: linked_post,
link_text:
"https://bsky.app/profile/bob.bsky.social/post/abc123",
),
).and_return("[external post link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Check out [external post link]")
end
end
context "when linked post does not exist in database" do
it "renders external link" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text:
"https://bsky.app/profile/bob.bsky.social/post/abc123",
url: "https://bsky.app/profile/bob.bsky.social/post/abc123",
),
).and_return("[external post link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Check out [external post link]")
end
end
context "when link is not a Bluesky post link" do
let(:text) { "Visit https://example.com" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 6,
"byteEnd" => 25,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#link",
"uri" => "https://example.com",
},
],
},
]
end
it "renders external link" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "https://example.com",
url: "https://example.com",
),
).and_return("[external link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Visit [external link]")
end
end
end
context "with hashtag facets" do
let(:text) { "Love this #art" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 10,
"byteEnd" => 14,
},
"features" => [
{ "$type" => "app.bsky.richtext.facet#tag", "tag" => "art" },
],
},
]
end
it "renders hashtag as external link to Bluesky search" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "#art",
url: "https://bsky.app/hashtag/art",
),
).and_return("[external hashtag link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Love this [external hashtag link]")
end
end
context "with multiple facets" do
let(:mentioned_user) do
create(
:domain_user_bluesky_user,
handle: "alice.bsky.social",
did: "did:plc:alice123",
)
end
let(:linked_post) do
create(
:domain_post_bluesky_post,
rkey: "abc123",
at_uri: "at://did:plc:bob123/app.bsky.feed.post/abc123",
)
end
let(:text) do
"Hey @alice.bsky.social, check https://bsky.app/profile/bob.bsky.social/post/abc123 #cool"
end
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 4,
"byteEnd" => 22,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 30,
"byteEnd" => 82,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#link",
"uri" => "https://bsky.app/profile/bob.bsky.social/post/abc123",
},
],
},
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 83,
"byteEnd" => 88,
},
"features" => [
{ "$type" => "app.bsky.richtext.facet#tag", "tag" => "cool" },
],
},
]
end
before do
mentioned_user
linked_post
end
it "processes all facets correctly" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/inline_link_domain_user",
locals: hash_including(user: mentioned_user),
).and_return("[external user link]")
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/inline_link_domain_post",
locals: hash_including(post: linked_post),
).and_return("[external post link]")
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "#cool",
url: "https://bsky.app/hashtag/cool",
),
).and_return("[external hashtag link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to include("[external user link]")
expect(result).to include("[external post link]")
expect(result).to include("[external hashtag link]")
end
end
context "with overlapping facets" do
let(:text) { "Check @alice.bsky.social" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 6,
"byteEnd" => 24,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 10,
"byteEnd" => 20,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#link",
"uri" => "https://example.com",
},
],
},
]
end
it "handles overlapping facets by processing in order and skipping conflicts" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "@alice.bsky.social",
url: "https://bsky.app/profile/did:plc:alice123",
),
).and_return("[external user link]")
result = helper.render_bsky_post_facets(text, facets)
# Should process the first facet and skip the overlapping one
expect(result).to eq("Check [external user link]")
end
end
context "with UTF-8 characters" do
let(:text) { "Hello 🌟 @alice.bsky.social! 🎉" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 11,
"byteEnd" => 29,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
]
end
it "correctly handles byte offsets with UTF-8 characters" do
expect(helper).to receive(:render).with(
partial: "domain/has_description_html/external_link",
locals:
hash_including(
link_text: "@alice.bsky.social",
url: "https://bsky.app/profile/did:plc:alice123",
),
).and_return("[external user link]")
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Hello 🌟 [external user link]! 🎉")
end
end
context "with malformed facet data" do
let(:text) { "Hello @alice.bsky.social!" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 6,
}, # Missing byteEnd
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
]
end
it "gracefully handles malformed facets and returns original text" do
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Hello @alice.bsky.social!")
end
end
context "with invalid byte ranges" do
let(:text) { "Short" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 0,
"byteEnd" => 100,
}, # Beyond text length
"features" => [
{
"$type" => "app.bsky.richtext.facet#mention",
"did" => "did:plc:alice123",
},
],
},
]
end
it "gracefully handles invalid byte ranges and returns original text" do
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Short")
end
end
context "with unknown facet types" do
let(:text) { "Hello world!" }
let(:facets) do
[
{
"$type" => "app.bsky.richtext.facet",
"index" => {
"byteStart" => 0,
"byteEnd" => 5,
},
"features" => [
{
"$type" => "app.bsky.richtext.facet#unknown",
"data" => "some_data",
},
],
},
]
end
it "ignores unknown facet types and returns original text" do
result = helper.render_bsky_post_facets(text, facets)
expect(result).to eq("Hello world!")
end
end
end
end