Skip to content
Merged
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
7 changes: 5 additions & 2 deletions README-iOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apple/shared/CoreEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/include/fastsm/store/app_settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions core/src/session/core_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
5 changes: 5 additions & 0 deletions core/src/store/app_settings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion ios/src/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand Down
30 changes: 22 additions & 8 deletions ios/src/RootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
5 changes: 2 additions & 3 deletions ios/src/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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()
}
}
4 changes: 4 additions & 0 deletions ios/src/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions tests/test_speech.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Loading