Skip to content

Commit 08d864a

Browse files
committed
Did a lot.
1 parent 3c9da59 commit 08d864a

21 files changed

Lines changed: 1126 additions & 1653 deletions

File tree

API-backend-prompts-to-build.md

Lines changed: 0 additions & 250 deletions
This file was deleted.

App/Composition/AppDelegate.swift

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
import AppKit
1616
import SwiftUI
1717
import UserNotifications
18+
import InterlinedDomain
1819

1920
/// `NSApplicationDelegate` adapter installed via
2021
/// `@NSApplicationDelegateAdaptor` in `InterlinedListApp`. Owns:
2122
///
2223
/// 1. The dock-tile badge writer (`updateDockBadge(unreadCount:)`).
23-
/// 2. The `UNUserNotificationCenterDelegate` hook so that activating a
24-
/// delivered notification brings the app forward (deep-link routing
25-
/// proper lands in a follow-up — see `// TODO(M5.x)` below).
24+
/// 2. The `UNUserNotificationCenterDelegate` hook that activates the app
25+
/// and routes to the relevant content when the user taps a delivered
26+
/// banner — see `userNotificationCenter(_:didReceive:withCompletionHandler:)`.
2627
///
2728
/// Everything else stays pure SwiftUI.
2829
final class AppDelegate: NSObject, NSApplicationDelegate {
@@ -62,17 +63,27 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
6263
completionHandler([.banner, .sound])
6364
}
6465

65-
/// Called when the user clicks a delivered notification. Routes the
66-
/// main window to the Notifications sidebar section and activates
67-
/// the app. The `NotificationsRootView` will refresh on appear so
68-
/// the relevant item is visible.
66+
/// Called when the user taps a delivered notification banner. Brings
67+
/// the app forward, then resolves a typed `NotificationTarget` from
68+
/// the banner's `userInfo` dict and posts `.notificationDeepLink` so
69+
/// `MainWindowView` can route the sidebar and feature views can push
70+
/// the relevant detail on their own navigation stacks.
71+
///
72+
/// Fallback: when the `userInfo` dict does not carry enough keys to
73+
/// produce a typed target — e.g. any notification scheduled before
74+
/// this routing was added — the resolved target is
75+
/// `.unknown(actionURL: nil)` and `MainWindowView` falls back to
76+
/// selecting the Notifications sidebar section, preserving the
77+
/// pre-M5.x behaviour.
6978
func userNotificationCenter(
7079
_ center: UNUserNotificationCenter,
7180
didReceive response: UNNotificationResponse,
7281
withCompletionHandler completionHandler: @escaping () -> Void
7382
) {
7483
NSApp.activate(ignoringOtherApps: true)
75-
NotificationCenter.default.post(name: .notificationsShow, object: nil)
84+
let userInfo = response.notification.request.content.userInfo
85+
let target = NotificationTarget(userInfo: userInfo)
86+
NotificationCenter.default.post(name: .notificationDeepLink, object: target)
7687
completionHandler()
7788
}
7889
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// NotificationDeepLinkRouter
2+
//
3+
// App-layer extension that closes the loop between
4+
// `LocalNotificationScheduler` (which embeds a typed `userInfo` dict
5+
// into every scheduled banner) and `AppDelegate` (which reads that dict
6+
// back from a tapped `UNNotificationResponse` and reconstructs a
7+
// `NotificationTarget`).
8+
//
9+
// The keys are namespaced under `"interlinedlist."` to avoid collisions
10+
// with any APNs aps payload keys the server might include in a push
11+
// notification's userInfo dict.
12+
//
13+
// Architecture notes
14+
// ------------------
15+
// * This file lives in `App/Composition/`, which is allowed to cross
16+
// every layer boundary per Decision 0003.
17+
// * It extends `NotificationTarget` (a domain type) with an App-layer
18+
// initialiser that understands the `userInfo` key contract. The domain
19+
// package itself stays ignorant of userInfo dicts — that detail belongs
20+
// at the app boundary, not in the reusable domain layer.
21+
// * `NotificationUserInfoKeys` is `enum` (no instantiation) to serve as
22+
// a namespace; all values are compile-time `static let` strings.
23+
24+
import Foundation
25+
import InterlinedDomain
26+
27+
// MARK: - userInfo key constants
28+
29+
/// Stable string constants for the keys
30+
/// `LocalNotificationScheduler` writes into
31+
/// `UNMutableNotificationContent.userInfo` when scheduling a local
32+
/// notification banner, and that `AppDelegate` reads back from a tapped
33+
/// `UNNotificationResponse`.
34+
enum NotificationUserInfoKeys {
35+
/// The domain `Notification.id` — used to identify which row to mark
36+
/// read after the user taps the banner.
37+
static let notificationId = "interlinedlist.notificationId"
38+
/// The raw `NotificationKind` string (`"dig"`, `"reply"`, …). Used
39+
/// to reconstruct the kind so `NotificationTarget.init(userInfo:)`
40+
/// can follow the same dispatch table as `NotificationMappers.swift`.
41+
static let type = "interlinedlist.type"
42+
/// Present when the target is `.message(id:)`.
43+
static let targetMessageId = "interlinedlist.targetMessageId"
44+
/// Present when the target is `.list(id:)`.
45+
static let targetListId = "interlinedlist.targetListId"
46+
/// Present when the target is `.user(id:)`.
47+
static let targetUserId = "interlinedlist.targetUserId"
48+
/// Present when the target is `.organization(id:)`.
49+
static let targetOrgId = "interlinedlist.targetOrgId"
50+
/// The actor's username — purely informational, not used for routing.
51+
static let actorUsername = "interlinedlist.actorUsername"
52+
/// Fallback URL string for `.unknown(actionURL:)` targets.
53+
static let actionUrl = "interlinedlist.actionUrl"
54+
}
55+
56+
// MARK: - NotificationTarget + userInfo parsing
57+
58+
extension NotificationTarget {
59+
60+
/// Reconstructs a typed `NotificationTarget` from the `userInfo` dict
61+
/// embedded by `LocalNotificationScheduler`. Returns
62+
/// `.unknown(actionURL:)` whenever the dict does not carry enough
63+
/// information to produce a more specific target — callers can still
64+
/// open a web fallback via the `actionURL` associated value in that
65+
/// case.
66+
///
67+
/// Mirrors the dispatch table in `NotificationMappers.swift` so the
68+
/// two ends of the banner round-trip stay in sync: the mapper turns
69+
/// server DTO metadata into a target; this initialiser turns app
70+
/// userInfo back into the same target.
71+
init(userInfo: [AnyHashable: Any]) {
72+
let typeString = userInfo[NotificationUserInfoKeys.type] as? String
73+
let kind = NotificationKind(rawValue: typeString)
74+
let actionURL = (userInfo[NotificationUserInfoKeys.actionUrl] as? String)
75+
.flatMap(URL.init(string:))
76+
77+
switch kind {
78+
case .dig, .reply, .mention:
79+
if let id = userInfo[NotificationUserInfoKeys.targetMessageId] as? String {
80+
self = .message(id: id)
81+
return
82+
}
83+
84+
case .listShared, .listRowAdded:
85+
if let id = userInfo[NotificationUserInfoKeys.targetListId] as? String {
86+
self = .list(id: id)
87+
return
88+
}
89+
90+
case .followRequest, .followAccepted:
91+
if let id = userInfo[NotificationUserInfoKeys.targetUserId] as? String {
92+
self = .user(id: id)
93+
return
94+
}
95+
96+
case .orgInvite:
97+
if let id = userInfo[NotificationUserInfoKeys.targetOrgId] as? String {
98+
self = .organization(id: id)
99+
return
100+
}
101+
102+
case .other:
103+
break
104+
}
105+
106+
self = .unknown(actionURL: actionURL)
107+
}
108+
}

App/Features/Compose/ComposerWindowView.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,30 @@ struct ComposerWindowView: View {
6565
}
6666
}
6767
.onChange(of: viewModel?.didFinish) { _, finished in
68-
if finished == true { dismiss() }
68+
// When cross-post results are present the sheet owns the final
69+
// dismiss (the Done button calls dismissCrossPostResults then
70+
// dismiss). Only auto-dismiss here when there is nothing to show.
71+
if finished == true, viewModel?.crossPostResults == nil {
72+
dismiss()
73+
}
74+
}
75+
// NW-2: present the per-platform cross-post status sheet after a
76+
// successful publish. The sheet's Done button calls
77+
// `dismissCrossPostResults()` and then closes the window.
78+
.sheet(isPresented: Binding(
79+
get: { viewModel?.crossPostResults != nil },
80+
set: { newValue in
81+
if !newValue {
82+
viewModel?.dismissCrossPostResults()
83+
}
84+
}
85+
)) {
86+
if let results = viewModel?.crossPostResults {
87+
CrossPostResultsSheet(results: results) {
88+
viewModel?.dismissCrossPostResults()
89+
dismiss()
90+
}
91+
}
6992
}
7093
}
7194

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// LocalNotificationScheduler
2+
//
3+
// Schedules a macOS `UNUserNotification` banner for a single
4+
// `InterlinedDomain.Notification` value. The scheduler embeds a typed
5+
// `userInfo` dict — keyed by `NotificationUserInfoKeys` — so that
6+
// `AppDelegate` can reconstruct a `NotificationTarget` when the user
7+
// taps the delivered banner (the deep-link routing half of the
8+
// notification feature, per PLAN.md §6 M5.x).
9+
//
10+
// Responsibilities
11+
// ----------------
12+
// * Build a `UNMutableNotificationContent` with title, sound, and userInfo.
13+
// * Schedule an immediate `UNNotificationRequest` via the shared
14+
// `UNUserNotificationCenter`.
15+
// * NOT request UN permission — that belongs to
16+
// `NotificationsPermissionCoordinator`.
17+
// * NOT de-duplicate previously-shown notifications — callers own that
18+
// set. The composition root can track shown IDs in `UserDefaults`.
19+
//
20+
// Per Decision 0003 this file imports only `InterlinedDomain`; it does
21+
// not import `InterlinedKit`. The `NotificationUserInfoKeys` constants
22+
// are in `App/Composition/` and are visible to all files in the
23+
// `InterlinedList` module without an extra import.
24+
25+
import Foundation
26+
import UserNotifications
27+
import InterlinedDomain
28+
29+
// MARK: - Protocol
30+
31+
/// Narrow scheduling surface for App-layer code that needs to surface
32+
/// an `InterlinedDomain.Notification` as a macOS system banner. A
33+
/// protocol so unit tests can inject a recording stub without touching
34+
/// `UNUserNotificationCenter`.
35+
protocol LocalNotificationScheduling: Sendable {
36+
/// Schedules a local notification banner for `notification`.
37+
/// Idempotent by request identifier (`notification.id`) — scheduling
38+
/// the same id twice replaces the earlier pending request.
39+
func schedule(_ notification: InterlinedDomain.Notification) async
40+
}
41+
42+
// MARK: - Live implementation
43+
44+
/// Production scheduler. Delegates to `UNUserNotificationCenter`; the
45+
/// center is injected so test overrides work without the real system.
46+
final class LocalNotificationScheduler: LocalNotificationScheduling, @unchecked Sendable {
47+
48+
private let center: UNUserNotificationCenter
49+
50+
init(center: UNUserNotificationCenter = .current()) {
51+
self.center = center
52+
}
53+
54+
func schedule(_ notification: InterlinedDomain.Notification) async {
55+
let content = UNMutableNotificationContent()
56+
content.title = derivedTitle(for: notification)
57+
if let body = notification.body, !body.isEmpty {
58+
content.body = body
59+
}
60+
content.sound = .default
61+
content.userInfo = Self.userInfo(for: notification)
62+
63+
let request = UNNotificationRequest(
64+
identifier: notification.id,
65+
content: content,
66+
trigger: nil // nil trigger = deliver immediately
67+
)
68+
// Swallow scheduling errors: if the user has denied permission the
69+
// banner silently drops; the in-app tray still shows the row.
70+
try? await center.add(request)
71+
}
72+
73+
// MARK: - userInfo builder
74+
75+
/// Assembles the `userInfo` dict that `AppDelegate` reads back from
76+
/// `UNNotificationResponse.notification.request.content.userInfo`
77+
/// when the user taps the delivered banner.
78+
///
79+
/// All keys use the `NotificationUserInfoKeys` constants so the
80+
/// writing and reading sides of the contract share a single source of
81+
/// truth and are immune to typos.
82+
static func userInfo(
83+
for notification: InterlinedDomain.Notification
84+
) -> [String: String] {
85+
var dict: [String: String] = [:]
86+
dict[NotificationUserInfoKeys.notificationId] = notification.id
87+
dict[NotificationUserInfoKeys.type] = notification.kind.rawValue
88+
89+
if let username = notification.actor?.username, !username.isEmpty {
90+
dict[NotificationUserInfoKeys.actorUsername] = username
91+
}
92+
93+
switch notification.target {
94+
case .message(let id):
95+
dict[NotificationUserInfoKeys.targetMessageId] = id
96+
case .list(let id):
97+
dict[NotificationUserInfoKeys.targetListId] = id
98+
case .user(let id):
99+
dict[NotificationUserInfoKeys.targetUserId] = id
100+
case .organization(let id):
101+
dict[NotificationUserInfoKeys.targetOrgId] = id
102+
case .unknown(let url):
103+
if let urlString = url?.absoluteString {
104+
dict[NotificationUserInfoKeys.actionUrl] = urlString
105+
}
106+
case .none:
107+
break
108+
}
109+
110+
return dict
111+
}
112+
113+
// MARK: - Title derivation
114+
115+
/// Falls back to `NotificationRowCopy.copy` when the server-supplied
116+
/// title is absent so every banner has human-readable text.
117+
private func derivedTitle(for note: InterlinedDomain.Notification) -> String {
118+
NotificationRowCopy.copy(
119+
for: note.kind,
120+
actor: note.actor,
121+
title: note.title,
122+
body: nil
123+
)
124+
}
125+
}

App/Features/Scheduled/ScheduledPostsRootView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
// error / empty logic in the view model so unit tests cover the
77
// behavior without touching SwiftUI.
88
//
9-
// v1 is read-only (backend ask P3.3 — no cancel / reschedule endpoint):
10-
// the list surfaces what is queued and the empty state points the user
11-
// at the composer for scheduling a new post. Rows carry no destructive
12-
// affordance.
9+
// Rows support cancel (DELETE /api/messages/[id]) and reschedule
10+
// (PUT /api/messages/[id] with a new `scheduledAt`). Both operations
11+
// use the optimistic-UI pattern (NW-3): the list is updated locally
12+
// before the network call and rolled back on failure.
1313
//
1414
// Per Decision 0003 the view consumes only `InterlinedDomain`.
1515

App/Features/Scheduled/ScheduledPostsViewModel.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// ScheduledPostsViewModel
22
//
3-
// Drives `ScheduledPostsRootView`: the read-only list of the caller's
4-
// pending scheduled posts (PLAN.md §1 "Scheduled posts", §5 "Scheduled
5-
// sidebar section", §6 M6). Reads through `MessagesServicing.scheduledPosts()`
6-
// only — no direct API access — so unit tests substitute a stub service.
3+
// Drives `ScheduledPostsRootView`: the list of the caller's pending
4+
// scheduled posts (PLAN.md §1 "Scheduled posts", §5 "Scheduled sidebar
5+
// section", §6 M6). Reads through `MessagesServicing.scheduledPosts()`
6+
// no direct API access — so unit tests substitute a stub service.
77
//
8-
// v1 is intentionally read-only. The API exposes no cancel / reschedule
9-
// endpoint (backend ask P3.3), so the list shows what is queued and links
10-
// the user to the composer for creating new scheduled posts; rows carry no
11-
// delete / edit affordance. When P3.3 lands, the row gains a destructive
12-
// action and this view model grows an optimistic-removal path.
8+
// NW-3: cancel and reschedule are now supported. Both use the optimistic-UI
9+
// pattern (snapshot → mutate locally → service call → on success replace
10+
// with server copy; on failure restore snapshot). The row context menu
11+
// surfaces both actions; `actionError` captures the last mutation failure
12+
// without replacing the loaded list.
1313
//
1414
// Per Decision 0003 this view model consumes only `InterlinedDomain`.
1515

App/Features/Settings/LinkedAccountsView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ struct LinkedAccountsView: View {
6161
}
6262

6363
Section("Link an account") {
64+
Text("Linking an account opens your default browser to complete authorization. Return to InterlinedList when done.")
65+
.font(.caption)
66+
.foregroundStyle(.secondary)
6467
if viewModel.isLinking {
6568
HStack {
6669
ProgressView()

App/Features/Timeline/MessageRowView.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,19 @@ struct MessageRowView: View {
230230
}
231231
}
232232
}
233+
234+
// Report — visible for every message regardless of ownership
235+
// (App Store Review Guideline 1.2: User-Generated Content requires
236+
// a mechanism to report objectionable content). No backend report
237+
// endpoint exists yet — open the support URL so users can contact
238+
// the team directly.
239+
Button {
240+
if let url = URL(string: "https://interlinedlist.com/support") {
241+
NSWorkspace.shared.open(url)
242+
}
243+
} label: {
244+
Label("Report\u{2026}", systemImage: "flag")
245+
}
233246
}
234247

235248
private func repostBanner(original: Message) -> some View {

0 commit comments

Comments
 (0)