Commit 9315908a60
Verified · cmc
Layout: unified · split
gitbay/Account/NotificationsViewModel.swift +17 −10
| @@ -26,16 +26,23 @@ nonisolated struct InboxNotification: Decodable, Sendable, Hashable, Identifiabl | |||
| 26 | /// or the `repo` field. A shape the app cannot route (wrong section, | 26 | /// or the `repo` field. A shape the app cannot route (wrong section, |
| 27 | /// missing or non-numeric id, wrong number of components) yields | 27 | /// missing or non-numeric id, wrong number of components) yields |
| 28 | /// nil, which a row renders as untappable rather than crashing. | 28 | /// nil, which a row renders as untappable rather than crashing. |
| 29 | var destination: NotificationDestination? { | 29 | var destination: NotificationDestination? { notificationDestination(forPath: path) } |
| 30 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) | 30 | } |
| 31 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | 31 | |
| 32 | let repo = "\(parts[0])/\(parts[1])" | 32 | /// Where a notification's `path` points — a web-style route, |
| 33 | switch parts[2] { | 33 | /// `<owner>/<repo>/<section>/<n>`. A shape the app cannot route (wrong |
| 34 | case "issues": return .issue(repo: repo, number: number) | 34 | /// section, missing or non-numeric id, wrong number of components) |
| 35 | case "mrs": return .mr(repo: repo, number: number) | 35 | /// yields nil. The merge request section is `mrs` on the wire, never |
| 36 | case "builds": return .build(repo: repo, number: number) | 36 | /// `merge_requests`. |
| 37 | default: return nil | 37 | nonisolated func notificationDestination(forPath path: String) -> NotificationDestination? { |
| 38 | } | 38 | let parts = path.split(separator: "/", omittingEmptySubsequences: false) |
| 39 | guard parts.count == 4, let number = Int64(parts[3]) else { return nil } | ||
| 40 | let repo = "\(parts[0])/\(parts[1])" | ||
| 41 | switch parts[2] { | ||
| 42 | case "issues": return .issue(repo: repo, number: number) | ||
| 43 | case "mrs": return .mr(repo: repo, number: number) | ||
| 44 | case "builds": return .build(repo: repo, number: number) | ||
| 45 | default: return nil | ||
| 39 | } | 46 | } |
| 40 | } | 47 | } |
| 41 | 48 | ||
gitbay/Push/PushTarget.swift added +20
| @@ -0,0 +1,20 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A tapped notification resolved against the accounts signed in on | ||
| 4 | /// this device: which account it belongs to, and the screen it opens. | ||
| 5 | nonisolated struct PushTarget: Equatable, Sendable { | ||
| 6 | let accountID: String | ||
| 7 | let destination: NotificationDestination | ||
| 8 | |||
| 9 | /// Nil when no signed-in account matches — you signed out of it — | ||
| 10 | /// or when the path names nothing the app can route to. | ||
| 11 | init?(payload: PushPayload, accounts: [Account]) { | ||
| 12 | guard let destination = notificationDestination(forPath: payload.path) else { return nil } | ||
| 13 | let match = accounts.first { | ||
| 14 | $0.username == payload.user && $0.instance.baseURL == payload.instance | ||
| 15 | } | ||
| 16 | guard let match else { return nil } | ||
| 17 | self.accountID = match.id | ||
| 18 | self.destination = destination | ||
| 19 | } | ||
| 20 | } | ||
gitbayTests/PushTargetTests.swift added +48
| @@ -0,0 +1,48 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func account(_ host: String, _ user: String) throws -> Account { | ||
| 6 | Account(instance: try GitbayInstance(url: "https://\(host)"), username: user) | ||
| 7 | } | ||
| 8 | |||
| 9 | private func payload(_ instance: String, _ user: String, _ path: String) throws -> PushPayload { | ||
| 10 | try #require(PushPayload(userInfo: [ | ||
| 11 | "aps": ["alert": ["title": "t", "body": "b"]], | ||
| 12 | "instance": instance, "user": user, "path": path, | ||
| 13 | ])) | ||
| 14 | } | ||
| 15 | |||
| 16 | @Test func targetResolvesToTheMatchingAccount() throws { | ||
| 17 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | ||
| 18 | let target = try #require(PushTarget( | ||
| 19 | payload: try payload("https://dev.local", "cmc", "krz/gitbay/mrs/7"), | ||
| 20 | accounts: accounts)) | ||
| 21 | #expect(target.accountID == accounts[1].id) | ||
| 22 | #expect(target.destination == .mr(repo: "krz/gitbay", number: 7)) | ||
| 23 | } | ||
| 24 | |||
| 25 | // Same username on two instances is two accounts; the instance decides. | ||
| 26 | @Test func targetDistinguishesTheSameUserOnTwoInstances() throws { | ||
| 27 | let accounts = [try account("gitbay.org", "cmc"), try account("dev.local", "cmc")] | ||
| 28 | let target = try #require(PushTarget( | ||
| 29 | payload: try payload("https://gitbay.org", "cmc", "a/b/issues/1"), | ||
| 30 | accounts: accounts)) | ||
| 31 | #expect(target.accountID == accounts[0].id) | ||
| 32 | } | ||
| 33 | |||
| 34 | @Test func targetIsNilForAnAccountSignedOut() throws { | ||
| 35 | let accounts = [try account("gitbay.org", "cmc")] | ||
| 36 | #expect(PushTarget(payload: try payload("https://other.example", "cmc", "a/b/issues/1"), | ||
| 37 | accounts: accounts) == nil) | ||
| 38 | } | ||
| 39 | |||
| 40 | // A repository root, or a section the app has no screen for, resolves | ||
| 41 | // to no target: the tap opens the app without navigating. | ||
| 42 | @Test func targetIsNilForAnUnroutablePath() throws { | ||
| 43 | let accounts = [try account("gitbay.org", "cmc")] | ||
| 44 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay"), | ||
| 45 | accounts: accounts) == nil) | ||
| 46 | #expect(PushTarget(payload: try payload("https://gitbay.org", "cmc", "krz/gitbay/wiki/Home"), | ||
| 47 | accounts: accounts) == nil) | ||
| 48 | } | ||