Skip to content

feat(deep-links): open the web's canonical permalinks in-app (W7) - #71

Open
Adron wants to merge 1 commit into
mainfrom
fix/web-canonical-permalinks
Open

feat(deep-links): open the web's canonical permalinks in-app (W7)#71
Adron wants to merge 1 commit into
mainfrom
fix/web-canonical-permalinks

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Links copied from the web mostly did not open in the app, and the most common one landed on the wrong screen: /user/<u>/status/<id> is the web's canonical message permalink, but AppDeepLink.parse read only the first path segment and opened the author's profile instead of the post. A bare /lists/<id> — a URL iOS itself generates via ILWebURL.list(_:) — was explicitly rejected.

Closes #61.

What's included

Every row of the issue's table now opens its own screen, in both the interlinedlist:// and https://interlinedlist.com (bare + www.) forms:

Link Opens
/user/<u>/status/<id> the post (.messageMessageLinkView)
/user/<u>/lists/<id> .publicListPublicListDetailView
/user/<u>/documents/<id> .publicDocumentPublicDocumentReader
/lists/<id> .listListLinkViewListDetailView
/message/<id>, /documents/<id>, /lists/shared/<t>, /documents/shared/<t> unchanged (now regression-locked)
  • Parser — the user case inspects the second segment (status / lists / documents) before falling back to the profile; anything else under a profile is still the profile. The lists case accepts a bare id alongside the share-token form. Unknown targets and foreign hosts still return nil.
  • Bare list routingListLinkView resolves the list from AppDataStore (owned, then shared-in) and only falls back to a new APIClient.list(id:) (GET /api/lists/:id) when it isn't already known.
  • No-access state — 403/404 normalises to ListAccessError.noAccess and renders "You don't have access to this list", never a crash or a silent no-op. Both statuses arrive from the backend with an {error} body, so the generic mapping flattened them into .forbidden/.server and the opener could not tell "not yours" from "server trouble"; a new get(_:mappingStatuses:) transport helper catches the status before that mapping runs. A 401 still routes to handleUnauthorized(), not to the no-access state.
  • Stale premise deleted — the claim that a bare /lists/:id can't be routed for want of an owner username is gone from the code comment and the-gaps.md (two live rows). It was false: GET /api/lists/:id is Bearer-ready and authorizes by role (owner / manager / collaborator / watcher).
  • New files registered in project.pbxproj (no synced groups): APIClient+Lists.swift, ListLinkView.swift, APIClientSingleListTests.swift.

Testing

  • xcodebuild ... buildBUILD SUCCEEDED
  • xcodebuild ... -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test969 tests, 0 failures
  • New coverage: one test per row of the URL table (both schemes, bare + www.), the ILWebURL.list.list round-trip, foreign-host rejection, and the 403/404/401/500 paths of APIClient.list(id:).

Notes

  • Universal Links sanity-check: the live AASA already wildcards /user/*, /message/*, /lists/*, /documents/*, and AASA * spans /, so every newly-routed shape is covered — no AASA or entitlement change needed. Inbound https still waits on the Associated Domains portal capability tracked in App-Store-Deployment-Checklist.md (A2).
  • Deep-link ?token query items remain parse-only and are never logged.
  • The simulator smoke test could not be completed: simctl openurl raises an "Open in InterlinedList?" system dialog and no UI-automation tap tool is enabled in this environment. Routing is covered by the unit tests, and every deep-link destination needs only authState, which each new sheet case injects.

🤖 Generated with Claude Code

Links copied from the web mostly did not open in the app, and the most
common one landed on the wrong screen: `/user/<u>/status/<id>` is the
web's canonical *message* permalink, but the parser read only the first
path segment and opened the author's profile instead of the post.

- AppDeepLink gains `.list(id:)`, `.publicList(owner:id:)` and
  `.publicDocument(owner:id:)`; the `user` case now inspects the second
  segment (`status` / `lists` / `documents`) before falling back to the
  profile, and the `lists` case accepts a bare id alongside the
  share-token form.
- `.list(id:)` routes to ListLinkView, which resolves the list from the
  store's owned/shared collections before falling back to a new
  `APIClient.list(id:)` (`GET /api/lists/:id`), then hands a full
  UserList to ListDetailView.
- A 403/404 is normalised to `ListAccessError.noAccess` and rendered as
  a plain "no access to this list" state. Both statuses arrive from the
  backend *with* an `{error}` body, so the generic mapping flattened them
  into `.forbidden`/`.server` and the opener could not tell "not yours"
  apart from "server trouble"; a new `get(_:mappingStatuses:)` transport
  helper catches the status before that mapping runs.
- Delete the stale claim that a bare `/lists/:id` cannot be routed for
  want of an owner username, in the code and in the-gaps.md. It was
  false: `GET /api/lists/:id` is Bearer-ready and authorizes by role
  (owner/manager/collaborator/watcher).
- Tests: one per row of the issue's URL table, in both the custom-scheme
  and https (bare + www) forms, plus foreign-host rejection and the
  no-access path. 969 unit tests green.

The AASA already wildcards `/user/*`, `/message/*`, `/lists/*` and
`/documents/*`, so every new shape is covered for Universal Links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd9n499evEUh6A7hifcF3N
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.

W7: Open the web canonical permalinks in-app (/user/:u/status/:id, list and document permalinks)

1 participant