Skip to content

fix(domain,app): share the public permalink, and add Get embed code - #72

Merged
Adron merged 2 commits into
devfrom
fix/timeline-public-permalink
Sep 13, 2026
Merged

fix(domain,app): share the public permalink, and add Get embed code#72
Adron merged 2 commits into
devfrom
fix/timeline-public-permalink

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

The Link action shipped in PR #32 copies https://interlinedlist.com/messages/<id>. That is an authenticated route — an anonymous visitor is redirected to /login — so every link the app hands out lands strangers on a sign-in page, including the URL pasted into GitHub issues. This switches the builder to the web's real public permalink, /user/<username>/status/<id>, and adds the second action the web's Share menu offers, Get embed code.

Closes #38.

Live verification (read-only, anonymous — no auth, no writes)

Found a public message via GET /api/user/hubcity/messages (answers 200 with no credentials), then confirmed both of the issue's claims:

Probe Result
GET /messages/d05faf17-… 200, final URL /login
GET /user/hubcity/status/d05faf17-… 200, serves the message
GET /embed/message/d05faf17-… 200
GET /embed/widgets.js 200, application/javascript, 5349 bytes

The issue says not to invent the embed markup. Rather than clicking through the UI, the snippet was transcribed from the web's own share component in its JS bundle (chunk 9424, module 97606), which also pinned down that the origin is trailing-slash-stripped and that &<>"' are escaped in that order, & first.

Changes

  • MessagePermalink — builds <base>/user/<username>/status/<id>. The author handle becomes a required input (url(forMessageID:authorUsername:base:)); both segments are percent-encoded with the path-segment set (/ removed), so neither an id nor a handle can forge extra path segments. A blank id or handle returns nil so the UI hides the affordance instead of offering a link that 404s.
  • MessagePermalink.embedHTML(forMessageID:authorUsername:base:) — the web's snippet verbatim: the blockquote.il-embed carrying the raw id, the no-script fallback anchor, and the async /embed/widgets.js loader. Every interpolated value is HTML-escaped in the web's exact order.
  • Message.permalink() / Message.embedHTML() — also return nil for a private message. Only public messages resolve for a signed-out reader, so a copy action on a private post would produce a link nobody else can open. Every consumer inherits the gate, including the GitHub-issue body, which now carries the attribution line with no URL rather than an unopenable one.
  • MessageRowView — the single Link button becomes a Share menu with the web's two options, Copy link and Get embed code, built from one shareItems helper so the action bar and the context menu cannot drift. Both use SwiftUI.ShareLink, whose share sheet includes Copy — no NSPasteboard, no AppKit in the App target.
  • Tests — the domain quartet plus the app-side assertions that what actually reaches GitHub is the public URL.

Every place the app hands out a message URL

Swept for URL construction, ShareLink usages, status/, appendingPathComponent, and host literals. Exactly three, all routed through Message.permalink(), so all three are fixed at once: the row's action-bar Link, the same action in the row context menu, and CreateIssueFromMessageViewModel's issue body. Nothing else produces one — list/document share links use a separate token route, and cross-post URLs come from the server.

Note: the issue lists "the Push & Comment body" as the third consumer. That is stale — RepostSheetViewModel embeds no URL; the third site is the context menu.

Tests added

Happy: …thenUsesPublicUserStatusRoute, …whenAskedForPermalink_thenMatchesBuilder, …thenMatchesTheWebSnippetVerbatim, …whenAskedForEmbedHTML_thenMatchesBuilder.
Invalid: …EmptyID…, …WhitespaceOnlyID…, …MissingAuthorHandle…, …BlankAuthorHandle….
Upstream-failure slot (a pure projection has no I/O to fail, so it is spent on the private path per the issue): …givenPrivateMessage_whenAskedForPermalink_thenOffersNoLink, …thenOffersNoEmbed, …whenBuildingViaRawBuilder_thenStillBuilds.
Boundary: trailing-slash base (URL and script src), subpath base, percent-encoding, slash-forging, whitespace trim, HTML-escaping of a quote-breaking id, & in the base.
App: test_givenPrivateMessage_whenInit_thenBodyCarriesAttributionButNoLink, plus corrected assertions in the two existing issue-body tests.

Verification

Full E2E gate, re-run after merging origin/dev into the branch:

  • xcodebuild … build** BUILD SUCCEEDED **
  • xcodebuild … testExecuted 753 tests, with 0 failures (0 unexpected) / ** TEST SUCCEEDED **
  • swift test --package-path Packages/InterlinedKitExecuted 398 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedDomainExecuted 782 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedPersistenceExecuted 135 tests, with 0 failures (0 unexpected)
  • grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands0 hits (Decision 0003)
  • Contract tests ran with the .env account → Executed 4 tests, with 0 failures (0 unexpected)

Reviewer note — one deliberate divergence from web parity

The web does not hide its Share button on private posts: it renders unconditionally and only adds a "Private" badge. This PR implements the issue's instruction to hide both actions, which is the better behaviour, but it is a deliberate divergence rather than a match. Easy to revert to parity if you'd rather.

🤖 Generated with Claude Code

Adron and others added 2 commits September 9, 2026 09:43
The Link action shipped in PR #32 handed out `/messages/<id>`, which is an
authenticated route: an anonymous visitor is redirected to `/login`, so every
link the app produced — including the one pasted into GitHub issues — landed
strangers on a sign-in page. Verified live on 2026-09-09 against a public
message:

    GET /messages/d05faf17-…             -> 200, final URL /login
    GET /user/hubcity/status/d05faf17-…  -> 200, serves the message

`MessagePermalink` now builds `<base>/user/<username>/status/<id>`. The author
handle becomes a required input and both segments are percent-encoded with the
path-segment set (no `/`), so neither an id nor a handle can forge extra path
segments. A blank id or handle yields nil, and the UI hides the affordance
rather than offering a link that 404s.

Adds `embedHTML(forMessageID:authorUsername:base:)`, transcribed verbatim from
the web's own share component rather than invented: the `blockquote.il-embed`
carrying the raw id, the no-script fallback anchor, and the async
`/embed/widgets.js` loader (confirmed live, 200 application/javascript). Every
interpolated value is HTML-escaped in the web's exact order, `&` first.

`Message.permalink()` / `Message.embedHTML()` also return nil for a private
message. Only public messages resolve for a signed-out reader, so a copy action
on a private post would produce a link nobody else can open. Every consumer
inherits the gate, including the GitHub-issue body, which now carries the
attribution line with no URL rather than an unopenable one.

App-side, the row's single Link button becomes a Share menu with the web's two
options — "Copy link" and "Get embed code" — built from one `shareItems` helper
so the action bar and the context menu cannot drift. Both use
`SwiftUI.ShareLink`, whose share sheet includes Copy: no NSPasteboard, no AppKit.

Closes #38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tr46TLA4EzViEZMuvUfzJt
@Adron
Adron merged commit 0f3b431 into dev Sep 13, 2026
8 checks passed
@Adron
Adron deleted the fix/timeline-public-permalink branch September 13, 2026 18:30
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