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/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. | 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);