From af76598cec3edcbe004565d2239bd2a32c760e22 Mon Sep 17 00:00:00 2001 From: Johann Date: Fri, 25 Sep 2026 22:41:13 +0800 Subject: [PATCH] Customize iOS VoiceOver magic tap Add a Behavior setting to use the secondary action, always open compose, or pass the gesture to iOS. When View Media is selected, open compose for posts without a usable media URL. Persist the new setting, expose playable-media availability in timeline rows, and update the iOS guide, changelog, and settings persistence test. Co-Authored-By: OpenAI Codex --- README-iOS.md | 7 +++-- apple/shared/CoreEvents.swift | 3 +++ core/include/fastsm/store/app_settings.hpp | 2 ++ core/src/session/core_session.cpp | 6 +++++ core/src/store/app_settings.cpp | 5 ++++ docs/changelog.txt | 1 + ios/src/MainViewController.swift | 7 ++++- ios/src/RootViewController.swift | 30 ++++++++++++++++------ ios/src/SceneDelegate.swift | 5 ++-- ios/src/SettingsViewController.swift | 4 +++ tests/test_speech.cpp | 2 ++ 11 files changed, 58 insertions(+), 14 deletions(-) diff --git a/README-iOS.md b/README-iOS.md index 9112b28..4cb936b 100644 --- a/README-iOS.md +++ b/README-iOS.md @@ -51,8 +51,11 @@ versions. - Two-finger scrub (the escape gesture) closes the current timeline when it can be closed, or plays a boundary sound when it cannot. Home and Notifications cannot be closed. -- Two-finger double-tap (the magic tap) runs the secondary action on the focused - post, or opens the composer if no post is focused. +- Two-finger double-tap (the magic tap) normally runs the secondary action on + the focused post, or opens the composer if no post is focused. When the + secondary action is View media and the post has no usable media, it opens the + composer instead. In Settings under Behavior, choose **VoiceOver magic tap** + to always open compose or let iOS handle the gesture. - Your reading position is remembered for each timeline and each account. ## Acting on a post diff --git a/apple/shared/CoreEvents.swift b/apple/shared/CoreEvents.swift index b82c69a..0c4709e 100644 --- a/apple/shared/CoreEvents.swift +++ b/apple/shared/CoreEvents.swift @@ -38,6 +38,7 @@ struct Row: Decodable, Equatable { var favorited = false var boosted = false var hasMedia = false + var hasPlayableMedia = false var isReply = false var isMine = false var gapAfter = false @@ -62,6 +63,7 @@ struct Row: Decodable, Equatable { enum CodingKeys: String, CodingKey { case id, text, favorited, boosted, acct, time, thread, links case hasMedia = "has_media" + case hasPlayableMedia = "has_playable_media" case hasHashtags = "has_hashtags" case isReply = "is_reply" case isMine = "is_mine" @@ -82,6 +84,7 @@ struct Row: Decodable, Equatable { favorited = try c.decodeIfPresent(Bool.self, forKey: .favorited) ?? false boosted = try c.decodeIfPresent(Bool.self, forKey: .boosted) ?? false hasMedia = try c.decodeIfPresent(Bool.self, forKey: .hasMedia) ?? false + hasPlayableMedia = try c.decodeIfPresent(Bool.self, forKey: .hasPlayableMedia) ?? false hasHashtags = try c.decodeIfPresent(Bool.self, forKey: .hasHashtags) ?? false isReply = try c.decodeIfPresent(Bool.self, forKey: .isReply) ?? false isMine = try c.decodeIfPresent(Bool.self, forKey: .isMine) ?? false diff --git a/core/include/fastsm/store/app_settings.hpp b/core/include/fastsm/store/app_settings.hpp index 5c08635..6b5f8e5 100644 --- a/core/include/fastsm/store/app_settings.hpp +++ b/core/include/fastsm/store/app_settings.hpp @@ -55,6 +55,8 @@ struct AppSettings { std::string enter_user_action = "actions"; // actions | profile | timeline // The secondary interact (Shift+Enter / "SecondaryAction") on a post. std::string secondary_post_action = "play_media"; // play_media | post_info | thread | reply | links + // iOS VoiceOver magic tap: use the secondary action, open compose, or let iOS handle it. + std::string ios_magic_tap_action = "secondary"; // "secondary" | "compose" | "system" bool media_background = false; // play audio without opening the player window // When replying, keep the person you're replying to mentioned up front and // append every other participant's @ at the end of the post instead. Off by diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index 587171a..12611f6 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -5274,6 +5274,12 @@ json CoreSession::row_json(const TimelineItem& item, std::int64_t now) const { r["muted"] = true; // conversation muted -> Status menu shows a check if (!s->media_attachments.empty()) r["has_media"] = true; // gates the "View media" action + for (const auto& media : s->media_attachments) { + if (!media.url.empty()) { + r["has_playable_media"] = true; // matches cmd_play_media's usable attachments + break; + } + } if (!s->tags.empty()) r["has_hashtags"] = true; // gates the "Open hashtag timeline" action if (s->in_reply_to_id && !s->in_reply_to_id->empty()) diff --git a/core/src/store/app_settings.cpp b/core/src/store/app_settings.cpp index 330967f..a754120 100644 --- a/core/src/store/app_settings.cpp +++ b/core/src/store/app_settings.cpp @@ -171,6 +171,10 @@ AppSettings settings_from_json(const json& root) { settings.enter_user_action = root.value("enter_user_action", std::string("actions")); settings.secondary_post_action = root.value("secondary_post_action", std::string("play_media")); + const std::string magic_tap_action = root.value("ios_magic_tap_action", std::string("secondary")); + if (magic_tap_action == "secondary" || magic_tap_action == "compose" || + magic_tap_action == "system") + settings.ios_magic_tap_action = magic_tap_action; settings.media_background = root.value("media_background", false); settings.reply_mentions_at_end = root.value("reply_mentions_at_end", false); settings.invisible_mode = root.value("invisible_mode", std::string("off")); @@ -277,6 +281,7 @@ json settings_to_json(const AppSettings& settings) { root["enter_post_action"] = settings.enter_post_action; root["enter_user_action"] = settings.enter_user_action; root["secondary_post_action"] = settings.secondary_post_action; + root["ios_magic_tap_action"] = settings.ios_magic_tap_action; root["media_background"] = settings.media_background; root["reply_mentions_at_end"] = settings.reply_mentions_at_end; root["invisible_mode"] = settings.invisible_mode; diff --git a/docs/changelog.txt b/docs/changelog.txt index 7571a51..36184a6 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -4,6 +4,7 @@ FastSMRW changelog 0.6.0 ----- +- New: on iPhone, choose whether the VoiceOver magic tap uses your secondary action, opens compose, or leaves the gesture to iOS; a magic tap set to View Media opens compose when a post has no playable media. - Fixed: on Mastodon, posting no longer fails with an error sound when you have many timelines open; background refreshing now slows down before it uses up your server's request limit. - Changed: threads and user timelines with nothing new for a day now check for updates every 15 minutes instead of every minute, unless you're viewing them, so keeping many open no longer strains your server's request limit. - New: on iPhone, an Accounts button next to the More button lists your accounts so you can jump straight to one, and add, remove or configure accounts. diff --git a/ios/src/MainViewController.swift b/ios/src/MainViewController.swift index 724a9b1..1313a82 100644 --- a/ios/src/MainViewController.swift +++ b/ios/src/MainViewController.swift @@ -42,7 +42,12 @@ final class MainViewController: UIViewController { /// The post row VoiceOver is currently on (nil when focus is elsewhere — /// a bar button, the tab strip, …). Drives the magic-tap behavior. private weak var focusedPostCell: PostCell? - var isPostFocused: Bool { focusedPostCell != nil } + var focusedPost: Row? { + guard let cell = focusedPostCell, + let indexPath = tableView.indexPath(for: cell), + rows.indices.contains(indexPath.row) else { return nil } + return rows[indexPath.row] + } /// Reading position per timeline, tracked by post id so it survives leaving /// / returning and posts streaming in above — same pattern as Mac/Windows. private var selectionByKey: [String: String] = [:] diff --git a/ios/src/RootViewController.swift b/ios/src/RootViewController.swift index 2125155..a5089e9 100644 --- a/ios/src/RootViewController.swift +++ b/ios/src/RootViewController.swift @@ -109,12 +109,26 @@ final class RootViewController: UIViewController { } extension RootViewController: MagicTapResponder { - /// On a post → its configurable secondary action; anywhere else → compose. - func performMagicTap() { - if mainVC?.isPostFocused == true { - state.performAction("SecondaryAction") - } else { - state.requestCompose(mode: "new") - } - } + func performMagicTap() -> Bool { + switch state.settingsRaw["ios_magic_tap_action"] as? String ?? "secondary" { + case "system": + return false + case "compose": + state.requestCompose(mode: "new") + case "secondary": + if let post = mainVC?.focusedPost { + let secondary = state.settingsRaw["secondary_post_action"] as? String ?? "play_media" + if secondary == "play_media" && !post.hasPlayableMedia { + state.requestCompose(mode: "new") + } else { + state.performAction("SecondaryAction", id: post.id) + } + } else { + state.requestCompose(mode: "new") + } + default: + return false + } + return true + } } diff --git a/ios/src/SceneDelegate.swift b/ios/src/SceneDelegate.swift index 6f61ff6..ceee007 100644 --- a/ios/src/SceneDelegate.swift +++ b/ios/src/SceneDelegate.swift @@ -85,7 +85,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { /// controller, which knows whether a post is focused). @MainActor protocol MagicTapResponder: AnyObject { - func performMagicTap() + func performMagicTap() -> Bool } /// A window that handles the VoiceOver magic tap globally. Overriding it here — @@ -95,7 +95,6 @@ protocol MagicTapResponder: AnyObject { final class MagicTapWindow: UIWindow { override func accessibilityPerformMagicTap() -> Bool { guard let responder = rootViewController as? MagicTapResponder else { return false } - responder.performMagicTap() - return true + return responder.performMagicTap() } } diff --git a/ios/src/SettingsViewController.swift b/ios/src/SettingsViewController.swift index d0d87ac..4134b2e 100644 --- a/ios/src/SettingsViewController.swift +++ b/ios/src/SettingsViewController.swift @@ -161,6 +161,10 @@ final class SettingsViewController: UITableViewController { def: "actions"), .picker("Secondary action", key: "secondary_post_action", options: interactActionOptions, def: "play_media"), + .picker("VoiceOver magic tap", key: "ios_magic_tap_action", + options: [("Secondary action", "secondary"), + ("Open compose", "compose"), + ("Use iOS action", "system")], def: "secondary"), .postActions("Post Actions"), .toggle("Keep the media player in the background", key: "media_background", def: false), diff --git a/tests/test_speech.cpp b/tests/test_speech.cpp index ccfa25d..9ea908a 100644 --- a/tests/test_speech.cpp +++ b/tests/test_speech.cpp @@ -52,6 +52,7 @@ void test_settings_roundtrip() { cfg.settings.text.max_mentions = 3; cfg.settings.text.absolute_time = true; cfg.settings.reverse_timelines = true; + cfg.settings.ios_magic_tap_action = "system"; cfg.settings.speech = SpeechSettings::defaults(); cfg.settings.speech.separator = " | "; for (auto& it : cfg.settings.speech.status) { @@ -73,6 +74,7 @@ void test_settings_roundtrip() { CHECK_EQ(loaded.settings.text.max_mentions, 3); CHECK(loaded.settings.text.absolute_time); CHECK(loaded.settings.reverse_timelines); + CHECK_EQ(loaded.settings.ios_magic_tap_action, std::string("system")); CHECK_EQ(loaded.settings.speech.separator, std::string(" | ")); bool handle_on = false; for (const auto& it : loaded.settings.speech.status)