fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
Android UI Tests / ui-tests (pull_request) Has been cancelled
Android UI Tests / ui-tests (pull_request) Has been cancelled
When the share link's expiry is in the past, the preview now
swaps the "How to join" steps for a dead-end message ("This
invite has expired. Ask <sender> to send a new link.") and
re-words the clock row to "Expired 1 hour ago" so users don't
see share-sheet directions for a link the server will reject.
Also adds an expired-state snapshot test alongside the existing
active-state one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -278,13 +278,19 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
|
|
||||||
titleLabel.text = residence.residenceName
|
titleLabel.text = residence.residenceName
|
||||||
subtitleLabel.text = "honeyDue Residence Invite"
|
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
|
// Branch the copy on whether the share link has already lapsed.
|
||||||
// QuickLook chrome (bottom on file previews, top in mail
|
// Active invites get the standard "How to join" numbered steps;
|
||||||
// previews, sometimes hidden behind a "More" menu) so we lean
|
// expired invites get a clear dead-end message asking the
|
||||||
// on the SF Symbol glyph inline instead of describing a
|
// recipient to ping the sender for a new link — no point
|
||||||
// position that may not match what the recipient sees.
|
// showing share-sheet directions for a link the server will
|
||||||
instructionLabel.attributedText = Self.makeResidenceInstructions()
|
// 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
|
// Clear existing details
|
||||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
@@ -295,42 +301,55 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
||||||
let formatted = Self.formatExpiresAt(expiresAt)
|
if let expiredAgo {
|
||||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
// "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
|
// 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
|
/// Callers must check [expiredRelativePhraseOrNil] first — this
|
||||||
/// seconds (`2026-05-12T17:11:02.067272789Z`); previous behaviour
|
/// function assumes a future expiry and produces wording that only
|
||||||
/// echoed that string verbatim (gitea#7). When parsing succeeds we
|
/// makes sense in that case.
|
||||||
/// produce a relative phrase ("today at 5:11 PM", "in 23 hours",
|
static func formatActiveExpiry(_ isoString: String) -> String {
|
||||||
/// "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 {
|
|
||||||
guard let date = parseIsoDate(isoString) else { return isoString }
|
guard let date = parseIsoDate(isoString) else { return isoString }
|
||||||
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let elapsed = date.timeIntervalSince(now)
|
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 {
|
if elapsed < 24 * 60 * 60 {
|
||||||
return relativeFormatter.localizedString(for: date, relativeTo: now)
|
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))"
|
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? {
|
private static func parseIsoDate(_ raw: String) -> Date? {
|
||||||
if let d = isoFormatterWithFraction.date(from: raw) { return d }
|
if let d = isoFormatterWithFraction.date(from: raw) { return d }
|
||||||
if let d = isoFormatterNoFraction.date(from: raw) { return d }
|
if let d = isoFormatterNoFraction.date(from: raw) { return d }
|
||||||
@@ -415,6 +434,50 @@ class PreviewViewController: UIViewController, QLPreviewingController {
|
|||||||
|
|
||||||
return result
|
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
|
// MARK: - Type Discriminator
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ final class Issue7PreviewScreenshotTest: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func test_residence_invite_preview_after_issue7_fix() {
|
func test_residence_invite_preview_after_issue7_fix() {
|
||||||
let vc = MockPreviewViewController(residence: ResidencePreview.fixtureForIssue7)
|
let vc = MockPreviewViewController(
|
||||||
// Force dark mode to match the gitea#7 screenshot exactly.
|
residence: ResidencePreview.fixtureForIssue7,
|
||||||
|
state: .active
|
||||||
|
)
|
||||||
vc.overrideUserInterfaceStyle = .dark
|
vc.overrideUserInterfaceStyle = .dark
|
||||||
|
|
||||||
assertSnapshot(
|
assertSnapshot(
|
||||||
@@ -67,6 +69,33 @@ final class Issue7PreviewScreenshotTest: XCTestCase {
|
|||||||
named: "issue7_residence_invite_preview_dark"
|
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)
|
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
|
||||||
@@ -87,10 +116,20 @@ private struct ResidencePreview {
|
|||||||
|
|
||||||
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
|
// 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
|
@MainActor
|
||||||
private final class MockPreviewViewController: UIViewController {
|
private final class MockPreviewViewController: UIViewController {
|
||||||
|
|
||||||
private let residence: ResidencePreview
|
private let residence: ResidencePreview
|
||||||
|
private let state: PreviewRenderState
|
||||||
|
|
||||||
private let containerView = UIView()
|
private let containerView = UIView()
|
||||||
private let iconImageView = UIImageView()
|
private let iconImageView = UIImageView()
|
||||||
@@ -102,8 +141,9 @@ private final class MockPreviewViewController: UIViewController {
|
|||||||
private let instructionLabel = UILabel()
|
private let instructionLabel = UILabel()
|
||||||
private let arrowImageView = UIImageView()
|
private let arrowImageView = UIImageView()
|
||||||
|
|
||||||
init(residence: ResidencePreview) {
|
init(residence: ResidencePreview, state: PreviewRenderState) {
|
||||||
self.residence = residence
|
self.residence = residence
|
||||||
|
self.state = state
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,18 +268,68 @@ private final class MockPreviewViewController: UIViewController {
|
|||||||
|
|
||||||
titleLabel.text = residence.residenceName
|
titleLabel.text = residence.residenceName
|
||||||
subtitleLabel.text = "honeyDue Residence Invite"
|
subtitleLabel.text = "honeyDue Residence Invite"
|
||||||
instructionLabel.attributedText = makeResidenceInstructions()
|
|
||||||
|
|
||||||
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
||||||
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
||||||
}
|
}
|
||||||
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
|
||||||
let formatted = formatExpiresAt(expiresAt)
|
switch state {
|
||||||
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
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) {
|
private func addDetailRow(icon: String, text: String) {
|
||||||
let row = UIStackView()
|
let row = UIStackView()
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
@@ -313,9 +403,11 @@ private final class MockPreviewViewController: UIViewController {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors PreviewViewController.formatExpiresAt with a fixed "now"
|
// Mirrors PreviewViewController.formatActiveExpiry with a fixed
|
||||||
// so the rendering is identical regardless of when the test runs.
|
// "now" so the rendering is identical regardless of when the test
|
||||||
private func formatExpiresAt(_ raw: String) -> String {
|
// 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 isoWithFraction: ISO8601DateFormatter = {
|
||||||
let f = ISO8601DateFormatter()
|
let f = ISO8601DateFormatter()
|
||||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Reference in New Issue
Block a user