Skip to content

fix(desktop): route forum message links to the post, not the post list - #7361

Open
PresentJay wants to merge 4 commits into
block:mainfrom
PresentJay:fix/forum-message-deep-links
Open

PresentJay wants to merge 4 commits into
block:mainfrom
PresentJay:fix/forum-message-deep-links

Conversation

@PresentJay

@PresentJay PresentJay commented Sep 5, 2026 •

Copy link
Copy Markdown

Why

Fixes #7315. A buzz://message link into a forum channel opens the post
list and never selects the post, so a forum message is unreachable from its own
"Copy link".

Root cause

Both message-link entry points routed everything through goChannel:

  • desktop/src/shared/useMessageDeepLinks.ts — the deep-link listener
  • desktop/src/shared/ui/markdown.tsx — the in-app buzz:// handler

/channels/$channelId cannot select a forum post: channels.$channelId.tsx
hardcodes selectedPostId={null}, and useChannelRouteTarget returns early
when activeChannel.channelType === "forum". The route that works —
/channels/$channelId/posts/$postId — was never used by message links.

What

Add resolveMessageLinkDestination(channelId, messageId, threadRootId),
mirroring resolveSearchHitDestination. Search already solves exactly this:
openSearchHitWithNavigation resolves a destination first and branches to
goForumPost for forum targets. A message link carries no kind of its own, so
the event is fetched and branched on KIND_FORUM_POST / KIND_FORUM_COMMENT;
a comment resolves its post via getThreadReference and passes itself as
replyId.

Both entry points share useOpenMessageLink. Queued OS links are acknowledged
only after their destination is resolved and navigation is accepted. Lookup
failures, missing forum roots, and refused navigation leave the durable entry
available for retry. In-app clicks retain their best-effort channel fallback.

Pending lookups cannot navigate after community teardown or listener cancellation.
A shared request token makes the latest message-link activation win, including
clicks from separate mounted message renderers; community reset invalidates the token.

Extracting the hook also takes markdown.tsx from 1862 to 1850 lines — the
file-size ratchet blocks growth on files already over the limit, and inlining
the branch there would have added 14.

Two comments asserting the old behaviour is fine are corrected in the same
change (messageLink.ts, useMessageDeepLinks.ts); the issue calls both out.

Before / after

The two destinations, in the mock watercooler forum. A forum message link
used to land on the left and now lands on the right.

Before — /channels/<forum>?messageId=<post>: the post list, nothing selected:

before

After — /channels/<forum>/posts/<post>: the post, with its 25 replies:

after

Tests

resolveMessageLinkDestination.test.mjs covers forum post, forum comment
(asserting both postId and replyId), stream message, a comment with no
resolvable root, and a failed lookup. useOpenMessageLink.test.mjs mounts the
production hooks with the real resolver and durable deep-link drain. It covers
routing, acknowledgement ordering, failure/refusal and retry, teardown, and
out-of-order lookup completion across hook instances. Mutation checks verify
that restoring fallback acknowledgement or removing the request fence fails
the relevant regressions.

Fork PRs need maintainer approval before CI runs, so local results for the
pushed head, via the repo's pre-push lanes:

desktop-check      ✔  biome + check:px-text + check:pubkey-truncation
desktop-typecheck  ✔  tsc --noEmit
desktop-test       ✔
file-size-check    ✔  markdown.tsx 1862 -> 1850

@PresentJay
PresentJay requested a review from a team as a code owner September 5, 2026 00:32
@github-actions

github-actions Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 3c7f288c60d67df78577b237e27c3dfc8831aaa1...e775087209b6a701eeb8e28ee3ceac9552b3b950.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review e775087209b6a701eeb8e28ee3ceac9552b3b950 to authorize a new review.
Any previous review applies only to its recorded range.

@chessing234 chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forum vs stream branching matches search routing, and the failure path staying clickable is right. good regression coverage.

@chessing234 chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deep link handler returns true before openMessageLink finishes, so we ack the pending link even if nav never lands — channel links still await goChannel. please make openMessageLink return the navigation promise and only return true after it resolves.

@PresentJay

PresentJay commented Sep 5, 2026 •

Copy link
Copy Markdown
Author

@chessing234 Good catch — that was a regression I introduced. Before this PR the handler
was return goChannel(...), so the listener's promise already settled on the
navigation; extracting the hook turned it into void …then(…) and dropped
that. The asymmetry with the channel listener right above it is exactly the
tell.

Fixed as you asked:

  • useOpenMessageLink now returns Promise<void> — the void …then(…) is
    gone, and both branches await their navigation, so the promise settles only
    after goForumPost / goChannel resolve.
  • useMessageDeepLinks awaits it before resolving true.

I did not add a test for the ordering, and want to be explicit about why rather
than leave it implied:

  • The return type carries half of it — going back to fire-and-forget now fails
    tsc.
  • The ack contract itself is already covered on the listener side
    (deep-link.test.mjs: rejected navigation remains queued and is not
    acknowledged
    , refused navigation stays at the FIFO head). What I broke was
    a caller violating that contract, not the contract.
  • A hook-level ordering assertion needs a real router (the pattern in
    inboxReopenNavigation.test.mjs), which makes goChannel actually navigate
    and the assertion fragile.

If you would rather have it pinned by a test anyway, say the word and I will add
the router-backed one.

Local results for the new head: pnpm typecheck, pnpm check, and pnpm test
(6454 pass / 0 fail) are clean.

@chessing234 chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openMessageLink now returns the nav promise and the listener only acks after it resolves, same as goChannel above it. good.

@PresentJay

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-07T13:59:39.620486Z e775087 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@PresentJay

Copy link
Copy Markdown
Author

Could a maintainer approve the pending CI and Desktop Release Candidate workflow runs and take a code-owner look at this PR?

The navigation/acknowledgement issue raised in review has been fixed and re-approved. The local results reported above for the current head (d488231bc4e9e18e3a2ec6eb7fbb11a749d5e2c8) are clean: typecheck, lint, and all 6,454 desktop tests.

Both workflow runs are waiting for approval:

I've also requested a Codex review on this head. Thanks for taking a look!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d488231bc4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return the navigation result before acknowledging the link

When an OS message link arrives while a thread reply is being edited, the registered navigation guard makes goChannel or goForumPost resolve to false, but useOpenMessageLink discards that result and this callback unconditionally returns true. listenForNavigationDeepLinks consequently removes the durable queue entry even though navigation was refused, so finishing or cancelling the edit cannot retry the link. Propagate the navigation helper's acceptance result instead of always acknowledging it.

AGENTS.md reference: AGENTS.md:L167-L174

Useful? React with 👍 / 👎.

Comment on lines +34 to +36
).then(async (destination) => {
if (destination.kind === "forum-post") {
await goForumPost(destination.channelId, destination.postId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel link resolution when its community unmounts

If getEventById remains pending while the user switches communities, the effect cleanup only sets cancelled before this promise continuation runs; the old callback can therefore call the global router after the new community is active and navigate it to the previous community's channel or post. The deep-link drain generation prevents acknowledgement but does not prevent this stale route write, so pass a lifecycle signal into this path and recheck it after destination resolution before navigating.

AGENTS.md reference: AGENTS.md:L176-L186

Useful? React with 👍 / 👎.

import assert from "node:assert/strict";
import test from "node:test";

import { resolveMessageLinkDestination } from "./resolveMessageLinkDestination.ts";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exercise the production routing seam in the regression test

These tests import only the pure destination resolver and never exercise useOpenMessageLink or either production entry point. Replacing the new hook's branch with an unconditional goChannel call would therefore leave every test green while recreating #7315, so add a navigation-seam test that asserts forum links actually invoke goForumPost and stream links invoke goChannel.

AGENTS.md reference: AGENTS.md:L188-L192

Useful? React with 👍 / 👎.

@PresentJay

Copy link
Copy Markdown
Author

Addressed all three Codex findings in 9258422fb:

  • Propagate the navigation helper's boolean result through useOpenMessageLink and the deep-link callback. Refused forum/stream navigation stays at the durable queue head; a later accepted retry acknowledges it once. The channel-only listener now preserves that result too.
  • Cancel pending message-link routing when the owning hook unmounts or the deep-link listener is disabled/torn down. The lifecycle signal is checked after destination lookup and before either navigation helper runs; the hook also protects in-app callers on unmount.
  • Add nine tests that mount the production hooks and exercise the real resolver and durable deep-link drain, with only navigation and Tauri IPC substituted. They verify forum post/comment vs. stream routing, refused navigation and retry, acknowledgement ordering, and pending lookup cancellation.

Validation:

  • Focused hook/resolver/deep-link tests: 24 passed.
  • Full desktop suite: 6,463 passed, 0 failed.
  • pnpm check, pnpm typecheck, and git diff --check passed.
  • Mutation checks in an isolated copy: forcing channel-only routing fails 2 tests; removing cancellation checks fails 3; acknowledging refused navigation fails 4. Against the prior review head, the new suite fails 8 of 9 tests.
  • just ci was attempted; Rust formatting passed, but I stopped the Rust Clippy compilation when local disk space fell to approximately 1.3 GB. The repository-wide gate is therefore not verified locally.

The updated fork workflows will still need maintainer approval to run.

@PresentJay

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9258422fb4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +76 to +82
} catch (error) {
console.error(
"Failed to resolve message link destination",
messageId,
error,
);
return channelDestination;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep failed destination lookups queued

When get_event fails transiently or the target was deleted, this catch converts the failure into a channel destination. For a queued OS link to a forum message, goChannel can then return success, causing drainPendingNavigationDeepLinks to acknowledge and remove the durable entry even though the forum post was never opened. Propagate a failed resolution to the queue consumer so it remains retryable; any best-effort in-app fallback can be handled separately.

AGENTS.md reference: AGENTS.md:L167-L174

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e775087. Failed event lookups and forum comments without a resolvable root now return an unresolved destination. The OS deep-link caller disables channel fallback, so these cases neither navigate nor acknowledge the durable entry. In-app clicks retain their best-effort channel fallback.

Production-hook/drain regressions cover transient errors, missing events, successful retry, and missing forum roots. Re-enabling fallback for the queue consumer fails 3 tests. Focused tests pass 32/32; the final pre-push desktop suite, typecheck, lint and file-size gates passed.

Comment on lines +38 to +43
const destination = await resolveMessageLinkDestination(
link.channelId,
link.messageId,
link.threadRootId,
);
if (cancelled()) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fence overlapping message-link resolutions

When a user activates two message links before the first get_event request completes—for example, two links within the currently mounted channel—the faster second lookup navigates first, but the slower older lookup can later navigate back over it. The current abort checks only cover component teardown or an external signal, not superseding activations, so destination resolution needs a latest-request generation fence before routing.

AGENTS.md reference: AGENTS.md:L176-L179

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e775087. Each activation replaces a shared request token, checked after destination resolution and before routing or fallback. This also fences competing activations from separate mounted Markdown renderers. resetCommunityState invalidates the token before asynchronous teardown.

Production-hook tests cover older success/failure arriving after a newer activation, separate hook instances, and community reset before unmount. Removing the request fence fails 4 tests. Focused tests pass 32/32; the final pre-push desktop suite, typecheck, lint and file-size gates passed.

@PresentJay

Copy link
Copy Markdown
Author

@codex review

@PresentJay

Copy link
Copy Markdown
Author

Validation update for e775087: the final pre-push desktop suite passed 6,471/6,471, alongside desktop lint, typecheck and file-size checks. Focused navigation tests passed 32/32. A fresh just ci run also passed workspace Rust fmt/Clippy, Tauri Rust fmt/Clippy and web checks. I stopped it during Flutter SDK provisioning for mobile-check when free disk space fell to about 3.6 GB; mobile and the later CI stages remain unverified locally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e775087209

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +52 to +55
const event = await fetchEvent(messageId);

if (event.kind === KIND_FORUM_POST) {
return { kind: "forum-post", channelId, postId: messageId };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify the event belongs to the linked channel

For a malformed or manually authored link whose channel names A while id names a forum event in B, this unscoped lookup routes to /channels/A/posts/<B-event>. The forum thread backend fetches its root by exact ID without an #h filter and assigns the supplied channel ID, so the UI can display B's post under A and a reply can then be submitted into A against that foreign root. Compare the fetched event's h tag with channelId before returning any destination, as the existing message-link metadata resolver already does.

AGENTS.md reference: AGENTS.md:L265-L267

Useful? React with 👍 / 👎.

Comment on lines +75 to +81
} catch (error) {
console.error(
"Failed to resolve message link destination",
messageId,
error,
);
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let terminal lookup failures advance the deep-link queue

When get_event returns the definitive event not found response for a deleted message, this catch treats it like a retryable outage and the OS caller returns false. Because take_pending_navigation_deep_link repeatedly returns the unchanged front of its VecDeque and the drain stops before acknowledging it, every later valid channel or message link is blocked until the queue is cleared by a community switch or process exit. Fresh evidence beyond the earlier retry-retention discussion is this FIFO head-of-line blocking for permanent failures; distinguish terminal not-found errors so the drain can resolve or discard that item and continue.

AGENTS.md reference: AGENTS.md:L198-L201

Useful? React with 👍 / 👎.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop: buzz://message deep links into forum channels open the post list, never the post

2 participants