Skip to content

fix(notifications): show an explicit unavailable state instead of a dead WebView - #18

Closed
Aalv3 wants to merge 1 commit into
fix/auth-failure-classification-20260830from
fix/native-first-party-unavailable-20260909
Closed

Aalv3 wants to merge 1 commit into
fix/auth-failure-classification-20260830from
fix/native-first-party-unavailable-20260909

Conversation

@Aalv3

@Aalv3 Aalv3 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Backlog #9 follow-up. Fixes the destination-render FAIL found on device in PR #16. JS-only.

What was wrong

PR #16 correctly stopped notification taps from silently doing nothing, but routed first-party destinations into the in-app WebView. On device that produced a blank screen stuck on "Still loading...".

The WebView never issued a request. Its own policy blocks the load:

// WebViewComponent.onShouldStartLoadWithRequest
if (classifyNavigation(request.url) === 'internal') {
  if (memberRoute.disposition === 'native') { ; return false; }
  // Canonical pages without an explicit native route must not
  // fall through to an unauthenticated Discourse/PWA session.
  return false;   // ← blocks the main document
}

That guard is correct. The WebView has no authenticated Discourse session, and there is no way to establish one on demand:

  • config/routes.rb exposes no /user-api-key/otp — only new, create, device, device/poll, activate, device/authorize, device/deny, revoke, undo-revoke.
  • UserApiKeyScope::SCOPES maps one_time_password: []no routes at all; it is checked inside the authorization flow, so the OTP exists only as a side effect of an ASWebAuthenticationSession callback.

So loading these destinations would show a login wall — precisely what the guard prevents. No server OTP bridge is being built here.

Fix

A recognised first-party destination with no native screen now shows an explicit state instead of navigating:

Not available in the app
This notification links to a page the app cannot open yet. It has been marked as read, and nothing else is affected.
[ Close ]

No loading state, no login wall, no external hand-off, no off-origin navigation. Destinations remain classified first_party_web, so a real destination can be added later without re-deriving the routing.

The presentation decision moved into a pure js/notificationDestination.js so every branch is directly tested rather than asserted by grepping source — that gap is what let the PR #16 defect through.

Covered classes

granted_badge, group_message_summary, liked_consolidated, membership_request_accepted, chat mention and chat message — all present the identical state, and opensWebView() is asserted false for each.

Preserved

Native Topic and MemberProfile routing unchanged (12 topic types tested). Staff /admin still hands off externally; non-staff denied. Malformed badge payloads, unknown types, off-origin, look-alike hosts, plain HTTP and unauthenticated callers all denied with nothing loaded. Notification read-marking still precedes destination resolution.

Validation

validate:system: format/lint clean, 96 suites / 786 tests (+31), verify:ota 17/17, verify:ios-auth 12/12, native Release BUILD SUCCEEDED.

js/notificationDestination.js (new), js/Discourse.js (+35/−17), tests. No ios/, android/, vendor/, config, dependency or lockfile change. Runtime an-ios-android-1.0.0-native-2 unchanged.

One pre-existing PR #16 test asserted the old route.disposition shape inside openUrl; it now asserts the refactored presentation.kind form plus that openUrl never navigates to the WebView.

…ead WebView

PR #16 stopped notification taps silently doing nothing, but routed first-party
destinations into the in-app WebView, and on device that produced a blank screen
stuck on "Still loading...".

The WebView never issued a request. Its own navigation policy in
WebViewComponent.onShouldStartLoadWithRequest returns false for any
canonical-origin URL whose route is not 'native', with the comment that such
pages "must not fall through to an unauthenticated Discourse/PWA session".
That guard is correct: the WebView has no authenticated Discourse session. The
OTP that establishes one is issued only as part of an
ASWebAuthenticationSession authorization callback, and Discourse core exposes
no on-demand OTP route - config/routes.rb has no /user-api-key/otp, and the
one_time_password scope maps to no routes at all. So loading these destinations
would show a login wall, which is exactly what the guard prevents.

Rather than navigate into that dead end or loosen the guard, a recognised
first-party destination with no native screen now tells the member plainly:
"Not available in the app - This notification links to a page the app cannot
open yet. It has been marked as read, and nothing else is affected." with a
single Close. No loading state, no login wall, no external hand-off, no
off-origin navigation.

The presentation decision moved into a pure notificationDestination module so
every branch is directly testable rather than asserted by reading source, and
openUrl branches on the resulting kind. Destinations stay classified as
first_party_web, so a proper destination can be added later without
re-deriving the routing.

Native Topic and MemberProfile routing is unchanged, staff /admin still hands
off externally, and malformed, unknown, off-origin, unauthenticated and
non-staff admin destinations remain denied with nothing loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fk48MTrNBBSZeLvcJmc8SR
@Aalv3

Aalv3 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #22. This PR's bounded unavailable state is absorbed into the unified intent model as the class-C destination, alongside native Topic/MemberProfile routing and the new native BadgeEarned screen. Closing in favour of the single implementation.

@Aalv3 Aalv3 closed this Sep 9, 2026
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.

1 participant