diff --git a/android/app/src/main/java/me/masonasons/fastsm/MainActivity.kt b/android/app/src/main/java/me/masonasons/fastsm/MainActivity.kt index 5c1be70..7e9d616 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/MainActivity.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/MainActivity.kt @@ -44,6 +44,7 @@ import me.masonasons.fastsm.util.CustomTabs class MainActivity : ComponentActivity() { private val vm: CoreViewModel by viewModels() + private var hasResumed = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -57,6 +58,13 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + // Startup already loads and syncs the timelines. Later resumes begin a + // new marker-sync window so a position moved on another client is pulled. + if (hasResumed) vm.resume() else hasResumed = true + } + // singleTop: the fastsm://oauth redirect re-enters the live activity here. override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) diff --git a/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt b/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt index e6cd28c..d378c13 100644 --- a/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt +++ b/android/app/src/main/java/me/masonasons/fastsm/ui/CoreViewModel.kt @@ -626,6 +626,8 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) { fun refreshAll() = core.dispatch("refresh_all") + fun resume() = core.dispatch("resume") + /** Open a post's conversation as a new tab. */ fun openThread(id: String) = core.dispatch("open_thread") { put("id", id) } diff --git a/apple/shared/AppState.swift b/apple/shared/AppState.swift index 9845e25..9b52ebb 100644 --- a/apple/shared/AppState.swift +++ b/apple/shared/AppState.swift @@ -261,6 +261,10 @@ final class AppState { client.send("perform_action", ["action": action, "id": id]) } func refresh() { client.send("refresh") } + /// The app became active after being backgrounded. Unlike a manual refresh, + /// this starts a fresh Home-marker sync window so another client's position + /// can be adopted. + func resume() { client.send("resume") } // Settings: mutate the full object and echo it back so the core keeps every // field (it re-applies defaults for anything missing). diff --git a/core/include/fastsm/session/core_session.hpp b/core/include/fastsm/session/core_session.hpp index 870cbc4..9b0b169 100644 --- a/core/include/fastsm/session/core_session.hpp +++ b/core/include/fastsm/session/core_session.hpp @@ -65,6 +65,7 @@ class CoreSession { void cmd_select_account(const nlohmann::json& cmd); void cmd_refresh(); void cmd_refresh_all(); + void cmd_resume(); // app returned from background: refresh + pull remote Home marker void cmd_load_older(const nlohmann::json& cmd); void cmd_load_gap(const nlohmann::json& cmd); void cmd_note_selection(const nlohmann::json& cmd); @@ -379,6 +380,9 @@ class CoreSession { std::string marker_pending_key_; // account_key of the pending save std::string marker_pending_id_; // status id to push std::unordered_map marker_last_saved_; // key -> last id sent + // A marker that a bounded history walk could not find. Do not repeat the + // same expensive walk every auto-refresh; a changed server marker may retry. + std::unordered_map marker_recovery_attempted_; int marker_gen_ = 0; // cancels superseded saves // Include each post's links ({title,url}) in its row JSON, so a mobile app // can offer one action per link. On only when the "expand_links" post diff --git a/core/include/fastsm/timeline/timeline_controller.hpp b/core/include/fastsm/timeline/timeline_controller.hpp index 65e6b0f..64e2faf 100644 --- a/core/include/fastsm/timeline/timeline_controller.hpp +++ b/core/include/fastsm/timeline/timeline_controller.hpp @@ -79,6 +79,23 @@ class TimelineController { int max_pages, int fetch_limit, const std::function& fetch); + // Missing-marker recovery (pure scan + asynchronous controller wrapper). + // Home-position sync normally restores directly from the cache/refreshed + // rows. If the server marker is older than that window, walk newest-first + // until the marked status arrives, then merge that contiguous history + // without treating old rows as newly received. + struct MarkerScan { + std::vector history; + std::vector> marks; + std::optional tail; + bool found = false; + }; + static MarkerScan scan_to_marker( + const std::string& status_id, int max_pages, int fetch_limit, + const std::function& fetch); + void load_to_marker(const std::string& status_id, int max_pages, + std::function done); + // Global "reverse timelines" preference: when on, the visible list is flipped // (oldest at top, newest at bottom) for time-ordered feeds only. raw_ stays // canonical newest-first, so merge/gap/pagination are unaffected — only the @@ -187,9 +204,16 @@ class TimelineController { bool user_moved_position() const { return user_moved_position_; } void reset_user_moved() { user_moved_position_ = false; } std::optional selected_status_id() const; - // Move the reading position to the row carrying `status_id`; returns true if - // such a row exists and the position actually changed. + // Move the reading position to the row carrying `status_id`. If that status + // was fetched but hidden by a filter, use its nearest visible neighbor. + // Returns true if the position actually changed. bool restore_marker_position(const std::string& status_id); + // During the initial server-marker lookup, front ends may focus the default + // edge row simply because the list appeared (not because the user navigated). + // Treat only that one provisional selection as passive; any move away from + // the edge still cancels the restore and wins over the server position. + void begin_marker_restore() { marker_restore_pending_ = true; } + void finish_marker_restore() { marker_restore_pending_ = false; } // Cache key of the timeline that was current when this one was spawned, so // closing it returns there instead of a neighbor (Mac parity). Empty for the @@ -230,6 +254,7 @@ class TimelineController { bool muted_ = false; // user muted this tab's new-item earcon bool auto_read_ = false; // user enabled auto-read for this tab bool user_moved_position_ = false; // user moved the home position since the last sync cycle + bool marker_restore_pending_ = false; // server marker lookup/backfill is in progress std::optional scrollback_cursor_; std::vector gaps_; // tracked middle gaps (after_id -> cursor) // Page-boundary cursors (row id -> cursor to fetch the page just below it), diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index cd16dc8..066b8d7 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -365,6 +365,8 @@ void CoreSession::handle(const json& cmd) { cmd_refresh(); else if (c == "refresh_all") cmd_refresh_all(); + else if (c == "resume") + cmd_resume(); else if (c == "load_older") cmd_load_older(cmd); else if (c == "load_gap") @@ -846,6 +848,18 @@ void CoreSession::cmd_refresh_all() { tc->refresh(); } +void CoreSession::cmd_resume() { + TimelineController* tc = current(); + if (!tc) + return; + // A move made before this app was backgrounded is already being pushed to + // the server; it must not suppress the first marker pull after another + // client may have advanced the shared position. Any movement made after + // resuming sets the flag again and still wins over the asynchronous pull. + tc->reset_user_moved(); + tc->refresh(); +} + void CoreSession::cmd_load_gap(const json& cmd) { if (TimelineController* tc = current()) tc->load_gap(cmd.value("id", std::string{})); @@ -3846,8 +3860,10 @@ std::unique_ptr CoreSession::make_controller(SocialAccount* // position — unless the user has already moved the cursor themselves this // session (matches FastSM/FastSMApple). tc->on_refreshed = [this, p] { - if (!sync_enabled_for(p)) + if (!sync_enabled_for(p)) { + p->finish_marker_restore(); return; + } // One sync cycle per refresh. If the user has actively moved since the // last cycle, their position is already being pushed to the server by // note_selection — do NOT pull the (now older) server position back and @@ -3856,8 +3872,11 @@ std::unique_ptr CoreSession::make_controller(SocialAccount* // client follows an active one. The window resets each cycle. const bool moved = p->user_moved_position(); p->reset_user_moved(); - if (moved) + if (moved) { + p->finish_marker_restore(); return; + } + p->begin_marker_restore(); SocialAccount* account = p->account(); const std::string key = account->account_key(); // Fetch on the interactive worker so it runs alongside the refresh's page @@ -3866,17 +3885,56 @@ std::unique_ptr CoreSession::make_controller(SocialAccount* const std::optional marker = account->home_marker(); loop_.post([this, key, marker] { TimelineController* home = home_controller_for(key); - if (!home || !sync_enabled_for(home)) + if (!home) return; - if (home->user_moved_position()) + if (!sync_enabled_for(home)) { + home->finish_marker_restore(); + return; + } + if (home->user_moved_position()) { + home->finish_marker_restore(); return; // the user started reading during the fetch — don't yank them - if (!marker || marker->empty()) + } + if (!marker || marker->empty()) { + marker_recovery_attempted_.erase(key); + home->finish_marker_restore(); return; - if (home->restore_marker_position(*marker)) + } + if (home->restore_marker_position(*marker)) { + marker_recovery_attempted_.erase(key); + home->finish_marker_restore(); emit_select_row(home, home->selected_id()); + return; + } + // The marker fell beyond this client's cache/refresh window. + // Recover a contiguous slice from the newest post through the + // marker, bounded to avoid an unbounded request loop on a deleted + // status or a server returning inconsistent pagination. + if (marker_recovery_attempted_[key] == *marker) { + home->finish_marker_restore(); + return; + } + marker_recovery_attempted_[key] = *marker; + constexpr int kMarkerRecoveryPages = 25; + home->load_to_marker( + *marker, kMarkerRecoveryPages, + [this, key, marker = *marker](bool found) { + TimelineController* restored = home_controller_for(key); + if (!restored) + return; + const bool user_moved = restored->user_moved_position(); + restored->finish_marker_restore(); + if (!found || user_moved || !sync_enabled_for(restored)) + return; + marker_recovery_attempted_.erase(key); + if (restored->restore_marker_position(marker)) + emit_select_row(restored, restored->selected_id()); + }); }); }); }; + if (sync_enabled_for(p)) + p->begin_marker_restore(); return tc; } diff --git a/core/src/timeline/timeline_controller.cpp b/core/src/timeline/timeline_controller.cpp index 4594408..600ac5a 100644 --- a/core/src/timeline/timeline_controller.cpp +++ b/core/src/timeline/timeline_controller.cpp @@ -41,6 +41,16 @@ void TimelineController::note_selection(const std::string& id) { } } selected_id_ = id; + // iOS/VoiceOver (and some desktop list controls) focus the default edge row + // as soon as a newly-populated list appears. While the first server-marker + // restore is pending, that focus echo is provisional rather than evidence + // that the user deliberately moved. A move to any other row still fires the + // callback and therefore cancels the restore as before. + const int selected = visible_index_of(selected_id_); + const int default_edge = + reversed() && !visible_.empty() ? static_cast(visible_.size()) - 1 : 0; + if (marker_restore_pending_ && selected == default_edge) + return; if (on_user_moved) on_user_moved(); } @@ -480,6 +490,107 @@ TimelineController::RefreshScan TimelineController::scan_refresh( return out; } +TimelineController::MarkerScan TimelineController::scan_to_marker( + const std::string& status_id, int max_pages, int fetch_limit, + const std::function& fetch) { + MarkerScan out; + if (status_id.empty() || max_pages < 1) + return out; + + PageCursor cursor = PageCursor::start(); + for (int page_number = 0; page_number < max_pages; ++page_number) { + TimelinePage page = fetch(cursor); + const size_t page_size = page.items.size(); + out.tail = page.next_cursor; + if (page_size == 0) + break; + + std::string last_consumed; + for (auto& item : page.items) { + last_consumed = item.id(); + const Status* status = item.status(); + const bool is_marker = status && status->id == status_id; + out.history.push_back(std::move(item)); + if (is_marker) { + out.found = true; + break; + } + } + if (out.found) + break; + + if (page.next_cursor && !last_consumed.empty()) + out.marks.push_back({last_consumed, *page.next_cursor}); + if (!page.next_cursor || static_cast(page_size) < fetch_limit) + break; + cursor = *page.next_cursor; + } + return out; +} + +void TimelineController::load_to_marker(const std::string& status_id, int max_pages, + std::function done) { + if (status_id.empty() || loading_ || !account_ || !worker_ || !main_) { + if (done) + done(false); + return; + } + loading_ = true; + worker_->post([this, status_id, max_pages, done = std::move(done)]() mutable { + MarkerScan scan = scan_to_marker( + status_id, max_pages, fetch_limit_, + [this](const PageCursor& cursor) { + return account_->items(source_, fetch_limit_, cursor); + }); + main_->post([this, status_id, scan = std::move(scan), done = std::move(done)]() mutable { + std::unordered_set existing; + existing.reserve(raw_.size() + scan.history.size()); + for (const auto& item : raw_) + existing.insert(item.id()); + for (auto& item : scan.history) + if (existing.insert(item.id()).second) + raw_.push_back(std::move(item)); + for (auto& mark : scan.marks) + page_marks_[mark.first] = mark.second; + + if (source_.is_time_ordered()) + std::stable_sort(raw_.begin(), raw_.end(), + [](const TimelineItem& a, const TimelineItem& b) { + if (a.is_pinned() != b.is_pinned()) + return a.is_pinned(); + return a.sort_date() > b.sort_date(); + }); + + // A successful Mastodon marker scan is contiguous from the newest row + // through the marked status. Resume ordinary scrollback immediately + // below that status; any prior middle-gap records are now redundant. + if (scan.found && source_.paginates_by_item_id()) { + scrollback_cursor_ = PageCursor::max_id(status_id); + auto marker = std::find_if(raw_.begin(), raw_.end(), + [&](const TimelineItem& item) { + const Status* status = item.status(); + return status && status->id == status_id; + }); + if (marker != raw_.end()) + page_marks_[marker->id()] = *scrollback_cursor_; + gaps_.clear(); + } else if (scan.tail) { + // If the safety bound was reached, keep the newly-discovered tail + // available to the normal Load Older path. + scrollback_cursor_ = scan.tail; + } + + rebuild_visible(); + persist(); + loading_ = false; + if (on_change) + on_change(); + if (done) + done(scan.found); + }); + }); +} + void TimelineController::refresh() { if (source_.is_static()) // seeded rows never refresh from the network return; @@ -550,6 +661,32 @@ bool TimelineController::restore_marker_position(const std::string& status_id) { return true; } } + + // The marker can be present in raw_ but absent from visible_ because a + // server/client filter hides it. Anchor to the closest visible status in + // canonical timeline order instead of abandoning the restore at the top. + auto marker = std::find_if(raw_.begin(), raw_.end(), [&](const TimelineItem& item) { + const Status* status = item.status(); + return status && status->id == status_id; + }); + if (marker == raw_.end()) + return false; + const size_t marker_index = static_cast(marker - raw_.begin()); + for (size_t distance = 1; distance < raw_.size(); ++distance) { + const size_t newer = marker_index >= distance ? marker_index - distance : raw_.size(); + const size_t older = marker_index + distance; + for (const size_t candidate : {newer, older}) { + if (candidate >= raw_.size()) + continue; + const std::string& id = raw_[candidate].id(); + if (visible_index_of(id) < 0) + continue; + if (selected_id_ == id) + return false; + selected_id_ = id; + return true; + } + } return false; } diff --git a/docs/changelog.txt b/docs/changelog.txt index 1af1b42..a289f54 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -6,6 +6,7 @@ FastSMRW changelog - New: open the built-in user guide from inside the app — press F1 on Windows and Mac, or choose Help in the More menu on iPhone and Android. - Fixed: home position sync now saves your spot no matter how you move through the timeline (including the invisible interface), and no longer tugs you around while you're actively reading. +- Fixed: home position sync now catches up when you return to a backgrounded app, and can find your last-read post even when it is older than this device's saved timeline. - Fixed: the Mac and iPhone settings now make it clear that "Off" disables emoji removal rather than hiding emoji. 0.5.2 diff --git a/ios/src/SceneDelegate.swift b/ios/src/SceneDelegate.swift index 9a5c0cd..bfb5ed2 100644 --- a/ios/src/SceneDelegate.swift +++ b/ios/src/SceneDelegate.swift @@ -67,7 +67,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { if wasBackgrounded { wasBackgrounded = false - state?.refresh() + state?.resume() } } diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index 3d82755..884562d 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -14,6 +14,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let state = AppState() private var mainWindowController: MainWindowController? private var settingsWindowController: SettingsWindowController? + private var hasBeenActive = false func applicationDidFinishLaunching(_ notification: Notification) { NSApp.mainMenu = MainMenu.build() @@ -38,6 +39,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { state.start() } + func applicationDidBecomeActive(_ notification: Notification) { + // The initial activation is already covered by start(). On subsequent + // activations (for example Command-Tabbing back), refresh and begin a + // fresh server-marker pull so this client follows the active device. + guard hasBeenActive else { + hasBeenActive = true + return + } + state?.resume() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } // Custom URL scheme: fastsm://oauth?code=… completes a Mastodon login. diff --git a/tests/main.cpp b/tests/main.cpp index a3e9673..9ea07c0 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -114,8 +114,11 @@ void test_refresh_fills_gap_below_streamed_top(); void test_refresh_steady_state_stops_early(); void test_refresh_fills_multipage_gap(); void test_refresh_keeps_updated_conversation(); +void test_marker_recovery_pages_until_found(); +void test_marker_recovery_obeys_page_bound(); void test_lost_row_keeps_reading_position(); void test_position_hint_falls_back_to_nearest(); +void test_marker_restore_ignores_only_provisional_edge_focus(); static void test_version() { CHECK(fastsm::version() != nullptr); @@ -213,8 +216,11 @@ int main() { test_refresh_steady_state_stops_early(); test_refresh_fills_multipage_gap(); test_refresh_keeps_updated_conversation(); + test_marker_recovery_pages_until_found(); + test_marker_recovery_obeys_page_bound(); test_lost_row_keeps_reading_position(); test_position_hint_falls_back_to_nearest(); + test_marker_restore_ignores_only_provisional_edge_focus(); std::printf("%d checks, %d failures\n", fastsmtest::checks(), fastsmtest::failures()); return fastsmtest::failures() == 0 ? 0 : 1; diff --git a/tests/test_timeline_refresh.cpp b/tests/test_timeline_refresh.cpp index 9d3fcb8..1ad48f3 100644 --- a/tests/test_timeline_refresh.cpp +++ b/tests/test_timeline_refresh.cpp @@ -115,6 +115,42 @@ void test_refresh_keeps_updated_conversation() { CHECK(scan.hit_known); // and the unchanged conversation stopped the scan } +// A server-synced home marker can be older than this device's cache. Recovery +// must keep paging until that exact status arrives, retain the contiguous history +// above it, and stop there rather than needlessly walking farther back. +void test_marker_recovery_pages_until_found() { + int fetches = 0; + auto fetch = [&](const PageCursor& cursor) { + ++fetches; + if (cursor.kind == CursorKind::Start) + return mkpage({"n6", "n5"}, PageCursor::max_id("n5")); + if (cursor.value == "n5") + return mkpage({"n4", "n3"}, PageCursor::max_id("n3")); + return mkpage({"n2", "marker", "older"}, PageCursor::max_id("older")); + }; + const auto scan = TimelineController::scan_to_marker( + "marker", /*max_pages=*/10, /*fetch_limit=*/2, fetch); + CHECK(scan.found); + CHECK_EQ(fetches, 3); + CHECK_EQ(scan.history.size(), size_t(6)); + if (scan.history.size() == 6) + CHECK_EQ(scan.history.back().id(), std::string("s:marker")); +} + +// A missing/deleted marker cannot cause an unbounded network walk. Reaching the +// safety cap returns the fetched prefix for stitching/manual scrollback. +void test_marker_recovery_obeys_page_bound() { + int fetches = 0; + auto fetch = [&](const PageCursor&) { + ++fetches; + return mkpage({"new", "old"}, PageCursor::max_id(std::to_string(fetches))); + }; + const auto scan = TimelineController::scan_to_marker( + "missing", /*max_pages=*/4, /*fetch_limit=*/2, fetch); + CHECK(!scan.found); + CHECK_EQ(fetches, 4); +} + // Losing the row you're reading must not lose your place in the timeline. A post // can vanish under the cursor at any time — the author deletes it, or a filter // starts hiding it — and every front end finds the reading position by id, so a @@ -184,3 +220,22 @@ void test_position_hint_falls_back_to_nearest() { tc.set_filter(nullptr); CHECK_EQ(tc.selected_id(), std::string("s:mid")); } + +// List controls can focus the first row automatically while a startup marker +// restore is pending. Only that default-edge echo is passive; navigating even +// one row away must still count as a real move and cancel the restore. +void test_marker_restore_ignores_only_provisional_edge_focus() { + TimelineSource src; + src.kind = TimelineSource::Kind::PostUsers; // static: no I/O + TimelineController tc(nullptr, src, nullptr, nullptr, nullptr, 40); + for (const char* id : {"third", "second", "first"}) + tc.ingest_realtime(mkitem(id)); + + int moves = 0; + tc.on_user_moved = [&] { ++moves; }; + tc.begin_marker_restore(); + tc.note_selection(tc.items().front().id()); + CHECK_EQ(moves, 0); + tc.note_selection(tc.items()[1].id()); + CHECK_EQ(moves, 1); +} diff --git a/windows/src/main_window.cpp b/windows/src/main_window.cpp index 745e205..261a4b7 100644 --- a/windows/src/main_window.cpp +++ b/windows/src/main_window.cpp @@ -507,6 +507,15 @@ LRESULT MainWindow::WndProc(UINT msg, WPARAM wp, LPARAM lp) { SetFocus(timeline_view_); return 0; + case WM_ACTIVATEAPP: + if (!wp) { + was_deactivated_ = true; + } else if (was_deactivated_) { + was_deactivated_ = false; + dispatch_cmd({{"cmd", "resume"}}); + } + return 0; + case WM_HOTKEY: { // A global invisible-interface hotkey fired: run its core action. const std::string action = hotkey_driver_.action_for(static_cast(wp)); diff --git a/windows/src/main_window.hpp b/windows/src/main_window.hpp index d845d77..aedcf31 100644 --- a/windows/src/main_window.hpp +++ b/windows/src/main_window.hpp @@ -237,6 +237,7 @@ class MainWindow { std::map layer_bindings_; // cached bare-key layer map std::string layer_activation_ = "control+win+space"; // cached layer toggle combo bool overlay_layer_ = false; // the layer was called up on demand (EnterLayer) + bool was_deactivated_ = false; // another app was active; resume sync on return // Idempotency guards: apply_invisible() re-fetches keymaps only when one of these // changed (otherwise every unrelated settings broadcast would thrash the driver), // and install_active_driver() skips a redundant (re)install of the same signature.