Forte Board Reply Restore — Step 4: Implementation Summary

Stage 1 - Dynamic return_to tracking tag and selection

  • Changes:

    • public/assets/paned_board_reader.js: added composeReturnToUrl(threadId) (builds /forte plus tag/selected query params via URLSearchParams, the same technique urlForState() already uses, kept as a separate function so existing tag/sort pushState behavior is untouched) and currentSelectedThreadId() (reads the currently --selected row, if any).

    • setComposeTarget(threadId) now also writes the panel's return_to hidden input via composeReturnToUrl(threadId), alongside the existing thread_id/parent_id updates.

    • applyFolderSelection(tag) now calls setComposeTarget(currentSelectedThreadId()) after updating the URL, so return_to picks up a tag change even when the selected thread survives the filter (the "selection is cleared by the filter" case was already covered, since that routes through resetContentPane()'s existing setComposeTarget("") call).

    • Removed the now-duplicate currentTagFromUrl() definition (hoisted the original up next to the new helpers; only one copy remains).

  • Verification:

    • node --check public/assets/paned_board_reader.js clean.

    • Headless-browser test (Selenium + chromium-browser) on /forte: return_to starts as /forte; selecting a thread updates it to /forte?selected={id}; filtering by a tag that still includes that thread updates it to /forte?tag={tag}&selected={id}; filtering by a tag that excludes it clears back to /forte?tag={tag} (selection cleared). Zero console errors in either run.

  • Notes:

    • This only changes the panel's outgoing return_to value; the server doesn't yet honor a tag/selected query string on /forte returns (that's Stage 2), so submitting a reply right now would still fall back to the classic behavior established in forte_board_reply until Stage 2 lands.

Stage 2 - Server accepts and rebuilds a safe /forte return path

  • Changes:

    • Application.php::resolveComposeReplyReturnTo(): extended to also match /forte with an optional query string, delegating to new buildForteBoardReturnTo(string $requestedQueryString): string.

    • buildForteBoardReturnTo(): parses the requested query string and rebuilds the return URL from only a fixed allowlist (tag matched against ^[a-z0-9-]+$, selected matched against ^[A-Za-z0-9._:-]+$) — the client-supplied query string is never passed through verbatim; anything absent, invalid, or unrecognized is silently dropped rather than rejecting the whole redirect, so a bad tag doesn't also cost a valid selected (or vice versa).

    • Fixed a bug this surfaced in handleComposeReplySubmit(): the success-path $location was built by unconditionally appending ?created_post_id=..., which produced a malformed double-? URL (e.g. /forte?tag=general?created_post_id=...) whenever $returnTo already carried its own query string. Now appends with & when $returnTo already contains ?, ? otherwise.

  • Verification:

    • php -l clean.

    • curl -i -X POST /compose/reply matrix: return_to=/forte?tag=general&selected=root-001 → Location: /forte?tag=general&selected=root-001&created_post_id=... (correctly joined with &, not a second ?); selected-only and tag-only variants both round-trip correctly; plain /forte still works.

    • Invalid tag (<script>) with a valid selected → tag is dropped, selected survives (/forte?selected=root-001&...), confirming per-field stripping rather than an all-or-nothing rejection.

    • An unrecognized extra param (evil=https://evil.example.com) is silently discarded — never reaches the rebuilt URL, confirming the allowlist approach closes that injection path.

    • Regression checks unchanged from forte_reply/forte_board_reply: a fully foreign return_to and a missing return_to both still fall back to the classic /threads/{id} location; the existing /threads/{id}/forte single-thread return path still resolves correctly.

  • Notes:

    • The double-? bug was only reachable once return_to could carry its own query string (as of Stage 1), so it couldn't have been hit by forte_reply/forte_board_reply's earlier, always-bare return_to values — this was a genuine gap opened by this feature, not a preexisting one.

Stage 3 - Restore selection on page load

  • Changes:

    • public/assets/paned_board_reader.js: at the end of the DOMContentLoaded setup, reads selected from location.search; if it matches a rendered, currently-visible (!row.hidden) row, calls the existing selectThread() to restore it. Tag restoration needed no client change — it was already fully server-rendered via $selectedTag/the hidden attribute on rows (confirmed by inspecting paned_board_thread_list.php), so this stage only needed to add the selection half.

  • Verification:

    • node --check public/assets/paned_board_reader.js clean.

    • Headless-browser test (Selenium + chromium-browser): loading /forte?tag=general&selected=root-001 directly shows that row selected, its content unhidden, and Reply enabled — all from a fresh page load, no interaction. Loading /forte?selected=does-not-exist-xyz leaves the default placeholder state untouched with Reply disabled and no console errors, confirming the "thread no longer exists" fallback from Step 2's core requirements.

  • Notes:

    • The !row.hidden guard means a selected id for a thread that's filtered out by the current tag is treated the same as a nonexistent one (falls back to no selection) rather than fighting the active filter — consistent with "restore only if it makes sense" rather than forcing a filter change the reader didn't ask for.

Stage 4 - Full round trip and regression verification

  • Changes: none (integration/regression verification only, as planned).

  • Verification:

    • Headless-browser test (Selenium + chromium-browser) covering the full flow: filter by a tag → select a thread → open the composer and confirm return_to is /forte?tag=...&selected=root-001 → submit a reply → confirm the post-submit URL carries the same tag/selected → confirm the reloaded page shows that thread selected (paned-list-row--selected present) and Reply enabled. Zero console errors.

    • curl http://127.0.0.1:8001/forte/threads/root-001/replies after that submission shows the new reply body, confirming the reply itself was created correctly through the full flow (not just the redirect).

    • Edge case (Step 2's stated requirement): loading /forte fresh with no filter/no selection yields return_to = '/forte' exactly as before this feature — no regression for the common case.

    • Regression check: the single-thread reader's compose panel at /threads/root-001/forte still has return_to = /threads/root-001/forte, confirming forte_reply's return path is completely untouched by this feature.

  • Notes:

    • This completes all 4 planned stages for forte_board_reply_restore. Together with forte_reply and forte_board_reply, Forte's reply flow now preserves reader context (selection, and on the board view, tag filter) across every reply surface.