Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 41 additions & 18 deletions App/Features/Timeline/MessageRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,20 @@ struct MessageRowView: View {
}

/// The row's action bar. Order matches the web's message actions:
/// Reply, I Dig!, Push, Push & Comment, Link.
/// Reply, I Dig!, Push, Push & Comment, Share.
///
/// Reply / Dig / Push degrade to a plain count label when the host wired
/// no handler (search results, previews), so a read-only row still shows
/// the numbers without offering a control that would do nothing. Link is
/// unconditional — it is a pure client-side permalink and needs no host
/// wiring, so it works everywhere the message has an id.
/// the numbers without offering a control that would do nothing. Share
/// needs no host wiring — it is a pure client-side projection — so it
/// appears wherever the message is public and carries an id and a handle.
private var footer: some View {
HStack(spacing: 16) {
replyButton
digButton
pushButton
pushAndCommentButton
linkButton
shareButton

if message.visibility == .private {
Label("Private", systemImage: "lock")
Expand Down Expand Up @@ -312,20 +312,45 @@ struct MessageRowView: View {
}
}

/// Link to this specific post. `SwiftUI.ShareLink` (disambiguated from
/// `InterlinedDomain.ShareLink`) opens the system share sheet, which
/// includes Copy \u{2014} no `NSPasteboard`, no AppKit in the App target.
/// Share this post. Mirrors the web's Share dropdown, which offers exactly
/// two actions (GitHub #38): **Copy link** \u{2014} the public permalink at
/// `/user/<handle>/status/<id>` \u{2014} and **Get embed code**, the HTML
/// snippet for pasting into a blog post.
///
/// Both use `SwiftUI.ShareLink` (disambiguated from
/// `InterlinedDomain.ShareLink`), whose system share sheet includes Copy
/// \u{2014} no `NSPasteboard`, no AppKit in the App target.
///
/// The whole menu disappears for a **private** post: `permalink()` and
/// `embedHTML()` both return nil there, because only public messages
/// resolve for the person on the other end of the link.
@ViewBuilder
private var linkButton: some View {
if let url = message.permalink() {
SwiftUI.ShareLink(item: url) {
private var shareButton: some View {
if let url = message.permalink(), let embed = message.embedHTML() {
Menu {
shareItems(url: url, embed: embed)
} label: {
Image(systemName: "link")
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Link to this post")
.help("Share or copy a link to this post")
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Share this post")
.help("Copy link or get embed code for this post")
}
}

/// The two share actions, shared verbatim by the action-bar menu and the
/// row's context menu so the two discovery paths can never drift.
@ViewBuilder
private func shareItems(url: URL, embed: String) -> some View {
SwiftUI.ShareLink(item: url) {
Label("Copy link", systemImage: "link")
}
SwiftUI.ShareLink(item: embed) {
Label("Get embed code", systemImage: "chevron.left.forwardslash.chevron.right")
}
}

Expand Down Expand Up @@ -388,10 +413,8 @@ struct MessageRowView: View {
}
}

if let url = message.permalink() {
SwiftUI.ShareLink(item: url) {
Label("Link", systemImage: "link")
}
if let url = message.permalink(), let embed = message.embedHTML() {
shareItems(url: url, embed: embed)
}

if actions.onReply != nil || actions.onPush != nil || actions.onPushAndComment != nil {
Expand Down
35 changes: 31 additions & 4 deletions AppTests/CreateIssueFromMessageViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ import InterlinedKit
@MainActor
final class CreateIssueFromMessageViewModelTests: XCTestCase {

private func message(text: String = "First line\nSecond line", username: String = "ada", id: String = "msg-1") -> Message {
MessageFixtures.message(id: id, author: MessageFixtures.author(username: username), text: text)
private func message(
text: String = "First line\nSecond line",
username: String = "ada",
id: String = "msg-1",
visibility: Visibility = .public
) -> Message {
MessageFixtures.message(
id: id,
author: MessageFixtures.author(username: username),
text: text,
visibility: visibility
)
}

private func makeVM(_ stub: StubGitHubService, message: Message? = nil) -> CreateIssueFromMessageViewModel {
Expand All @@ -26,10 +36,26 @@ final class CreateIssueFromMessageViewModelTests: XCTestCase {

XCTAssertEqual(vm.title, "Fix the parser")
XCTAssertTrue(vm.body.contains("more detail"))
XCTAssertTrue(vm.body.contains("https://example.test/messages/abc"))
// GitHub #38: the body must carry the **public** permalink. The old
// /messages/<id> form bounced anyone reading the issue to /login.
XCTAssertTrue(vm.body.contains("https://example.test/user/ada/status/abc"))
XCTAssertFalse(vm.body.contains("/messages/abc"))
XCTAssertTrue(vm.body.contains("@ada"))
}

func test_givenPrivateMessage_whenInit_thenBodyCarriesAttributionButNoLink() {
// Given a private message turned into an issue.
let vm = makeVM(
StubGitHubService(),
message: message(text: "Fix the parser", username: "ada", id: "abc", visibility: .private)
)

// Then the attribution line survives but no URL is pasted — a private
// post resolves for nobody reading the issue (GitHub #38).
XCTAssertTrue(vm.body.contains("From @ada on InterlinedList"))
XCTAssertFalse(vm.body.contains("https://example.test"))
}

func test_givenEmptyMessage_whenInit_thenTitleFallsBackToAuthor() {
let vm = makeVM(StubGitHubService(), message: message(text: " ", username: "grace"))
XCTAssertEqual(vm.title, "Post by @grace")
Expand Down Expand Up @@ -102,7 +128,8 @@ final class CreateIssueFromMessageViewModelTests: XCTestCase {

XCTAssertEqual(vm.createdIssue?.number, 55)
XCTAssertEqual(stub.createdDrafts.first?.title, "Fix the parser")
XCTAssertTrue(stub.createdDrafts.first?.body?.contains("example.test/messages") ?? false)
// GitHub #38: what actually reaches GitHub must be the public permalink.
XCTAssertTrue(stub.createdDrafts.first?.body?.contains("example.test/user/ada/status/msg-1") ?? false)
XCTAssertNil(vm.error)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,52 +1,154 @@
import Foundation

/// Canonical web permalink for a message ("Link" in the web message actions).
/// Canonical public web permalink for a message, plus the embed snippet that
/// sits beside it in the web's Share menu ("Copy link" / "Get embed code").
///
/// The link is a pure client-side projection — there is no API route that
/// hands back a message URL — so the shape lives here in the domain rather
/// than being re-derived by each feature that needs it. Three App-layer
/// surfaces consume it: the message row's Link action, the "Push & Comment"
/// body, and `CreateIssueFromMessageViewModel`'s issue body, which previously
/// owned a private copy of this logic.
/// Both are pure client-side projections — there is no API route that hands
/// back a message URL or an embed blob — so the shapes live here in the domain
/// rather than being re-derived by each feature that needs them. Three
/// App-layer surfaces consume them: the message row's Share menu, the same
/// menu mirrored into the row's context menu, and
/// `CreateIssueFromMessageViewModel`'s issue body.
///
/// Shape: `<base>/messages/<id>` — matching the web app's own route.
/// Shape: `<base>/user/<username>/status/<id>`.
///
/// GitHub #38 — this used to build `<base>/messages/<id>`, which is an
/// **authenticated** route: anonymous visitors are redirected to `/login`, so
/// every link the app handed out (including the one pasted into GitHub issues)
/// landed strangers on a sign-in page. Verified live on 2026-09-09:
///
/// GET /messages/d05faf17-… -> 200, final URL /login
/// GET /user/hubcity/status/d05faf17-… -> 200, serves the message
///
/// The shapes below mirror the web's own share component verbatim.
public enum MessagePermalink {

/// Production web front-end. Overridable at every call site so tests and
/// a future staging build never hard-code the live host.
public static let defaultWebBaseURL = URL(string: "https://interlinedlist.com")!

/// Path-segment-safe character set: `urlPathAllowed` still permits `/`,
/// which would let an id containing a slash silently forge extra path
/// segments. Removing it forces such an id to percent-encode instead.
/// which would let an id or username containing a slash silently forge
/// extra path segments. Removing it forces such a value to percent-encode.
private static let pathSegmentAllowed: CharacterSet = {
var set = CharacterSet.urlPathAllowed
set.remove("/")
return set
}()

/// Builds the permalink for `id`, or `nil` when the id is empty / blank
/// or cannot be encoded. Returning `nil` rather than a half-formed URL
/// lets the UI hide the affordance instead of offering a broken link.
public static func url(forMessageID id: String, base: URL = defaultWebBaseURL) -> URL? {
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) else {
/// Builds the public permalink for `id` authored by `authorUsername`, or
/// `nil` when either component is empty / blank or cannot be encoded.
///
/// The author handle is a **required** input rather than an optional with a
/// fallback: without it there is no valid public route at all, and
/// returning `nil` lets the UI hide the affordance instead of offering a
/// link that 404s.
///
/// Note: the web encodes only the username and interpolates the id raw. We
/// encode both — for every server-issued id (a UUID) the result is
/// identical, and encoding closes the path-forging hole for a malformed one.
public static func url(
forMessageID id: String,
authorUsername: String,
base: URL = defaultWebBaseURL
) -> URL? {
guard
let encodedID = encodedSegment(id),
let encodedUsername = encodedSegment(authorUsername)
else { return nil }
return URL(string: "\(normalizedOrigin(base))/user/\(encodedUsername)/status/\(encodedID)")
}

/// Builds the HTML snippet the web's "Get embed code" action copies, or
/// `nil` when the permalink itself cannot be formed.
///
/// The markup is **transcribed from the web's own share component**, not
/// invented: a `blockquote.il-embed` carrying the raw message id, a
/// fallback anchor for readers whose page never runs the script, and the
/// async `/embed/widgets.js` loader that upgrades the blockquote into the
/// rendered card. `/embed/widgets.js` was confirmed live (200,
/// `application/javascript`) on 2026-09-09.
///
/// Every interpolated value is HTML-escaped exactly as the web escapes it
/// (`&` first, so an already-escaped entity can't be produced), because the
/// id, the URL and the origin all land inside double-quoted attributes.
public static func embedHTML(
forMessageID id: String,
authorUsername: String,
base: URL = defaultWebBaseURL
) -> String? {
guard let canonical = url(forMessageID: id, authorUsername: authorUsername, base: base) else {
return nil
}
// Normalise the base so a caller-supplied trailing slash can't produce
// a double slash in the middle of the path.
// The web writes the *raw* (trimmed) id into `data-message-id`, not the
// percent-encoded path segment — the widget matches on the id it was
// given, so keep them identical.
let escapedID = htmlEscaped(id.trimmingCharacters(in: .whitespacesAndNewlines))
let escapedURL = htmlEscaped(canonical.absoluteString)
let escapedOrigin = htmlEscaped(normalizedOrigin(base))
return """
<blockquote class="il-embed" data-message-id="\(escapedID)">
<a href="\(escapedURL)" target="_blank" rel="noopener">View this message on InterlinedList</a>
</blockquote>
<script async src="\(escapedOrigin)/embed/widgets.js"></script>
"""
}

// MARK: - Helpers

/// Trims, rejects blank, then percent-encodes one path segment.
private static func encodedSegment(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed)
}

/// Strips trailing slashes from the base so a caller-supplied one can't
/// produce a double slash in the middle of the path. Mirrors the web's
/// own `origin.replace(/\/+$/, "")`.
private static func normalizedOrigin(_ base: URL) -> String {
var stem = base.absoluteString
while stem.hasSuffix("/") { stem.removeLast() }
return URL(string: "\(stem)/messages/\(encoded)")
return stem
}

/// The web's escape function, character-for-character and in the same
/// order — `&` must be replaced first or the later entities get mangled.
private static func htmlEscaped(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
}

public extension Message {

/// This message's canonical web permalink, or `nil` when the id can't
/// form one. See `MessagePermalink`.
/// This message's **shareable** public permalink, or `nil` when it cannot
/// be handed to anyone else.
///
/// `nil` in two cases, and both are deliberate (GitHub #38):
///
/// - the author handle or the id is missing / blank, so no valid route
/// exists;
/// - the message is **private**. Only public messages resolve for a
/// signed-out visitor, so a copy action on a private post would produce a
/// link nobody else can open — worse than offering no action at all.
///
/// Every caller therefore gets the visibility gate for free, including the
/// GitHub-issue body, which must not embed a link that 404s for a reader.
func permalink(base: URL = MessagePermalink.defaultWebBaseURL) -> URL? {
MessagePermalink.url(forMessageID: id, base: base)
guard visibility == .public else { return nil }
return MessagePermalink.url(forMessageID: id, authorUsername: author.username, base: base)
}

/// The HTML snippet for embedding this message in a page, or `nil` under
/// exactly the same conditions as `permalink(base:)`. See
/// `MessagePermalink.embedHTML(forMessageID:authorUsername:base:)`.
func embedHTML(base: URL = MessagePermalink.defaultWebBaseURL) -> String? {
guard visibility == .public else { return nil }
return MessagePermalink.embedHTML(forMessageID: id, authorUsername: author.username, base: base)
}
}
Loading