Conversation
This commit implements a hybrid approach for push notification action buttons,
supporting both dynamic (unlimited customization) and predefined (fallback) categories.
## What's New
### Dynamic Action Buttons (Primary)
- **Fully customizable button labels** per notification (e.g., "Shop Now", "Add to Cart")
- **No manual registration required** - categories registered automatically in NSE
- **Per-button deep links** - each button can have its own destination URL
- **iOS 15+ icon support** - SF Symbols on buttons using NSInvocation reflection
- **Localization-ready** - button labels come from server payload
- **FIFO category pruning** - maintains 128 category limit automatically
### How It Works
1. Push arrives with `action_buttons` array in payload
2. NSE intercepts, parses buttons, generates unique category ID
3. Registers category dynamically before notification displays
4. User taps button → tracks `$opened_push_action` event with action_id
### Implementation Details
**New Files:**
- `Sources/KlaviyoSwiftExtension/KlaviyoCategoryController.swift`
- Manages dynamic category lifecycle (registration, persistence, pruning)
- Uses app group UserDefaults for persistence
- FIFO pruning at 128 categories
- Smart merge to avoid overwriting existing categories
- `Sources/KlaviyoSwiftExtension/KlaviyoActionButtonParser.swift`
- Parses action button definitions from push payload
- Creates UNNotificationAction instances
- Handles button reversal (2-button iOS convention)
- iOS 15+ icon support via NSInvocation reflection
- `PUSH_ACTION_BUTTONS_PAYLOAD_SPEC.md`
- Comprehensive payload specification for backend team
- Validation rules and examples
- Migration guide from predefined to dynamic
- Troubleshooting guide
**Modified Files:**
- `Sources/KlaviyoSwiftExtension/KlaviyoExtension.swift`
- Added `handleActionButtons()` method
- Integrates dynamic category registration into NSE flow
- Respects developer-set categories (no overwriting)
- `Sources/KlaviyoSwift/Utilities/UNNotificationResponse+Klaviyo.swift`
- Added `isActionButtonTap`, `actionButtonId`, `actionButtonURL`, `actionButtonLabel`
- Supports both dynamic and predefined payload formats
- Extracts action-specific deep links and labels for analytics
- `Sources/KlaviyoSwift/Klaviyo.swift`
- Updated notification response handler to detect action button taps
- Added `handleActionButtonTap()` method
- Tracks `$opened_push_action` events with action metadata
- `Sources/KlaviyoSwift/Models/Event.swift`
- Added `_openedPushAction` event type
- Maps to `$opened_push_action` in Klaviyo
### Payload Format
**Dynamic Buttons (Recommended):**
```json
{
"aps": {
"alert": "Flash Sale - 50% Off!",
"mutable-content": 1
},
"body": {
"_k": "unique_notification_id",
"url": "myapp://home",
"action_buttons": [
{
"id": "com.klaviyo.action.shop",
"label": "Shop Now",
"url": "myapp://sale/flash",
"icon": "cart.fill"
},
{
"id": "com.klaviyo.action.later",
"label": "Remind Later",
"url": "myapp://reminders"
}
]
}
}
```
**Predefined Categories (Fallback):**
- Existing predefined categories remain unchanged
- No breaking changes to current implementations
### Event Tracking
**Action Button Tap:**
- Event: `$opened_push_action`
- Properties: `action_id`, `action_label` (dynamic only), all notification properties
- Allows filtering by button type and A/B testing button labels
### Benefits Over Predefined Approach
✅ **Unlimited customization** - Any button labels, not limited to 4 combinations
✅ **Better for ecommerce** - Brand-appropriate labels ("Shop Now" vs generic "View")
✅ **Localization** - Server sends translated labels per user
✅ **A/B testing** - Test different button copy easily
✅ **Better UX** - No registration call needed from app developers
✅ **Production-proven** - OneSignal uses this approach at massive scale
### Backwards Compatibility
- Predefined categories still work unchanged
- Apps can use both approaches simultaneously
- Dynamic is primary, predefined is fallback (when NSE unavailable)
### Technical Approach
Based on research of OneSignal's iOS SDK implementation:
- Uses per-notification unique categories (not predefined sets)
- Registers categories in NSE (not at app launch)
- FIFO pruning prevents category bloat
- Smart merging respects developer's custom categories
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Simplified icon creation code using direct method invocation instead of NSInvocation - Fixed optional binding error in KlaviyoExtension (categoryIdentifier is non-optional) - Build now succeeds without errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Includes 10 ready-to-use test payloads covering: - Basic 2-button layouts - SF Symbols icons (iOS 15+) - 3-button configurations - Single button CTAs - E-commerce scenarios (cart, back-in-stock, etc.) - Predefined category fallback - Hybrid approach (both formats) - Localization examples - Error case handling Also includes: - Quick test script for apns-cli - Verification checklist - SF Symbols reference for e-commerce - Troubleshooting guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Created 10 ready-to-use JSON payload files in test-payloads/ directory: - 1-basic-two-buttons.json - Simple flash sale with 2 buttons - 2-with-icons.json - Order delivered with SF Symbols icons - 3-three-buttons.json - New arrivals with 3 buttons - 4-single-button.json - Cart reminder with single CTA - 5-abandoned-cart.json - Cart recovery scenario - 6-back-in-stock.json - Product availability alert - 7-predefined-fallback.json - Predefined category (no mutable-content) - 8-hybrid.json - Both dynamic and predefined formats - 9-localization-spanish.json - Spanish localization example - 10-error-case.json - Invalid payload for error testing Can be used directly with apns-cli: apns-cli send --payload test-payloads/1-basic-two-buttons.json ... Includes README.md with quick start guide and test script. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
024ec4b to
ec1063c
Compare
* Remove predefined category support and icon support, refactor test payloads * run pre commit * Delete spec.md file
ec1063c to
3df84de
Compare
3df84de to
75920ef
Compare
* Remove predefined category support and icon support, refactor test payloads * run pre commit * Delete spec.md file * Add ActionType to KlaviyoCore and expose to KlaviyoSwiftExtension * Move ActionType to KlaviyoSwift * Add ActionType helper * Add ActionType to parser * Prevent open_app buttons from deferring to overall deep link * Fix logic on validation of openApp * Add tests * Move ActionType from KlaviyoSwift to KlaviyoCore * Remove unreachable code * Move validation to helper function, add test * Make logic more concise * Move parser tests to separate KlaviyoSwiftExtensionTests target, remove PoC test assets
* Update event schema with Action button metadata * Add tests * Fix casing * Remove TEST_PAYLOADS.md
Add button id to opened push event
* Refactor isKlaviyoNotification helper to use it in multiple dicts * Use proper klaviyo identifier * Simplify category registration to hardcoded value * Fix tests * nit: fix file headers * Safely register different categoryIdentifiers, ensure thread safety * Use new registerCategory with com.klaviyo.buttom with identifier format
* Add README section * Add sample payload and TOC * Add placeholder link * Fix presumed release number, fix copy
| } | ||
|
|
||
| return nil | ||
| } |
There was a problem hiding this comment.
Duplicated button lookup logic across three properties
Low Severity
actionButtonURL, actionButtonLabel, and actionButtonType each independently traverse the notification payload to find the matching action button dictionary by actionIdentifier. A private helper that returns the matching button dictionary (e.g., matchingActionButton -> [String: Any]?) would eliminate this triplication and reduce the risk of inconsistent changes later.
Triggered by project rule: Cursor Bugbot Configuration
* Move KlaviyoCategoryController to KlaviyoCore * Add pruneCategory method * Prune category on opened push event * Prune category if push was dismissed from Notification Center * put prefix on KlaviyoCategoryController * Add pruning against all displayed notifs * Return true * Rename KlaviyoCategoryController to KlaviyoCategoryManager * Condense into a defer
| // Prune the category if the push with action buttons was dismissed from the Notification Center | ||
| guard notificationResponse.actionIdentifier != UNNotificationDismissActionIdentifier else { | ||
| return true | ||
| } |
There was a problem hiding this comment.
Completion handler never called on dismiss path
High Severity
When a Klaviyo notification with action buttons is dismissed, the early return at the UNNotificationDismissActionIdentifier guard returns true without ever calling completionHandler(). Since .customDismissAction is set on the category in KlaviyoCategoryManager, this path will be triggered. The caller (as shown in the example app) skips calling completionHandler itself when handle() returns true, relying on the SDK to do it. This leaves Apple's required completionHandler from userNotificationCenter(_:didReceive:withCompletionHandler:) permanently uncalled.


ongoing feature branch for visibility