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..bdacde3 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)}); } } } @@ -262,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 b07aed0..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 @@ -538,8 +539,11 @@ 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}); + const size_t facet_link_count = text_links.size(); anchors(s.content, text_links); - if (text_links.empty()) { + if (text_links.size() == facet_link_count) { std::vector urls; find_urls_in_text(s.text, urls); for (const auto& u : urls) @@ -573,13 +577,20 @@ 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}); + const size_t facet_link_count = text_links.size(); anchors(s.content, text_links); // HTML anchors (skips @mention / #hashtag) std::vector out; - if (!text_links.empty()) { - for (const auto& [text, url] : text_links) + 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); - } else { - find_urls_in_text(s.text, out); // Bluesky: URLs live in plain text } return out; } diff --git a/core/src/store/timeline_codec.cpp b/core/src/store/timeline_codec.cpp index a1cb03e..56a4d8a 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'}; +// 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. 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..52fec15 100644 --- a/tests/test_bluesky_map.cpp +++ b/tests/test_bluesky_map.cpp @@ -142,36 +142,52 @@ 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")); + } } } 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": 10, "byteEnd": 14 }, + "features": [ { "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com/article" } ] }, + { "index": { "byteStart": 18, "byteEnd": 36 }, "features": [ { "$type": "app.bsky.richtext.facet#mention", "did": "did:plc:alice" } ] }, - { "index": { "byteStart": 22, "byteEnd": 27 }, + { "index": { "byteStart": 37, "byteEnd": 42 }, "features": [ { "$type": "app.bsky.richtext.facet#tag", "tag": "a11y" } ] } ] }, @@ -179,6 +195,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..84c825e 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,9 @@ 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].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)); CHECK(back.reblog->media_attachments[0].type == MediaAttachment::Kind::Image); diff --git a/tests/test_presentation.cpp b/tests/test_presentation.cpp index 6917f05..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); @@ -328,6 +344,23 @@ 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, 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(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(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; plain.text = "just some text, no links here.";