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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
4 changes: 4 additions & 0 deletions apple/shared/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions core/include/fastsm/session/core_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<std::string, std::string> 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<std::string, std::string> 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
Expand Down
29 changes: 27 additions & 2 deletions core/include/fastsm/timeline/timeline_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ class TimelineController {
int max_pages, int fetch_limit,
const std::function<TimelinePage(const PageCursor&)>& 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<TimelineItem> history;
std::vector<std::pair<std::string, PageCursor>> marks;
std::optional<PageCursor> tail;
bool found = false;
};
static MarkerScan scan_to_marker(
const std::string& status_id, int max_pages, int fetch_limit,
const std::function<TimelinePage(const PageCursor&)>& fetch);
void load_to_marker(const std::string& status_id, int max_pages,
std::function<void(bool found)> 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
Expand Down Expand Up @@ -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<std::string> 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
Expand Down Expand Up @@ -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<PageCursor> scrollback_cursor_;
std::vector<store::CacheGap> gaps_; // tracked middle gaps (after_id -> cursor)
// Page-boundary cursors (row id -> cursor to fetch the page just below it),
Expand Down
70 changes: 64 additions & 6 deletions core/src/session/core_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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{}));
Expand Down Expand Up @@ -3846,8 +3860,10 @@ std::unique_ptr<TimelineController> 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
Expand All @@ -3856,8 +3872,11 @@ std::unique_ptr<TimelineController> 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
Expand All @@ -3866,17 +3885,56 @@ std::unique_ptr<TimelineController> CoreSession::make_controller(SocialAccount*
const std::optional<std::string> 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;
}

Expand Down
137 changes: 137 additions & 0 deletions core/src/timeline/timeline_controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(visible_.size()) - 1 : 0;
if (marker_restore_pending_ && selected == default_edge)
return;
if (on_user_moved)
on_user_moved();
}
Expand Down Expand Up @@ -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<TimelinePage(const PageCursor&)>& 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<int>(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<void(bool found)> 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<std::string> 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;
Expand Down Expand Up @@ -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<size_t>(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;
}

Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ios/src/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_ scene: UIScene) {
if wasBackgrounded {
wasBackgrounded = false
state?.refresh()
state?.resume()
}
}

Expand Down
Loading
Loading