From 4bad25ef98dd4fbd0eece3cabc417d111b028bb0 Mon Sep 17 00:00:00 2001 From: Justin Ekis <5169520+jekis913@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:02:22 -0500 Subject: [PATCH 1/3] Fix opening Bluesky facet links Preserve rich-text link facets in the shared status model so Open Links can use descriptive linked text instead of relying on visible URLs. Persist facet links in timeline caches and cover mapping, presentation, and cache round trips with tests. Co-Authored-By: Codex --- core/include/fastsm/models/status.hpp | 8 ++++++++ core/src/platform/bluesky/bluesky_map.cpp | 6 +++++- core/src/presentation/status_presenter.cpp | 4 ++++ core/src/store/timeline_codec.cpp | 11 ++++++++++- docs/changelog.txt | 1 + tests/test_bluesky_map.cpp | 15 +++++++++++---- tests/test_models.cpp | 3 +++ tests/test_presentation.cpp | 14 ++++++++++++++ 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/core/include/fastsm/models/status.hpp b/core/include/fastsm/models/status.hpp index 7058ee4..f77a0a7 100644 --- a/core/include/fastsm/models/status.hpp +++ b/core/include/fastsm/models/status.hpp @@ -20,6 +20,13 @@ struct StatusFilterMatch { bool hide = false; // filter_action: true = "hide", false = "warn" }; +// A semantically linked span in a post's plain text. Bluesky carries these as +// rich-text facets, where the visible label may differ from the destination. +struct StatusTextLink { + std::string text; + std::string url; +}; + // A post/status. Boosts and quotes reference another Status via shared_ptr to // break the recursion (the Swift core uses a boxed indirect enum). struct Status { @@ -51,6 +58,7 @@ struct Status { std::shared_ptr quote; // the quoted status std::vector media_attachments; std::vector mentions; + std::vector text_links; std::vector tags; // hashtag names in this post (no '#'), Mastodon std::optional visibility; // Mastodon only std::optional spoiler_text; diff --git a/core/src/platform/bluesky/bluesky_map.cpp b/core/src/platform/bluesky/bluesky_map.cpp index 4c122ba..5596764 100644 --- a/core/src/platform/bluesky/bluesky_map.cpp +++ b/core/src/platform/bluesky/bluesky_map.cpp @@ -78,7 +78,7 @@ std::shared_ptr map_quote_record(const json& rec) { return std::make_shared(std::move(q)); } -// Populate mentions/tags from a post record's richtext `facets` (byte-range +// Populate links/mentions/tags from a post record's richtext `facets` (byte-range // annotations over the plain text). Mention facets carry the DID; the handle is // the sliced display text ("@handle"). Tag facets carry the bare tag. void map_facets(const json& record, Status& s) { @@ -113,6 +113,10 @@ void map_facets(const json& record, Status& s) { tag = slice.substr(1); if (!tag.empty()) s.tags.push_back(std::move(tag)); + } else if (type == "app.bsky.richtext.facet#link") { + std::string uri = str(feat, "uri"); + if (!uri.empty()) + s.text_links.push_back({slice, std::move(uri)}); } } } diff --git a/core/src/presentation/status_presenter.cpp b/core/src/presentation/status_presenter.cpp index b07aed0..d0883d3 100644 --- a/core/src/presentation/status_presenter.cpp +++ b/core/src/presentation/status_presenter.cpp @@ -538,6 +538,8 @@ std::vector post_links(const Status& status) { // Links embedded in the text. A Mastodon post carries HTML in `content`; a // Bluesky post has none, so fall back to scanning its plain `text`. std::vector> text_links; + for (const auto& link : s.text_links) + text_links.push_back({link.text, link.url}); anchors(s.content, text_links); if (text_links.empty()) { std::vector urls; @@ -573,6 +575,8 @@ std::vector post_links(const Status& status) { std::vector post_text_link_urls(const Status& status) { const Status& s = status.display_status(); // unwrap a boost std::vector> text_links; + for (const auto& link : s.text_links) + text_links.push_back({link.text, link.url}); anchors(s.content, text_links); // HTML anchors (skips @mention / #hashtag) std::vector out; if (!text_links.empty()) { diff --git a/core/src/store/timeline_codec.cpp b/core/src/store/timeline_codec.cpp index a1cb03e..5436f85 100644 --- a/core/src/store/timeline_codec.cpp +++ b/core/src/store/timeline_codec.cpp @@ -10,7 +10,8 @@ namespace { // cleanly (a magic mismatch -> empty) instead of being read with a mismatched // reader. v2 added Status::url. v6 added Notification group_key + notifications_count. // v7 added Status::filtered + tags. v9 added Bluesky reply-parent metadata. -constexpr char kMagic[4] = {'F', 'S', 'C', 'A'}; +// v10 added Status::text_links. +constexpr char kMagic[4] = {'F', 'S', 'C', 'B'}; // Guard against runaway recursion if a file is ever corrupt/misaligned: boost/ // quote nesting is shallow in practice. constexpr int kMaxStatusDepth = 24; @@ -289,6 +290,11 @@ void write_status(Writer& w, const Status& s) { w.u32(static_cast(s.tags.size())); for (const auto& t : s.tags) w.str(t); // else "Follow hashtag" pre-fills blank for cached posts + w.u32(static_cast(s.text_links.size())); + for (const auto& link : s.text_links) { + w.str(link.text); + w.str(link.url); + } } Status read_status(Reader& r, int depth = 0) { @@ -359,6 +365,9 @@ Status read_status(Reader& r, int depth = 0) { const std::uint32_t tag_n = r.u32(); for (std::uint32_t i = 0; i < tag_n && r.ok; ++i) s.tags.push_back(r.str()); + const std::uint32_t link_n = r.u32(); + for (std::uint32_t i = 0; i < link_n && r.ok; ++i) + s.text_links.push_back({r.str(), r.str()}); return s; } diff --git a/docs/changelog.txt b/docs/changelog.txt index 00d98a1..95dbfed 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -7,6 +7,7 @@ FastSMRW changelog - New: send someone a direct message from their profile, from the post menu, or with the D key — it opens a new post already addressed to them and set to go only to them (Mastodon). - Fixed: on the Mac and iOS, the cursor now starts at the end of a post you are editing instead of in front of it. - Fixed: the ages shown on posts (like "2m" or "1h") now keep counting up while you sit on a timeline, instead of freezing until something new arrives. +- Fixed: Open Links now finds Bluesky links hidden behind descriptive text, not just web addresses written out in the post. 0.5.5 ----- diff --git a/tests/test_bluesky_map.cpp b/tests/test_bluesky_map.cpp index ce1aff8..5672472 100644 --- a/tests/test_bluesky_map.cpp +++ b/tests/test_bluesky_map.cpp @@ -160,18 +160,20 @@ void test_bluesky_notification_mapping() { } void test_bluesky_facet_mapping() { - // A post whose record carries mention + tag facets populates mentions/tags. + // A post whose record carries link + mention + tag facets preserves all three. const char* kFaceted = R"JSON({ "post": { "uri": "at://did:plc:x/app.bsky.feed.post/2", "cid": "cid2", "author": { "did": "did:plc:x", "handle": "bob.test", "displayName": "Bob" }, "record": { - "text": "hi @alice.bsky.social #a11y", + "text": "read this hi @alice.bsky.social #a11y", "createdAt": "2024-06-28T10:00:00Z", "facets": [ - { "index": { "byteStart": 3, "byteEnd": 21 }, + { "index": { "byteStart": 5, "byteEnd": 9 }, + "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com/article" } ] }, + { "index": { "byteStart": 13, "byteEnd": 31 }, "features": [ { "$type": "app.bsky.richtext.facet#mention", "did": "did:plc:alice" } ] }, - { "index": { "byteStart": 22, "byteEnd": 27 }, + { "index": { "byteStart": 32, "byteEnd": 37 }, "features": [ { "$type": "app.bsky.richtext.facet#tag", "tag": "a11y" } ] } ] }, @@ -179,6 +181,11 @@ void test_bluesky_facet_mapping() { } })JSON"; const Status s = bluesky::map_feed_item(json::parse(kFaceted)); + CHECK_EQ(s.text_links.size(), size_t(1)); + if (!s.text_links.empty()) { + CHECK_EQ(s.text_links[0].text, std::string("this")); + CHECK_EQ(s.text_links[0].url, std::string("https://example.com/article")); + } CHECK_EQ(s.mentions.size(), size_t(1)); if (!s.mentions.empty()) { CHECK_EQ(s.mentions[0].id, std::string("did:plc:alice")); diff --git a/tests/test_models.cpp b/tests/test_models.cpp index fdded1a..18a450a 100644 --- a/tests/test_models.cpp +++ b/tests/test_models.cpp @@ -31,6 +31,7 @@ static Status sample_inner() { s.platform = Platform::Mastodon; s.tags.push_back("cats"); s.tags.push_back("welcome"); + s.text_links.push_back({"welcome", "https://example.com/article"}); s.filtered.push_back({"Spoilers", true}); // a "hide" filter match s.filtered.push_back({"Politics", false}); // a "warn" filter match @@ -85,6 +86,8 @@ void test_status_roundtrip() { CHECK(back.reblog->filtered[1].hide == false); CHECK_EQ(back.reblog->tags.size(), size_t(2)); CHECK_EQ(back.reblog->tags[1], std::string("welcome")); + CHECK_EQ(back.reblog->text_links.size(), size_t(1)); + CHECK_EQ(back.reblog->text_links[0].url, std::string("https://example.com/article")); CHECK(back.reblog->visibility.value() == Visibility::Unlisted); CHECK_EQ(back.reblog->media_attachments.size(), size_t(1)); CHECK(back.reblog->media_attachments[0].type == MediaAttachment::Kind::Image); diff --git a/tests/test_presentation.cpp b/tests/test_presentation.cpp index 6917f05..34080ff 100644 --- a/tests/test_presentation.cpp +++ b/tests/test_presentation.cpp @@ -328,6 +328,20 @@ void test_post_links() { CHECK_EQ(bl[0].url, std::string("https://bsky.example/x")); CHECK_EQ(bl[1].title, std::string("Open this post in browser")); + // Bluesky facets can link ordinary display text whose destination is not + // visible in the post body. + Status faceted; + faceted.text = "read this article"; + faceted.text_links.push_back({"this article", "https://example.com/article"}); + std::vector fl = present::post_links(faceted); + CHECK_EQ(fl.size(), static_cast(1)); + CHECK_EQ(fl[0].title, std::string("this article")); + CHECK_EQ(fl[0].url, std::string("https://example.com/article")); + const std::vector facet_urls = present::post_text_link_urls(faceted); + CHECK_EQ(facet_urls.size(), static_cast(1)); + if (!facet_urls.empty()) + CHECK_EQ(facet_urls[0], std::string("https://example.com/article")); + // A post with only its own URL still offers that one link. Status plain; plain.text = "just some text, no links here."; From dcddd170e75f48aec9954af99cdd5ab6d64dc1e8 Mon Sep 17 00:00:00 2001 From: Justin Ekis <5169520+jekis913@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:15:25 -0500 Subject: [PATCH 2/3] Handle Bluesky facet links consistently Map rich-text facets on notification statuses and merge faceted links with unfaceted literal URLs without duplicate destinations. Extend coverage for notification links, UTF-8 byte offsets, mixed link sources, deduplication, and cached link labels. Co-Authored-By: Codex --- core/src/platform/bluesky/bluesky_map.cpp | 1 + core/src/presentation/status_presenter.cpp | 20 ++++++++-------- tests/test_bluesky_map.cpp | 28 ++++++++++++++++------ tests/test_models.cpp | 1 + tests/test_presentation.cpp | 11 +++++---- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/core/src/platform/bluesky/bluesky_map.cpp b/core/src/platform/bluesky/bluesky_map.cpp index 5596764..bdacde3 100644 --- a/core/src/platform/bluesky/bluesky_map.cpp +++ b/core/src/platform/bluesky/bluesky_map.cpp @@ -266,6 +266,7 @@ Notification map_notification(const json& j) { if (const json* record = obj(j, "record")) { s.text = str(*record, "text"); s.created_at = util::parse_iso8601(str(*record, "createdAt")).value_or(n.created_at); + map_facets(*record, s); } if (s.created_at == 0) s.created_at = n.created_at; diff --git a/core/src/presentation/status_presenter.cpp b/core/src/presentation/status_presenter.cpp index d0883d3..feb02f4 100644 --- a/core/src/presentation/status_presenter.cpp +++ b/core/src/presentation/status_presenter.cpp @@ -541,12 +541,10 @@ std::vector post_links(const Status& status) { for (const auto& link : s.text_links) text_links.push_back({link.text, link.url}); anchors(s.content, text_links); - if (text_links.empty()) { - std::vector urls; - find_urls_in_text(s.text, urls); - for (const auto& u : urls) - text_links.push_back({std::string{}, u}); - } + std::vector urls; + find_urls_in_text(s.text, urls); + for (const auto& u : urls) + text_links.push_back({std::string{}, u}); for (const auto& [text, url] : text_links) { // The link-preview card's title decorates its matching text link. if (has_card && url == s.card->url && !s.card->title.empty()) @@ -579,11 +577,13 @@ std::vector post_text_link_urls(const Status& status) { text_links.push_back({link.text, link.url}); anchors(s.content, text_links); // HTML anchors (skips @mention / #hashtag) std::vector out; - if (!text_links.empty()) { - for (const auto& [text, url] : text_links) + std::vector literal_urls; + find_urls_in_text(s.text, literal_urls); + for (const auto& url : literal_urls) + text_links.push_back({std::string{}, url}); + for (const auto& [text, url] : text_links) { + if (std::find(out.begin(), out.end(), url) == out.end()) out.push_back(url); - } else { - find_urls_in_text(s.text, out); // Bluesky: URLs live in plain text } return out; } diff --git a/tests/test_bluesky_map.cpp b/tests/test_bluesky_map.cpp index 5672472..52fec15 100644 --- a/tests/test_bluesky_map.cpp +++ b/tests/test_bluesky_map.cpp @@ -142,20 +142,34 @@ void test_bluesky_notification_mapping() { CHECK_EQ(like.account.display_name, std::string("Dana")); CHECK(like.status == nullptr); // like/repost carry no incoming post - // A reply notification reads as a Mention and carries the incoming post text. + // A reply notification reads as a Mention and carries the incoming post text + // and rich-text link facets. const char* kReply = R"JSON({ "uri": "at://did:plc:eve/app.bsky.feed.post/r1", "cid": "c2", "author": { "did": "did:plc:eve", "handle": "eve.test", "displayName": "Eve" }, "reason": "reply", - "record": { "text": "@me hi there", "createdAt": "2024-06-28T13:00:00.000Z" }, + "record": { + "text": "@me read this", + "createdAt": "2024-06-28T13:00:00.000Z", + "facets": [ + { "index": { "byteStart": 9, "byteEnd": 13 }, + "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com/notification" } ] } + ] + }, "indexedAt": "2024-06-28T13:00:01.000Z" })JSON"; const Notification reply = bluesky::map_notification(json::parse(kReply)); CHECK(reply.type == Notification::Kind::Mention); // reply/quote read as mention CHECK(reply.status != nullptr); if (reply.status) { - CHECK_EQ(reply.status->text, std::string("@me hi there")); + CHECK_EQ(reply.status->text, std::string("@me read this")); CHECK_EQ(reply.status->id, std::string("at://did:plc:eve/app.bsky.feed.post/r1")); + CHECK_EQ(reply.status->text_links.size(), size_t(1)); + if (!reply.status->text_links.empty()) { + CHECK_EQ(reply.status->text_links[0].text, std::string("this")); + CHECK_EQ(reply.status->text_links[0].url, + std::string("https://example.com/notification")); + } } } @@ -166,14 +180,14 @@ void test_bluesky_facet_mapping() { "uri": "at://did:plc:x/app.bsky.feed.post/2", "cid": "cid2", "author": { "did": "did:plc:x", "handle": "bob.test", "displayName": "Bob" }, "record": { - "text": "read this hi @alice.bsky.social #a11y", + "text": "😀 read this hi @alice.bsky.social #a11y", "createdAt": "2024-06-28T10:00:00Z", "facets": [ - { "index": { "byteStart": 5, "byteEnd": 9 }, + { "index": { "byteStart": 10, "byteEnd": 14 }, "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com/article" } ] }, - { "index": { "byteStart": 13, "byteEnd": 31 }, + { "index": { "byteStart": 18, "byteEnd": 36 }, "features": [ { "$type": "app.bsky.richtext.facet#mention", "did": "did:plc:alice" } ] }, - { "index": { "byteStart": 32, "byteEnd": 37 }, + { "index": { "byteStart": 37, "byteEnd": 42 }, "features": [ { "$type": "app.bsky.richtext.facet#tag", "tag": "a11y" } ] } ] }, diff --git a/tests/test_models.cpp b/tests/test_models.cpp index 18a450a..84c825e 100644 --- a/tests/test_models.cpp +++ b/tests/test_models.cpp @@ -87,6 +87,7 @@ void test_status_roundtrip() { CHECK_EQ(back.reblog->tags.size(), size_t(2)); CHECK_EQ(back.reblog->tags[1], std::string("welcome")); CHECK_EQ(back.reblog->text_links.size(), size_t(1)); + CHECK_EQ(back.reblog->text_links[0].text, std::string("welcome")); CHECK_EQ(back.reblog->text_links[0].url, std::string("https://example.com/article")); CHECK(back.reblog->visibility.value() == Visibility::Unlisted); CHECK_EQ(back.reblog->media_attachments.size(), size_t(1)); diff --git a/tests/test_presentation.cpp b/tests/test_presentation.cpp index 34080ff..8af8f80 100644 --- a/tests/test_presentation.cpp +++ b/tests/test_presentation.cpp @@ -331,16 +331,19 @@ void test_post_links() { // Bluesky facets can link ordinary display text whose destination is not // visible in the post body. Status faceted; - faceted.text = "read this article"; + faceted.text = "read this article, https://other.example/page and https://example.com/article"; faceted.text_links.push_back({"this article", "https://example.com/article"}); std::vector fl = present::post_links(faceted); - CHECK_EQ(fl.size(), static_cast(1)); + CHECK_EQ(fl.size(), static_cast(2)); CHECK_EQ(fl[0].title, std::string("this article")); CHECK_EQ(fl[0].url, std::string("https://example.com/article")); + CHECK_EQ(fl[1].url, std::string("https://other.example/page")); const std::vector facet_urls = present::post_text_link_urls(faceted); - CHECK_EQ(facet_urls.size(), static_cast(1)); - if (!facet_urls.empty()) + CHECK_EQ(facet_urls.size(), static_cast(2)); + if (facet_urls.size() == 2) { CHECK_EQ(facet_urls[0], std::string("https://example.com/article")); + CHECK_EQ(facet_urls[1], std::string("https://other.example/page")); + } // A post with only its own URL still offers that one link. Status plain; From 6cf0a832d89acd70dc5a06c1d75fcef1a8c4dba5 Mon Sep 17 00:00:00 2001 From: Justin Ekis <5169520+jekis913@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:28:45 -0500 Subject: [PATCH 3/3] Preserve Mastodon anchor link behavior Only scan rendered post text for literal URLs when no openable HTML anchors were parsed, retaining Bluesky facet-plus-literal handling without inventing destinations from Mastodon anchor labels. Add the direct algorithm include, correct the cache schema comment, and cover the Mastodon regression. Co-Authored-By: Codex --- core/src/presentation/status_presenter.cpp | 23 ++++++++++++++-------- core/src/store/timeline_codec.cpp | 2 +- tests/test_presentation.cpp | 16 +++++++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/core/src/presentation/status_presenter.cpp b/core/src/presentation/status_presenter.cpp index feb02f4..bee06ef 100644 --- a/core/src/presentation/status_presenter.cpp +++ b/core/src/presentation/status_presenter.cpp @@ -1,5 +1,6 @@ #include "fastsm/presentation/status_presenter.hpp" +#include #include #include #include @@ -540,11 +541,14 @@ std::vector post_links(const Status& status) { std::vector> text_links; for (const auto& link : s.text_links) text_links.push_back({link.text, link.url}); + const size_t facet_link_count = text_links.size(); anchors(s.content, text_links); - std::vector urls; - find_urls_in_text(s.text, urls); - for (const auto& u : urls) - text_links.push_back({std::string{}, u}); + if (text_links.size() == facet_link_count) { + std::vector urls; + find_urls_in_text(s.text, urls); + for (const auto& u : urls) + text_links.push_back({std::string{}, u}); + } for (const auto& [text, url] : text_links) { // The link-preview card's title decorates its matching text link. if (has_card && url == s.card->url && !s.card->title.empty()) @@ -575,12 +579,15 @@ std::vector post_text_link_urls(const Status& status) { std::vector> text_links; for (const auto& link : s.text_links) text_links.push_back({link.text, link.url}); + const size_t facet_link_count = text_links.size(); anchors(s.content, text_links); // HTML anchors (skips @mention / #hashtag) std::vector out; - std::vector literal_urls; - find_urls_in_text(s.text, literal_urls); - for (const auto& url : literal_urls) - text_links.push_back({std::string{}, url}); + if (text_links.size() == facet_link_count) { + std::vector literal_urls; + find_urls_in_text(s.text, literal_urls); + for (const auto& url : literal_urls) + text_links.push_back({std::string{}, url}); + } for (const auto& [text, url] : text_links) { if (std::find(out.begin(), out.end(), url) == out.end()) out.push_back(url); diff --git a/core/src/store/timeline_codec.cpp b/core/src/store/timeline_codec.cpp index 5436f85..56a4d8a 100644 --- a/core/src/store/timeline_codec.cpp +++ b/core/src/store/timeline_codec.cpp @@ -10,7 +10,7 @@ namespace { // cleanly (a magic mismatch -> empty) instead of being read with a mismatched // reader. v2 added Status::url. v6 added Notification group_key + notifications_count. // v7 added Status::filtered + tags. v9 added Bluesky reply-parent metadata. -// v10 added Status::text_links. +// v11 added Status::text_links. constexpr char kMagic[4] = {'F', 'S', 'C', 'B'}; // Guard against runaway recursion if a file is ever corrupt/misaligned: boost/ // quote nesting is shallow in practice. diff --git a/tests/test_presentation.cpp b/tests/test_presentation.cpp index 8af8f80..b9ab8af 100644 --- a/tests/test_presentation.cpp +++ b/tests/test_presentation.cpp @@ -314,6 +314,22 @@ void test_post_links() { CHECK_EQ(links[3].url, std::string("https://x.social/@me/123")); CHECK_EQ(links[3].title, std::string("Open this post in browser")); + // Mastodon HTML is authoritative: URL-looking anchor text must not become a + // second destination distinct from the anchor's href. + Status deceptive_anchor; + deceptive_anchor.content = + "https://displayed.example"; + deceptive_anchor.text = "https://displayed.example"; + const std::vector anchor_links = present::post_links(deceptive_anchor); + CHECK_EQ(anchor_links.size(), static_cast(1)); + if (!anchor_links.empty()) + CHECK_EQ(anchor_links[0].url, std::string("https://actual.example")); + const std::vector anchor_urls = + present::post_text_link_urls(deceptive_anchor); + CHECK_EQ(anchor_urls.size(), static_cast(1)); + if (!anchor_urls.empty()) + CHECK_EQ(anchor_urls[0], std::string("https://actual.example")); + // A boost unwraps to the boosted post's links. Status boost; boost.reblog = std::make_shared(s);