Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions core/include/fastsm/models/status.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,6 +58,7 @@ struct Status {
std::shared_ptr<Status> quote; // the quoted status
std::vector<MediaAttachment> media_attachments;
std::vector<Mention> mentions;
std::vector<StatusTextLink> text_links;
std::vector<std::string> tags; // hashtag names in this post (no '#'), Mastodon
std::optional<Visibility> visibility; // Mastodon only
std::optional<std::string> spoiler_text;
Expand Down
7 changes: 6 additions & 1 deletion core/src/platform/bluesky/bluesky_map.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ std::shared_ptr<Status> map_quote_record(const json& rec) {
return std::make_shared<Status>(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) {
Expand Down Expand Up @@ -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)});
}
}
}
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 16 additions & 5 deletions core/src/presentation/status_presenter.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "fastsm/presentation/status_presenter.hpp"

#include <algorithm>
#include <cctype>
#include <cstring>
#include <string>
Expand Down Expand Up @@ -538,8 +539,11 @@ std::vector<PostLink> 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<std::pair<std::string, std::string>> 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<std::string> urls;
find_urls_in_text(s.text, urls);
for (const auto& u : urls)
Expand Down Expand Up @@ -573,13 +577,20 @@ std::vector<PostLink> post_links(const Status& status) {
std::vector<std::string> post_text_link_urls(const Status& status) {
const Status& s = status.display_status(); // unwrap a boost
std::vector<std::pair<std::string, std::string>> 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<std::string> out;
if (!text_links.empty()) {
for (const auto& [text, url] : text_links)
if (text_links.size() == facet_link_count) {
std::vector<std::string> 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
}
Comment thread
jekis913 marked this conversation as resolved.
return out;
}
Expand Down
11 changes: 10 additions & 1 deletion core/src/store/timeline_codec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -289,6 +290,11 @@ void write_status(Writer& w, const Status& s) {
w.u32(static_cast<std::uint32_t>(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<std::uint32_t>(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) {
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
35 changes: 28 additions & 7 deletions tests/test_bluesky_map.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -142,43 +142,64 @@ 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" } ] }
]
},
"likeCount": 0, "repostCount": 0, "replyCount": 0
}
})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"));
Expand Down
4 changes: 4 additions & 0 deletions tests/test_models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions tests/test_presentation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"<a href=\"https://actual.example\">https://displayed.example</a>";
deceptive_anchor.text = "https://displayed.example";
const std::vector<present::PostLink> anchor_links = present::post_links(deceptive_anchor);
CHECK_EQ(anchor_links.size(), static_cast<size_t>(1));
if (!anchor_links.empty())
CHECK_EQ(anchor_links[0].url, std::string("https://actual.example"));
const std::vector<std::string> anchor_urls =
present::post_text_link_urls(deceptive_anchor);
CHECK_EQ(anchor_urls.size(), static_cast<size_t>(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<Status>(s);
Expand All @@ -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<present::PostLink> fl = present::post_links(faceted);
CHECK_EQ(fl.size(), static_cast<size_t>(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<std::string> facet_urls = present::post_text_link_urls(faceted);
CHECK_EQ(facet_urls.size(), static_cast<size_t>(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.";
Expand Down
Loading