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
37 changes: 26 additions & 11 deletions Flipcash/Core/Controllers/Deep Links/Route.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ nonisolated extension Route {
case token(PublicKey)
case chat(ConversationID)
case chatSendCash(ConversationID)
/// A tipcard link for a user who hasn't claimed a handle —
/// `flipcash.com/<userId>`. Also reached by the older `/tip/<userId>`
/// form, which stays parseable for links already shared.
case tip(UserID)
/// A vanity tipcard link — `flipcash.com/<handle>`, no `/tip/`
/// segment and no `@`. The same destination as ``tip(_:)``, reached by
/// the handle its owner claimed rather than by their user id.
/// A vanity tipcard link — `flipcash.com/<handle>`, no `@`. The same
/// destination as ``tip(_:)``, reached by the handle its owner claimed
/// rather than by their user id.
case username(Username)
case give
case balance
Expand Down Expand Up @@ -146,7 +149,9 @@ nonisolated extension Route {
}
return .chat(id)
case "tip":
// The tipcard share URL: `/tip/{userId}`, lowercase UUID.
// The tipcard share URL as it used to be built: `/tip/{userId}`,
// lowercase UUID. New links put the id at the root instead
// (below); this stays for the ones already out there.
guard components.count > 1, let userID = UUID(uuidString: components[1]) else {
return nil
}
Expand All @@ -163,13 +168,23 @@ nonisolated extension Route {
case "discover" where scheme == customScheme:
return .discover
default:
// A single unmatched segment is a claimed handle. Lowercased
// first: handles are stored lowercase, and a link a messaging
// app auto-capitalized is still the same link. Whether the
// handle *can* be claimed — the reserved list — is the server's
// to say, so nothing is filtered out here beyond the routes
// above.
if components.count == 1, let username = Username(components[0].lowercased()) {
guard components.count == 1 else {
return .unknown(url.lastPathComponent)
}
// A single unmatched segment is a tipcard: the user id when its
// owner has no handle, otherwise the handle itself. A uuid can
// never be mistaken for a handle — dashes aren't in the handle
// character set, and 36 characters overruns its length — so the
// two forms share the root without ambiguity.
if let userID = UUID(uuidString: components[0]) {
return .tip(userID)
}
// Lowercased first: handles are stored lowercase, and a link a
// messaging app auto-capitalized is still the same link.
// Whether the handle *can* be claimed — the reserved list — is
// the server's to say, so nothing is filtered out here beyond
// the routes above.
if let username = Username(components[0].lowercased()) {
return .username(username)
}
return .unknown(url.lastPathComponent)
Expand Down
5 changes: 3 additions & 2 deletions Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ struct TipCardLinkRow: View {
/// stub so a full-width one doesn't push the row's copy button off the edge.
///
/// A claimed handle is never clipped here. It is at most 15 characters —
/// shorter than the `/tip/` segment it replaces — so it is handed to the
/// layout whole and elides only if it truly overruns the row.
/// well under the uuid that sits in the same place when there's no handle —
/// so it is handed to the layout whole and elides only if it truly overruns
/// the row.
static func displayText(for url: URL) -> String {
let stripped = url.absoluteString
.replacingOccurrences(of: "https://", with: "")
Expand Down
10 changes: 6 additions & 4 deletions Flipcash/Utilities/URL+Links.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ extension URL {

/// The public page that opens this user's tipcard.
///
/// A claimed handle gets the vanity form — no `/tip/` segment and no `@`,
/// so the link reads as a name. Both platforms build the URL here and only
/// here, on the apex host: its `apple-app-site-association` ends in a `/*`
/// Both forms sit at the root — a claimed handle, or the lowercase user id
/// when there isn't one — with no `/tip/` segment and no `@`, so the link
/// reads as a name as soon as its owner claims one and the shape of the URL
/// doesn't change when they do. Both platforms build it here and only here,
/// on the apex host: its `apple-app-site-association` ends in a `/*`
/// component, so a bare handle opens the app, and the marketing pages the
/// app itself links to are excluded there by path.
static func tipcard(for userID: UserID, username: Username?) -> URL {
let host = "https://flipcash.com"
if let username {
return URL(string: "\(host)/\(username.value)")!
}
return URL(string: "\(host)/tip/\(userID.uuidString.lowercased())")!
return URL(string: "\(host)/\(userID.uuidString.lowercased())")!
}

static var privacyPolicy: URL {
Expand Down
14 changes: 13 additions & 1 deletion FlipcashTests/RouteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,11 @@ struct RouteTests {
}
}

@Test("Tip route parses the user id from both URL formats", arguments: [
@Test("Tip route parses the user id from every host and scheme", arguments: [
"https://flipcash.com/11111111-2222-3333-4444-555555555555",
"https://app.flipcash.com/11111111-2222-3333-4444-555555555555",
"flipcash://11111111-2222-3333-4444-555555555555",
// The `/tip/` form links already shared still carry.
"https://app.flipcash.com/tip/11111111-2222-3333-4444-555555555555",
"flipcash://tip/11111111-2222-3333-4444-555555555555",
])
Expand All @@ -302,6 +306,14 @@ struct RouteTests {
#expect(Route(url: URL(string: urlString)!) == nil)
}

@Test("A root segment that only resembles a uuid stays unknown")
func tipRoute_partialUUID_isUnknown() throws {
let path = try #require(Route(url: URL(string: "https://flipcash.com/11111111-2222")!)?.path)
if case .unknown = path {} else {
Issue.record("A truncated uuid should parse as .unknown")
}
}

// MARK: - Vanity Handle URLs -

@Test("A vanity link parses the handle from every host and scheme", arguments: [
Expand Down
4 changes: 2 additions & 2 deletions FlipcashTests/TipCardLinkRowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ struct TipCardLinkRowTests {

@Test("A uuid is still clipped to a stub")
func displayText_uuid_clipped() {
let url = URL(string: "https://flipcash.com/tip/b0ced1d2-3f4a-4b5c-8d9e-0f1a2b3c4d5e")!
#expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/tip/b0ced…")
let url = URL(string: "https://flipcash.com/b0ced1d2-3f4a-4b5c-8d9e-0f1a2b3c4d5e")!
#expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/b0ced…")
}

@Test("The longest handle renders whole")
Expand Down
4 changes: 2 additions & 2 deletions FlipcashTests/TipcardLinkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ struct TipcardLinkTests {

private let userID = UUID(uuidString: "B0CED1D2-3F4A-4B5C-8D9E-0F1A2B3C4D5E")!

@Test("An unclaimed card links by lowercase uuid")
@Test("An unclaimed card links by lowercase uuid, at the root")
func tipcard_noUsername_uuidForm() {
let url = URL.tipcard(for: userID, username: nil)
#expect(url.absoluteString == "https://flipcash.com/tip/b0ced1d2-3f4a-4b5c-8d9e-0f1a2b3c4d5e")
#expect(url.absoluteString == "https://flipcash.com/b0ced1d2-3f4a-4b5c-8d9e-0f1a2b3c4d5e")
}

@Test("A claimed card links by handle, at the root and without an @")
Expand Down