diff --git a/iosApp/HoneyDueQLPreview/PreviewViewController.swift b/iosApp/HoneyDueQLPreview/PreviewViewController.swift index 41ed41f..f21a3a0 100644 --- a/iosApp/HoneyDueQLPreview/PreviewViewController.swift +++ b/iosApp/HoneyDueQLPreview/PreviewViewController.swift @@ -278,13 +278,19 @@ class PreviewViewController: UIViewController, QLPreviewingController { titleLabel.text = residence.residenceName subtitleLabel.text = "honeyDue Residence Invite" - // Numbered steps so non-technical recipients know exactly what - // to do. The share button's location varies across the iOS - // QuickLook chrome (bottom on file previews, top in mail - // previews, sometimes hidden behind a "More" menu) so we lean - // on the SF Symbol glyph inline instead of describing a - // position that may not match what the recipient sees. - instructionLabel.attributedText = Self.makeResidenceInstructions() + + // Branch the copy on whether the share link has already lapsed. + // Active invites get the standard "How to join" numbered steps; + // expired invites get a clear dead-end message asking the + // recipient to ping the sender for a new link — no point + // showing share-sheet directions for a link the server will + // reject. + let expiredAgo = Self.expiredRelativePhraseOrNil(residence.expiresAt) + if let expiredAgo { + instructionLabel.attributedText = Self.makeExpiredInstructions(sharedBy: residence.sharedBy) + } else { + instructionLabel.attributedText = Self.makeResidenceInstructions() + } // Clear existing details detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } @@ -295,42 +301,55 @@ class PreviewViewController: UIViewController, QLPreviewingController { } if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { - let formatted = Self.formatExpiresAt(expiresAt) - addDetailRow(icon: "clock", text: "Expires \(formatted)") + if let expiredAgo { + // "Expired 1 hour ago" — capitalised past-tense; no + // "Expires " prefix because the share link no longer + // expires, it has already done so (gitea#7 review). + addDetailRow(icon: "clock", text: "Expired \(expiredAgo)") + } else { + let formatted = Self.formatActiveExpiry(expiresAt) + addDetailRow(icon: "clock", text: "Expires \(formatted)") + } } } // MARK: - Formatting helpers - /// Render the share-link expiry as a human-readable phrase. + /// Render an *active* (not-yet-expired) share-link expiry as a + /// human-readable phrase. Within a day uses + /// `RelativeDateTimeFormatter` ("in 23 hours" / "in 12 minutes"); + /// further out switches to absolute date + time so users planning + /// ahead see exactly when the invite lapses. Falls back to the raw + /// ISO string if parsing fails so the row never goes blank. /// - /// The wire format from the API is ISO-8601 with optional fractional - /// seconds (`2026-05-12T17:11:02.067272789Z`); previous behaviour - /// echoed that string verbatim (gitea#7). When parsing succeeds we - /// produce a relative phrase ("today at 5:11 PM", "in 23 hours", - /// "May 12, 2026 at 5:11 PM"); if parsing fails we return the - /// original string unchanged so the recipient still has *something*. - static func formatExpiresAt(_ isoString: String) -> String { + /// Callers must check [expiredRelativePhraseOrNil] first — this + /// function assumes a future expiry and produces wording that only + /// makes sense in that case. + static func formatActiveExpiry(_ isoString: String) -> String { guard let date = parseIsoDate(isoString) else { return isoString } - let now = Date() let elapsed = date.timeIntervalSince(now) - - // If the link is already expired, say so plainly. - if elapsed <= 0 { - return "expired \(relativeFormatter.localizedString(for: date, relativeTo: now))" - } - - // Within a day → relative ("in 5 hours" / "in 12 minutes"). if elapsed < 24 * 60 * 60 { return relativeFormatter.localizedString(for: date, relativeTo: now) } - - // Otherwise an absolute date + time so users planning ahead see - // exactly when the invite lapses. return "on \(absoluteFormatter.string(from: date))" } + /// If the share link has already lapsed, return the relative + /// "X ago" phrase. `nil` means active (or unparseable) — callers + /// should fall back to [formatActiveExpiry] for those cases. The + /// split lets `updateUIForResidence` branch the entire UI block + /// (row text + instruction card) on the same signal (gitea#7 + /// review: an expired link should send the recipient back to the + /// sender for a new invite, not show share-sheet directions for a + /// link the server will reject). + static func expiredRelativePhraseOrNil(_ isoString: String?) -> String? { + guard let isoString, let date = parseIsoDate(isoString) else { return nil } + let now = Date() + if date.timeIntervalSince(now) > 0 { return nil } + return relativeFormatter.localizedString(for: date, relativeTo: now) + } + private static func parseIsoDate(_ raw: String) -> Date? { if let d = isoFormatterWithFraction.date(from: raw) { return d } if let d = isoFormatterNoFraction.date(from: raw) { return d } @@ -415,6 +434,50 @@ class PreviewViewController: UIViewController, QLPreviewingController { return result } + + /// Expired-state copy for the instruction card. Tells the recipient + /// the share link is no longer valid and to ping the sender (by + /// email if we know it) for a new one — replaces the active "How to + /// join" steps since the server will reject the bundled code + /// anyway. + private static func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString { + // Slightly warmer tint than the active instruction copy — the + // app's `appError` red would feel alarmist for "just ask again", + // and the secondary-label gray reads as muted/disabled which is + // accurate to the link's actual state. + let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium) + let tint = UIColor.secondaryLabel + let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + let titleTint = UIColor.label + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 2 + paragraph.alignment = .left + + let result = NSMutableAttributedString() + result.append(NSAttributedString( + string: "This invite has expired.\n", + attributes: [ + .font: titleFont, + .foregroundColor: titleTint, + .paragraphStyle: paragraph, + ] + )) + + let body = if let s = sharedBy, !s.isEmpty { + "Ask \(s) to send a new link." + } else { + "Ask the sender to share a new link." + } + result.append(NSAttributedString( + string: body, + attributes: [ + .font: bodyFont, + .foregroundColor: tint, + .paragraphStyle: paragraph, + ] + )) + return result + } } // MARK: - Type Discriminator diff --git a/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift index 3100b0b..a92d286 100644 --- a/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift +++ b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift @@ -49,8 +49,10 @@ final class Issue7PreviewScreenshotTest: XCTestCase { } func test_residence_invite_preview_after_issue7_fix() { - let vc = MockPreviewViewController(residence: ResidencePreview.fixtureForIssue7) - // Force dark mode to match the gitea#7 screenshot exactly. + let vc = MockPreviewViewController( + residence: ResidencePreview.fixtureForIssue7, + state: .active + ) vc.overrideUserInterfaceStyle = .dark assertSnapshot( @@ -67,6 +69,33 @@ final class Issue7PreviewScreenshotTest: XCTestCase { named: "issue7_residence_invite_preview_dark" ) } + + func test_residence_invite_preview_expired_state() { + // Same residence + sender, but expiry already 1 hour in the + // past. Verifies the expired branch: the instruction card + // swaps to "ask the sender for a new link" and the detail row + // reads "Expired 1 hour ago" instead of the future-tense + // "Expires in …" phrasing. + let vc = MockPreviewViewController( + residence: ResidencePreview.fixtureForIssue7, + state: .expired(elapsedSecondsSinceExpiry: 60 * 60) + ) + vc.overrideUserInterfaceStyle = .dark + + assertSnapshot( + of: vc, + as: .image( + on: .iPhone13, + precision: 1.0, + perceptualPrecision: 1.0, + traits: .init(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .dark), + UITraitCollection(displayScale: 2.0), + ]) + ), + named: "issue7_residence_invite_preview_expired_dark" + ) + } } // MARK: - Sample residence (matches the gitea#7 screenshot setup) @@ -87,10 +116,20 @@ private struct ResidencePreview { // MARK: - Mock view controller (UIKit copy of `updateUIForResidence`) +/// Renderer state for the screenshot fixture. Active = link still +/// valid; expired = link lapsed `elapsedSecondsSinceExpiry` seconds +/// ago. Both render with deterministic data so the recorded PNG is +/// stable across runs. +private enum PreviewRenderState { + case active + case expired(elapsedSecondsSinceExpiry: TimeInterval) +} + @MainActor private final class MockPreviewViewController: UIViewController { private let residence: ResidencePreview + private let state: PreviewRenderState private let containerView = UIView() private let iconImageView = UIImageView() @@ -102,8 +141,9 @@ private final class MockPreviewViewController: UIViewController { private let instructionLabel = UILabel() private let arrowImageView = UIImageView() - init(residence: ResidencePreview) { + init(residence: ResidencePreview, state: PreviewRenderState) { self.residence = residence + self.state = state super.init(nibName: nil, bundle: nil) } @@ -228,18 +268,68 @@ private final class MockPreviewViewController: UIViewController { titleLabel.text = residence.residenceName subtitleLabel.text = "honeyDue Residence Invite" - instructionLabel.attributedText = makeResidenceInstructions() detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } if let sharedBy = residence.sharedBy, !sharedBy.isEmpty { addDetailRow(icon: "person", text: "Shared by \(sharedBy)") } - if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { - let formatted = formatExpiresAt(expiresAt) - addDetailRow(icon: "clock", text: "Expires \(formatted)") + + switch state { + case .active: + instructionLabel.attributedText = makeResidenceInstructions() + if let expiresAt = residence.expiresAt, !expiresAt.isEmpty { + addDetailRow(icon: "clock", text: "Expires \(formatActiveExpiry(expiresAt))") + } + case .expired(let elapsed): + instructionLabel.attributedText = makeExpiredInstructions(sharedBy: residence.sharedBy) + addDetailRow(icon: "clock", text: "Expired \(relativePhrase(secondsAgo: elapsed))") } } + private func relativePhrase(secondsAgo: TimeInterval) -> String { + // Deterministic relative phrase — we set "now" to be exactly + // `secondsAgo` after the (fake) expiry, so the formatter says + // "1 hour ago" instead of whatever the real clock would give. + let fakeNow = Date() + let pastExpiry = fakeNow.addingTimeInterval(-secondsAgo) + let relative = RelativeDateTimeFormatter() + relative.unitsStyle = .full + return relative.localizedString(for: pastExpiry, relativeTo: fakeNow) + } + + /// Expired-state copy mirroring `PreviewViewController.makeExpiredInstructions`. + private func makeExpiredInstructions(sharedBy: String?) -> NSAttributedString { + let bodyFont = UIFont.systemFont(ofSize: 15, weight: .medium) + let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 2 + paragraph.alignment = .left + + let result = NSMutableAttributedString() + result.append(NSAttributedString( + string: "This invite has expired.\n", + attributes: [ + .font: titleFont, + .foregroundColor: UIColor.label, + .paragraphStyle: paragraph, + ] + )) + let body = if let s = sharedBy, !s.isEmpty { + "Ask \(s) to send a new link." + } else { + "Ask the sender to share a new link." + } + result.append(NSAttributedString( + string: body, + attributes: [ + .font: bodyFont, + .foregroundColor: UIColor.secondaryLabel, + .paragraphStyle: paragraph, + ] + )) + return result + } + private func addDetailRow(icon: String, text: String) { let row = UIStackView() row.axis = .horizontal @@ -313,9 +403,11 @@ private final class MockPreviewViewController: UIViewController { return result } - // Mirrors PreviewViewController.formatExpiresAt with a fixed "now" - // so the rendering is identical regardless of when the test runs. - private func formatExpiresAt(_ raw: String) -> String { + // Mirrors PreviewViewController.formatActiveExpiry with a fixed + // "now" so the rendering is identical regardless of when the test + // runs. The expired branch uses [relativePhrase(secondsAgo:)] + // instead — see the active/expired switch in `applyResidence`. + private func formatActiveExpiry(_ raw: String) -> String { let isoWithFraction: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png new file mode 100644 index 0000000..36bb8ae Binary files /dev/null and b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_expired_state.issue7_residence_invite_preview_expired_dark.png differ