fix(qlpreview): expired-state copy + dedicated row text (gitea#7 review)
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:
Trey T
2026-05-11 13:57:54 -05:00
parent f4c2780e34
commit 83c3428b05
3 changed files with 193 additions and 38 deletions
@@ -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