Skip to content

FORGE-740 and FORGE-741-Chat Notifications#118

Merged
Bryan Malumphy (bmalumphy) merged 3 commits intomainfrom
feature/FORGE-740-Chat-Notifications
Mar 17, 2026
Merged

FORGE-740 and FORGE-741-Chat Notifications#118
Bryan Malumphy (bmalumphy) merged 3 commits intomainfrom
feature/FORGE-740-Chat-Notifications

Conversation

@bmalumphy
Copy link
Contributor

ScreenRecording_03-14-2026.13-54-10_1.MP4

FORGE-740 — Chat Notifications via Local Communications

Summary

Adds automatic local push notifications to the DittoChat Swift SDK. When a new message arrives in any synced room the notification fires as a UNUserNotification banner — no server required. Notifications continue working while the app is backgrounded via a combination of raw DittoStoreObserver retention, BLE background modes, and a BGAppRefreshTask for WiFi-only scenarios.


Changes

sdks/swift/Sources/DittoChatCore/ — SDK

PushNotifications/ChatNotificationManager.swift (new)

Internal @MainActor class that owns one DittoStoreObserver per synced room.

  • On each observer callback, filters messages to only those that are: newer than manager init time, from another user, not archived, and not already notified this session.
  • Uses raw DittoStoreObserver (not the Combine observePublisher wrapper) so observations are held by the strong [roomId: DittoStoreObserver] dictionary and cannot be cancelled by an AnyCancellable going out of scope.
  • deliverOn: .global(qos: .utility) — callback fires immediately on a background thread when Ditto's sync engine has data, without waiting for the main RunLoop. Message parsing happens on the background thread; only the actor-isolated state write hops to .main via DispatchQueue.main.async.
  • syncRooms(_ rooms: [Room]) reconciles the observed set as the rooms list changes. stopAll() tears down cleanly on logout.

PushNotifications/DittoChatPushNotification.swift (new)

Public types for APNs (server-side push) integration:

Type Purpose
DittoChatNotificationKey userInfo key constants (dittoChatRoomId, dittoChatMessageId)
DittoChatNotificationAction Enum returned by handleNotification(userInfo:): .openRoom, .openMessage, .none
DittoChatPushNotificationDelegate Protocol with didSendMessage and didSendImageMessage callbacks so the host app can forward events to a push server

DittoChat.swift

  • Creates ChatNotificationManager in init and subscribes it to p2pStore.publicRoomsPublisher via a retained AnyCancellable.
  • Calls notificationManager?.stopAll() in logout().
  • New public API:
    • registerDeviceToken(_ data: Data) — converts raw APNs token to hex, stores in deviceToken.
    • handleNotification(userInfo:) -> DittoChatNotificationAction — interprets an incoming push payload for navigation routing.
    • pushNotificationDelegate — weak property; set via builder.
  • DittoChatBuilder.setPushNotificationDelegate(_:) — optional builder setter.
  • createMessage and createImageMessage now call the delegate after each write.

CLAUDE.md (new)

Architecture reference for the Swift SDK covering builder pattern, MVVM conventions, Ditto integration patterns, error handling, and the full local + APNs notification architecture.


apps/ios/DittoChatDemo/ — Demo App

Info.plist

Added three entries:

UIBackgroundModes
  bluetooth-central      ← keeps Ditto's BLE transport alive when backgrounded
  bluetooth-peripheral   ← allows this device to advertise to peers in background
  fetch                  ← lets iOS wake the app for BGAppRefreshTask

BGTaskSchedulerPermittedIdentifiers
  com.ditto.DittoChatDemo.refresh

Without bluetooth-central and bluetooth-peripheral iOS suspends Ditto's BLE transport immediately on background transition, so no data arrives and no observer ever fires. Without the BGTaskSchedulerPermittedIdentifiers entry, BGTaskScheduler silently rejects the task submission at runtime.

DittoChatDemoApp.swift

Added AppDelegate (via @UIApplicationDelegateAdaptor) with two responsibilities:

UNUserNotificationCenterDelegate

  • willPresent — returns .banner + .sound so message notifications display as banners even when the app is already open (iOS suppresses them by default in foreground).
  • didReceive — extracts dittoChatRoomId from userInfo and broadcasts it on NotificationCenter.default (.dittoChatOpenRoom) so ContentView can navigate to the correct room without needing a direct reference to the AppDelegate.

BGTaskScheduler

  • Registers com.ditto.DittoChatDemo.refresh in didFinishLaunchingWithOptions.
  • Schedules the next refresh in applicationDidEnterBackground and at the end of each handler so there is always a pending request queued.
  • The refresh handler gives Ditto 20 seconds of execution time, then calls setTaskCompleted. During that window Ditto reconnects to peers, syncs pending documents, and fires any queued DittoStoreObserver callbacks — covering WiFi-only scenarios where no BLE event would trigger a natural wakeup.

ContentViewModel.swift

  • Marked @MainActor (required because DittoChatBuilder.build() is @MainActor).
  • DittoChat instance moved here as @Published var dittoChat: DittoChat?, created once in setup() alongside Ditto. Previously ContentView.body created a new DittoChat on every SwiftUI render with try!, which would have re-requested notification permissions and spun up duplicate observers on every state change.

ContentView.swift

  • Switched to @StateObject so ContentViewModel is owned by (not re-created with) the view.
  • Uses viewModel.dittoChat (stable) instead of inline DittoChatBuilder().build().
  • Added hidden NavigationLink + RoomDeepLinkView: listens for .dittoChatOpenRoom on NotificationCenter, sets pendingRoomId, activates the link. RoomDeepLinkView calls dittoChat.readRoomById(id:) async via .task and presents ChatScreen once the room resolves — deep-linking directly into the relevant conversation on notification tap.

How It Works End-to-End

Ditto peer sends a message
  └─> BLE / WiFi transport delivers document to this device
        └─> DittoStoreObserver callback fires on .global(qos: .utility)
              └─> Message array parsed on background thread
                    └─> DispatchQueue.main.async
                          └─> ChatNotificationManager.handle(messages:roomId:roomName:)
                                └─> filters: new, not own, not archived, not already notified
                                      └─> UNUserNotificationCenter.add(request) → banner appears

User taps the banner
  └─> AppDelegate.userNotificationCenter(_:didReceive:)
        └─> NotificationCenter.default.post(.dittoChatOpenRoom, userInfo: ["roomId": id])
              └─> ContentView.onReceive → pendingRoomId set, NavigationLink activated
                    └─> RoomDeepLinkView.task → dittoChat.readRoomById(id:)
                          └─> ChatScreen(room: room, dittoChat: dittoChat)

Testing Notes

  • Foreground: Send a message from another device — banner should appear over the app.
  • Background (BLE): Put app in background, send from a nearby device over BLE — banner should appear within a few seconds.
  • Background (WiFi/Cloud): With BLE off, send via cloud sync — banner may take up to the next BGAppRefreshTask wakeup (~15 min, or trigger manually in Xcode via e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.ditto.DittoChatDemo.refresh"]).
  • Notification tap: Tap a banner while app is backgrounded — should deep-link into the correct chat room.
  • Own messages: Messages sent by the local user should never produce a notification.
  • History on relaunch: Messages that existed before the app launched should not produce notifications.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 14, 2026

🟢 Test Coverage Report - @dittolive/ditto-chat-core

Overall Coverage: 87.93%

Metric Coverage Status
🟢 Lines 86.6% green
🟢 Statements 86.6% green
🟢 Functions 92.45% green
🟢 Branches 86.08% green

📊 View Detailed Coverage Report

ℹ️ Coverage Thresholds
  • 🟢 Excellent (≥ 80%)
  • 🟡 Good (60-79%)
  • 🟠 Fair (40-59%)
  • 🔴 Poor (< 40%)

@github-actions
Copy link
Contributor

github-actions bot commented Mar 14, 2026

🟢 Test Coverage Report - @dittolive/ditto-chat-ui

Overall Coverage: 89.67%

Metric Coverage Status
🟢 Lines 91.94% green
🟢 Statements 91.94% green
🟢 Functions 86.11% green
🟢 Branches 88.67% green

📊 View Detailed Coverage Report

ℹ️ Coverage Thresholds
  • 🟢 Excellent (≥ 80%)
  • 🟡 Good (60-79%)
  • 🟠 Fair (40-59%)
  • 🔴 Poor (< 40%)

@bmalumphy Bryan Malumphy (bmalumphy) force-pushed the feature/FORGE-740-Chat-Notifications branch from 929e7ee to 461e78a Compare March 15, 2026 17:24
@bmalumphy Bryan Malumphy (bmalumphy) changed the title FORGE-740-Chat Notifications FORGE-740 and FORGE-741-Chat Notifications Mar 16, 2026
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some things to think about and maybe add a linear ticket for. otherwise LGTM

@bmalumphy Bryan Malumphy (bmalumphy) merged commit db1188f into main Mar 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants