mirror of
git://jb55.com/damus
synced 2024-09-30 00:40:45 +00:00
Merge tag 'v1.7-rc2'
v1.7 Madeira release RC255c26d22cb
v1.7 (11)4c8134908c
Enable IAP feature for release3ac7d75235
Add UI error message when IAP succeeds but receipt verification failsb49a5f4d29
Purple: Improve UX on Damus Purple renewals3569919eaf
Add Damus Purple impending expiry notification support
This commit is contained in:
commit
75d87fee9d
@ -492,6 +492,7 @@
|
|||||||
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
|
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
|
||||||
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
|
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
|
||||||
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; };
|
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; };
|
||||||
|
D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */; };
|
||||||
D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; };
|
D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; };
|
||||||
D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; };
|
D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; };
|
||||||
D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; };
|
D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; };
|
||||||
@ -545,6 +546,8 @@
|
|||||||
D7CB5D5D2B1176B200AD4105 /* MediaUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */; };
|
D7CB5D5D2B1176B200AD4105 /* MediaUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */; };
|
||||||
D7CB5D5F2B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; };
|
D7CB5D5F2B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; };
|
||||||
D7CB5D602B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; };
|
D7CB5D602B11770C00AD4105 /* FollowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CB5D5E2B11770C00AD4105 /* FollowState.swift */; };
|
||||||
|
D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */; };
|
||||||
|
D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */; };
|
||||||
D7CCFC072B05833200323D86 /* NdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90548A2A6AEDEE00811EEC /* NdbNote.swift */; };
|
D7CCFC072B05833200323D86 /* NdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90548A2A6AEDEE00811EEC /* NdbNote.swift */; };
|
||||||
D7CCFC082B05834500323D86 /* NoteId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC14FF42A740BB7007AEB17 /* NoteId.swift */; };
|
D7CCFC082B05834500323D86 /* NoteId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC14FF42A740BB7007AEB17 /* NoteId.swift */; };
|
||||||
D7CCFC0B2B0585EA00323D86 /* nostrdb.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9FBB82A6B3B26007E485C /* nostrdb.c */; settings = {COMPILER_FLAGS = "-w"; }; };
|
D7CCFC0B2B0585EA00323D86 /* nostrdb.c in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9FBB82A6B3B26007E485C /* nostrdb.c */; settings = {COMPILER_FLAGS = "-w"; }; };
|
||||||
@ -1396,6 +1399,7 @@
|
|||||||
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
|
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
|
||||||
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
|
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
|
||||||
D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; };
|
D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; };
|
||||||
|
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; };
|
||||||
D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; };
|
D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; };
|
||||||
D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; };
|
D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; };
|
||||||
D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; };
|
D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; };
|
||||||
@ -1414,6 +1418,8 @@
|
|||||||
D7CB5D502B1174D100AD4105 /* FriendFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendFilter.swift; sourceTree = "<group>"; };
|
D7CB5D502B1174D100AD4105 /* FriendFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendFilter.swift; sourceTree = "<group>"; };
|
||||||
D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploader.swift; sourceTree = "<group>"; };
|
D7CB5D5B2B1176B200AD4105 /* MediaUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploader.swift; sourceTree = "<group>"; };
|
||||||
D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
|
D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
|
||||||
|
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; };
|
||||||
|
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; };
|
||||||
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; };
|
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; };
|
||||||
D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; };
|
D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; };
|
||||||
D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = "<group>"; };
|
D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = "<group>"; };
|
||||||
@ -1732,6 +1738,7 @@
|
|||||||
4C30AC7329A5680900E2BD5A /* EventGroupView.swift */,
|
4C30AC7329A5680900E2BD5A /* EventGroupView.swift */,
|
||||||
4C30AC7529A5770900E2BD5A /* NotificationItemView.swift */,
|
4C30AC7529A5770900E2BD5A /* NotificationItemView.swift */,
|
||||||
4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */,
|
4C30AC7F29A6A53F00E2BD5A /* ProfilePicturesView.swift */,
|
||||||
|
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */,
|
||||||
);
|
);
|
||||||
path = Notifications;
|
path = Notifications;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2535,6 +2542,7 @@
|
|||||||
B501062C2B363036003874F5 /* AuthIntegrationTests.swift */,
|
B501062C2B363036003874F5 /* AuthIntegrationTests.swift */,
|
||||||
E0E024102B7C19C20075735D /* TranslationTests.swift */,
|
E0E024102B7C19C20075735D /* TranslationTests.swift */,
|
||||||
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */,
|
E06336A92B75832100A88E6B /* ImageMetadataTest.swift */,
|
||||||
|
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */,
|
||||||
);
|
);
|
||||||
path = damusTests;
|
path = damusTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2705,6 +2713,7 @@
|
|||||||
D74F43082B23F09300425B75 /* Purple */ = {
|
D74F43082B23F09300425B75 /* Purple */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D7CBD1D22B8D21C100BFD889 /* Extensions */,
|
||||||
D74F43092B23F0BE00425B75 /* DamusPurple.swift */,
|
D74F43092B23F0BE00425B75 /* DamusPurple.swift */,
|
||||||
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */,
|
D74F430B2B23FB9B00425B75 /* StoreObserver.swift */,
|
||||||
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */,
|
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */,
|
||||||
@ -2734,6 +2743,14 @@
|
|||||||
path = Assets;
|
path = Assets;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D7CBD1D22B8D21C100BFD889 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F71694E82A66221E001F4053 /* Onboarding */ = {
|
F71694E82A66221E001F4053 /* Onboarding */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -3031,6 +3048,7 @@
|
|||||||
4CDD1AE22A6B3074001CD4DF /* NdbTagsIterator.swift in Sources */,
|
4CDD1AE22A6B3074001CD4DF /* NdbTagsIterator.swift in Sources */,
|
||||||
4C216F34286F5ACD00040376 /* DMView.swift in Sources */,
|
4C216F34286F5ACD00040376 /* DMView.swift in Sources */,
|
||||||
D7CB5D512B1174D100AD4105 /* FriendFilter.swift in Sources */,
|
D7CB5D512B1174D100AD4105 /* FriendFilter.swift in Sources */,
|
||||||
|
D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */,
|
||||||
4C32B9572A9AD44700DC3548 /* Root.swift in Sources */,
|
4C32B9572A9AD44700DC3548 /* Root.swift in Sources */,
|
||||||
4C3EA64428FF558100C48A62 /* sha256.c in Sources */,
|
4C3EA64428FF558100C48A62 /* sha256.c in Sources */,
|
||||||
504323A72A34915F006AE6DC /* RelayModel.swift in Sources */,
|
504323A72A34915F006AE6DC /* RelayModel.swift in Sources */,
|
||||||
@ -3305,6 +3323,7 @@
|
|||||||
4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */,
|
4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */,
|
||||||
50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */,
|
50A16FFD2AA7525700DFEC1F /* DamusVideoPlayerViewModel.swift in Sources */,
|
||||||
4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */,
|
4CFF8F6B29CD0079008DB934 /* RepostedEvent.swift in Sources */,
|
||||||
|
D78CD5982B8990300014D539 /* DamusAppNotificationView.swift in Sources */,
|
||||||
D724D8272B64B40B00ABE789 /* DamusPurpleAccountView.swift in Sources */,
|
D724D8272B64B40B00ABE789 /* DamusPurpleAccountView.swift in Sources */,
|
||||||
4C8682872814DE470026224F /* ProfileView.swift in Sources */,
|
4C8682872814DE470026224F /* ProfileView.swift in Sources */,
|
||||||
5C0707D12A1ECB38004E7B51 /* DamusLogoGradient.swift in Sources */,
|
5C0707D12A1ECB38004E7B51 /* DamusLogoGradient.swift in Sources */,
|
||||||
@ -3475,6 +3494,7 @@
|
|||||||
3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */,
|
3A3040ED29A5CB86008A0F29 /* ReplyDescriptionTests.swift in Sources */,
|
||||||
D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */,
|
D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */,
|
||||||
3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */,
|
3AAC7A022A60FE72002B50DF /* LocalizationUtilTests.swift in Sources */,
|
||||||
|
D7CBD1D62B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift in Sources */,
|
||||||
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */,
|
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */,
|
||||||
4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */,
|
4C8D00D429E3C5D40036AF10 /* NIP19Tests.swift in Sources */,
|
||||||
3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */,
|
3A30410129AB12AA008A0F29 /* EventGroupViewTests.swift in Sources */,
|
||||||
@ -3931,6 +3951,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@ -3980,6 +4001,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
DEVELOPMENT_TEAM = XK7H4JAB3D;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
@ -490,18 +490,25 @@ struct ContentView: View {
|
|||||||
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
|
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
Task {
|
Task {
|
||||||
// TODO: Improve UX for renewals (#2013)
|
|
||||||
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
|
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
|
||||||
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
|
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
|
||||||
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
|
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
|
||||||
if there_is_a_completed_checkout == true && account_info?.active == true {
|
if there_is_a_completed_checkout == true && account_info?.active == true {
|
||||||
|
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
|
||||||
// Show welcome sheet
|
// Show welcome sheet
|
||||||
self.active_sheet = .purple_onboarding
|
self.active_sheet = .purple_onboarding
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { (phase: ScenePhase) in
|
.onChange(of: scenePhase) { (phase: ScenePhase) in
|
||||||
guard let damus_state else { return }
|
guard let damus_state else { return }
|
||||||
switch phase {
|
switch phase {
|
||||||
@ -724,6 +731,9 @@ struct ContentView: View {
|
|||||||
if let damus_state, damus_state.purple.enable_purple {
|
if let damus_state, damus_state.purple.enable_purple {
|
||||||
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
|
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
|
||||||
StoreObserver.standard.delegate = damus_state.purple
|
StoreObserver.standard.delegate = damus_state.purple
|
||||||
|
Task {
|
||||||
|
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||||
|
@ -276,6 +276,15 @@ class HomeModel {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
|
||||||
|
if self.notifications.insert_app_notification(notification: notification) {
|
||||||
|
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
|
||||||
|
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
|
||||||
|
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func filter_events() {
|
func filter_events() {
|
||||||
events.filter { ev in
|
events.filter { ev in
|
||||||
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
|
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
|
||||||
|
@ -17,7 +17,8 @@ struct NewEventsBits: OptionSet {
|
|||||||
static let likes = NewEventsBits(rawValue: 1 << 4)
|
static let likes = NewEventsBits(rawValue: 1 << 4)
|
||||||
static let search = NewEventsBits(rawValue: 1 << 5)
|
static let search = NewEventsBits(rawValue: 1 << 5)
|
||||||
static let dms = NewEventsBits(rawValue: 1 << 6)
|
static let dms = NewEventsBits(rawValue: 1 << 6)
|
||||||
|
static let damus_app_notifications = NewEventsBits(rawValue: 1 << 7)
|
||||||
|
|
||||||
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
|
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
|
||||||
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions]
|
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions, .damus_app_notifications]
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ enum NotificationItem {
|
|||||||
case profile_zap(ZapGroup)
|
case profile_zap(ZapGroup)
|
||||||
case event_zap(NoteId, ZapGroup)
|
case event_zap(NoteId, ZapGroup)
|
||||||
case reply(NostrEvent)
|
case reply(NostrEvent)
|
||||||
|
case damus_app_notification(DamusAppNotification)
|
||||||
|
|
||||||
var is_reply: NostrEvent? {
|
var is_reply: NostrEvent? {
|
||||||
if case .reply(let ev) = self {
|
if case .reply(let ev) = self {
|
||||||
@ -33,6 +34,8 @@ enum NotificationItem {
|
|||||||
return nil
|
return nil
|
||||||
case .repost:
|
case .repost:
|
||||||
return nil
|
return nil
|
||||||
|
case .damus_app_notification(_):
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +51,8 @@ enum NotificationItem {
|
|||||||
return zapgrp.last_event_at
|
return zapgrp.last_event_at
|
||||||
case .reply(let reply):
|
case .reply(let reply):
|
||||||
return reply.created_at
|
return reply.created_at
|
||||||
|
case .damus_app_notification(let notification):
|
||||||
|
return notification.last_event_at
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +68,8 @@ enum NotificationItem {
|
|||||||
return zapgrp.would_filter(isIncluded)
|
return zapgrp.would_filter(isIncluded)
|
||||||
case .reply(let ev):
|
case .reply(let ev):
|
||||||
return !isIncluded(ev)
|
return !isIncluded(ev)
|
||||||
|
case .damus_app_notification(_):
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +86,8 @@ enum NotificationItem {
|
|||||||
case .reply(let ev):
|
case .reply(let ev):
|
||||||
if isIncluded(ev) { return .reply(ev) }
|
if isIncluded(ev) { return .reply(ev) }
|
||||||
return nil
|
return nil
|
||||||
|
case .damus_app_notification(_):
|
||||||
|
return self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,6 +103,9 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
|||||||
var reactions: [NoteId: EventGroup] = [:]
|
var reactions: [NoteId: EventGroup] = [:]
|
||||||
var reposts: [NoteId: EventGroup] = [:]
|
var reposts: [NoteId: EventGroup] = [:]
|
||||||
var replies: [NostrEvent] = []
|
var replies: [NostrEvent] = []
|
||||||
|
var incoming_app_notifications: [DamusAppNotification] = []
|
||||||
|
var app_notifications: [DamusAppNotification] = []
|
||||||
|
var has_app_notification = Set<DamusAppNotification.Content>()
|
||||||
var has_reply = Set<NoteId>()
|
var has_reply = Set<NoteId>()
|
||||||
var has_ev = Set<NoteId>()
|
var has_ev = Set<NoteId>()
|
||||||
|
|
||||||
@ -160,6 +172,10 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
|||||||
notifs.append(.reply(reply))
|
notifs.append(.reply(reply))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for app_notification in app_notifications {
|
||||||
|
notifs.append(.damus_app_notification(app_notification))
|
||||||
|
}
|
||||||
|
|
||||||
notifs.sort { $0.last_event_at > $1.last_event_at }
|
notifs.sort { $0.last_event_at > $1.last_event_at }
|
||||||
return notifs
|
return notifs
|
||||||
}
|
}
|
||||||
@ -254,6 +270,33 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func insert_app_notification(notification: DamusAppNotification) -> Bool {
|
||||||
|
if has_app_notification.contains(notification.content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_queue {
|
||||||
|
incoming_app_notifications.append(notification)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if insert_app_notification_immediate(notification: notification) {
|
||||||
|
self.notifications = build_notifications()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert_app_notification_immediate(notification: DamusAppNotification) -> Bool {
|
||||||
|
if has_app_notification.contains(notification.content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
self.app_notifications.append(notification)
|
||||||
|
has_app_notification.insert(notification.content)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func insert_zap(_ zap: Zapping) -> Bool {
|
func insert_zap(_ zap: Zapping) -> Bool {
|
||||||
if should_queue {
|
if should_queue {
|
||||||
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
|
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
|
||||||
@ -319,6 +362,10 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
|||||||
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
|
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for incoming_app_notification in incoming_app_notifications {
|
||||||
|
inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted
|
||||||
|
}
|
||||||
|
|
||||||
if inserted {
|
if inserted {
|
||||||
self.notifications = build_notifications()
|
self.notifications = build_notifications()
|
||||||
}
|
}
|
||||||
@ -326,3 +373,19 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
|||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DamusAppNotification {
|
||||||
|
let notification_timestamp: Date
|
||||||
|
var last_event_at: UInt32 { UInt32(notification_timestamp.timeIntervalSince1970) }
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(content: Content, timestamp: Date) {
|
||||||
|
self.notification_timestamp = timestamp
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Content: Hashable, Equatable {
|
||||||
|
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
|
||||||
|
case purple_expired(expiry_date: UInt64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,6 +13,7 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
var storekit_manager: StoreKitManager
|
var storekit_manager: StoreKitManager
|
||||||
var checkout_ids_in_progress: Set<String> = []
|
var checkout_ids_in_progress: Set<String> = []
|
||||||
|
var onboarding_status: OnboardingStatus
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
var account_cache: [Pubkey: Account]
|
var account_cache: [Pubkey: Account]
|
||||||
@ -25,6 +26,16 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
self.account_cache = [:]
|
self.account_cache = [:]
|
||||||
self.account_uuid_cache = [:]
|
self.account_uuid_cache = [:]
|
||||||
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
|
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
|
||||||
|
self.onboarding_status = OnboardingStatus()
|
||||||
|
Task {
|
||||||
|
let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
|
||||||
|
if account == nil {
|
||||||
|
self.onboarding_status.account_existed_at_the_start = false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.onboarding_status.account_existed_at_the_start = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Functions
|
// MARK: Functions
|
||||||
@ -45,7 +56,8 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
// Whether to enable Apple In-app purchase support
|
// Whether to enable Apple In-app purchase support
|
||||||
var enable_purple_iap_support: Bool {
|
var enable_purple_iap_support: Bool {
|
||||||
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
|
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
|
||||||
return self.settings.enable_experimental_purple_iap_support
|
// return self.settings.enable_experimental_purple_iap_support
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func account_exists(pubkey: Pubkey) async -> Bool? {
|
func account_exists(pubkey: Pubkey) async -> Bool? {
|
||||||
@ -109,7 +121,7 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
|
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
|
||||||
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
|
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
|
||||||
// Send the receipt to the server
|
// Send the receipt to the server
|
||||||
await self.send_receipt()
|
try await self.send_receipt()
|
||||||
default:
|
default:
|
||||||
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
|
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
|
||||||
throw PurpleError.iap_purchase_error(result: result)
|
throw PurpleError.iap_purchase_error(result: result)
|
||||||
@ -150,12 +162,11 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
return account_uuid_info.account_uuid
|
return account_uuid_info.account_uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
func send_receipt() async {
|
func send_receipt() async throws {
|
||||||
// Get the receipt if it's available.
|
// Get the receipt if it's available.
|
||||||
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
|
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
|
||||||
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
|
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
|
||||||
|
|
||||||
do {
|
|
||||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||||
let receipt_base64_string = receiptData.base64EncodedString()
|
let receipt_base64_string = receiptData.base64EncodedString()
|
||||||
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
||||||
@ -180,13 +191,9 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
||||||
default:
|
default:
|
||||||
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||||
|
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Log.error("Couldn't read receipt data with error: %s", for: .damus_purple, error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +276,44 @@ class DamusPurple: StoreObserverDelegate {
|
|||||||
throw PurpleError.error_processing_response
|
throw PurpleError.error_processing_response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? {
|
||||||
|
let url = environment.api_base_url().appendingPathComponent("ln-checkout")
|
||||||
|
|
||||||
|
let json_text: [String: String] = ["product_template_name": product_template_name]
|
||||||
|
let json_data = try JSONSerialization.data(withJSONObject: json_text)
|
||||||
|
|
||||||
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
|
method: .post,
|
||||||
|
url: url,
|
||||||
|
payload: json_data,
|
||||||
|
payload_type: .json,
|
||||||
|
auth_keypair: self.keypair
|
||||||
|
)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200:
|
||||||
|
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
|
||||||
|
case 404:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw PurpleError.error_processing_response
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL {
|
||||||
|
let checkout = try await self.new_ln_checkout(product_template_name: product_template_name)
|
||||||
|
guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response }
|
||||||
|
try await self.verify_npub_for_checkout(checkout_id: checkout_id)
|
||||||
|
return self.environment.purple_landing_page_url()
|
||||||
|
.appendingPathComponent("checkout")
|
||||||
|
.appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)])
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// This function checks the status of all checkout objects in progress with the server, and it does two things:
|
/// This function checks the status of all checkout objects in progress with the server, and it does two things:
|
||||||
/// - It returns the ones that were freshly completed
|
/// - It returns the ones that were freshly completed
|
||||||
@ -404,6 +449,7 @@ extension DamusPurple {
|
|||||||
case http_response_error(status_code: Int, response: Data)
|
case http_response_error(status_code: Int, response: Data)
|
||||||
case error_processing_response
|
case error_processing_response
|
||||||
case iap_purchase_error(result: Product.PurchaseResult)
|
case iap_purchase_error(result: Product.PurchaseResult)
|
||||||
|
case iap_receipt_verification_error(status: Int, response: Data)
|
||||||
case translation_no_response
|
case translation_no_response
|
||||||
case checkout_npub_verification_error
|
case checkout_npub_verification_error
|
||||||
}
|
}
|
||||||
@ -411,4 +457,22 @@ extension DamusPurple {
|
|||||||
struct TranslationResult: Codable {
|
struct TranslationResult: Codable {
|
||||||
let text: String
|
let text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct OnboardingStatus {
|
||||||
|
var account_existed_at_the_start: Bool? = nil
|
||||||
|
var onboarding_was_shown: Bool = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
|
||||||
|
self.account_existed_at_the_start = account_active_at_the_start
|
||||||
|
self.onboarding_was_shown = onboarding_was_shown
|
||||||
|
}
|
||||||
|
|
||||||
|
func user_has_never_seen_the_onboarding_before() -> Bool {
|
||||||
|
return onboarding_was_shown == false && account_existed_at_the_start == false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// DamusPurpleNotificationManagement.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-02-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A definition of how many days in advance to notify the user of impending expiration. (e.g. 3 days before expiration AND 2 days before expiration AND 1 day before expiration)
|
||||||
|
fileprivate let PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE: Set<Int> = [7, 3, 1]
|
||||||
|
fileprivate let ONE_DAY: TimeInterval = 60 * 60 * 24
|
||||||
|
|
||||||
|
extension DamusPurple {
|
||||||
|
typealias NotificationHandlerFunction = (DamusAppNotification) async -> Void
|
||||||
|
|
||||||
|
func check_and_send_app_notifications_if_needed(handler: NotificationHandlerFunction) async {
|
||||||
|
await self.check_and_send_purple_expiration_notifications_if_needed(handler: handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if we need to send a DamusPurple impending expiration notification to the user, and sends them if needed.
|
||||||
|
///
|
||||||
|
/// **Note:** To keep things simple at this point, this function uses a "best effort" strategy, and silently fails if something is wrong, as it is not an essential component of the app — to avoid adding more error handling complexity to the app
|
||||||
|
private func check_and_send_purple_expiration_notifications_if_needed(handler: NotificationHandlerFunction) async {
|
||||||
|
if self.storekit_manager.recorded_purchased_products.count > 0 {
|
||||||
|
// If user has a recurring IAP purchase, there no need to notify them of impending expiration
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let purple_expiration_date: Date = try? await self.get_maybe_cached_account(pubkey: self.keypair.pubkey)?.expiry else {
|
||||||
|
return // If there are no expiry dates (e.g. The user is not a Purple user) or we cannot get it for some reason (e.g. server is temporarily down and we have no cache), don't bother sending notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
let days_to_expiry: Int = round_days_to_date(purple_expiration_date, from: Date.now)
|
||||||
|
|
||||||
|
let applicable_impending_expiry_notification_schedule_items: [Int] = PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE.filter({ $0 >= days_to_expiry })
|
||||||
|
|
||||||
|
for applicable_impending_expiry_notification_schedule_item in applicable_impending_expiry_notification_schedule_items {
|
||||||
|
// Send notifications predicted by the schedule
|
||||||
|
// Note: The `insert_app_notification` has built-in logic to prevent us from sending two identical notifications, so we need not worry about it here.
|
||||||
|
await handler(.init(
|
||||||
|
content: .purple_impending_expiration(
|
||||||
|
days_remaining: applicable_impending_expiry_notification_schedule_item,
|
||||||
|
expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)
|
||||||
|
),
|
||||||
|
timestamp: purple_expiration_date.addingTimeInterval(-Double(applicable_impending_expiry_notification_schedule_item) * ONE_DAY))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if days_to_expiry < 0 {
|
||||||
|
await handler(.init(
|
||||||
|
content: .purple_expired(expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)),
|
||||||
|
timestamp: purple_expiration_date)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func round_days_to_date(_ target_date: Date, from from_date: Date) -> Int {
|
||||||
|
return Int(round(target_date.timeIntervalSince(from_date) / ONE_DAY))
|
||||||
|
}
|
@ -23,11 +23,11 @@ class StoreObserver: NSObject, SKPaymentTransactionObserver {
|
|||||||
//Handle transaction states here.
|
//Handle transaction states here.
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await self.delegate?.send_receipt()
|
try await self.delegate?.send_receipt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol StoreObserverDelegate {
|
protocol StoreObserverDelegate {
|
||||||
func send_receipt() async
|
func send_receipt() async throws
|
||||||
}
|
}
|
||||||
|
181
damus/Views/Notifications/DamusAppNotificationView.swift
Normal file
181
damus/Views/Notifications/DamusAppNotificationView.swift
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
//
|
||||||
|
// DamusAppNotificationView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-02-23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
fileprivate let DEEP_WEBSITE_LINK = false
|
||||||
|
|
||||||
|
// TODO: Load products in a more dynamic way (if we move forward with checkout deep linking)
|
||||||
|
fileprivate let PURPLE_ONE_MONTH = "purple_one_month"
|
||||||
|
fileprivate let PURPLE_ONE_YEAR = "purple_one_year"
|
||||||
|
|
||||||
|
struct DamusAppNotificationView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let notification: DamusAppNotification
|
||||||
|
var relative_date: String {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
if abs(notification.notification_timestamp.timeIntervalSinceNow) > 60 {
|
||||||
|
return formatter.localizedString(for: notification.notification_timestamp, relativeTo: Date.now)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return NSLocalizedString("now", comment: "Relative time label that indicates a notification happened now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
AppIcon()
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.clipShape(.rect(cornerSize: CGSize(width: 10.0, height: 10.0)))
|
||||||
|
.shadow(radius: 5, y: 5)
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(alignment: .center, spacing: 3) {
|
||||||
|
Text(NSLocalizedString("Damus", comment: "Name of the app for the title of an internal notification"))
|
||||||
|
.font(.body.weight(.bold))
|
||||||
|
Text("·")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(relative_date)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image("check-circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 15, height: 15)
|
||||||
|
Text(NSLocalizedString("Internal app notification", comment: "Badge indicating that a notification is an official internal app notification"))
|
||||||
|
.font(.caption2)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.white)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.background(PinkGradient)
|
||||||
|
.cornerRadius(30.0)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
switch notification.content {
|
||||||
|
case .purple_impending_expiration(let days_remaining, _):
|
||||||
|
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: days_remaining, expired: false)
|
||||||
|
case .purple_expired(expiry_date: _):
|
||||||
|
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: 0, expired: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 5)
|
||||||
|
.padding(.bottom, 15)
|
||||||
|
|
||||||
|
ThiccDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PurpleExpiryNotificationView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let days_remaining: Int
|
||||||
|
let expired: Bool
|
||||||
|
|
||||||
|
func try_to_open_verified_checkout(product_template_name: String) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let url = try await damus_state.purple.generate_verified_ln_checkout_link(product_template_name: product_template_name)
|
||||||
|
await self.open_url(url: url)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
await self.open_url(url: damus_state.purple.environment.purple_landing_page_url().appendingPathComponent("checkout"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func open_url(url: URL) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(self.message())
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||||
|
if DEEP_WEBSITE_LINK {
|
||||||
|
// TODO: It might be better to fetch products from the server instead of hardcoding them here. As of writing this is disabled, so not a big concern.
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
self.try_to_open_verified_checkout(product_template_name: "purple_one_month")
|
||||||
|
}, label: {
|
||||||
|
Text("Renew (1 mo)", comment: "Button to take user to renew subscription for one month")
|
||||||
|
})
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
Button(action: {
|
||||||
|
self.try_to_open_verified_checkout(product_template_name: "purple_one_year")
|
||||||
|
}, label: {
|
||||||
|
Text("Renew (1 yr)", comment: "Button to take user to renew subscription for one year")
|
||||||
|
})
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
NavigationLink(destination: DamusPurpleView(damus_state: damus_state), label: {
|
||||||
|
HStack {
|
||||||
|
Text("Manage subscription", comment: "Button to take user to manage Damus Purple subscription")
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||||
|
Image("arrow-right")
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func message() -> String {
|
||||||
|
if expired == true {
|
||||||
|
return NSLocalizedString("Your Purple subscription has expired. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription has expired, prompting them to renew.")
|
||||||
|
}
|
||||||
|
if days_remaining == 1 {
|
||||||
|
return NSLocalizedString("Your Purple subscription expires in 1 day. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring in one day, prompting them to renew.")
|
||||||
|
}
|
||||||
|
let message_format = NSLocalizedString("Your Purple subscription expires in %@ days. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring soon, prompting them to renew.")
|
||||||
|
return String(format: message_format, String(days_remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `AppIcon` code from: https://stackoverflow.com/a/65153628 and licensed with CC BY-SA 4.0 with the following modifications:
|
||||||
|
// - Made image resizable using `.resizable()`
|
||||||
|
extension Bundle {
|
||||||
|
var iconFileName: String? {
|
||||||
|
guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||||
|
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||||
|
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
|
||||||
|
let iconFileName = iconFiles.last
|
||||||
|
else { return nil }
|
||||||
|
return iconFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct AppIcon: View {
|
||||||
|
var body: some View {
|
||||||
|
Bundle.main.iconFileName
|
||||||
|
.flatMap { UIImage(named: $0) }
|
||||||
|
.map { Image(uiImage: $0).resizable() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
ThiccDivider()
|
||||||
|
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_impending_expiration(days_remaining: 3, expiry_date: 1709156602), timestamp: Date.now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_expired(expiry_date: 1709156602), timestamp: Date.now))
|
||||||
|
}
|
@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
enum ShowItem {
|
enum ShowItem {
|
||||||
case show(NostrEvent?)
|
case show(NostrEvent?)
|
||||||
case dontshow(NostrEvent?)
|
case dontshow(NostrEvent?)
|
||||||
|
case show_damus_app_notification(DamusAppNotification)
|
||||||
}
|
}
|
||||||
|
|
||||||
func notification_item_event(events: EventCache, notif: NotificationItem) -> ShowItem {
|
func notification_item_event(events: EventCache, notif: NotificationItem) -> ShowItem {
|
||||||
@ -24,6 +25,8 @@ func notification_item_event(events: EventCache, notif: NotificationItem) -> Sho
|
|||||||
return .dontshow(events.lookup(evid))
|
return .dontshow(events.lookup(evid))
|
||||||
case .profile_zap:
|
case .profile_zap:
|
||||||
return .show(nil)
|
return .show(nil)
|
||||||
|
case .damus_app_notification(let app_notification):
|
||||||
|
return .show_damus_app_notification(app_notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +66,8 @@ struct NotificationItemView: View {
|
|||||||
EventView(damus: state, event: ev, options: options)
|
EventView(damus: state, event: ev, options: options)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
case .damus_app_notification(let notification):
|
||||||
|
DamusAppNotificationView(damus_state: state, notification: notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
ThiccDivider()
|
ThiccDivider()
|
||||||
@ -79,6 +84,8 @@ struct NotificationItemView: View {
|
|||||||
if let ev {
|
if let ev {
|
||||||
Item(ev)
|
Item(ev)
|
||||||
}
|
}
|
||||||
|
case .show_damus_app_notification(let notification):
|
||||||
|
DamusAppNotificationView(damus_state: state, notification: notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,8 @@ struct DamusPurpleNewUserOnboardingView: View {
|
|||||||
guard let account = try? await damus_state.purple.fetch_account(pubkey: damus_state.pubkey), account.active else {
|
guard let account = try? await damus_state.purple.fetch_account(pubkey: damus_state.pubkey), account.active else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Let's mark onboarding as "shown"
|
||||||
|
damus_state.purple.onboarding_status.onboarding_was_shown = true
|
||||||
// Let's notify other views across SwiftUI to update our user's Purple status.
|
// Let's notify other views across SwiftUI to update our user's Purple status.
|
||||||
notify(.purple_account_update(account))
|
notify(.purple_account_update(account))
|
||||||
}
|
}
|
||||||
|
@ -144,6 +144,12 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
|||||||
// If account is no longer active or was purchased via IAP, then show IAP purchase/manage options
|
// If account is no longer active or was purchased via IAP, then show IAP purchase/manage options
|
||||||
if let account_uuid {
|
if let account_uuid {
|
||||||
DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, account_uuid: account_uuid, subscribe: subscribe)
|
DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, account_uuid: account_uuid, subscribe: subscribe)
|
||||||
|
if let iap_error {
|
||||||
|
Text(String(format: NSLocalizedString("There has been an unexpected error with the in-app purchase. Please try again later or contact support@damus.io. Error: %@", comment: "In-app purchase error message for the user"), iap_error))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
37
damusTests/DamusPurpleImpendingExpirationTests.swift
Normal file
37
damusTests/DamusPurpleImpendingExpirationTests.swift
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// DamusPurpleImpendingExpirationTests.swift
|
||||||
|
// damusTests
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-02-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import damus
|
||||||
|
|
||||||
|
final class DamusPurpleImpendingExpirationTests : XCTestCase {
|
||||||
|
func testNotificationContentSetDoesNotAllowRepetition() {
|
||||||
|
var notification_contents: Set<DamusAppNotification.Content> = []
|
||||||
|
let expiry_date = UInt64(Date.now.timeIntervalSince1970)
|
||||||
|
let now = Date.now
|
||||||
|
let notification_1 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 3, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_1.content)
|
||||||
|
let notification_2 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 3, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_2.content)
|
||||||
|
let notification_3 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 2, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_3.content)
|
||||||
|
let notification_4 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 2, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_4.content)
|
||||||
|
let notification_5 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 1, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_5.content)
|
||||||
|
let notification_6 = DamusAppNotification(content: .purple_impending_expiration(days_remaining: 1, expiry_date: expiry_date), timestamp: now)
|
||||||
|
notification_contents.insert(notification_6.content)
|
||||||
|
XCTAssertEqual(notification_contents.count, 3)
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_1.content))
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_2.content))
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_3.content))
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_4.content))
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_5.content))
|
||||||
|
XCTAssertTrue(notification_contents.contains(notification_6.content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user