From 3200c291b70001f427ec32ba846534472bfe835d Mon Sep 17 00:00:00 2001 From: serrebidev Date: Tue, 20 Oct 2026 01:23:59 -0700 Subject: [PATCH] Reply, Favourite and Boost buttons on mention notifications Mastodon's Web Push payload carries the notification id and the account's access token. A new core command, push_action, finds the account by that token, looks up the notification's post (GET /api/v1/notifications/:id) and favourites, boosts or replies to it, with the same mentions and visibility as a reply from the timeline. It loads the accounts itself when the core was never started, so it works with the app closed. On Android, mention and status pushes get the three buttons; Reply takes typed text through RemoteInput. PushActionReceiver runs a short-lived core for the press and toasts the outcome. Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/AndroidManifest.xml | 5 + .../fastsm/push/FastSmMessagingService.kt | 19 ++- .../fastsm/push/PushActionReceiver.kt | 130 ++++++++++++++++++ .../platform/mastodon/mastodon_account.hpp | 4 + .../fastsm/platform/social_account.hpp | 6 + core/include/fastsm/session/core_session.hpp | 3 + .../platform/mastodon/mastodon_account.cpp | 20 +++ core/src/session/core_session.cpp | 54 ++++++++ docs/changelog.txt | 1 + tests/main.cpp | 2 + tests/test_push.cpp | 130 ++++++++++++++++++ 11 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c10334a..c5d95ea 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -38,5 +38,10 @@ + + + diff --git a/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt b/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt index 5dc4390..f496291 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/push/FastSmMessagingService.kt @@ -41,6 +41,8 @@ class FastSmMessagingService : FirebaseMessagingService() { var text = "New notification" var id = 0 var type = "" + var notificationId = "" + var token = "" if (blob != null) { val payload = runCatching { @@ -69,10 +71,14 @@ class FastSmMessagingService : FirebaseMessagingService() { // picks the channel, so mentions and boosts can sound // different (or not at all) in Android's own settings. type = o.optString("notification_type") + // Mastodon sends the notification's id and the account's + // token, which the Reply/Favourite/Boost buttons act with. + notificationId = o.optString("notification_id") + token = o.optString("access_token") } } } - show(applicationContext, id, title, text, type) + show(applicationContext, id, title, text, type, notificationId, token) } companion object { @@ -121,7 +127,15 @@ class FastSmMessagingService : FirebaseMessagingService() { if (types.isNotEmpty()) manager.deleteNotificationChannel(LEGACY_CHANNEL) } - private fun show(context: Context, id: Int, title: String, text: String, type: String) { + private fun show( + context: Context, + id: Int, + title: String, + text: String, + type: String, + notificationId: String, + token: String, + ) { ensureChannels(context) // An unknown type, or one whose channel somehow isn't there yet, // falls back rather than vanishing. @@ -144,6 +158,7 @@ class FastSmMessagingService : FirebaseMessagingService() { .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(tap) + .also { PushActionReceiver.addActions(context, it, id, type, token, notificationId) } .build() if (!PushManager.hasNotificationPermission(context)) return runCatching { NotificationManagerCompat.from(context).notify(id, notification) } diff --git a/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt b/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt new file mode 100644 index 0000000..e4162ae --- /dev/null +++ b/android/app/src/main/java/me/masonasons/fastsm/push/PushActionReceiver.kt @@ -0,0 +1,130 @@ +package me.masonasons.fastsm.push + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import me.masonasons.fastsm.core.FastSmCore +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** + * Runs a push notification's Reply, Favourite or Boost button. + * + * The press can come with the app closed, so this starts its own short-lived + * core and sends it push_action; the core finds the account by the push's + * access token, looks the notification's post up and acts on it. The outcome is + * a toast, which screen readers read, and the notification goes once it worked. + */ +class PushActionReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.getStringExtra(EXTRA_ACTION) ?: return + val token = intent.getStringExtra(EXTRA_TOKEN) ?: return + val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return + val shownId = intent.getIntExtra(EXTRA_SHOWN_ID, 0) + val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()?.trim() + val app = context.applicationContext + val pending = goAsync() + thread(name = "fastsm-push-action") { + try { + val ok = run(app, token, notificationId, action, text) + if (ok) NotificationManagerCompat.from(app).cancel(shownId) + val message = when { + !ok -> "Couldn't ${verb(action)}. Open FastSMRW to try again." + action == ACTION_REPLY -> "Reply sent" + action == ACTION_BOOST -> "Boosted" + else -> "Favourited" + } + Handler(Looper.getMainLooper()).post { Toast.makeText(app, message, Toast.LENGTH_SHORT).show() } + } finally { + pending.finish() + } + } + } + + private fun run(context: Context, token: String, id: String, action: String, text: String?): Boolean { + val core = FastSmCore.create(context) + val done = CountDownLatch(1) + var ok = false + core.setEventListener { e -> + if (e.optString("event") == "push_action_result") { + ok = e.optBoolean("ok") + done.countDown() + } + } + core.dispatch("push_action") { + put("access_token", token) + put("notification_id", id) + put("action", action) + if (text != null) put("text", text) + } + // A broadcast has about ten seconds; leave room to post the toast. + val answered = done.await(8, TimeUnit.SECONDS) + core.destroy() + return answered && ok + } + + private fun verb(action: String) = when (action) { + ACTION_REPLY -> "send the reply" + ACTION_BOOST -> "boost" + else -> "favourite" + } + + companion object { + const val ACTION_REPLY = "reply" + const val ACTION_FAVOURITE = "favourite" + const val ACTION_BOOST = "boost" + private const val KEY_REPLY = "reply_text" + private const val EXTRA_ACTION = "action" + private const val EXTRA_TOKEN = "access_token" + private const val EXTRA_NOTIFICATION_ID = "notification_id" + private const val EXTRA_SHOWN_ID = "shown_id" + + /** Mastodon notification types that are about a post someone else wrote. */ + private val ACTIONABLE_TYPES = setOf("mention", "status") + + /** + * Adds Reply, Favourite and Boost to a mention's notification. Nothing is + * added without the account's token and the notification id from the push. + */ + fun addActions( + context: Context, + builder: NotificationCompat.Builder, + shownId: Int, + type: String, + token: String, + notificationId: String, + ) { + if (type !in ACTIONABLE_TYPES || token.isEmpty() || notificationId.isEmpty()) return + fun intent(action: String, index: Int, mutable: Boolean): PendingIntent { + val i = Intent(context, PushActionReceiver::class.java) + .putExtra(EXTRA_ACTION, action) + .putExtra(EXTRA_TOKEN, token) + .putExtra(EXTRA_NOTIFICATION_ID, notificationId) + .putExtra(EXTRA_SHOWN_ID, shownId) + // Reply's must be mutable so the system can add the typed text. + val flags = PendingIntent.FLAG_UPDATE_CURRENT or + (if (mutable) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast(context, shownId * 3 + index, i, flags) + } + val reply = RemoteInput.Builder(KEY_REPLY).setLabel("Reply").build() + builder + .addAction( + NotificationCompat.Action.Builder(0, "Reply", intent(ACTION_REPLY, 0, mutable = true)) + .addRemoteInput(reply) + .setAllowGeneratedReplies(false) + .build(), + ) + .addAction(0, "Favourite", intent(ACTION_FAVOURITE, 1, mutable = false)) + .addAction(0, "Boost", intent(ACTION_BOOST, 2, mutable = false)) + } + } +} diff --git a/core/include/fastsm/platform/mastodon/mastodon_account.hpp b/core/include/fastsm/platform/mastodon/mastodon_account.hpp index 0edfad6..00b3232 100644 --- a/core/include/fastsm/platform/mastodon/mastodon_account.hpp +++ b/core/include/fastsm/platform/mastodon/mastodon_account.hpp @@ -58,6 +58,10 @@ class MastodonAccount : public SocialAccount { std::optional lookup_user(const std::string& handle) override; std::optional default_visibility() const override { return default_visibility_; } std::optional fetch_status(const std::string& id) override; + bool owns_access_token(const std::string& token) const override { + return !token.empty() && token == credentials_.access_token; + } + std::optional notification_status(const std::string& id) override; std::vector search_accounts(const std::string& query, int limit) override; FullRelationResult fetch_all_relations(const std::string& id, bool following) override; std::optional relationship(const std::string& id) override; diff --git a/core/include/fastsm/platform/social_account.hpp b/core/include/fastsm/platform/social_account.hpp index 1627e66..3d210de 100644 --- a/core/include/fastsm/platform/social_account.hpp +++ b/core/include/fastsm/platform/social_account.hpp @@ -367,6 +367,12 @@ class SocialAccount { } // Remove this account's push subscription. Return success. virtual bool unsubscribe_push() { return false; } + // Whether a Web Push payload's access_token is this account's, so a button on + // a push notification acts as the account it was sent to. + virtual bool owns_access_token(const std::string& /*token*/) const { return false; } + // The post a notification is about (Mastodon /api/v1/notifications/:id), or + // nullopt when it has none or can't be fetched. Runs on the worker thread. + virtual std::optional notification_status(const std::string& /*id*/) { return std::nullopt; } // Update only alert types, preserving the subscription endpoint and keys. virtual PushSubscribe update_push_alerts(const PushAlerts& /*alerts*/) { return PushSubscribe::Failed; diff --git a/core/include/fastsm/session/core_session.hpp b/core/include/fastsm/session/core_session.hpp index cd40bb4..3bb5edd 100644 --- a/core/include/fastsm/session/core_session.hpp +++ b/core/include/fastsm/session/core_session.hpp @@ -155,6 +155,9 @@ class CoreSession { // the setting, so a quiet renewal at startup says nothing. void cmd_push_subscribe(const nlohmann::json& cmd); void cmd_push_unsubscribe(const nlohmann::json& cmd); // {announce?} + // A push notification's button: {access_token, notification_id, action: + // "favourite"|"boost"|"reply", text?} -> push_action_result. + void cmd_push_action(const nlohmann::json& cmd); // {alerts:{type:bool,...}, apply_to_subscriptions?:false, announce?:true} // Saves a patch; updates existing subscriptions only when the device enables push. void cmd_push_update_alerts(const nlohmann::json& cmd); diff --git a/core/src/platform/mastodon/mastodon_account.cpp b/core/src/platform/mastodon/mastodon_account.cpp index 759659c..7d3a621 100644 --- a/core/src/platform/mastodon/mastodon_account.cpp +++ b/core/src/platform/mastodon/mastodon_account.cpp @@ -1109,6 +1109,26 @@ std::optional MastodonAccount::fetch_status(const std::string& id) { } } +std::optional MastodonAccount::notification_status(const std::string& id) { + // GET /api/v1/notifications/:id -- a push payload carries only the + // notification's id; its buttons act on the post it is about. + if (id.empty()) + return std::nullopt; + const std::string url = credentials_.instance_url + "/api/v1/notifications/" + id; + std::string body; + long status = 0; + if (!request("GET", url, "", "", body, status)) + return std::nullopt; + try { + json j = json::parse(body); + if (!j.is_object() || !j.contains("status") || !j["status"].is_object()) + return std::nullopt; + return mastodon::map_status(j["status"]); + } catch (...) { + return std::nullopt; + } +} + std::optional MastodonAccount::lookup_user(const std::string& handle) { // Resolve a typed handle to a full account. /accounts/lookup takes the acct // form (user@domain, or just user for a local account); a leading '@' is diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index b8a57ad..7dae77c 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -695,6 +695,8 @@ void CoreSession::handle(const json& cmd) { cmd_push_subscribe(cmd); else if (c == "push_unsubscribe") cmd_push_unsubscribe(cmd); + else if (c == "push_action") + cmd_push_action(cmd); else if (c == "push_update_alerts") cmd_push_update_alerts(cmd); else if (c == "list_followed_hashtags") @@ -1824,6 +1826,58 @@ void CoreSession::cmd_push_subscribe(const json& cmd) { }); } +void CoreSession::cmd_push_action(const json& cmd) { + const std::string token = cmd.value("access_token", std::string{}); + const std::string id = cmd.value("notification_id", std::string{}); + const std::string action = cmd.value("action", std::string{}); + const std::string text = cmd.value("text", std::string{}); + auto finish = [this, id, action](bool ok) { + emit({{"event", "push_action_result"}, {"ok", ok}, {"action", action}, {"notification_id", id}}); + }; + auto run = [this, token, id, action, text, finish] { + SocialAccount* account = nullptr; + for (SocialAccount* a : accounts_.accounts()) + if (a && a->owns_access_token(token)) { + account = a; + break; + } + if (!account || id.empty() || (action == "reply" && text.empty())) { + finish(false); + return; + } + worker_.post([this, account, id, action, text, finish] { + bool ok = false; + if (std::optional s = account->notification_status(id)) { + if (action == "favourite") { + ok = account->favorite(*s); + } else if (action == "boost") { + ok = account->boost(*s); + } else if (action == "reply") { + // As a reply from the timeline: everyone in the thread is + // mentioned and the post's visibility is kept. + PostDraft draft; + draft.text = present::mention_prefix(*s, account->me()) + text; + draft.reply_to_id = s->id; + draft.visibility = s->visibility; + ok = account->post(draft).has_value(); + } + } + loop_.post([finish, ok] { finish(ok); }); + }); + }; + if (!accounts_.accounts().empty()) { + run(); + return; + } + // A notification button can be pressed with the app closed, in a core that + // was never started: load the accounts as start does, but build nothing else. + worker_.post([this, run] { + if (accounts_.accounts().empty()) + accounts_.load(store::AppConfigStore(config_path_).load()); + loop_.post(run); + }); +} + void CoreSession::cmd_push_unsubscribe(const json& cmd) { const bool announce = cmd.value("announce", false); std::vector targets; diff --git a/docs/changelog.txt b/docs/changelog.txt index 819bfd8..3b6a7af 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -4,6 +4,7 @@ FastSMRW changelog 0.6.1 ----- +- New: on Android, notifications for mentions now have Reply, Favourite and Boost buttons, so you can answer or react without opening the app. - Fixed: on Android, FastSMRW now installs on 32-bit phones, which previously refused it with "app not compatible" and no way around it. - Changed: @-mention autocomplete now suggests people you've seen go past in your timelines, instantly and without waiting on the server, and matches on display names as well as handles. - Fixed: completing a mention with the cursor in the middle of a half-typed handle no longer leaves the end of it behind, and no longer adds a second space when one is already there. diff --git a/tests/main.cpp b/tests/main.cpp index 0153b35..f03afa6 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -159,6 +159,7 @@ void test_push_settings(); void test_push_requests(); void test_push_session(); void test_push_without_accounts(); +void test_push_action(); static void test_version() { CHECK(fastsm::version() != nullptr); @@ -296,6 +297,7 @@ int main() { test_push_requests(); test_push_session(); test_push_without_accounts(); + test_push_action(); std::printf("%d checks, %d failures\n", fastsmtest::checks(), fastsmtest::failures()); return fastsmtest::failures() == 0 ? 0 : 1; diff --git a/tests/test_push.cpp b/tests/test_push.cpp index 0a6b6f9..36bbbab 100644 --- a/tests/test_push.cpp +++ b/tests/test_push.cpp @@ -268,3 +268,133 @@ void test_push_without_accounts() { fixture.session.reset(); // drain the queued config save before reading it CHECK(!store::AppConfigStore(fixture.dir / "config.json").load().settings.push_alerts.poll); } + +namespace { +// Answers a mention notification (id 77, about post 500) and records every +// request, so a push_action test can see what the core sent where. +struct ActionHttp : net::IHttpClient { + std::mutex mutex; + std::vector requests; + + net::HttpResponse send(const net::HttpRequest& request) override { + { + std::lock_guard lock(mutex); + requests.push_back(request); + } + const json status = {{"id", "500"}, {"created_at", "2026-10-20T00:00:00.000Z"}, + {"content", "

hi

"}, {"visibility", "direct"}, {"mentions", json::array()}, + {"account", {{"id", "9"}, {"username", "carol"}, {"acct", "carol@three.example"}, + {"display_name", "Carol"}}}}; + net::HttpResponse response; + response.status = 200; + if (request.url.find("/api/v1/notifications/77") != std::string::npos) + response.body = json{{"id", "77"}, {"type", "mention"}, {"status", status}}.dump(); + else if (request.url.find("/api/v1/notifications/") != std::string::npos) + response.status = 404; + else + response.body = status.dump(); + return response; + } + + std::vector take() { + std::lock_guard lock(mutex); + auto result = std::move(requests); + requests.clear(); + return result; + } +}; +} // namespace + +void test_push_action() { + // A core that was never started, as when a notification button is pressed + // with the app closed: push_action loads the accounts itself. + const auto dir = std::filesystem::temp_directory_path() / + ("fastsmrw_push_action_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(dir); + store::AppConfig config; + config.settings.sounds_enabled = false; + for (int i = 0; i < 2; ++i) { + store::AccountRecord account; + account.me.id = std::to_string(i + 1); + account.me.acct = i == 0 ? "alice" : "bob"; + account.account_key = "mastodon:" + account.me.id; + account.credential.mastodon = MastodonCredentials{ + i == 0 ? "https://one.example" : "https://two.example", "", "", "token-" + std::to_string(i + 1)}; + config.accounts.push_back(account); + } + CHECK(store::AppConfigStore(dir / "config.json").save(config)); + auto transport = std::make_unique(); + ActionHttp* http = transport.get(); + std::mutex mutex; + std::condition_variable cv; + std::vector results; + CoreSession::Paths paths; + paths.config_dir = dir; + auto session = std::make_unique(paths, std::move(transport), [&](const std::string& event) { + json e = json::parse(event); + if (e.value("event", "") != "push_action_result") + return; + { + std::lock_guard lock(mutex); + results.push_back(e); + } + cv.notify_all(); + }); + auto act = [&](const json& cmd) { + session->dispatch(cmd.dump()); + std::unique_lock lock(mutex); + CHECK(cv.wait_for(lock, std::chrono::seconds(5), [&] { return !results.empty(); })); + json result = results.empty() ? json::object() : results.front(); + results.clear(); + return result; + }; + + // The button acts as the account the push was sent to: bob, by his token. + json r = act({{"cmd", "push_action"}, {"access_token", "token-2"}, {"notification_id", "77"}, + {"action", "favourite"}}); + CHECK(r.value("ok", false)); + CHECK_EQ(r.value("action", ""), std::string("favourite")); + auto requests = http->take(); + CHECK_EQ(requests.size(), size_t(2)); + if (requests.size() == 2) { + CHECK_EQ(requests[0].url, std::string("https://two.example/api/v1/notifications/77")); + CHECK_EQ(requests[1].url, std::string("https://two.example/api/v1/statuses/500/favourite")); + CHECK_EQ(requests[1].method, std::string("POST")); + } + + r = act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, {"action", "boost"}}); + CHECK(r.value("ok", false)); + requests = http->take(); + CHECK(!requests.empty() && requests.back().url == "https://one.example/api/v1/statuses/500/reblog"); + + // A reply mentions the author and keeps a direct message direct. + r = act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, + {"action", "reply"}, {"text", "On my way"}}); + CHECK(r.value("ok", false)); + requests = http->take(); + CHECK(!requests.empty()); + if (!requests.empty()) { + const auto& post = requests.back(); + CHECK_EQ(post.url, std::string("https://one.example/api/v1/statuses")); + CHECK(post.body.find("in_reply_to_id=500") != std::string::npos); + CHECK(post.body.find("visibility=direct") != std::string::npos); + CHECK(post.body.find("carol%40three.example") != std::string::npos); + CHECK(post.body.find("On") != std::string::npos); + } + + // An unknown token, an empty reply, or a notification without a post fail + // without acting. + CHECK(!act({{"cmd", "push_action"}, {"access_token", "stale"}, {"notification_id", "77"}, + {"action", "favourite"}}).value("ok", true)); + CHECK(!act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "77"}, + {"action", "reply"}, {"text", ""}}).value("ok", true)); + CHECK(http->take().empty()); + CHECK(!act({{"cmd", "push_action"}, {"access_token", "token-1"}, {"notification_id", "78"}, + {"action", "favourite"}}).value("ok", true)); + requests = http->take(); + CHECK_EQ(requests.size(), size_t(1)); // the lookup, and no favourite + + session.reset(); + std::error_code ec; + std::filesystem::remove_all(dir, ec); +}