From 6f05cf52cf9d6fa3c1f8036bdc4f047de5065685 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sat, 17 Oct 2026 15:37:19 +0100 Subject: [PATCH 1/2] fix(ui): fetch the outer thread again when unwinding to it The return stack unwound to the outer thread, but the session still held the inner conversation, so the outer screen showed the wrong thread. Each return now records the thread it showed, and going back to a thread requests it again. A test counts that request, so it does not depend on when the network answers. Co-Authored-By: Claude Haiku 5.5 --- CHANGELOG.md | 1 + src/app/app.c | 58 ++++++++++++++++++++++++++++++-------- src/app/app_home.c | 2 +- src/app/app_internal.h | 13 +++++++-- src/app/app_menus.c | 8 ++++-- src/atproto/session.h | 1 + src/atproto/session_read.c | 16 ++++++++++- tests/snapshot_main.c | 7 ++++- 8 files changed, 87 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f7393..431dd6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ the GitHub release notes, so keep the `## [x.y.z] - date` headings exact. ### Fixed +- Going back to a thread fetches its conversation again. The return stack remembers which thread each screen showed, so after a nested unwind the outer thread no longer shows the inner conversation. (#110) - Backing out of a thread opened from a profile that was itself opened from a thread no longer bounces between the profile and the outer thread. Each screen returns to the one it came from, down to the timeline, through a return stack in place of the single return slots. The snapshot suite now walks that chain. (#110) ## [0.11.1] - 2026-10-16 diff --git a/src/app/app.c b/src/app/app.c index a6e3f43..c548ca4 100644 --- a/src/app/app.c +++ b/src/app/app.c @@ -90,9 +90,33 @@ cobalt_app_create(void) /* A thread or profile opened from `from` pushes it, so B from the new screen * goes back to it. When the stack is full the oldest return is dropped rather * than the newest, which is the one B is about to need. */ +/* The uri of the post the thread on screen was opened on, which is what + * cobalt_session_begin_thread takes to fetch that conversation again. Empty + * when no thread is loaded. Read it before a new thread is requested, since + * the request replaces the one on screen. */ void -cobalt_app_push_return(cobalt_app *app, cobalt_screen from) +cobalt_app_current_thread_uri(char *out, size_t cap) { + const cobalt_thread *thread = cobalt_session_thread(); + + if (!out || cap == 0) { + return; + } + out[0] = '\0'; + if (thread->focus >= 0 && thread->focus < thread->count) { + snprintf(out, cap, "%s", thread->posts[thread->focus].uri); + } +} + +/* A thread or profile opened from `from` pushes it, so B from the new screen + * goes back to it. `thread_uri` is the conversation `from` showed, when it was + * a thread. When the stack is full the oldest return is dropped rather than the + * newest, which is the one B is about to need. */ +void +cobalt_app_push_return(cobalt_app *app, cobalt_screen from, const char *thread_uri) +{ + cobalt_return *entry; + if (!app) { return; } @@ -101,16 +125,26 @@ cobalt_app_push_return(cobalt_app *app, cobalt_screen from) (COBALT_RETURN_DEPTH - 1) * sizeof(app->return_stack[0])); app->return_depth--; } - app->return_stack[app->return_depth++] = from; + entry = &app->return_stack[app->return_depth++]; + entry->screen = from; + snprintf(entry->thread_uri, sizeof entry->thread_uri, "%s", thread_uri ? thread_uri : ""); } +/* Returns the screen to go back to. Going back to a thread also fetches that + * conversation again, since the session may hold another one by now. */ cobalt_screen cobalt_app_pop_return(cobalt_app *app) { + const cobalt_return *entry; + if (!app || app->return_depth == 0) { return COBALT_SCREEN_HOME; } - return app->return_stack[--app->return_depth]; + entry = &app->return_stack[--app->return_depth]; + if (entry->screen == COBALT_SCREEN_THREAD && entry->thread_uri[0]) { + cobalt_session_begin_thread(entry->thread_uri); + } + return entry->screen; } cobalt_screen @@ -625,7 +659,7 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_PROFILE_VIEW_OPEN_THREAD: cobalt_thread_view_reset(&app->thread); - cobalt_app_push_return(app, COBALT_SCREEN_PROFILE); + cobalt_app_push_return(app, COBALT_SCREEN_PROFILE, NULL); app->screen = COBALT_SCREEN_THREAD; break; case COBALT_PROFILE_VIEW_MENU: @@ -645,12 +679,12 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_TIMELINE_OPEN_THREAD: cobalt_thread_view_reset(&app->thread); - cobalt_app_push_return(app, COBALT_SCREEN_TIMELINE); + cobalt_app_push_return(app, COBALT_SCREEN_TIMELINE, NULL); app->screen = COBALT_SCREEN_THREAD; break; case COBALT_TIMELINE_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_TIMELINE); + cobalt_app_push_return(app, COBALT_SCREEN_TIMELINE, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_TIMELINE_COMPOSE: @@ -718,12 +752,12 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_NOTIFY_OPEN_THREAD: cobalt_thread_view_reset(&app->thread); - cobalt_app_push_return(app, COBALT_SCREEN_NOTIFICATIONS); + cobalt_app_push_return(app, COBALT_SCREEN_NOTIFICATIONS, NULL); app->screen = COBALT_SCREEN_THREAD; break; case COBALT_NOTIFY_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_NOTIFICATIONS); + cobalt_app_push_return(app, COBALT_SCREEN_NOTIFICATIONS, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_NOTIFY_MENU: { @@ -878,7 +912,7 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_GRAPH_VIEW_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_FOLLOWS_LIST); + cobalt_app_push_return(app, COBALT_SCREEN_FOLLOWS_LIST, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_GRAPH_VIEW_STAY: @@ -894,7 +928,7 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_GRAPH_VIEW_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_LIKES_LIST); + cobalt_app_push_return(app, COBALT_SCREEN_LIKES_LIST, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_GRAPH_VIEW_STAY: @@ -910,7 +944,7 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_SEARCH_VIEW_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_SEARCH); + cobalt_app_push_return(app, COBALT_SCREEN_SEARCH, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_SEARCH_VIEW_OPEN_POSTS: @@ -931,7 +965,7 @@ app_update_inner(cobalt_app *app, const cobalt_input *in, uint32_t now_ms) break; case COBALT_LISTS_VIEW_OPEN_PROFILE: cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_LISTS); + cobalt_app_push_return(app, COBALT_SCREEN_LISTS, NULL); app->screen = COBALT_SCREEN_PROFILE; break; case COBALT_LISTS_VIEW_STAY: diff --git a/src/app/app_home.c b/src/app/app_home.c index 3346b31..60aa0f9 100644 --- a/src/app/app_home.c +++ b/src/app/app_home.c @@ -382,7 +382,7 @@ cobalt_app_update_account(cobalt_app *app, const cobalt_input *in) if (self[0]) { cobalt_session_begin_profile(self); cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, COBALT_SCREEN_ACCOUNT); + cobalt_app_push_return(app, COBALT_SCREEN_ACCOUNT, NULL); app->screen = COBALT_SCREEN_PROFILE; COBALT_LOGI("account: opened own profile"); } diff --git a/src/app/app_internal.h b/src/app/app_internal.h index ceb6b53..980e86b 100644 --- a/src/app/app_internal.h +++ b/src/app/app_internal.h @@ -51,6 +51,14 @@ * return is dropped. */ #define COBALT_RETURN_DEPTH 16 +/* A screen to return to, with the conversation it showed when it was left. + * The session keeps only the last thread it fetched, so returning to a thread + * fetches it again from this uri rather than showing whatever came after. */ +typedef struct { + cobalt_screen screen; + char thread_uri[COBALT_POST_URI_MAX]; +} cobalt_return; + struct cobalt_app { cobalt_screen screen; cobalt_display_mode display; @@ -99,7 +107,7 @@ struct cobalt_app { /* Where B from the thread or profile screen returns to. Each screen that * opens a thread or profile pushes the one it came from, so a chain such as * timeline -> thread -> profile -> thread unwinds one step at a time. */ - cobalt_screen return_stack[COBALT_RETURN_DEPTH]; + cobalt_return return_stack[COBALT_RETURN_DEPTH]; int return_depth; /* Where B from a likes/reposts list returns to — the timeline or the * thread. The post it belongs to is already in app->graph.actor. */ @@ -142,8 +150,9 @@ void cobalt_app_draw_tv_idle(cobalt_render *r); /* app_menus.c */ void cobalt_app_open_post_menu(cobalt_app *app, const cobalt_post *post, bool in_thread); void cobalt_app_open_profile_menu(cobalt_app *app); -void cobalt_app_push_return(cobalt_app *app, cobalt_screen from); +void cobalt_app_push_return(cobalt_app *app, cobalt_screen from, const char *thread_uri); cobalt_screen cobalt_app_pop_return(cobalt_app *app); +void cobalt_app_current_thread_uri(char *out, size_t cap); void cobalt_app_open_notification_menu(cobalt_app *app, const cobalt_notification *item); void cobalt_app_popup_choose(cobalt_app *app, int index); diff --git a/src/app/app_menus.c b/src/app/app_menus.c index f1517ef..a9d1046 100644 --- a/src/app/app_menus.c +++ b/src/app/app_menus.c @@ -118,12 +118,14 @@ void cobalt_app_popup_choose(cobalt_app *app, int index) { const cobalt_popup_item *it = &app->popup.items[index]; + char outer[COBALT_POST_URI_MAX]; switch (it->kind) { case COBALT_POPUP_PROFILE: case COBALT_POPUP_MENTION: if (cobalt_session_begin_profile(it->arg)) { cobalt_profile_view_rewind(&app->profile); - cobalt_app_push_return(app, app->popup_screen); + cobalt_app_current_thread_uri(outer, sizeof outer); + cobalt_app_push_return(app, app->popup_screen, outer); app->screen = COBALT_SCREEN_PROFILE; cobalt_popup_close(&app->popup); } @@ -216,9 +218,11 @@ cobalt_app_popup_choose(cobalt_app *app, int index) } break; case COBALT_POPUP_THREAD: + /* Read the conversation on screen before the request replaces it. */ + cobalt_app_current_thread_uri(outer, sizeof outer); if (cobalt_session_begin_thread(it->arg)) { cobalt_thread_view_reset(&app->thread); - cobalt_app_push_return(app, app->popup_screen); + cobalt_app_push_return(app, app->popup_screen, outer); app->screen = COBALT_SCREEN_THREAD; cobalt_popup_close(&app->popup); } diff --git a/src/atproto/session.h b/src/atproto/session.h index 5e0be16..1ec84c3 100644 --- a/src/atproto/session.h +++ b/src/atproto/session.h @@ -214,6 +214,7 @@ const cobalt_feed *cobalt_session_feed(void); * Fetch the conversation around `uri`. Replaces whatever thread was loaded. */ bool cobalt_session_begin_thread(const char *uri); +int cobalt_session_thread_requests(void); /* The thread as last fetched; never NULL. */ const cobalt_thread *cobalt_session_thread(void); diff --git a/src/atproto/session_read.c b/src/atproto/session_read.c index db9de83..015471a 100644 --- a/src/atproto/session_read.c +++ b/src/atproto/session_read.c @@ -794,6 +794,10 @@ cobalt_session_chat_started_convo(void) return g_session.chat_started; } +/* How many thread requests were accepted, so a test can see a conversation was + * fetched again without depending on when the network answers. */ +static int g_thread_requests; + bool cobalt_session_begin_thread(const char *uri) { @@ -804,5 +808,15 @@ cobalt_session_begin_thread(const char *uri) cobalt_job_input in; memset(&in, 0, sizeof(in)); snprintf(in.uri, sizeof(in.uri), "%s", uri); - return cobalt_session_submit(COBALT_JOB_THREAD, &in); + if (!cobalt_session_submit(COBALT_JOB_THREAD, &in)) { + return false; + } + g_thread_requests++; + return true; +} + +int +cobalt_session_thread_requests(void) +{ + return g_thread_requests; } diff --git a/tests/snapshot_main.c b/tests/snapshot_main.c index c40a1ee..73a5e8a 100644 --- a/tests/snapshot_main.c +++ b/tests/snapshot_main.c @@ -375,7 +375,12 @@ main(int argc, char **argv) CHECK(cobalt_app_screen(g_app) == COBALT_SCREEN_THREAD); frame(COBALT_BTN_BACK); settle(10); CHECK(cobalt_app_screen(g_app) == COBALT_SCREEN_PROFILE); - frame(COBALT_BTN_BACK); settle(10); + /* Going back to the outer thread fetches it again: the session held the + * inner conversation, so without the refetch the outer screen showed it. */ + const int requests = cobalt_session_thread_requests(); + frame(COBALT_BTN_BACK); + CHECK(cobalt_session_thread_requests() == requests + 1); + settle(10); CHECK(cobalt_app_screen(g_app) == COBALT_SCREEN_THREAD); frame(COBALT_BTN_BACK); settle(10); CHECK(cobalt_app_screen(g_app) == COBALT_SCREEN_TIMELINE); From a5c64e775a973c6a176317db79401e6424604cc7 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sat, 17 Oct 2026 15:41:57 +0100 Subject: [PATCH 2/2] docs(parity): note that unwinding to a thread fetches it again The flow-extra check asks for docs to move with code. The #110 row now says the outer conversation is refetched on the way back. Co-Authored-By: Claude Haiku 5.5 --- docs/PARITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PARITY.md b/docs/PARITY.md index 6ba5e57..fa5c264 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -70,7 +70,7 @@ These are Wii U specific and have no row in the other clients. |---|---|---| | GamePad touch coverage of the popup and header pills | issue #112 | | | TV layout uses the extra space | issue #111 | | -| Remember scroll position when returning from a thread or profile | partial #110 | The timeline keeps its cursor and scroll across a thread and across a profile, and notifications across a thread or profile; snapshot checks in `tests/snapshot_main.c` assert it on the host. A nested chain (timeline, thread, profile, thread) used to loop between the profile and the outer thread on B, and now unwinds one screen at a time through a return stack; `tests/snapshot_main.c` asserts it. I could not reproduce a timeline rewind and have not seen it on a console, so issue #110 stays open for the owner to confirm. | +| Remember scroll position when returning from a thread or profile | partial #110 | The timeline keeps its cursor and scroll across a thread and across a profile, and notifications across a thread or profile; snapshot checks in `tests/snapshot_main.c` assert it on the host. A nested chain (timeline, thread, profile, thread) used to loop between the profile and the outer thread on B, and now unwinds one screen at a time through a return stack. Going back to a thread fetches it again, so the outer conversation is the one shown; `tests/snapshot_main.c` asserts both. I could not reproduce a timeline rewind and have not seen it on a console, so issue #110 stays open for the owner to confirm. | | More menu unified with the thread and profile screens | implemented | Y opens it on the timeline, thread, notifications and profile. The profile's X, Y and + moved into it; notifications get profile, post and refresh. Snapshot checks in `tests/snapshot_main.c`; search results are the timeline screen and already had it. Host only. | | User guide with controls per screen and screenshots | implemented | `docs/guide.md`, checked by `tools/check-guide.py`. | | First on-console acceptance pass | issue #24 | Needs the owner's console. |