mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile/ios): add NIP-PL notification service foundation
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
88be670406
commit
e4b1e81312
@@ -6,4 +6,8 @@
|
||||
// `mobile/ios/Flutter/AppOverrides.xcconfig` containing
|
||||
// `BUNDLE_IDENTIFIER = your.app.id` (gitignored).
|
||||
BUNDLE_IDENTIFIER = com.buzz.buzzMobile
|
||||
BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
|
||||
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
|
||||
BUZZ_IOS_PUSH_ENVIRONMENT = development
|
||||
BUZZ_APP_ATTEST_ENVIRONMENT = development
|
||||
#include? "AppOverrides.xcconfig"
|
||||
|
||||
@@ -7,4 +7,8 @@
|
||||
BUNDLE_IDENTIFIER = com.buzz.buzzMobile
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
CODE_SIGN_IDENTITY = iPhone Developer
|
||||
BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
|
||||
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
|
||||
BUZZ_IOS_PUSH_ENVIRONMENT = production
|
||||
BUZZ_APP_ATTEST_ENVIRONMENT = production
|
||||
#include? "AppOverrides.xcconfig"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuzzAppGroupIdentifier</key>
|
||||
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
|
||||
<key>BuzzKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NotificationService</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var bestAttemptContent: UNMutableNotificationContent?
|
||||
private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver()
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
bestAttemptContent = content
|
||||
|
||||
resolver.resolve { [weak self] resolution in
|
||||
guard let self else { return }
|
||||
if let resolution {
|
||||
content.title = resolution.title
|
||||
content.body = resolution.body
|
||||
if let subtitle = resolution.subtitle {
|
||||
content.subtitle = subtitle
|
||||
}
|
||||
if let threadIdentifier = resolution.threadIdentifier {
|
||||
content.threadIdentifier = threadIdentifier
|
||||
}
|
||||
}
|
||||
self.finish(content)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let bestAttemptContent {
|
||||
finish(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent) {
|
||||
guard let contentHandler else { return }
|
||||
self.contentHandler = nil
|
||||
contentHandler(content)
|
||||
}
|
||||
}
|
||||
|
||||
struct BuzzPushResolution: Decodable {
|
||||
let title: String
|
||||
let body: String
|
||||
let subtitle: String?
|
||||
let threadIdentifier: String?
|
||||
}
|
||||
|
||||
protocol BuzzPushNotificationResolving {
|
||||
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
|
||||
}
|
||||
|
||||
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
private let snapshotFile = "push-communities.json"
|
||||
private let session: URLSession
|
||||
private let appGroupIdentifier: String?
|
||||
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier")
|
||||
as? String
|
||||
) {
|
||||
self.session = session
|
||||
self.appGroupIdentifier = appGroupIdentifier
|
||||
}
|
||||
|
||||
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
|
||||
guard let community = loadCommunities().first(where: { $0.pubkey?.isEmpty == false }) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// The NIP-PL APNs payload intentionally carries no relay or event id. The
|
||||
// service extension therefore performs a bounded catch-up against locally
|
||||
// configured origins and only replaces the fixed placeholder if authoritative
|
||||
// relay data is available before the NSE deadline.
|
||||
let filters: [[String: Any]] = [
|
||||
[
|
||||
"kinds": [9, 40002, 45001, 45003],
|
||||
"#p": [community.pubkey!],
|
||||
"limit": 10,
|
||||
]
|
||||
]
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: filters) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let url = URL(string: "/query", relativeTo: community.relayURL)!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
session.dataTask(with: request) { data, response, _ in
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode),
|
||||
let data,
|
||||
let events = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
|
||||
!events.isEmpty
|
||||
else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let resolution = Self.decodeResolution(
|
||||
events: events,
|
||||
community: community
|
||||
)
|
||||
completion(resolution)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private static func decodeResolution(events: [[String: Any]], community: BuzzPushCommunity)
|
||||
-> BuzzPushResolution?
|
||||
{
|
||||
guard let myPubkey = community.pubkey?.lowercased() else { return nil }
|
||||
let candidates = events.compactMap(BuzzPushEvent.init(json:)).filter { event in
|
||||
event.pubkey.lowercased() != myPubkey && [9, 40002, 45001, 45003].contains(event.kind)
|
||||
}
|
||||
guard
|
||||
let event = candidates.sorted(by: { left, right in
|
||||
if left.createdAt != right.createdAt { return left.createdAt > right.createdAt }
|
||||
return left.id < right.id
|
||||
}).first
|
||||
else { return nil }
|
||||
let body = previewBody(event.content)
|
||||
guard !body.isEmpty else { return nil }
|
||||
return BuzzPushResolution(
|
||||
title: shortPubkey(event.pubkey),
|
||||
body: body,
|
||||
subtitle: community.name,
|
||||
threadIdentifier: event.channelId ?? community.id
|
||||
)
|
||||
}
|
||||
|
||||
private static func previewBody(_ content: String) -> String {
|
||||
var result = content.replacingOccurrences(
|
||||
of: #"```[\s\S]*?```"#,
|
||||
with: "[code]",
|
||||
options: .regularExpression
|
||||
)
|
||||
result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression)
|
||||
result = result.replacingOccurrences(
|
||||
of: #"!\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
|
||||
result = result.replacingOccurrences(
|
||||
of: #"\[([^\]]+)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
|
||||
result = result.replacingOccurrences(
|
||||
of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
|
||||
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard result.count > 180 else { return result }
|
||||
return String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
|
||||
}
|
||||
|
||||
private static func shortPubkey(_ pubkey: String) -> String {
|
||||
guard pubkey.count > 8 else { return pubkey }
|
||||
return String(pubkey.prefix(8)) + "…"
|
||||
}
|
||||
|
||||
private func loadCommunities() -> [BuzzPushCommunity] {
|
||||
guard let appGroupIdentifier,
|
||||
let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
else { return [] }
|
||||
let url = container.appendingPathComponent(snapshotFile)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
|
||||
else { return [] }
|
||||
return decoded.communities
|
||||
}
|
||||
}
|
||||
|
||||
struct BuzzPushEvent {
|
||||
let id: String
|
||||
let pubkey: String
|
||||
let createdAt: Int
|
||||
let kind: Int
|
||||
let tags: [[String]]
|
||||
let content: String
|
||||
|
||||
init?(json: [String: Any]) {
|
||||
guard let id = json["id"] as? String,
|
||||
let pubkey = json["pubkey"] as? String,
|
||||
let createdAt = json["created_at"] as? Int,
|
||||
let kind = json["kind"] as? Int,
|
||||
let tags = json["tags"] as? [[String]],
|
||||
let content = json["content"] as? String
|
||||
else { return nil }
|
||||
self.id = id
|
||||
self.pubkey = pubkey
|
||||
self.createdAt = createdAt
|
||||
self.kind = kind
|
||||
self.tags = tags
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var channelId: String? {
|
||||
tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
|
||||
}
|
||||
}
|
||||
|
||||
struct BuzzPushSnapshot: Decodable {
|
||||
let communities: [BuzzPushCommunity]
|
||||
}
|
||||
|
||||
struct BuzzPushCommunity: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let relayUrl: String
|
||||
let pubkey: String?
|
||||
|
||||
var relayURL: URL {
|
||||
URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")!
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; };
|
||||
BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
@@ -20,6 +22,13 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
BZZ00000000000000000014 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = BZZ0000000000000000000E;
|
||||
remoteInfo = NotificationService;
|
||||
};
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
@@ -30,6 +39,17 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
BZZ00000000000000000004 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -43,6 +63,11 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
|
||||
BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -70,6 +95,13 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
BZZ0000000000000000000C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3C28C6B702C81085E6F96F2A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -125,6 +157,7 @@
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BZZ0000000000000000000B /* NotificationService */,
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
@@ -139,6 +172,7 @@
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
BZZ00000000000000000009 /* NotificationService.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -150,6 +184,7 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
BZZ0000000000000000000A /* Runner.entitlements */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
@@ -168,6 +203,16 @@
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BZZ0000000000000000000B /* NotificationService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BZZ00000000000000000006 /* NotificationService.swift */,
|
||||
BZZ00000000000000000007 /* Info.plist */,
|
||||
BZZ00000000000000000008 /* NotificationService.entitlements */,
|
||||
);
|
||||
path = NotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -200,18 +245,37 @@
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
BZZ00000000000000000004 /* Embed App Extensions */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
BZZ00000000000000000013 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
BZZ0000000000000000000E /* NotificationService */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */;
|
||||
buildPhases = (
|
||||
BZZ0000000000000000000D /* Sources */,
|
||||
BZZ0000000000000000000C /* Frameworks */,
|
||||
BZZ00000000000000000010 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = NotificationService;
|
||||
productName = NotificationService;
|
||||
productReference = BZZ00000000000000000009 /* NotificationService.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -222,6 +286,9 @@
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
BZZ0000000000000000000E = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
@@ -247,11 +314,19 @@
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
BZZ0000000000000000000E /* NotificationService */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
BZZ00000000000000000010 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -368,6 +443,14 @@
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
BZZ0000000000000000000D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BZZ00000000000000000001 /* NotificationService.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -389,6 +472,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
BZZ00000000000000000013 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = BZZ0000000000000000000E /* NotificationService */;
|
||||
targetProxy = BZZ00000000000000000014 /* PBXContainerItemProxy */;
|
||||
};
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
@@ -477,6 +565,7 @@
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -659,6 +748,7 @@
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -682,6 +772,7 @@
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_BITCODE = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -695,9 +786,91 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BZZ00000000000000000011 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BZZ00000000000000000012 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BZZ00000000000000000015 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = NotificationService/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BZZ00000000000000000011 /* Debug */,
|
||||
BZZ00000000000000000012 /* Release */,
|
||||
BZZ00000000000000000015 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -6,12 +6,23 @@ import UserNotifications
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var mediaUploadChannel: FlutterMethodChannel?
|
||||
private var pushChannel: FlutterMethodChannel?
|
||||
private var appGroupIdentifier: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {
|
||||
granted, _ in
|
||||
if granted {
|
||||
DispatchQueue.main.async {
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
@@ -24,6 +35,81 @@ import UserNotifications
|
||||
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleMediaUploadMethodCall(call, result: result)
|
||||
}
|
||||
|
||||
pushChannel = FlutterMethodChannel(
|
||||
name: "buzz/push",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
pushChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handlePushMethodCall(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
|
||||
pushChannel?.invokeMethod(
|
||||
"apnsTokenChanged",
|
||||
arguments: ["token": deviceToken.map { String(format: "%02x", $0) }.joined()])
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
|
||||
pushChannel?.invokeMethod(
|
||||
"apnsRegistrationFailed", arguments: ["message": error.localizedDescription])
|
||||
}
|
||||
|
||||
private func handlePushMethodCall(
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
switch call.method {
|
||||
case "saveCommunitySnapshot":
|
||||
guard let arguments = call.arguments as? [String: Any],
|
||||
let communities = arguments["communities"] as? [[String: Any]]
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments", message: "Expected communities array.", details: nil))
|
||||
return
|
||||
}
|
||||
do {
|
||||
try savePushCommunitySnapshot(communities)
|
||||
result(nil)
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "save_failed", message: "Unable to save push community snapshot.",
|
||||
details: error.localizedDescription))
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws {
|
||||
guard let appGroupIdentifier else {
|
||||
throw NSError(
|
||||
domain: "BuzzPush", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"])
|
||||
}
|
||||
guard
|
||||
let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "BuzzPush", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"])
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: ["communities": communities], options: [.sortedKeys])
|
||||
let destination = container.appendingPathComponent("push-communities.json")
|
||||
try data.write(to: destination, options: [.atomic])
|
||||
}
|
||||
|
||||
private func handleMediaUploadMethodCall(
|
||||
@@ -156,10 +242,12 @@ import UserNotifications
|
||||
let sourceURL = URL(fileURLWithPath: sourcePath)
|
||||
let asset = AVURLAsset(url: sourceURL)
|
||||
|
||||
guard let exportSession = AVAssetExportSession(
|
||||
asset: asset,
|
||||
presetName: AVAssetExportPresetPassthrough
|
||||
) else {
|
||||
guard
|
||||
let exportSession = AVAssetExportSession(
|
||||
asset: asset,
|
||||
presetName: AVAssetExportPresetPassthrough
|
||||
)
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
@@ -183,7 +271,8 @@ import UserNotifications
|
||||
case .completed:
|
||||
result(outputURL.path)
|
||||
default:
|
||||
let errorMessage = exportSession.error?.localizedDescription
|
||||
let errorMessage =
|
||||
exportSession.error?.localizedDescription
|
||||
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
|
||||
result(
|
||||
FlutterError(
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuzzAppGroupIdentifier</key>
|
||||
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
|
||||
<key>BuzzKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(BUZZ_IOS_PUSH_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.app-attest.environment</key>
|
||||
<string>$(BUZZ_APP_ATTEST_ENVIRONMENT)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,10 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'shared/push/push_bridge.dart';
|
||||
import 'shared/theme/theme_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
installBuzzPushMethodHandler();
|
||||
|
||||
// Pre-load preferences so the first frame uses the saved theme/accent.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../push/push_bridge.dart';
|
||||
import 'community.dart';
|
||||
import 'community_storage.dart';
|
||||
|
||||
@@ -36,11 +37,14 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
final updatedList = [...current];
|
||||
updatedList[existingIndex] = updated;
|
||||
state = AsyncData(updatedList);
|
||||
await registerBuzzPushCommunitySnapshot(updatedList);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
await storage.save(community);
|
||||
state = AsyncData([...current, community]);
|
||||
final updatedList = [...current, community];
|
||||
state = AsyncData(updatedList);
|
||||
await registerBuzzPushCommunitySnapshot(updatedList);
|
||||
return community.id;
|
||||
}
|
||||
|
||||
@@ -49,7 +53,9 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
await storage.remove(id);
|
||||
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.where((w) => w.id != id).toList());
|
||||
final updatedList = current.where((w) => w.id != id).toList();
|
||||
state = AsyncData(updatedList);
|
||||
await registerBuzzPushCommunitySnapshot(updatedList);
|
||||
|
||||
// If we removed the active community, switch to another or sign out.
|
||||
final activeId = await storage.loadActiveId();
|
||||
@@ -90,6 +96,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
final updatedList = [...current];
|
||||
updatedList[index] = updated;
|
||||
state = AsyncData(updatedList);
|
||||
await registerBuzzPushCommunitySnapshot(updatedList);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../community/community.dart';
|
||||
import '../relay/nostr_models.dart';
|
||||
import '../relay/relay_provider.dart';
|
||||
import 'push_models.dart';
|
||||
|
||||
const _channel = MethodChannel('buzz/push');
|
||||
|
||||
Future<void> registerBuzzPushCommunitySnapshot(
|
||||
List<Community> communities,
|
||||
) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
||||
try {
|
||||
final snapshots = [
|
||||
for (final community in communities)
|
||||
BuzzPushCommunitySnapshot(
|
||||
id: community.id,
|
||||
name: community.name,
|
||||
relayUrl: community.relayUrl,
|
||||
pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec),
|
||||
),
|
||||
];
|
||||
await _channel.invokeMethod<void>('saveCommunitySnapshot', {
|
||||
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
|
||||
});
|
||||
} on MissingPluginException {
|
||||
// Flutter tests and non-Runner embeddings do not install the native bridge.
|
||||
}
|
||||
}
|
||||
|
||||
Future<BuzzPushResolution?> resolveBuzzPushPayload(
|
||||
Map<String, dynamic> arguments,
|
||||
) async {
|
||||
final myPubkey = arguments['pubkey'] as String?;
|
||||
final communityName = arguments['communityName'] as String? ?? 'Buzz';
|
||||
if (myPubkey == null || myPubkey.isEmpty) return null;
|
||||
|
||||
final eventPayloads = arguments['events'];
|
||||
if (eventPayloads is! List) return null;
|
||||
final events = <NostrEvent>[];
|
||||
for (final payload in eventPayloads) {
|
||||
if (payload is Map) {
|
||||
try {
|
||||
events.add(NostrEvent.fromJson(Map<String, dynamic>.from(payload)));
|
||||
} catch (_) {
|
||||
// Ignore malformed relay rows and preserve the fallback notification.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final profiles = <String, ProfileData>{};
|
||||
final profilePayloads = arguments['profiles'];
|
||||
if (profilePayloads is List) {
|
||||
for (final payload in profilePayloads) {
|
||||
if (payload is Map) {
|
||||
try {
|
||||
final event = NostrEvent.fromJson(Map<String, dynamic>.from(payload));
|
||||
final profile = ProfileData.fromEvent(event);
|
||||
profiles[profile.pubkey.toLowerCase()] = profile;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolveBuzzPushNotification(
|
||||
events: events,
|
||||
myPubkey: myPubkey,
|
||||
communityName: communityName,
|
||||
channelName: arguments['channelName'] as String?,
|
||||
profilesByPubkey: profiles,
|
||||
);
|
||||
}
|
||||
|
||||
void installBuzzPushMethodHandler() {
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'resolveNotification':
|
||||
final args = call.arguments;
|
||||
if (args is! Map) return null;
|
||||
return (await resolveBuzzPushPayload(
|
||||
Map<String, dynamic>.from(args),
|
||||
))?.toJson();
|
||||
default:
|
||||
throw MissingPluginException('Unknown buzz/push method ${call.method}');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import '../relay/nostr_models.dart';
|
||||
|
||||
const buzzPushFallbackBody = 'Reconnect to your relay now';
|
||||
|
||||
class BuzzPushCommunitySnapshot {
|
||||
final String id;
|
||||
final String name;
|
||||
final String relayUrl;
|
||||
final String? pubkey;
|
||||
|
||||
const BuzzPushCommunitySnapshot({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.relayUrl,
|
||||
this.pubkey,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'relayUrl': relayUrl,
|
||||
if (pubkey != null) 'pubkey': pubkey,
|
||||
};
|
||||
|
||||
factory BuzzPushCommunitySnapshot.fromJson(Map<String, dynamic> json) {
|
||||
return BuzzPushCommunitySnapshot(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
relayUrl: json['relayUrl'] as String,
|
||||
pubkey: json['pubkey'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BuzzPushResolution {
|
||||
final String title;
|
||||
final String body;
|
||||
final String? subtitle;
|
||||
final String? threadIdentifier;
|
||||
|
||||
const BuzzPushResolution({
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.subtitle,
|
||||
this.threadIdentifier,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'body': body,
|
||||
if (subtitle != null) 'subtitle': subtitle,
|
||||
if (threadIdentifier != null) 'threadIdentifier': threadIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
BuzzPushResolution? resolveBuzzPushNotification({
|
||||
required List<NostrEvent> events,
|
||||
required String myPubkey,
|
||||
required String communityName,
|
||||
String? channelName,
|
||||
Map<String, ProfileData> profilesByPubkey = const {},
|
||||
}) {
|
||||
final normalizedPubkey = myPubkey.toLowerCase();
|
||||
final candidates = [
|
||||
for (final event in events)
|
||||
if (_isUserVisiblePushEvent(event, normalizedPubkey)) event,
|
||||
];
|
||||
if (candidates.isEmpty) return null;
|
||||
candidates.sort((a, b) {
|
||||
final created = b.createdAt.compareTo(a.createdAt);
|
||||
return created != 0 ? created : a.id.compareTo(b.id);
|
||||
});
|
||||
final event = candidates.first;
|
||||
final author = profilesByPubkey[event.pubkey.toLowerCase()];
|
||||
final title = _firstNonEmpty([
|
||||
author?.displayName,
|
||||
author?.nip05,
|
||||
_shortPubkey(event.pubkey),
|
||||
]);
|
||||
final body = _previewBody(event.content);
|
||||
if (body.isEmpty) return null;
|
||||
return BuzzPushResolution(
|
||||
title: title,
|
||||
subtitle: channelName ?? communityName,
|
||||
body: body,
|
||||
threadIdentifier: event.channelId ?? communityName,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isUserVisiblePushEvent(NostrEvent event, String normalizedPubkey) {
|
||||
if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false;
|
||||
if (event.pubkey.toLowerCase() == normalizedPubkey) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
String _previewBody(String content) {
|
||||
String stripMarkdownLinks(String input) {
|
||||
return input.replaceAllMapped(
|
||||
RegExp(r'!?\[([^\]]*)\]\([^)]*\)'),
|
||||
(match) => match.group(1) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
final stripped =
|
||||
stripMarkdownLinks(
|
||||
stripMarkdownLinks(
|
||||
content
|
||||
.replaceAll(RegExp(r'```[\s\S]*?```'), '[code]')
|
||||
.replaceAllMapped(
|
||||
RegExp(r'`([^`]*)`'),
|
||||
(match) => match.group(1) ?? '',
|
||||
),
|
||||
),
|
||||
)
|
||||
.replaceAll(RegExp(r'https?://\S+'), '[link]')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
if (stripped.length <= 180) return stripped;
|
||||
return '${stripped.substring(0, 177).trimRight()}…';
|
||||
}
|
||||
|
||||
String _shortPubkey(String pubkey) {
|
||||
if (pubkey.length <= 8) return pubkey;
|
||||
return '${pubkey.substring(0, 8)}…';
|
||||
}
|
||||
|
||||
String _firstNonEmpty(Iterable<String?> values) {
|
||||
for (final value in values) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
||||
}
|
||||
return 'Buzz';
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/push/push_models.dart';
|
||||
import 'package:buzz/shared/relay/nostr_models.dart';
|
||||
|
||||
void main() {
|
||||
test('resolves newest user-visible event into notification content', () {
|
||||
final mine = 'a' * 64;
|
||||
final alice = 'b' * 64;
|
||||
final older = _event(
|
||||
id: '1' * 64,
|
||||
pubkey: alice,
|
||||
createdAt: 10,
|
||||
content: 'older',
|
||||
);
|
||||
final newest = _event(
|
||||
id: '2' * 64,
|
||||
pubkey: alice,
|
||||
createdAt: 20,
|
||||
content: 'hello [there](https://example.com)',
|
||||
tags: [
|
||||
['h', 'chan-1'],
|
||||
],
|
||||
);
|
||||
final profile = _event(
|
||||
id: '3' * 64,
|
||||
pubkey: alice,
|
||||
kind: EventKind.contactList,
|
||||
content: '{"display_name":"Alice"}',
|
||||
);
|
||||
|
||||
final resolved = resolveBuzzPushNotification(
|
||||
events: [older, newest],
|
||||
myPubkey: mine,
|
||||
communityName: 'Team',
|
||||
channelName: 'mobile',
|
||||
profilesByPubkey: {alice: ProfileData.fromEvent(profile)},
|
||||
);
|
||||
|
||||
expect(resolved, isNotNull);
|
||||
expect(resolved!.title, 'Alice');
|
||||
expect(resolved.subtitle, 'mobile');
|
||||
expect(resolved.body, 'hello there');
|
||||
expect(resolved.threadIdentifier, 'chan-1');
|
||||
});
|
||||
|
||||
test(
|
||||
'keeps fallback when only self-authored or non-message events exist',
|
||||
() {
|
||||
final mine = 'a' * 64;
|
||||
final resolved = resolveBuzzPushNotification(
|
||||
events: [
|
||||
_event(id: '1' * 64, pubkey: mine, content: 'self'),
|
||||
_event(
|
||||
id: '2' * 64,
|
||||
pubkey: 'b' * 64,
|
||||
kind: EventKind.reaction,
|
||||
content: '+',
|
||||
),
|
||||
],
|
||||
myPubkey: mine,
|
||||
communityName: 'Team',
|
||||
);
|
||||
|
||||
expect(resolved, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('trims long previews', () {
|
||||
final resolved = resolveBuzzPushNotification(
|
||||
events: [_event(id: '1' * 64, pubkey: 'b' * 64, content: 'x' * 240)],
|
||||
myPubkey: 'a' * 64,
|
||||
communityName: 'Team',
|
||||
);
|
||||
|
||||
expect(resolved!.body.length, lessThanOrEqualTo(180));
|
||||
expect(resolved.body.endsWith('…'), isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
NostrEvent _event({
|
||||
required String id,
|
||||
required String pubkey,
|
||||
required String content,
|
||||
int kind = EventKind.streamMessage,
|
||||
int createdAt = 1,
|
||||
List<List<String>> tags = const [],
|
||||
}) {
|
||||
return NostrEvent(
|
||||
id: id,
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
tags: tags,
|
||||
content: content,
|
||||
sig: '0' * 128,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user