Commit 9315908a60

9315908a600de42a60c4c2c3a9107e6d77bc457c

parent: 5f0b73e4e6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 21:44 UTC

push: resolve a payload to an account and a screen

The path parsing the inbox already did becomes a free function both
callers share rather than a second copy.

Ref krz/gitbay#89

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 37nonisolated 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 @@
1import 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.
5nonisolated 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 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func account(_ host: String, _ user: String) throws -> Account {
6 Account(instance: try GitbayInstance(url: "https://\(host)"), username: user)
7}
8
9private 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}