diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index cd39d6352..f9fca3e45 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -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/`. Also reached by the older `/tip/` + /// form, which stays parseable for links already shared. case tip(UserID) - /// A vanity tipcard link — `flipcash.com/`, 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/`, 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 @@ -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 } @@ -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) diff --git a/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift b/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift index c2b14bfa7..c5b0b7460 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift @@ -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: "") diff --git a/Flipcash/Utilities/URL+Links.swift b/Flipcash/Utilities/URL+Links.swift index 0cbf7ed53..583ac4ffa 100644 --- a/Flipcash/Utilities/URL+Links.swift +++ b/Flipcash/Utilities/URL+Links.swift @@ -19,9 +19,11 @@ 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 { @@ -29,7 +31,7 @@ extension URL { 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 { diff --git a/FlipcashTests/RouteTests.swift b/FlipcashTests/RouteTests.swift index 846d73e72..f56d0925b 100644 --- a/FlipcashTests/RouteTests.swift +++ b/FlipcashTests/RouteTests.swift @@ -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", ]) @@ -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: [ diff --git a/FlipcashTests/TipCardLinkRowTests.swift b/FlipcashTests/TipCardLinkRowTests.swift index 9ea5be7af..8ddd26df9 100644 --- a/FlipcashTests/TipCardLinkRowTests.swift +++ b/FlipcashTests/TipCardLinkRowTests.swift @@ -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") diff --git a/FlipcashTests/TipcardLinkTests.swift b/FlipcashTests/TipcardLinkTests.swift index 23c33f5cd..77bc184f2 100644 --- a/FlipcashTests/TipcardLinkTests.swift +++ b/FlipcashTests/TipcardLinkTests.swift @@ -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 @")