diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e16be1e309..ef261630d7 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -171,6 +171,8 @@ 942256A12C23F90700C0FDBF /* CustomTopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */; }; 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; + 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; + 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; @@ -184,6 +186,7 @@ 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; + 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949D91212E822D520074F595 /* String+SessionProBadge.swift */; }; 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */; }; 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */; }; @@ -192,8 +195,16 @@ 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */; }; 94AAB1572E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */; }; 94AAB1582E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */; }; + 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; + 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; - 94B6BAFA2E38454F00E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF92E38454F00E718BB /* SessionProState.swift */; }; + 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProState.swift */; }; + 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */; }; + 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */; }; + 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB012E3AE85800E718BB /* QRCode.swift */; }; + 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */; }; + 94B6BB062E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; + 94B6BB072E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */; }; 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BC2E0908340097754D /* LibSession+Pro.swift */; }; @@ -204,9 +215,7 @@ 94CD96322E1B88C20097754D /* ExpandingAttachmentsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */; }; 94CD96402E1BABE90097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; - 94CD96422E1BABE90097754D /* GenericCTAAnimation.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963D2E1BABE90097754D /* GenericCTAAnimation.webp */; }; 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; - 94CD96442E1BAC0F0097754D /* GenericCTAAnimation.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963D2E1BABE90097754D /* GenericCTAAnimation.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; @@ -246,7 +255,6 @@ B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3F255A580C00E217F9 /* String+SSK.swift */; }; B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; - B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; @@ -642,7 +650,7 @@ FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */; }; FD3F2EF22DF273D900FD6849 /* ThemedAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */; }; - FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */; }; + FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */; }; FD3FAB5F2AE9BC2200DC5421 /* EditGroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */; }; FD3FAB612AEA194E00DC5421 /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB602AEA194E00DC5421 /* UserListViewModel.swift */; }; FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB622AEB9A1500DC5421 /* ToastController.swift */; }; @@ -1040,6 +1048,7 @@ FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */; }; FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; + FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */; }; FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */; }; FDE71B0B2E79352D0023F5F9 /* DeveloperSettingsGroupsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */; }; FDE71B0D2E793B250023F5F9 /* DeveloperSettingsProViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */; }; @@ -1549,6 +1558,8 @@ 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTopTabBar.swift; sourceTree = ""; }; 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; + 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; + 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -1569,6 +1580,7 @@ 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+CopyButton.swift"; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; + 949D91212E822D520074F595 /* String+SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SessionProBadge.swift"; sourceTree = ""; }; 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Modal+SwiftUI.swift"; sourceTree = ""; }; 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProCTAModal.swift; sourceTree = ""; }; @@ -1576,8 +1588,14 @@ 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CyclicGradientView.swift; sourceTree = ""; }; 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShineButton.swift; sourceTree = ""; }; 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = PinnedConversationsCTA.webp; sourceTree = ""; }; + 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTA.webp; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; - 94B6BAF92E38454F00E718BB /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + 94B6BAF52E30A88800E718BB /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModal.swift; sourceTree = ""; }; + 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; + 94B6BB012E3AE85800E718BB /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; + 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Seperator+SwiftUI.swift"; sourceTree = ""; }; + 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTAAnimationCropped.webp; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProBadge.swift; sourceTree = ""; }; 94CD95BC2E0908340097754D /* LibSession+Pro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+Pro.swift"; sourceTree = ""; }; @@ -1587,7 +1605,6 @@ 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+Utilities.swift"; sourceTree = ""; }; 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; - 94CD963D2E1BABE90097754D /* GenericCTAAnimation.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTAAnimation.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; @@ -1627,7 +1644,6 @@ B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; - B886B4A82398BA1500211ABE /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; B88FA7B726045D100049422F /* SOGSAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSAPI.swift; sourceTree = ""; }; B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSuggestionGrid.swift; sourceTree = ""; }; B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; @@ -1992,7 +2008,7 @@ FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerSpec.swift; sourceTree = ""; }; FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedAttributedString.swift; sourceTree = ""; }; - FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+CurrentUser.swift"; sourceTree = ""; }; + FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Updating.swift"; sourceTree = ""; }; FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditGroupViewModel.swift; sourceTree = ""; }; FD3FAB602AEA194E00DC5421 /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; FD3FAB622AEB9A1500DC5421 /* ToastController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastController.swift; sourceTree = ""; }; @@ -2310,6 +2326,7 @@ FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationManager.swift; sourceTree = ""; }; FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionMessagingKit.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; + FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+FileServer.swift"; sourceTree = ""; }; FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionNetworkingKit.swift"; sourceTree = ""; }; FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsGroupsViewModel.swift; sourceTree = ""; }; FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsProViewModel.swift; sourceTree = ""; }; @@ -2722,7 +2739,6 @@ FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - B886B4A82398BA1500211ABE /* QRCode.swift */, FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, @@ -2849,10 +2865,14 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 942BA9402E4487EE007C4595 /* LightBox.swift */, + 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, + 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */, 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */, 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */, 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */, + 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */, 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */, FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, @@ -2907,9 +2927,10 @@ 94CD963F2E1BABE90097754D /* WebPImages */ = { isa = PBXGroup; children = ( + 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */, + 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */, 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */, 94CD963C2E1BABE90097754D /* GenericCTA.webp */, - 94CD963D2E1BABE90097754D /* GenericCTAAnimation.webp */, 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */, ); path = WebPImages; @@ -3334,6 +3355,8 @@ C331FFAE2558FA7700070591 /* Utilities */ = { isa = PBXGroup; children = ( + 94B6BB012E3AE85800E718BB /* QRCode.swift */, + 949D91212E822D520074F595 /* String+SessionProBadge.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, @@ -3654,7 +3677,7 @@ C3BBE0B32554F0D30050F1E3 /* Utilities */ = { isa = PBXGroup; children = ( - 94B6BAF92E38454F00E718BB /* SessionProState.swift */, + 94B6BAF52E30A88800E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */, @@ -3677,7 +3700,7 @@ FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, - FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */, + FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */, FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, FDE754FF2C9BB0FA002A2623 /* SessionEnvironment.swift */, @@ -4083,6 +4106,7 @@ FD78E9F72DDD742100D55B50 /* _042_MoveSettingsToLibSession.swift */, FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */, 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, + 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, ); path = Migrations; sourceTree = ""; @@ -4372,6 +4396,7 @@ children = ( FD6B92C42E77AD01004463B5 /* Crypto */, FD6B92A42E77A37A004463B5 /* Models */, + FDE71B012E77CCE30023F5F9 /* Types */, FD6B928B2E779DC8004463B5 /* FileServer.swift */, FD6B928F2E779EDA004463B5 /* FileServerAPI.swift */, FD6B928D2E779E95004463B5 /* FileServerEndpoint.swift */, @@ -5069,6 +5094,14 @@ path = JobRunner; sourceTree = ""; }; + FDE71B012E77CCE30023F5F9 /* Types */ = { + isa = PBXGroup; + children = ( + FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */, + ); + path = Types; + sourceTree = ""; + }; FDE71B092E7934DC0023F5F9 /* DeveloperSettings */ = { isa = PBXGroup; children = ( @@ -5787,9 +5820,10 @@ buildActionMask = 2147483647; files = ( 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */, - 94CD96442E1BAC0F0097754D /* GenericCTAAnimation.webp in Resources */, + 94AAB15E2E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */, 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */, 94AAB1582E24BD3700A6FA18 /* PinnedConversationsCTA.webp in Resources */, + 94B6BB062E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */, 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, @@ -5873,8 +5907,10 @@ C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */, FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */, B8D07405265C683300F77E07 /* ElegantIcons.ttf in Resources */, + 94B6BB072E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */, FDE71B0F2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit in Resources */, 45B74A7E2044AAB600CD42F8 /* complete.aifc in Resources */, + 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */, 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */, B8CCF6352396005F0091D419 /* SpaceMono-Regular.ttf in Resources */, 45B74A872044AAB600CD42F8 /* complete-quiet.aifc in Resources */, @@ -5895,7 +5931,6 @@ C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */, 94CD96402E1BABE90097754D /* GenericCTA.webp in Resources */, 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */, - 94CD96422E1BABE90097754D /* GenericCTAAnimation.webp in Resources */, 45B74A802044AAB600CD42F8 /* pulse-quiet.aifc in Resources */, 45B74A8B2044AAB600CD42F8 /* synth.aifc in Resources */, 45B74A752044AAB600CD42F8 /* synth-quiet.aifc in Resources */, @@ -6251,6 +6286,7 @@ 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, + 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, @@ -6263,10 +6299,13 @@ FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, + 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, + 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, FD42ECD02E289261002D03EA /* ThemeLinearGradient.swift in Sources */, FDE754BE2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift in Sources */, @@ -6276,7 +6315,9 @@ FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, 942256942C23F8DD00C0FDBF /* ActivityView.swift in Sources */, FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, + 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, + 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */, 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */, @@ -6372,6 +6413,7 @@ FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */, FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, + FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, @@ -6714,7 +6756,7 @@ FDD23AE22E457CE50057E853 /* _008_SNK_YDBToGRDBMigration.swift in Sources */, FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, - FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */, + FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */, FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */, FDD23ADF2E457CAA0057E853 /* _016_ThemePreferences.swift in Sources */, FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */, @@ -6724,6 +6766,7 @@ FDB5DAE02A95D84D002C8721 /* GroupUpdateMemberLeftMessage.swift in Sources */, FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, + 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */, FDB5DADE2A95D847002C8721 /* GroupUpdatePromoteMessage.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, FD428B1F2B4B758B006D0888 /* AppReadiness.swift in Sources */, @@ -6741,7 +6784,6 @@ FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, FD2272732C32911C004D8A6C /* ConfigurationSyncJob.swift in Sources */, FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, - 94B6BAFA2E38454F00E718BB /* SessionProState.swift in Sources */, FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD2272752C32911C004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, FD2272712C32911C004D8A6C /* MessageReceiveJob.swift in Sources */, @@ -6817,6 +6859,7 @@ FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */, + 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDE754F12C9BB08B002A2623 /* Crypto+LibSession.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, @@ -6895,7 +6938,6 @@ FD860CBE2D6E7DAA00BBE29C /* DeveloperSettingsViewModel+Testing.swift in Sources */, FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */, 7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */, - B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, @@ -8320,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8360,7 +8402,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8401,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8436,7 +8478,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8547,6 +8589,7 @@ FD2272502C32910F004D8A6C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_MODULE_VERIFIER = NO; GCC_DYNAMIC_NO_PIC = NO; GENERATE_INFOPLIST_FILE = YES; @@ -8581,6 +8624,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_MODULE_VERIFIER = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -8672,6 +8716,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_MODULE_VERIFIER = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -8748,6 +8793,7 @@ FD860CBF2D6E981900BBE29C /* Debug_Compile_LibSession */ = { isa = XCBuildConfiguration; buildSettings = { + DEVELOPMENT_TEAM = SUQ8J2PCT7; GENERATE_INFOPLIST_FILE = YES; PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionNetworkingKitTests; PRODUCT_NAME = SessionNetworkingKitTests; @@ -8779,6 +8825,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_MODULE_VERIFIER = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -8882,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8921,7 +8968,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9362,6 +9409,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -9394,6 +9442,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -9425,6 +9474,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SUQ8J2PCT7; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -9469,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9502,7 +9552,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -10422,7 +10472,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.1; + version = 1.5.6; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40c1f21016..36a72ad9fd 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "ba7d5f08e4eb71a2efe744df2ad677d8c180c6bb", - "version" : "1.5.1" + "revision" : "a092eb8fa4bbc93756530e08b6c281d9eda06c61", + "version" : "1.5.6" } }, { diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 85b9f8c668..c10dc1bfc1 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -380,6 +380,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel UIView.animate(withDuration: 0.25) { let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView remoteVideoView.alpha = isEnabled ? 1 : 0 + + // Retain floating view visibility if any of the video feeds are enabled + let isAnyVideoFeedEnabled: Bool = (isEnabled || self.call.isVideoEnabled) + + // Shows floating camera to allow user to switch to fullscreen or floating + // even if the other party has not yet turned on their video feed. + self.floatingViewContainer.isHidden = !isAnyVideoFeedEnabled } if self.callInfoLabelStackView.alpha < 0.5 { @@ -464,9 +471,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel setUpViewHierarchy() setUpProfilePictureImage() - if shouldRestartCamera { cameraManager.prepare() } - _ = call.videoCapturer // Force the lazy var to instantiate + titleLabel.text = self.call.contactName if self.call.hasConnected { callDurationLabel.isHidden = false @@ -659,14 +665,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel self.callInfoLabelStackView.alpha = 1 } - Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } - } + self.shouldHandleCallDismiss(delay: 2) } @objc private func answerCall() { @@ -680,21 +679,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - @objc private func endCall() { + @objc private func endCall(presentCameraRequestDialog: Bool = false) { dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in + self?.shouldHandleCallDismiss(delay: 1, presentCameraRequestDialog: presentCameraRequestDialog) + if let _ = error { self?.call.endSessionCall() dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere) } - - Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } - } } } @@ -721,8 +713,15 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel // MARK: - Video and Audio @objc private func operateCamera() { + if (call.isVideoEnabled) { - floatingViewContainer.isHidden = true + // Hides local video feed + (floatingViewVideoSource == .local + ? floatingLocalVideoView + : fullScreenLocalVideoView).alpha = 0 + + floatingViewContainer.isHidden = !call.isRemoteVideoEnabled + cameraManager.stop() videoButton.themeTintColor = .textPrimary videoButton.themeBackgroundColor = .backgroundSecondary @@ -730,34 +729,72 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel call.isVideoEnabled = false } else { - guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "permissionsRequired".localized(), - body: .text("permissionsCameraAccessRequiredCallsIos".localized()), - showCondition: .disabled, - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ) + + // Added delay of preview due to permission dialog alert dismissal on allow. + // It causes issue on `VideoPreviewVC` presentation animation, + // If camera permission is already allowed no animation delay is needed + let previewDelay = Permissions.camera == .undetermined ? 0.5 : 0 + + Permissions.requestCameraPermissionIfNeeded( + useCustomDeniedAlert: true, + using: dependencies + ) { [weak self, dependencies] isAuthorized in - self.navigationController?.present(confirmationModal, animated: true, completion: nil) - return + let status = Permissions.camera + + switch (isAuthorized, status) { + case (false, .denied): + guard let presentingViewController: UIViewController = (self?.navigationController ?? dependencies[singleton: .appContext].frontMostViewController) + else { return } + + DispatchQueue.main.async { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "cameraAccessRequired".localized(), + body: .attributedText( + "cameraAccessDeniedMessage" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(), + scrollMode: .never + ), + confirmTitle: "endCallToEnable".localized(), + confirmStyle: .danger, + cancelTitle: "remindMeLater".localized(), + cancelStyle: .alert_text, + onConfirm: { _ in + self?.endCall(presentCameraRequestDialog: true) + }, + onCancel: { modal in + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true + modal.dismiss(animated: true) + } + ) + ) + presentingViewController.present(confirmationModal, animated: true, completion: nil) + } + case (true, _): + DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in + let previewVC = VideoPreviewVC() + previewVC.delegate = self + self?.present(previewVC, animated: true, completion: nil) + } + break + default: break + } } - let previewVC = VideoPreviewVC() - previewVC.delegate = self - present(previewVC, animated: true, completion: nil) } } - + func cameraDidConfirmTurningOn() { floatingViewContainer.isHidden = false + let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView localVideoView.alpha = 1 + + // Camera preparation cameraManager.prepare() cameraManager.start() + videoButton.themeTintColor = .backgroundSecondary videoButton.themeBackgroundColor = .textPrimary switchCameraButton.isEnabled = true @@ -874,13 +911,26 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } + private func shouldHandleCallDismiss(delay: TimeInterval, presentCameraRequestDialog: Bool = false) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(Int(delay))) { [weak self, dependencies] in + guard self?.presentingViewController != nil else { return } + + self?.dismiss(animated: true, completion: { + self?.conversationVC?.becomeFirstResponder() + self?.conversationVC?.showInputAccessoryView() + + if presentCameraRequestDialog { + Permissions.showEnableCameraAccessInstructions(using: dependencies) + } else { + Permissions.remindCameraAccessRequirement(using: dependencies) + } + }) + } + } + // MARK: - AVRoutePickerViewDelegate - func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {} - func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {} } diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index eb702f6c4a..d4b17a7eaf 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -28,6 +28,7 @@ final class CallMissedTipsModal: Modal { result.text = "callsMissedCallFrom" .put(key: "name", value: caller) .localized() + result.accessibilityIdentifier = "Modal heading" result.themeTextColor = .textPrimary result.textAlignment = .center @@ -44,6 +45,7 @@ final class CallMissedTipsModal: Modal { result.themeAttributedText = "callsYouMissedCallPermissions" .put(key: "name", value: caller) .localizedFormatted(in: result) + result.accessibilityIdentifier = "Modal description" return result }() diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index a0dd9dba89..8a4817f5dd 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -193,7 +193,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 50caf2f972..58c317cf6f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -112,6 +112,20 @@ extension ConversationVC: navigationController?.pushViewController(viewController, animated: true) } + // MARK: - External keyboard + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + guard let key = press.key else { continue } + + if key.keyCode == .keyboardReturnOrEnter && key.modifierFlags.isEmpty { + // Enter only -> send + handleSendButtonTapped() + return + } + } + super.pressesBegan(presses, with: event) + } + // MARK: - Call @objc func startCall(_ sender: Any?) { @@ -237,30 +251,6 @@ extension ConversationVC: return true } - // MARK: - Session Pro CTA - - @discardableResult @MainActor func showSessionProCTAIfNeeded() -> Bool { - let dependencies: Dependencies = viewModel.dependencies - guard dependencies[feature: .sessionProEnabled] && (!viewModel.isSessionPro) else { - return false - } - self.hideInputAccessoryView() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, - dataManager: viewModel.dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - } - ) - ) - present(sessionProModal, animated: true, completion: nil) - - return true - } - // MARK: - UIGestureRecognizerDelegate func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true @@ -543,14 +533,28 @@ extension ConversationVC: } @MainActor func handleCharacterLimitLabelTapped() { - guard !showSessionProCTAIfNeeded() else { return } + guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) - let limit: Int = (viewModel.isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) + let limit: Int = (viewModel.isCurrentUserSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -621,9 +625,9 @@ extension ConversationVC: @MainActor func handleSendButtonTapped() { guard LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isSessionPro) + showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isCurrentUserSessionPro) return } @@ -635,7 +639,21 @@ extension ConversationVC: } @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded() else { return } + guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -811,6 +829,7 @@ extension ConversationVC: // FIXME: Remove this once we don't generate unique Profile entries for the current users blinded ids if (try? SessionId.Prefix(from: optimisticData.interaction.authorId)) != .standard { let currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + let sentTimestamp: TimeInterval = (Double(optimisticData.interaction.timestampMs) / 1000) try? Profile.updateIfNeeded( db, @@ -821,7 +840,7 @@ extension ConversationVC: fallback: .none, using: dependencies ), - sentTimestamp: (Double(optimisticData.interaction.timestampMs) / 1000), + profileUpdateTimestamp: currentUserProfile.profileLastUpdated, using: dependencies ) } @@ -1064,6 +1083,14 @@ extension ConversationVC: } return } + + if !self.isFirstResponder { + // Force this object to become the First Responder. This is necessary + // to trigger the display of its associated inputAccessoryView + // and/or inputView. + self.becomeFirstResponder() + } + UIView.animate(withDuration: 0.25, animations: { self.inputAccessoryView?.isHidden = false self.inputAccessoryView?.alpha = 1 @@ -1563,6 +1590,116 @@ extension ConversationVC: reply(cellViewModel, completion: nil) } + func showUserProfileModal(for cellViewModel: MessageViewModel) { + guard viewModel.threadData.threadCanWrite == true else { return } + // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) + guard (try? SessionId.Prefix(from: cellViewModel.authorId)) != .blinded25 else { return } + + let dependencies: Dependencies = viewModel.dependencies + + let (info, _) = ProfilePictureView.getProfilePictureInfo( + size: .hero, + publicKey: cellViewModel.authorId, + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: cellViewModel.profile, + using: dependencies + ) + + guard let profileInfo: ProfilePictureView.Info = info else { return } + + let (sessionId, blindedId): (String?, String?) = { + guard + (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey + else { + return (cellViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: cellViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) + }() + + let (displayName, contactDisplayName): (String?, String?) = { + guard let sessionId: String = sessionId else { + return (cellViewModel.authorNameSuppressedId, nil) + } + + let profile: Profile? = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? + dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } + ) + + return ( + (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), + profile?.displayName(for: .contact, ignoringNickname: true) + ) + }() + + let qrCodeImage: UIImage? = { + guard let sessionId: String = sessionId else { return nil } + return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore + }() + + let isMessasgeRequestsEnabled: Bool = { + guard cellViewModel.threadVariant == .community else { return true } + return cellViewModel.profile?.blocksCommunityMessageRequests != true + }() + + self.hideInputAccessoryView() + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: .init( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: displayName, + contactDisplayName: contactDisplayName, + isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), + isMessageRequestsEnabled: isMessasgeRequestsEnabled, + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + }, + onProBadgeTapped: { [weak self, dependencies] in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .generic, + dismissType: .single, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { modal in + dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + } + ) + } + ), + dataManager: dependencies[singleton: .imageDataManager], + afterClosed: { [weak self] in + self?.showInputAccessoryView() + } + ) + ) + present(userProfileModal, animated: true, completion: nil) + } + func startThread( with sessionId: String, openGroupServer: String?, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 671fbaa391..7ec260b5b3 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -838,7 +838,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa { if updatedThreadData.threadCanWrite == true { self.showInputAccessoryView() - } else { + } else if updatedThreadData.threadCanWrite == false && updatedThreadData.threadVariant != .community { self.hideInputAccessoryView() } @@ -1506,7 +1506,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we explicitly can't write to the thread then the input will be hidden but they keyboard // still reports that it takes up size, so just report 0 height in that case - if viewModel.threadData.threadCanWrite == false { + if viewModel.threadData.threadCanWrite == false && viewModel.threadData.threadVariant != .community { keyboardEndFrame = CGRect( x: UIScreen.main.bounds.minX, y: UIScreen.main.bounds.maxY, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 804521bd4d..82b8507cf7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -72,7 +72,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private var markAsReadPublisher: AnyPublisher? public let dependencies: Dependencies - public var isSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } + public var isCurrentUserSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 175222f4a0..9b9b0bc674 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -475,8 +475,13 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M UIView.animate(withDuration: 0.3) { [weak self] in self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } + self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index cbb7caa696..a4e2cd8585 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -145,7 +145,7 @@ protocol MessageCellDelegate: ReactionDelegate { func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) func openUrl(_ urlString: String) func handleReplyButtonTapped(for cellViewModel: MessageViewModel) - func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) + func showUserProfileModal(for cellViewModel: MessageViewModel) func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) func handleReadMoreButtonTapped(_ cell: UITableViewCell, for cellViewModel: MessageViewModel) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 20d64ebed9..f809ec9b80 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1034,26 +1034,17 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) + let tappedAuthorName: Bool = ( + authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && + !(cellViewModel.senderName ?? "").isEmpty + ) + let tappedProfilePicture: Bool = ( + profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && + cellViewModel.shouldShowProfile + ) - if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { - // For open groups only attempt to start a conversation if the author has a blinded id - guard cellViewModel.threadVariant != .community else { - // FIXME: Add in support for opening a conversation with a 'blinded25' id - guard (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15 else { return } - - delegate?.startThread( - with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) - return - } - - delegate?.startThread( - with: cellViewModel.authorId, - openGroupServer: nil, - openGroupPublicKey: nil - ) + if tappedAuthorName || tappedProfilePicture { + delegate?.showUserProfileModal(for: cellViewModel) } else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ee7a40590..5ae2cd91bc 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -212,7 +212,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), onTap: { [weak self] in @@ -1678,11 +1681,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob }, icon: .rightPlus, style: .circular, + description: nil, accessibility: Accessibility( identifier: "Image picker", label: "Image picker" ), dataManager: dependencies[singleton: .imageDataManager], + onProBageTapped: nil, onClick: { [weak self] onDisplayPictureSelected in self?.onDisplayPictureSelected = onDisplayPictureSelected self?.showPhotoLibraryForAvatar() @@ -1691,7 +1696,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _, _, _): return (source?.imageData != nil) default: return false } }, @@ -1701,7 +1706,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, _, _, _, _, _, _): guard let imageData: Data = source.imageData else { return } self?.updateGroupDisplayPicture( @@ -1766,9 +1771,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case .groupUploadImageData(let data): /// Show a blocking loading indicator while uploading but not while updating or syncing the group configs return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data) + .prepareAndUploadDisplayPicture(imageData: data, compression: true) .showingBlockingLoading(in: self?.navigatableState) - .map { url, filePath, key -> DisplayPictureManager.Update in + .map { url, filePath, key, _ -> DisplayPictureManager.Update in .groupUpdateTo(url: url, key: key, filePath: filePath) } .mapError { $0 as Error } diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index e723a66c1b..9e568bb8a2 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import DifferenceKit import SessionUIKit import SessionMessagingKit @@ -452,11 +453,12 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { authorId.truncated(threadVariant: self.messageViewModel.threadVariant) ), trailingAccessory: (!canRemoveEmoji ? nil : - .icon( - UIImage(named: "X")? - .withRenderingMode(.alwaysTemplate), - size: .medium - ) + .icon( + Lucide.image(icon: .x, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + size: .medium, + pinEdges: [.right] + ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 5ae0d22528..4de1f19992 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -223,6 +223,7 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI state: .defaultContacts, data: contacts .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } + .filter { $0.isContactApproved == true } // Only show default contacts that have been approved via message request .reduce(into: [String: SectionModel]()) { result, next in guard !next.threadIsNoteToSelf else { result[""] = SectionModel( diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..97431f5180 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -553,15 +553,18 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Handle App review @MainActor func viewDidAppear() { - guard state.pendingAppReviewPromptState != nil else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } - - dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false - - self?.handlePromptChangeState(updatedState) + if state.pendingAppReviewPromptState != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in + guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } + + dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false + + self?.handlePromptChangeState(updatedState) + } } + + // Camera reminder + willShowCameraPermissionReminder() } func scheduleAppReviewRetry() { @@ -706,6 +709,20 @@ public class HomeViewModel: NavigatableStateHolder { } } + // Camera permission + func willShowCameraPermissionReminder() { + guard + dependencies[singleton: .screenLock].checkIfScreenIsUnlocked(), // Show camera access reminder when app has been unlocked + !dependencies[defaults: .appGroup, key: .isCallOngoing] // Checks if there is still an ongoing call + else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [dependencies] in + Permissions.remindCameraAccessRequirement(using: dependencies) + } + } + @MainActor @objc func didReturnFromBackground() { // Observe changes to app state retry and flags when app goes to bg to fg @@ -718,6 +735,9 @@ public class HomeViewModel: NavigatableStateHolder { self?.handlePromptChangeState(updatedState) } } + + // Camera reminder + willShowCameraPermissionReminder() } // MARK: - Functions diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 28ab4d2426..4166834227 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -498,71 +498,75 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou Log.error("[MediaPageViewController] currentViewController was unexpectedly nil") return } - guard - let path: String = try? viewModel.dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( - downloadUrl: currentViewController.galleryItem.attachment.downloadUrl, - mimeType: currentViewController.galleryItem.attachment.contentType, - sourceFilename: currentViewController.galleryItem.attachment.sourceFilename - ), - viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) - else { return } - let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: path) ], applicationActivities: nil) - - if UIDevice.current.isIPad { - shareVC.excludedActivityTypes = [] - shareVC.popoverPresentationController?.permittedArrowDirections = [] - shareVC.popoverPresentationController?.sourceView = self.view - shareVC.popoverPresentationController?.sourceRect = self.view.bounds - } - - shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in - if let activityError = activityError { - Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") - } - else if completed { - Log.info("[MediaPageViewController] Did share with activityType: \(activityType.debugDescription)") - } - - /// Sanity check to make sure we don't unintentionally remove a proper attachment file - if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { - try? dependencies[singleton: .fileManager].removeItem(atPath: path) - } - - /// Notify any conversations to update if a message was sent via Session - UIActivityViewController.notifyIfNeeded(completed, using: dependencies) - + DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies = viewModel.dependencies] in guard - let activityType = activityType, - activityType == .saveToCameraRoll, - currentViewController.galleryItem.interactionVariant == .standardIncoming, - self.viewModel.threadVariant == .contact + let path: String = try? dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( + downloadUrl: currentViewController.galleryItem.attachment.downloadUrl, + mimeType: currentViewController.galleryItem.attachment.contentType, + sourceFilename: currentViewController.galleryItem.attachment.sourceFilename + ), + dependencies[singleton: .fileManager].fileExists(atPath: path) else { return } - let threadId: String = self.viewModel.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadVariant - - dependencies[singleton: .storage].writeAsync { db in - try MessageSender.send( - db, - message: DataExtractionNotification( - kind: .mediaSaved( - timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) - ), - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - .with(DisappearingMessagesConfiguration - .fetchOne(db, id: threadId)? - .forcedWithDisappearAfterReadIfNeeded() - ), - interactionId: nil, // Show no interaction for the current user - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) + DispatchQueue.main.async { [weak self] in + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: path) ], applicationActivities: nil) + + if UIDevice.current.isIPad, let view: UIView = self?.view { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = view + shareVC.popoverPresentationController?.sourceRect = view.bounds + } + + shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in + if let activityError = activityError { + Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") + } + else if completed { + Log.info("[MediaPageViewController] Did share with activityType: \(activityType.debugDescription)") + } + + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } + + /// Notify any conversations to update if a message was sent via Session + UIActivityViewController.notifyIfNeeded(completed, using: dependencies) + + guard + let activityType = activityType, + activityType == .saveToCameraRoll, + currentViewController.galleryItem.interactionVariant == .standardIncoming, + self?.viewModel.threadVariant == .contact, + let threadId: String = self?.viewModel.threadId, + let threadVariant: SessionThread.Variant = self?.viewModel.threadVariant + else { return } + + dependencies[singleton: .storage].writeAsync { db in + try MessageSender.send( + db, + message: DataExtractionNotification( + kind: .mediaSaved( + timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) + ), + sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + .with(DisappearingMessagesConfiguration + .fetchOne(db, id: threadId)? + .forcedWithDisappearAfterReadIfNeeded() + ), + interactionId: nil, // Show no interaction for the current user + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + } + } + self?.present(shareVC, animated: true, completion: nil) } } - self.present(shareVC, animated: true, completion: nil) } @objc public func didPressDelete(_ sender: Any) { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index e70361c687..27ee35d5db 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -918,7 +918,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // need to handle this behavior for legacy UINotification users anyway, we "allow" all // notification options here, and rely on the shared logic in NotificationPresenter to // honor notification sound preferences for both modern and legacy users. - completionHandler([.badge, .banner, .sound]) + completionHandler([.badge, .banner, .sound, .list]) } } diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json b/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json deleted file mode 100644 index 6364b3c6db..0000000000 --- a/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "profile_placeholder.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "profile_placeholder@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "profile_placeholder@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png deleted file mode 100644 index cf0843dcf1..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png and /dev/null differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png deleted file mode 100644 index 02649edd92..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png and /dev/null differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png deleted file mode 100644 index 3a6241122e..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png and /dev/null differ diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 77b4fd6490..bf1d25c869 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -18335,6 +18335,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Στέλνεται προαγωγή διαχειριστή" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Στέλνονται προαγωγές διαχειριστή" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -18447,6 +18475,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administraatori edutamine" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administraatori edutamine on saatmisel" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21016,6 +21072,12 @@ "value" : "Automatische nachtmodus" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczny tryb ciemny" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -31123,12 +31185,24 @@ "value" : "{app_pro} Badge" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badge {app_pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{app_pro} Badge" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odznaka {app_pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -65452,12 +65526,24 @@ "value" : "View and manage blocked contacts." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher et gérer les contacts bloqués." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bekijk en beheer geblokkeerde contacten." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przeglądaj i zarządzaj zablokowanymi kontaktami." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -78898,18 +78984,36 @@ "value" : "Funkce hlasových hovorů, která je nyní ve vývojové fázi (beta), odhalí vaši IP adresu těm, se kterými si voláte a také {session_foundation} serveru." } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine IP ist deinem Anrufpartner und einem {session_foundation} Server sichtbar während Beta Anrufe getätigt werden." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your IP is visible to your call partner and a {session_foundation} server while using beta calls." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre adresse IP est visible par votre interlocuteur et un serveur {session_foundation} pendant que vous utilisez des appels bêta." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Uw IP is zichtbaar voor uw oproep partner en een {session_foundation} server tijdens het gebruik van bètagesprekken." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój adres IP jest widoczny dla twojego rozmówcy i serwera {session_foundation}, kiedy używasz rozmów beta." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -80361,6 +80465,50 @@ } } }, + "cameraAccessDeniedMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} needs access to your camera to enable video calls, but this permission has been denied. You can’t update your camera permissions during a call.

Would you like to end the call now and enable camera access, or would you like to be reminded after the call?" + } + } + } + }, + "cameraAccessInstructions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessReminderMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "During your last call, you tried to use video but couldn’t because camera access was previously denied. To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessRequired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera Access Required" + } + } + } + }, "cameraErrorNotFound" : { "extractionState" : "manual", "localizations" : { @@ -83765,12 +83913,24 @@ "value" : "Cancel Plan" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler l’abonnement" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Abonnement annuleren" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj plan" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -83779,6 +83939,39 @@ } } }, + "cancelProPlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel {pro} Plan" + } + } + } + }, + "cancelProPlatform" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "cancelProPlatformStore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with." + } + } + } + }, "change" : { "extractionState" : "manual", "localizations" : { @@ -83806,12 +83999,24 @@ "value" : "Change" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wijzigen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmień" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -84332,18 +84537,36 @@ "value" : "Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla." } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ändere dein Passwort für {app_name}. Lokal gespeicherte Dateien werden mit deinem neuen Passwort entschlüsselt." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Change your password for {app_name}. Locally stored data will be re-encrypted with your new password." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changez votre mot de passe pour {app_name}. Les données stockées localement seront re-chiffrées avec votre nouveau mot de passe." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wijzig je wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met je nieuwe wachtwoord." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmień hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane nowym hasłem." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -84367,6 +84590,18 @@ "checkingProStatus" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusu yoxlanılır" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrola stavu {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -84381,7 +84616,29 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking your {pro} details. Some information on this page may be inaccurate until this check is complete." + "value" : "Checking your {pro} details. Some information on this page may be unavailable until this check is complete." + } + } + } + }, + "checkingProStatusEllipsis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking {pro} Status..." + } + } + } + }, + "checkingProStatusRenew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking your {pro} details. You cannot renew until this check is complete." } } } @@ -84389,6 +84646,18 @@ "checkingProStatusUpgradeDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusunuz yoxlanılır. Bu yoxlama tamamlandıqdan sonra {pro} ya yüksəldə biləcəksiniz." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci navýšit na {pro}." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -113769,12 +114038,24 @@ "value" : "Choose the content displayed in local notifications when an incoming message is received." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez le contenu affiché dans les notifications locales lorsqu'un message entrant est reçu." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kies de inhoud die wordt weergegeven in lokale meldingen wanneer een inkomend bericht wordt ontvangen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz treść wyświetlaną w lokalnych powiadomieniach, kiedy pojawia się nowa wiadomość." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -119636,12 +119917,24 @@ "value" : "Define how the Enter and Shift+Enter keys function in conversations." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définissez le fonctionnement des touches Entrée et Maj+Entrée dans les conversations." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stel in hoe de toetsen Enter en Shift+Enter functioneren in gesprekken." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zdefiniuj jak klawisz Enter i kombinacja Shift+Enter działają w konwersacjach." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -119683,12 +119976,24 @@ "value" : "SHIFT + ENTER sends a message, ENTER starts a new line." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MAJUSCULE + ENTRÉE envoie un message, ENTRÉE commence une nouvelle ligne." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "SHIFT + ENTER verzendt een bericht, ENTER begint een nieuwe regel." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHIFT + ENTER wysyła wiadomość, ENTER zaczyna nowy wiersz." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -119742,12 +120047,24 @@ "value" : "ENTER envía un mensaje, SHIFT + ENTER inicia una nueva línea." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ENTRÉE envoie un message, MAJ + ENTRÉE commence une nouvelle ligne." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "ENTER verzendt een bericht, SHIFT + ENTER begint een nieuwe regel." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ENTER wysyła wiadomość, SHIFT + ENTER zaczyna nowy wiersz." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -121232,12 +121549,24 @@ "value" : "Auto-delete messages older than 6 months in communities with 2000+ messages." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer automatiquement les messages de plus de 6 mois dans les communautés ayant plus de 2000 messages." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Berichten ouder dan 6 maanden automatisch verwijderen in community's met meer dan 2000 berichten." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatycznie usuwaj wiadomości starsze niż 6 miesięcy w społecznościach powyżej 2000 wiadomości." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -122255,12 +122584,24 @@ "value" : "Enter para enviar" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer avec Entrée" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verzenden met Enter" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyłaj przyciskiem Enter" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -122793,12 +123134,24 @@ "value" : "Send with Shift+Enter" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer avec Maj+Entrée" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verzenden met Shift+Enter" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyłaj za pomocą Shift+Enter" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -126384,18 +126737,36 @@ "value" : "Aktuální heslo" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktuelles Passwort" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mot de passe actuel" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Huidig wachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obecne hasło" + } + }, "sv-SE" : { "stringUnit" : { "state" : "translated", @@ -126431,12 +126802,24 @@ "value" : "Current Plan" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forfait actuel" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Huidig abonnement" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obecny plan" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -126957,12 +127340,24 @@ "value" : "Dark Mode" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode sombre" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Donkere modus" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryb ciemny" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -127171,6 +127566,12 @@ "value" : "Ein Datenbankfehler ist aufgetreten.

Exportiere deine App-Logs, um diese für eine Fehleranalyse zu teilen. Wenn dies nicht erfolgreich ist, installiere die {app_name} neu und stelle deinen Account wieder her." } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Παρουσιάστηκε σφάλμα βάσης δεδομένων.

Εξαγάγετε τα αρχεία καταγραφής της εφαρμογής σας για κοινή χρήση στην αντιμετώπιση προβλημάτων. Αν αυτό δεν είναι επιτυχές, επανεγκαταστήστε το {app_name} και επαναφέρετε τον λογαριασμό σας." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -137513,6 +137914,72 @@ } } }, + "deleteAttachments" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachment" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachments" + } + } + } + } + } + } + } + } + }, + "deleteAttachmentsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachment? The message associated with the attachment will also be deleted." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachments? The message associated with the attachments will also be deleted." + } + } + } + } + } + } + } + } + }, "deleteContactDescription" : { "extractionState" : "manual", "localizations" : { @@ -140311,6 +140778,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Είστε σίγουρος ότι θέλετε να διαγράψετε αυτό το μήνυμα;" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Είστε σίγουρος ότι θέλετε να διαγράψετε αυτά τα μηνύματα;" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -140423,6 +140918,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Olete kindel, et soovite selle sõnumi kustutada?" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Olete kindel, et soovite need sõnumid kustutada?" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -144477,6 +145000,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Είστε σίγουρος ότι θέλετε να διαγράψετε αυτό το μήνυμα μόνο από αυτή τη συσκευή;" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Είστε σίγουρος ότι θέλετε να διαγράψετε αυτά τα μηνύματα μόνο από αυτή τη συσκευή;" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -144589,6 +145140,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kas olete kindel, et soovite selle sõnumi ainult sellest seadmest kustutada?" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kas olete kindel, et soovite need sõnumid ainult sellest seadmetest kustutada?" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -149907,6 +150486,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seda sõnumit ei saa kõikidest teie seadmetest kustutada" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mõnda valitud sõnumit ei saa kõikidest teie seadmetest kustutada" + } + } + } + } + } + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -151577,6 +152184,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seda sõnumit ei saa kõigi jaoks kustutada" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mõned sõnumid mille oled valinud ei saa kõigi jaoks kustutada" + } + } + } + } + } + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -170205,6 +170840,12 @@ "value" : "Display" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affichage" + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -188488,6 +189129,17 @@ } } }, + "enableCameraAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Camera Access?" + } + } + } + }, "enableNotifications" : { "extractionState" : "manual", "localizations" : { @@ -188515,12 +189167,24 @@ "value" : "Show notifications when you receive new messages." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les notifications lorsque vous recevez de nouveaux messages." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon meldingen wanneer je nieuwe berichten ontvangt." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyłaj powiadomienia o nowych wiadomościach." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -188541,6 +189205,17 @@ } } }, + "endCallToEnable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End Call to Enable" + } + } + } + }, "enjoyingSession" : { "extractionState" : "manual", "localizations" : { @@ -189080,6 +189755,12 @@ "value" : "Enter" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrer" + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -189106,13 +189787,35 @@ } } }, + "enterPasswordDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the password you set for {app_name}" + } + } + } + }, + "enterPasswordTooltip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the password you use to unlock {app_name} on startup, not your Recovery Password" + } + } + } + }, "errorCheckingProStatus" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error checking {pro} status." + "value" : "Error checking {pro} status" } } } @@ -190721,7 +191424,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error loading {pro} plan." + "value" : "Error loading {pro} plan" } } } @@ -191890,12 +192593,24 @@ "value" : "Feedback" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Donner votre avis" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Feedback" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feedback" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -191943,12 +192658,24 @@ "value" : "Share your experience with {app_name} by completing a short survey." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partagez votre expérience avec {app_name} en répondant à un court sondage." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Deel je ervaring met {app_name} door een korte enquête in te vullen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podziel się wrażeniami o {app_name} wypełniając krótką ankietę." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -192954,12 +193681,24 @@ "value" : "Follow system settings." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faire correspondre aux paramètres systèmes." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Systeeminstellingen volgen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dopasuj do ustawień systemu." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -197765,6 +198504,12 @@ "value" : "Bist du sicher, dass du {group_name} verlassen möchtest?

Dadurch werden alle Mitglieder entfernt und alle Gruppendaten gelöscht." } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Είστε βέβαιοι ότι θέλετε να διαγράψετε το {group_name}?

Αυτό θα αφαιρέσει όλα τα μέλη και θα διαγράψει όλο το περιεχόμενο της ομάδας." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -204857,6 +205602,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Στέλνεται πρόσκληση" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Στέλνονται προσκλήσεις" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -204969,6 +205742,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saadan kutse" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saadan kutsed" + } + } + } + } + } + } + }, "fa" : { "stringUnit" : { "state" : "translated", @@ -233975,12 +234776,24 @@ "value" : "Check the {app_name} FAQ for answers to common questions." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consultez la FAQ de {app_name} pour obtenir des réponses aux questions fréquentes." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bekijk de {app_name} FAQ voor antwoorden op veelgestelde vragen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdź FAQ {app_name} by poznać odpowiedzi na często zadawane pytania." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -234501,12 +235314,24 @@ "value" : "Report a Bug" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signaler un bug" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Meld een bug" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zgłoś błąd" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -236494,12 +237319,30 @@ "value" : "Save this file, then share it with {app_name} developers." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrez ce fichier, puis partagez-le avec les développeurs de {app_name}." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre denne filen, så del den med {app_name}-utviklerne." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Sla dit bestand op en deel het vervolgens met de {app_name} ontwikkelaars." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz ten plik i wyślij go do deweloperów {app_name}." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -237026,12 +237869,24 @@ "value" : "Help translate {app_name} into over 80 languages!" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aidez à traduire {app_name} en plus de 80 langues !" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Help met het vertalen van {app_name} in meer dan 80 talen!" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomóż przetłumaczyć {app_name} na ponad 80 języków!" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -238031,12 +238886,24 @@ "value" : "Toggle system menu bar visibility." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer/désactiver la visibilité de la barre de menu système." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Zichtbaarheid systeem-menubalk in-/uitschakelen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż/ukryj pasek menu systemowego." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -239358,12 +240225,24 @@ "value" : "Important" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Important" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Belangrijk" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ważne" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -241443,6 +242322,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η πρόσκληση απέτυχε" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Οι προσκλήσεις απέτυχαν" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -241555,6 +242462,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kutse saatmine ebaõnnestus" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kutsete saatmine ebaõnnestus" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -242334,6 +243269,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η πρόσκληση δεν μπορούσε να σταλθεί. Θέλετε να προσπαθήσετε ξανά;" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Οι προσκλήσεις δεν μπορούσαν να σταλθούν. Θέλετε να προσπαθήσετε ξανά;" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -242446,6 +243409,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kutset ei õnnestunud saata. Kas soovite uuesti proovida?" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kutseid ei õnnestunud saata. Kas soovite uuesti proovida?" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -244031,6 +245022,12 @@ "launchOnStartDescriptionDesktop" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kompüteriniz açıldığı zaman {app_name}-u avtomatik başlat." + } + }, "cs" : { "stringUnit" : { "state" : "translated", @@ -244043,6 +245040,12 @@ "value" : "Launch {app_name} automatically when your computer starts up." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lancer automatiquement {app_name} au démarrage de votre ordinateur." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -244072,6 +245075,12 @@ "value" : "Launch on Startup" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lancer au démarrage" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -244083,6 +245092,12 @@ "launchOnStartupDisabledDesktop" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu ayar, Linux-dakı sisteminiz tərəfindən idarə olunur. Avtomatik açılışı fəallaşdırmaq üçün sistem ayarlarında {app_name} tətbiqini açılış tətbiqlərinizə əlavə edin." + } + }, "cs" : { "stringUnit" : { "state" : "translated", @@ -244095,6 +245110,12 @@ "value" : "This setting is managed by your system on Linux. To enable automatic startup, add {app_name} to your startup applications in system settings." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce paramètre est géré par votre système sous Linux. Pour activer le démarrage automatique, ajoutez {app_name} à vos applications de démarrage dans les paramètres système." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -254072,18 +255093,36 @@ "value" : "Odkazy" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfungen" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Links" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liens" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Koppelingen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linki" + } + }, "sv-SE" : { "stringUnit" : { "state" : "translated", @@ -259873,18 +260912,36 @@ "value" : "Logy" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protokolle" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Logs" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Journaux" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Logboeken" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dzienniki" + } + }, "sv-SE" : { "stringUnit" : { "state" : "translated", @@ -260081,12 +261138,24 @@ "value" : "Manage {pro}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gérer {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} beheren" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarządzaj {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -270341,12 +271410,24 @@ "value" : "Menu Bar" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barre de menu" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Menubalk" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasek Menu" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -271010,12 +272091,24 @@ "value" : "Copy Message" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier le message" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bericht kopiëren" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopiuj wiadomość" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -279795,6 +280888,24 @@ } } }, + "el" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Έχετε νέο μήνυμα στο {group_name}." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Έχετε %lld νέα μηνύματα στο {group_name}." + } + } + } + } + }, "en" : { "variations" : { "plural" : { @@ -279867,6 +280978,24 @@ } } }, + "et" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sul on uus sõnum {group_name}." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sul on %lld uut sõnumit {group_name}." + } + } + } + } + }, "fr" : { "variations" : { "plural" : { @@ -294193,6 +295322,24 @@ } } }, + "el" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Τα μηνύματα έχουν όριο {limit} χαρακτήρων. Σας απομένει %lld χαρακτήρας." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Τα μηνύματα έχουν όριο {limit} χαρακτήρων. Σας απομένουν %lld χαρακτήρες." + } + } + } + } + }, "en" : { "variations" : { "plural" : { @@ -294247,6 +295394,24 @@ } } }, + "et" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sõnumitel on tähemärgipiirang {limit} tähemärki. Teil on %lld tähemärki alles." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sõnumitel on tähemärgipiirang {limit} tähemärki. Teil on %lld tähemärki alles." + } + } + } + } + }, "fr" : { "variations" : { "plural" : { @@ -295261,18 +296426,36 @@ "value" : "Nové heslo" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neues Passwort" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "New Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveau mot de passe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Nieuw wachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nowe hasło" + } + }, "sv-SE" : { "stringUnit" : { "state" : "translated", @@ -295787,12 +296970,24 @@ "value" : "Next Steps" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Étapes suivantes" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Volgende stappen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Następne kroki" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -301312,18 +302507,36 @@ "value" : "Zobrazení upozornění" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungsanzeige" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Notification Display" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affichage des notifications" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Notificatie weergave" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyświetlanie powiadomień" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -304257,12 +305470,24 @@ "value" : "Display the sender's name and a preview of the message content." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le nom de l'expéditeur et un aperçu du contenu du message." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon de naam van de afzender en een voorbeeld van de berichtinhoud." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyświetlaj nazwę nadawcy i podgląd wiadomości." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -304298,18 +305523,36 @@ "value" : "Zobrazit pouze jméno odesílatele bez obsahu zprávy." } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur den Namen des Absenders ohne Nachrichteninhalt anzeigen." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Display only the sender's name without any message content." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher uniquement le nom de l'expéditeur sans aucun contenu du message." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon alleen de naam van de afzender zonder enige berichtinhoud." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyświetlaj tylko nazwę nadawcy, bez podglądu wiadomości." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -305949,12 +307192,24 @@ "value" : "Display a generic {app_name} notification without the sender's name or message content." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher une notification générique de {app_name} sans le nom de l'expéditeur ni le contenu du message." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon een algemene {app_name} melding zonder de naam van de afzender of de inhoud van het bericht." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyświetlaj tylko powiadomienie {app_name}, bez podglądu wiadomości ani nazwy nadawcy." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -309325,12 +310580,24 @@ "value" : "Play a sound when you receive receive new messages." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jouer un son lorsque vous recevez de nouveaux messages." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Speel een geluid af wanneer je nieuwe berichten ontvangt." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odtwórz dźwięk, kiedy otrzymasz nową wiadomość." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -326471,11 +327738,34 @@ "value" : "On your {device_type} device" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur votre appareil {device_type}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Op je {device_type} apparaat" } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "На вашому пристрої {device_type}" + } + } + } + }, + "onDeviceCancelDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings." + } } } }, @@ -326500,6 +327790,12 @@ "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -329400,6 +330696,39 @@ } } }, + "onLinkedDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On a linked device" + } + } + } + }, + "onPlatformStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On the {platform_store} website" + } + } + } + }, + "onPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On the {platform} website" + } + } + } + }, "onsErrorNotRecognized" : { "extractionState" : "manual", "localizations" : { @@ -330837,31 +332166,35 @@ } } }, - "openStoreWebsite" : { + "openPlatformStoreWebsite" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_store} veb saytını aç" - } - }, - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Otevřít webovou stránku {platform_store}" + "value" : "Open {platform_store} Website" } - }, + } + } + }, + "openPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open {platform_store} Website" + "value" : "Open {platform} Website" } - }, - "nl" : { + } + } + }, + "openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open de {platform_store} website" + "value" : "Open Settings" } } } @@ -331497,12 +332830,24 @@ "value" : "Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mot de passe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hasło" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -332053,12 +333398,24 @@ "value" : "Tu contraseña ha sido cambiada. Por favor, guárdala en un lugar seguro." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre mot de passe a été changé. Veuillez le conserver en sécurité." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Uw wachtwoord is gewijzigd. Hou het veilig." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje hasło zostało zmienione. Zapisz je w bezpiecznym miejscu." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -332118,6 +333475,12 @@ "value" : "Wijzig het wachtwoord dat nodig is om {app_name} te ontgrendelen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmień hasło wymagane do odblokowania {app_name}." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -332662,6 +334025,12 @@ "value" : "Wachtwoord aanmaken" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utwórz hasło" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -336672,12 +338041,24 @@ "value" : "Confirm New Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmer le nouveau mot de passe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bevestig nieuwe wachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potwierdź nowe hasło" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -337210,12 +338591,24 @@ "value" : "Your password has been removed." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre mot de passe a été supprimé." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Uw wachtwoord is verwijderd." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje hasło zostało usunięte." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -337257,18 +338650,36 @@ "value" : "Odebrat heslo pro odemykání {app_name}" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernung des Passwortes erforderlich um {app_name} zu entsperren" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Remove the password required to unlock {app_name}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retirer le mot de passe requis pour déverrouiller {app_name}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verwijder het wachtwoord dat nodig is om {app_name} te ontgrendelen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń hasło wymagane do odblokowania {app_name}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -337316,12 +338727,24 @@ "value" : "Passwords" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mots de passe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wachtwoorden" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hasła" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -337854,12 +339277,24 @@ "value" : "Your password has been set. Please keep it safe." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre mot de passe a été défini. Veuillez le conserver en sécurité." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Uw wachtwoord is ingesteld. Hou het veilig." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje hasło zostało utworzone. Zapisz je w bezpiecznym miejscu." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -337907,12 +339342,24 @@ "value" : "Require password to unlock {app_name} on startup." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mot de passe requis pour déverrouiller {app_name} au démarrage." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wachtwoord vereisen om {app_name} bij het opstarten te ontgrendelen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymagaj hasła do odblokowania {app_name} przy uruchomieniu." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -337960,12 +339407,24 @@ "value" : "Longer than 12 characters" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus de 12 caractères" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Langer dan 12 tekens" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dłuższe niż 12 znaków" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -338019,12 +339478,24 @@ "value" : "Includes a number" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inclut un chiffre" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bevat een cijfer" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zawiera cyfrę" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -338078,12 +339549,24 @@ "value" : "Includes a lowercase letter" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprend une lettre minuscule" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bevat een kleine letter" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zawiera małą literę" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -338131,11 +339614,29 @@ "value" : "Includes a symbol" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inclut un symbole" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bevat een symbool" } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zawiera symbol" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Містить символ" + } } } }, @@ -338166,12 +339667,24 @@ "value" : "Includes a uppercase letter" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contient une lettre majuscule" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bevat een hoofdletter" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zawiera wielką literę" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -338225,12 +339738,24 @@ "value" : "Password Strength Indicator" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indicateur de robustesse du mot de passe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Wachtwoordsterkte indicator" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wskaźnik siły hasła" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -338272,18 +339797,36 @@ "value" : "Nastavení silného hesla pomáhá chránit vaše zprávy a přílohy v případě ztráty nebo odcizení zařízení." } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein schwieriges Passwort hilft deine Nachrichten und Anlagen zu schützen, wenn dein Gerät jemals verloren geht oder gestohlen wird." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Setting a strong password helps protect your messages and attachments if your device is ever lost or stolen." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définir un mot de passe robuste permet de protéger vos messages et pièces jointes en cas de perte ou de vol de votre appareil." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Een sterk wachtwoord helpt je berichten en bijlagen te beschermen als je apparaat ooit verloren raakt of wordt gestolen." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienie silnego hasła pomaga chronić Twoje wiadomości i załączniki w przypadku utraty lub kradzieży urządzenia." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -342813,6 +344356,12 @@ "value" : "{app_name} läuft im Hintergrund weiter, wenn du das Fenster schließt." } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Το {app_name} συνεχίζει να εκτελείται στο παρασκήνιο όταν κλείνετε το παράθυρο." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -342837,6 +344386,12 @@ "value" : "{app_name} blijft op de achtergrond draaien wanneer je het venster sluit." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} nadal działa w tle po zamknięciu okna." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -352428,6 +353983,12 @@ "value" : "Plus Loads More..." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus de téléchargement..." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -352463,12 +354024,24 @@ "value" : "New features coming soon to {pro}. Discover what's next on the {pro} Roadmap {icon}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvelles fonctionnalités {pro} à venir. Découvrez ce qui vous attend dans la feuille de route {pro} {icon}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Nieuwe functies komen binnenkort naar {pro}. Ontdek wat er komt op de {pro} Roadmap {icon}" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nowe funkcje niedługo pojawią się w {pro}. Odkryj, co nowego na {pro} Roadmap {icon}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -352516,12 +354089,24 @@ "value" : "Preferencias" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préférences" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Voorkeuren" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferencje" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -353054,12 +354639,24 @@ "value" : "Preview Notification" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu de la notification" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Voorbeeldmelding" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podgląd powiadomień" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -353226,12 +354823,24 @@ "value" : "You're all set!" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tout est prêt !" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Alles is geregeld!" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wszystko gotowe!" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -353261,12 +354870,24 @@ "value" : "Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -353951,12 +355572,24 @@ "value" : "Animated Display Pictures" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Photos de profil animées" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Geanimeerde profielfoto's" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animowane obrazy profilu" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -353986,12 +355619,24 @@ "value" : "Set animated GIFs and WebP images as your display picture." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définissez des GIF et des images WebP animées comme photo de profil." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stel geanimeerde GIF's en WebP-afbeeldingen in als je profielfoto." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawiaj animowane obrazy GIF i WebP jako swój obraz profilu." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354146,18 +355791,36 @@ "value" : "{pro} se automaticky obnoví za {time}" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatische {pro} Erneuerung in {time}" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "{pro} auto-renewing in {time}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} se renouvelle automatiquement dans {time}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} wordt automatisch verlengd over {time}" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczne odnowienie subskrypcji {pro}: {time}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354187,12 +355850,24 @@ "value" : "{pro} Badge" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badge {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} badge" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odznaka {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354222,12 +355897,24 @@ "value" : "Badges" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badges" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Badges" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odznaki" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354257,12 +355944,24 @@ "value" : "Show your support for {app_name} with an exclusive badge next to your display name." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affichez votre soutien à {app_name} avec un badge exclusif, à côté de votre nom d'affichage." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon je steun voor {app_name} met een exclusieve badge naast je schermnaam." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż swoje wsparcie dla {app_name} ekskluzywną odznaką obok swojej nazwy wyświetlanej." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354342,6 +356041,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} {pro} Σήμα στάλθηκε" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} {pro} Εμβλήματα στάλθηκαν" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -354370,6 +356097,62 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} {pro} Tunnusmärk Saadetud" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} {pro} Tunnusmärki Saadetud" + } + } + } + } + } + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badge {total} {pro} envoyé" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badge {total} {pro} envoyé" + } + } + } + } + } + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -354397,6 +356180,46 @@ } } } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} odznaki {pro}" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} odznak {pro}" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} odznakę {pro}" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} odznak {pro}" + } + } + } + } + } + } } } }, @@ -354421,12 +356244,24 @@ "value" : "Show {app_pro} badge to other users" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l’insigne {app_pro} aux autres utilisateurs" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon het {app_pro} badge aan andere gebruikers" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż odznakę {app_pro} innym użytkownikom" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354438,11 +356273,47 @@ "proBetaFeatures" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Beta Xüsusiyyətləri" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funkce beta verze {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "{pro} Beta Features" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fonctionnalités {pro}" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} functies" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Funkcje {pro}" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Можливості {pro}" + } } } }, @@ -354467,12 +356338,24 @@ "value" : "{price} Billed Annually" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} facturé annuellement" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{price} Jaarlijks gefactureerd" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opłata roczna: {price}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354502,12 +356385,24 @@ "value" : "{price} Billed Monthly" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} facturé mensuellement" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{price} Maandelijks gefactureerd" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opłata miesięczna: {price}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354537,12 +356432,24 @@ "value" : "{price} Billed Quarterly" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} facturé trimestriellement" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{price} per kwartaal gefactureerd" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opłata kwartalna: {price}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -354950,31 +356857,114 @@ } } }, - "processingRefundRequest" : { + "proCancellation" : { "extractionState" : "manual", "localizations" : { - "az" : { + "cs" : { "stringUnit" : { "state" : "translated", - "value" : "{platform_account} geri ödəmə tələbinizi emal edir" + "value" : "Zrušit" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancellation" + } + } + } + }, + "proCancellationDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.

Because you originally signed up for {app_pro} using your {platform_account}, you'll need to use the same {platform_account} to cancel your plan." + } + } + } + }, + "proCancellationOptions" : { + "extractionState" : "manual", + "localizations" : { "cs" : { "stringUnit" : { "state" : "translated", - "value" : "{platform_account} zpracovává vaši žádost o vrácení peněz" + "value" : "Dva způsoby, jak zrušit váš tarif:" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "{platform_account} is processing your refund request" + "value" : "Two ways to cancel your plan:" + } + } + } + }, + "proCancellationShortDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.

Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires." + } + } + } + }, + "proCancelSorry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We’re sorry to see you cancel {pro}. Here's what you need to know before canceling your {app_pro} plan." + } + } + } + }, + "processingRefundRequest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{platform} is processing your refund request" + } + } + } + }, + "proClearAllDataDevice" : { + "extractionState" : "manual", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opravdu chcete smazat svá data z tohoto zařízení?

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." } }, - "nl" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete your data from this device?

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." + } + } + } + }, + "proClearAllDataNetwork" : { + "extractionState" : "manual", + "localizations" : { + "cs" : { "stringUnit" : { "state" : "translated", - "value" : "{platform_account} verwerkt je restitutieverzoek" + "value" : "Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." } } } @@ -355000,12 +356990,24 @@ "value" : "Your current plan is already discounted by {percent}% of the full {app_pro} price." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre abonnement actuel bénéficie déjà d'une remise de {percent}% sur le prix de {app_pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -355017,6 +357019,18 @@ "proErrorRefreshingStatus" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusunu təzələmə xətası" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chyba obnovování stavu {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -355040,12 +357054,24 @@ "value" : "Platnost vypršela" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgelaufen" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Expired" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiré" + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -355081,12 +357107,24 @@ "value" : "Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -355116,12 +357154,24 @@ "value" : "Expiring Soon" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiration imminente" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verloopt binnenkort" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedługo wygaśnie" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -355136,31 +357186,43 @@ "az" : { "stringUnit" : { "state" : "translated", - "value" : "{pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin." + "value" : "{pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin." } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." + "value" : "Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." + "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." + "value" : "Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}." + "value" : "Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}." } } } @@ -355186,6 +357248,12 @@ "value" : "{pro} expiring in {time}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} expire dans {time}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -355209,18 +357277,36 @@ "value" : "{pro} FAQ" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} FAQ" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "{pro} FAQ" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAQ {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} FAQ" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAQ {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -355232,11 +357318,47 @@ "proFaqDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} TVS-da tez-tez verilən suallara cavab tapın." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Najděte odpovědi na časté dotazy v nápovědě {app_pro}." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Find answers to common questions in the {app_pro} FAQ." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trouvez des réponses aux questions fréquentes dans la FAQ de {app_pro}." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vind antwoorden op veelgestelde vragen in de {app_pro} FAQ." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відповіді на загальні запитання знайдеш у ЧаПи {app_pro}." + } } } }, @@ -359108,6 +361230,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αναβαθμίστηκε η {total} ομάδα" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Αναβαθμίστηκαν {total} ομάδες" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -359136,6 +361286,34 @@ } } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} groupe mis à niveau" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} groupes mis à niveau" + } + } + } + } + } + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -359163,6 +361341,46 @@ } } } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulepszono {total} grupy" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulepszono {total} grup" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulepszono {total} grupę" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulepszono {total} grup" + } + } + } + } + } + } } } }, @@ -359187,12 +361405,24 @@ "value" : "Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l'accès à toutes les fonctionnalités {pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -359472,12 +361702,24 @@ "value" : "Larger Groups" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groupes plus grands" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Grotere groepen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Większe grupy" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -359507,6 +361749,12 @@ "value" : "Groups you are an admin in are automatically upgraded to support 300 members." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les groupes dont vous êtes administrateur sont automatiquement mis à niveau pour prendre en charge 300 membres." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -359524,6 +361772,12 @@ "proLargerGroupsTooltip" : { "extractionState" : "manual", "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Větší skupinové chaty (až pro 300 členů) brzy budou k dispozici pro všechny uživatele Pro Beta!" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -359553,12 +361807,24 @@ "value" : "Longer Messages" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Messages plus longs" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Langere berichten" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dłuższe wiadomości" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -359588,12 +361854,24 @@ "value" : "You can send messages up to 10,000 characters in all conversations." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous pouvez envoyer des messages jusqu'à 10000 caractères dans toutes les conversations." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Możesz wysyłać wiadomości aż do 10 000 znaków we wszystkich konwersacjach." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -359673,6 +361951,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Μεγαλύτερο Μήνυμα εστάλη" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Μεγαλύτερο Μήνυμα εστάλησαν" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -359701,6 +362007,34 @@ } } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Message plus long {total} envoyé" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Messages plus longs envoyés" + } + } + } + } + } + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -359728,6 +362062,46 @@ } } } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} dłuższe wiadomości" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} dłuższych wiadomości" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} dłuższą wiadomość" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłano {total} dłuższych wiadomości" + } + } + } + } + } + } } } }, @@ -360490,6 +362864,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η προώθηση απέτυχε" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Οι προωθήσεις απέτυχαν" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -360602,6 +363004,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edutamine ebaõnnestus" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edutamised ebaõnnestusid" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -361325,6 +363755,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η προώθηση δεν ήταν δυνατό να εφαρμοστεί. Θέλετε να προσπαθήσετε ξανά;" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Οι προωθήσεις δεν ήταν δυνατό να εφαρμοστούν. Θέλετε να προσπαθήσετε ξανά;" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -361437,6 +363895,34 @@ } } }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edutamist ei õnnestunud rakendada. Kas soovite uuesti proovida?" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edutamisi ei õnnestunud rakendada. Kas soovite uuesti proovida?" + } + } + } + } + } + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -362005,6 +364491,45 @@ } } }, + "proNewInstallation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With a new installation" + } + } + } + }, + "proNewInstallationDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings." + } + } + } + }, + "proOptionsRenewalSubtitle" : { + "extractionState" : "manual", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyní jsou k dispozici tři způsoby obnovy:" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, there are three ways to renew:" + } + } + } + }, "proPercentOff" : { "extractionState" : "manual", "localizations" : { @@ -362026,11 +364551,23 @@ "value" : "{percent}% Off" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{percent}% de réduction" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{percent}% korting" } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "{percent}% Знижки" + } } } }, @@ -362105,6 +364642,34 @@ } } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Καρφιτσωμένη Συνομιλία" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Καρφιτσωμένες Συνομιλίες" + } + } + } + } + } + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362133,6 +364698,34 @@ } } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Conversation épinglée" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} Conversations épinglées" + } + } + } + } + } + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -362160,6 +364753,46 @@ } } } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} przypięte konwersacje" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} przypiętych konwersacji" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} przypięta konwersacja" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "{total} przypiętych konwersacji" + } + } + } + } + } + } } } }, @@ -362184,12 +364817,24 @@ "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre formule {app_pro} est active !

Votre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362219,12 +364864,24 @@ "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre forfait {app_pro} est actif

Votre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd met een {current_plan} op {date}." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362239,31 +364896,43 @@ "az" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir.

Eksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin." + "value" : "{app_pro} planınızın müddəti {date} tarixində bitir.

Eksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin." } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}.

Aktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro." + "value" : "Váš tarif {app_pro} vyprší dne {date}.

Aktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." + "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre offre {app_pro} expirera le {date}.

Mettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}.

Werk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies." + "value" : "Je {app_pro} abonnement verloopt op {date}.

Werk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} wygasa {date}.

Zaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Твоя підписка {app_pro} спливе {date}.

Для збереження особливих можливостей подовж свою підписку." + "value" : "Твоя підписка {app_pro} спливе {date}.

Для збереження особливих можливостей подовж свою підписку." } } } @@ -362271,6 +364940,18 @@ "proPlanError" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} planı xətası" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chyba tarifu {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362285,31 +364966,43 @@ "az" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir." + "value" : "{app_pro} planınızın müddəti {date} tarixində bitir." } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}." + "value" : "Váš tarif {app_pro} vyprší dne {date}." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}." + "value" : "Your {app_pro} plan will expire on {date}." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre forfait {app_pro} expirera le {date}." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}." + "value" : "Je {app_pro} abonnement verloopt op {date}." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} wygasa {date}." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Підписка {app_pro} спливе {date}." + "value" : "Підписка {app_pro} спливе {date}." } } } @@ -362317,6 +365010,18 @@ "proPlanLoading" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} planı yüklənir" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Načítání tarifu {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362328,6 +365033,18 @@ "proPlanLoadingDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362339,6 +365056,18 @@ "proPlanLoadingEllipsis" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} planı yüklənir..." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Načítání tarifu {pro}..." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362350,6 +365079,18 @@ "proPlanNetworkLoadError" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.

Zkontrolujte připojení k síti a zkuste to znovu." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -362379,12 +365120,24 @@ "value" : "{pro} Plan Not Found" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forfait {pro} introuvable" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} abonnement niet gevonden" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie znaleziono planu {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362414,39 +365167,33 @@ "value" : "No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp." } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}." + } } } }, "proPlanPlatformRefund" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri ödəmə tələbini göndərmək üçün eyni {platform_account} hesabını istifadə etməlisiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Protože jste se původně zaregistrovali do {app_pro} přes obchod {platform_store}, budete muset pro žádost o vrácení peněz použít stejný účet {platform_account}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, you'll need to use the same {platform_account} to request a refund." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je hetzelfde {platform_account} gebruiken om een terugbetaling aan te vragen." + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to request a refund." } } } @@ -362454,28 +365201,10 @@ "proPlanPlatformRefundLong" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.

Aşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəmə tələbinizi göndərin.

{app_name} Dəstək komandası, geri ödəmə tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Protože jste si původně zakoupili {app_pro} přes obchod {platform_store}, váš požadavek na vrácení peněz bude zpracován podporou {app_name}.

Požádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.

Ačkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, pokud dochází k vysokému počtu žádostí." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, your refund request will be processed by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.

Vraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.

Hoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren" + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." } } } @@ -362501,12 +365230,24 @@ "value" : "Recover {pro} Plan" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Récupérer le forfait {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} abonnement herstellen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odzyskaj plan {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362536,12 +365277,24 @@ "value" : "Renew {pro} Plan" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renouveler l’abonnement {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} abonnement verlengen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odnów plan {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362553,28 +365306,10 @@ "proPlanRenewDesktop" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırda, {pro} planları, yalnızca {platform_store} və {platform_store} Mağazaları vasitəsilə satın alına və yenilənə bilər. {app_name} Masaüstü istifadə etdiyinizə görə planınızı burada yeniləyə bilməzsiniz.

{app_pro} gəlişdiriciləri, istifadəçilərin {pro} planlarını {platform_store} və {platform_store} Mağazalarından kənarda almağına imkan verəcək alternativ ödəniş variantları üzərində ciddi şəkildə çalışırlar. {pro} Yol Xəritəsi" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "V současnosti lze tarify {pro} zakoupit a obnovit pouze prostřednictvím obchodů {platform_store} nebo {platform_store}. Protože používáte {app_name} Desktop, nemůžete zde svůj plán obnovit.

Vývojáři {app_pro} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit tarify {pro} mimo obchody {platform_store} a {platform_store}. Plán vývoje {pro}" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Momenteel kunnen {pro} abonnementen alleen worden gekocht en verlengd via de {platform_store}- of {platform_store} winkels. Omdat je {app_name} Desktop gebruikt, kun je je abonnement hier niet verlengen.

De ontwikkelaars van {app_pro} werken hard aan alternatieve betaalmogelijkheden, zodat gebruikers {pro} abonnementen buiten de {platform_store}- en {platform_store} winkels kunnen aanschaffen. {pro} Routekaart" + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } } } @@ -362582,68 +365317,73 @@ "proPlanRenewDesktopLinked" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_store} və ya {platform_store} Mağazaları vasitəsilə planınızı {app_name} quraşdırılmış və əlaqələndirilmiş cihazda {app_pro} ayarlarında yeniləyin." - } - }, - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Obnovte svůj tarif v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím obchodu {platform_store} nebo {platform_store}." + "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." } - }, + } + } + }, + "proPlanRenewPlatformStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store} Store." + "value" : "Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with." } - }, - "nl" : { + } + } + }, + "proPlanRenewPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Verleng je abonnement in de {app_pro} instellingen op een gekoppeld apparaat met {app_name} geïnstalleerd via de {platform_store} of {platform_store} winkel." + "value" : "Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with." } } } }, - "proPlanRenewDesktopStore" : { + "proPlanRenewStart" : { "extractionState" : "manual", "localizations" : { "az" : { "stringUnit" : { "state" : "translated", - "value" : "{pro} üçün qeydiyyatdan keçdiyiniz {platform_account} hesabınızla {platform_store} veb saytında planınızı yeniləyin." + "value" : "Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin." } }, "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Obnovte svůj tarif na webu {platform_store} pomocí účtu {platform_account}, se kterým jste si pořídili {pro}." + "value" : "Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with." + "value" : "Renew your {app_pro} plan to start using powerful {app_pro} Beta features again." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}." } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Verleng je abonnement op de {platform_store} website met het {platform_account} waarmee je je voor {pro} hebt aangemeld." + "value" : "Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies." } - } - } - }, - "proPlanRenewStart" : { - "extractionState" : "manual", - "localizations" : { - "en" : { + }, + "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Renew your {app_pro} plan to start using powerful {app_pro} Beta features again." + "value" : "Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta." } } } @@ -362669,11 +365409,23 @@ "value" : "Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}." } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}." + } } } }, @@ -362698,12 +365450,24 @@ "value" : "{pro} Plan Restored" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Forfait rétabli" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} abonnement hersteld" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan {pro} został odzyskany" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362733,39 +365497,39 @@ "value" : "A valid plan for {app_pro} was detected and your {pro} status has been restored!" } }, - "nl" : { + "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld!" + "value" : "Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré !" } - } - } - }, - "proPlanSignUp" : { - "extractionState" : "manual", - "localizations" : { - "az" : { + }, + "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz." + "value" : "Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld!" } }, - "cs" : { + "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Protože jste se původně zaregistrovali do {app_pro} přes {platform_store}, je třeba abyste pro aktualizaci vašeho tarifu použili svůj {platform_account}." + "value" : "Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}!" } }, - "en" : { + "uk" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, you'll need to use your {platform_account} to update your plan." + "value" : "Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено!" } - }, - "nl" : { + } + } + }, + "proPlanSignUp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je je {platform_account} gebruiken om je abonnement bij te werken." + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to update your plan." } } } @@ -362791,12 +365555,24 @@ "value" : "1 Month - {monthly_price} / Month" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 mois – {monthly_price} / mois" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "1 maand - {monthly_price} / maand" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 miesiąc - {monthly_price} / miesiąc" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362826,12 +365602,24 @@ "value" : "3 Months - {monthly_price} / Month" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 mois - {monthly_price} / mois" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "3 maanden - {monthly_price} / maand" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 miesiące - {monthly_price} / miesiąc" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362861,12 +365649,24 @@ "value" : "12 Months - {monthly_price} / Month" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "12 mois - {monthly_price} / mois" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "12 maanden - {monthly_price} / maand" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "12 miesięcy - {monthly_price} / miesiąc" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362875,6 +365675,17 @@ } } }, + "proRefundAccountDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings." + } + } + } + }, "proRefundDescription" : { "extractionState" : "manual", "localizations" : { @@ -362896,12 +365707,24 @@ "value" : "We’re sorry to see you go. Here's what you need to know before requesting a refund." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nous sommes désolés de vous voir partir. Voici ce que vous devez savoir avant de demander un remboursement." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Het spijt ons dat je vertrekt. Dit moet je weten voordat je een terugbetaling aanvraagt." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przykro nam, że odchodzisz. Tutaj znajdziesz wszystko, co powinieneś wiedzieć przed złożeniem wniosku o zwrot." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362931,12 +365754,24 @@ "value" : "Refunding {pro}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remboursement de {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Terugbetalen {pro}" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwrot {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -362948,28 +365783,10 @@ "proRefundingDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planları üçün geri ödəmələr yalnız {platform_store} Mağazası vasitəsilə {platform_account} tərəfindən həyata keçirilir.

{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vrácení peněz za tarify {app_pro} je vyřizováno výhradně prostřednictvím {platform_account} v obchodě {platform_store}.

Vzhledem k pravidlům vracení peněz služby {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Refunds for {app_pro} plans are handled exclusively by {platform_account} through the {platform_store} Store.

Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Terugbetalingen voor {app_pro} abonnementen worden uitsluitend afgehandeld door {platform_account} via de {platform_store} Store.

Vanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling." + "value" : "Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." } } } @@ -362977,28 +365794,10 @@ "proRefundNextSteps" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_account} hazırda geri ödəniş tələbinizi emal edir. Bu, adətən 24-48 saat çəkir. Onların qərarından asılı olaraq, {app_name} tətbiqində {pro} statusunuzun dəyişdiyini görə bilərsiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_account} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24–48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "{platform_account} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_account} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}." + "value" : "{platform} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}." } } } @@ -363024,6 +365823,12 @@ "value" : "Your refund request will be handled by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre demande de remboursement sera traitée par le service de {app_name}.

Demandez un remboursement en appuyant sur le bouton ci-dessous et en remplissant le formulaire de demande de remboursement.

Bien que le service de {app_name} fait au mieux afin de traiter les demandes de remboursement dans un délai de 24-72 heures, le traitement peut être plus long en période de forte demande." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -363035,28 +365840,10 @@ "proRefundRequestStorePolicies" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geri ödəniş tələbiniz yalnız {platform_account} veb saytında {platform_account} hesabı üzərindən icra olunacaq.

{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaši žádost o vrácení peněz bude vyřizovat výhradně {platform_account} prostřednictvím webových stránek {platform_account}.

Vzhledem k pravidlům vracení peněz {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your refund request will be handled exclusively by {platform_account} through the {platform_account} website.

Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform_account} via de website van {platform_account}.

Vanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling." + "value" : "Your refund request will be handled exclusively by {platform} through the {platform} website.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." } } } @@ -363064,39 +365851,38 @@ "proRefundSupport" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Geri ödəmə tələbinizlə bağlı daha çox güncəlləmə üçün lütfən {platform_account} ilə əlaqə saxlayın. {platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz.

{platform_store} Geri ödəmə dəstəyi" + "value" : "Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.

{platform} Refund Support" } - }, + } + } + }, + "proRenewBeta" : { + "extractionState" : "manual", + "localizations" : { "cs" : { "stringUnit" : { "state" : "translated", - "value" : "Pro další informace o vaší žádosti o vrácení peněz kontaktujte prosím {platform_account}. Vzhledem k zásadám pro vrácení peněz {platform_account} nemají vývojáři aplikace {app_name} žádnou možnost ovlivnit výsledek žádosti o vrácení.

Podpora vrácení peněz {platform_store}" + "value" : "Obnovit {pro} Beta" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please contact {platform_account} for further updates on your refund request. Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.

{platform_store} Refund Support" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Neem contact op met {platform_account} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform_account} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.

{platform_store} Terugbetalingsondersteuning" + "value" : "Renew {pro} Beta" } } } }, - "proRenewBeta" : { + "proRenewingNoAccessBilling" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renew {pro} Beta" + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } } } @@ -363122,12 +365908,24 @@ "value" : "Refund Requested" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remboursement demandé" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Terugbetaling aangevraagd" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wniosek o zwrot wysłany" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363294,12 +366092,24 @@ "value" : "{pro} Settings" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} instellingen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363323,18 +366133,36 @@ "value" : "Vaše statistiky {pro}" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine {pro} Statistik" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your {pro} Stats" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vos statistiques {pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je {pro} statistieken" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje Statystyki {pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363346,6 +366174,18 @@ "proStatsLoading" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statistikaları yüklənir" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Načítání statistik {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363357,6 +366197,18 @@ "proStatsLoadingDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statistikalarınız yüklənir, lütfən gözləyin." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaše statistiky {pro} se načítají, počkejte prosím." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363386,12 +366238,24 @@ "value" : "{pro} stats reflect usage on this device and may appear differently on linked devices" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les statistiques {pro} reflètent l'utilisation sur cet appareil et peuvent apparaître différemment sur les appareils connectés" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{pro} statistieken weerspiegelen het gebruik op dit apparaat en kunnen anders weergegeven worden op gekoppelde apparaten" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statystyki {pro} pokazują użycie na tym urządzeniu i mogą wyglądać różnie na połączonych urządzeniach" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363403,6 +366267,18 @@ "proStatusError" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} status xətası" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chyba stavu {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363414,6 +366290,18 @@ "proStatusInfoInaccurateNetworkError" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bu səhifədə nümayiş olan məlumatlar, bağlantı bərpa olunana qədər qeyri-dəqiq ola bilər.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Informace zobrazené na této stránce mohou být nepřesné, dokud nebude připojení obnoveno.

Zkontrolujte připojení k síti a zkuste to znovu." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363425,6 +366313,18 @@ "proStatusLoading" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusu yüklənir" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stav načítání {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363436,6 +366336,18 @@ "proStatusLoadingDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} məlumatlarınız yüklənir. Bu səhifədəki bəzi əməliyyatlar yükləmə tamamlanana qədər əlçatan olmaya bilər." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Načítají se vaše informace {pro}. Některé akce na této stránce nemusí být dostupné, dokud nebude načítání dokončeno." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363447,6 +366359,18 @@ "proStatusLoadingSubtitle" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} status yüklənir" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "stav načítání {pro}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363458,6 +366382,18 @@ "proStatusNetworkErrorDescription" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {pro} ya yüksəldə bilməyəcəksiniz.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze provést navýšení na {pro}.

Zkontrolujte připojení k síti a zkuste to znovu." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363469,6 +366405,18 @@ "proStatusRefreshNetworkError" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} statusunuzu təzələmək üçün şəbəkəyə bağlana bilmir. Bu səhifədəki bəzi əməliyyatlar, bağlantı bərpa olunana qədər sıradan çıxarılacaq.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.

Zkontrolujte připojení k síti a zkuste to znovu." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -363477,6 +366425,17 @@ } } }, + "proStatusRenewError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, "proSupportDescription" : { "extractionState" : "manual", "localizations" : { @@ -363498,12 +366457,24 @@ "value" : "Need help with your {pro} plan? Submit a request to the support team." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besoin d'aide avec votre forfait {pro} ? Envoyez une demande à l'équipe d'assistance." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363527,18 +366498,36 @@ "value" : "Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro}" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durch die Aktualisierung stimmst du den Nutzungsbedingungen {icon} und der Datenschutzerklärung {icon} von {app_pro} zu" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En mettant à jour, vous acceptez les Conditions d'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro}" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Door bij te werken ga je akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon}" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dokonując zmian wyrażasz zgodę na Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363568,12 +366557,24 @@ "value" : "Unlimited Pins" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Épingles illimitées" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Onbeperkte Pins" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nielimitowane przypięcia" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363603,12 +366604,24 @@ "value" : "Organize all your chats with unlimited pinned conversations." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organisez toutes vos discussions avec un nombre illimité de conversations épinglées." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Organiseer al je chats met onbeperkt vastgezette gesprekken." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organizuj swoje czaty z nielimitowaną możliwością przypinania konwersacji." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -363638,6 +366651,12 @@ "value" : "You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?

En mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l'accès {pro}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -363667,11 +366686,23 @@ "value" : "Your plan will expire on {date}.

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre forfait expirera le {date}.

En le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je abonnement verloopt op {date}.

Door bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang." } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш план завершиться {date}.

Після оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro." + } } } }, @@ -371930,12 +374961,24 @@ "value" : "Use your recovery password to load your account on new devices.

Your account cannot be recovered without your recovery password. Make sure it's stored somewhere safe and secure — and don't share it with anyone." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisez votre mot de passe de récupération pour charger votre compte sur de nouveaux appareils.

Votre compte ne peut pas être récupéré sans votre mot de passe de récupération. Assurez-vous qu'il soit stocké en lieu sûr et sécurisé ;— et ne le partagez avec personne." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Gebruik uw herstelwachtwoord om uw account op nieuwe apparaten te laden.

Uw account kan niet worden hersteld zonder uw herstelwachtwoord. Zorg ervoor dat het ergens veilig is opgeslagen – en deel het met niemand." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Użyj swojego hasła odzyskiwania, by załadować swoje konto na nowych urządzeniach.

Twoje konto nie może być odzyskane bez tego hasła. Upewnij się, że przechowujesz je w bezpiecznym miejscu – i nie ujawniaj go nikomu." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -372546,6 +375589,12 @@ "value" : "복구 비밀번호를 불러오는 도중 오류가 발생했습니다.

문제를 해결하기 위해 로그를 내보낸 후 {app_name} 고객 지원 센터에 첨부하여 문의 해주세요." } }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En feil oppstod når ditt gjenopprettingspassord forsøkte å laste.

Vennligst eksporter loggene dine, så opplast filen gjennom Hjelpesenteret til {app_name}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -376012,6 +379061,12 @@ "value" : "Weet u zeker dat u uw herstelwachtwoord permanent wilt verbergen op dit apparaat?

Dit kan niet ongedaan gemaakt worden." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz na stałe ukryć swoje hasło odzyskiwania na tym urządzeniu?

Nie można tego cofnąć." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -377502,6 +380557,12 @@ "value" : "View Recovery Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le mot de passe de récupération" + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -377514,6 +380575,12 @@ "value" : "Bekijk Herstelwachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż hasło odzyskiwania" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -377561,6 +380628,12 @@ "value" : "Recovery Password Visibility" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visibilité du mot de passe de récupération" + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -377573,6 +380646,12 @@ "value" : "Zichtbaarheid herstelwachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widoczność hasła odzyskiwania" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -378733,28 +381812,21 @@ "refundPlanNonOriginatorApple" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Başda fərqli {platform_account} vasitəsilə {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Protože jste se původně zaregistrovali do {app_pro} přes jiný {platform_account}, je třeba použít ten {platform_account}, abyste aktualizovali váš tarif." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your plan." + "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your plan." } - }, - "nl" : { + } + } + }, + "refundRequestOptions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via een ander {platform_account}, moet je datzelfde {platform_account} gebruiken om je abonnement bij te werken." + "value" : "Two ways to request a refund:" } } } @@ -378983,6 +382055,24 @@ } } }, + "el" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld χαρακτήρας απομένει" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld χαρακτήρες απομένουν" + } + } + } + } + }, "en" : { "variations" : { "plural" : { @@ -379037,6 +382127,24 @@ } } }, + "et" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld tähemärk alles" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld tähemärki alles" + } + } + } + } + }, "fr" : { "variations" : { "plural" : { @@ -379357,6 +382465,17 @@ } } }, + "remindMeLater" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remind Me Later" + } + } + } + }, "remove" : { "extractionState" : "manual", "localizations" : { @@ -380342,12 +383461,24 @@ "value" : "Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimez votre mot de passe actuel pour {app_name}. Les données stockées localement seront à nouveau chiffrées à l'aide d'une clé générée aléatoirement, stockée sur votre appareil." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verwijder je huidige wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met een willekeurig gegenereerde sleutel, opgeslagen op je apparaat." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń swoje obecne hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane losowo wygenerowanym kluczem, przechowywanym na Twoim urządzeniu." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -380389,6 +383520,12 @@ "value" : "Renew" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renouveler" + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -380403,6 +383540,17 @@ } } }, + "renewingPro" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renewing {pro}" + } + } + } + }, "reply" : { "extractionState" : "manual", "localizations" : { @@ -380897,18 +384045,36 @@ "value" : "Požádat o vrácení platby" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rückerstattung anfordern" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Request Refund" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demander un remboursement" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Terugbetaling aanvragen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zawnioskuj o zwrot" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -380917,6 +384083,17 @@ } } }, + "requestRefundPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request a refund on the {platform} website, using the {platform_account} you signed up for {pro} with." + } + } + } + }, "resend" : { "extractionState" : "manual", "localizations" : { @@ -387397,22 +390574,70 @@ "screenshotProtectionDescriptionDesktop" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu cihazda çəkilən ekran şəkillərində {app_name} pəncərəsini gizlət." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skrývat okno {app_name} na snímcích obrazovky pořízených na tomto zařízení." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Conceal the {app_name} window in screenshots taken on this device." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer la fenêtre de {app_name} dans les captures d’écran prises sur cet appareil." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приховувати вікно {app_name} на знімках екрана, зроблених на цьому пристрої." + } } } }, "screenshotProtectionDesktop" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekran şəkli qoruması" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ochrana proti pořizování snímků obrazovky" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Screenshot Protection" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protection contre les captures d’écran" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Захист від знімків екрана" + } } } }, @@ -401998,18 +405223,36 @@ "value" : "{app_pro} Beta" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Beta" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "{app_pro} Beta" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Bêta" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "{app_pro} Bèta" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beta {app_pro}" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -403631,18 +406874,36 @@ "value" : "Nastavte heslo pro {app_name}. Lokálně uložená data budou šifrována tímto heslem. Při každém spuštění {app_name} budete vyzváni k zadání tohoto hesla." } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setze ein Passwort für {app_name}. Lokal gespeicherte Dateien werden mit diesem Passwort verschlüsselt. Du wirst jedes Mal nach diesem Passwort gefragt, wenn du {app_name} startest." + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Set a password for {app_name}. Locally stored data will be encrypted with this password. You will be asked to enter this password each time {app_name} starts." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Définissez un mot de passe pour {app_name}. Les données stockées localement seront chiffrées avec ce mot de passe. Il vous sera demandé de saisir ce mot de passe chaque fois que {app_name} sera lancé." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stel een wachtwoord in voor {app_name}. Lokaal opgeslagen gegevens worden versleuteld met dit wachtwoord. Je wordt gevraagd dit wachtwoord in te voeren telkens wanneer {app_name} wordt gestart." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustaw hasło dla {app_name}. Dane przechowywane lokalnie będą zaszyfrowane tym hasłem. Będziesz musiał je podać za każdym razem, kiedy uruchamiasz {app_name}." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -403684,6 +406945,12 @@ "value" : "Cannot Update Setting" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de mettre à jour les paramètres" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -404671,6 +407938,12 @@ "value" : "Startup" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrage" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -409003,12 +412276,24 @@ "value" : "Spell Checker" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correcteur d'orthographe" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Spellingcontrole" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdzanie pisowni" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -409535,12 +412820,24 @@ "value" : "Strength" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solidité" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Sterkte" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siła" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -409594,12 +412891,24 @@ "value" : "Having issues? Explore help articles or open a ticket with {app_name} Support." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous avez des problèmes ? Explorez des articles d'aide ou ouvrez un ticket avec le support {app_name}." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Problemen? Bekijk de hulpartikelen of open een ticket bij {app_name} Support." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masz problem? Przejrzyj artykuły pomocy lub utwórz zgłoszenie dla Supportu {app_name}." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -412227,12 +415536,24 @@ "value" : "Theme Preview" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu du thème" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Thema voorbeeld" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podgląd motywu" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -412274,12 +415595,24 @@ "value" : "Return" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retour" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Terug" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powrót" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -412577,12 +415910,24 @@ "value" : "Translate" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Traduction" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Vertalen" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przetłumacz" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -412624,6 +415969,12 @@ "value" : "Tray" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barre de tâches" + } + }, "nl" : { "stringUnit" : { "state" : "translated", @@ -415197,6 +418548,18 @@ "unsupportedCpu" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dəstəklənməyən CPU" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nepodporovaný procesor" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -419274,6 +422637,12 @@ "value" : "Eine neue Version ({version}) von {app_name} ist verfügbar." } }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Μια νέα έκδοση ({version}) της εφαρμογής {app_name} είναι διαθέσιμη." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -419447,12 +422816,24 @@ "value" : "Update Plan" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour le forfait" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Abonnement bijwerken" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaktualizuj plan" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -419482,12 +422863,24 @@ "value" : "Two ways to update your plan:" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deux façons de mettre à jour votre abonnement :" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Twee manieren om je abonnement bij te werken:" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dwa sposoby na aktualizację planu:" + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -419535,12 +422928,24 @@ "value" : "Actualizar información de perfil" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour les informations du profil" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Profielinformatie bijwerken" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaktualizuj informacje w profilu" + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -419594,12 +422999,24 @@ "value" : "Your display name and display picture are visible in all conversations." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre nom d'affichage et votre photo de profil sont visibles dans toutes les conversations." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Je weergavenaam en profielfoto zijn zichtbaar in alle gesprekken." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoja nazwa wyświetlana i obraz profilu są widoczne we wszystkich konwersacjach." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -420132,12 +423549,24 @@ "value" : "Updates" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises à jour" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Updates" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualizacje" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -421149,12 +424578,24 @@ "value" : "Updating..." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour..." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Bijwerken..." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualizowanie..." + } + }, "ro" : { "stringUnit" : { "state" : "translated", @@ -421178,6 +424619,12 @@ "upgradeSession" : { "extractionState" : "manual", "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navýšit {app_name}" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -423739,12 +427186,24 @@ "value" : "Links will open in your browser." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les liens s'ouvriront dans votre navigateur." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Links worden in uw browser geopend." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linki będą otwierane w Twojej przeglądarce." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -424232,31 +427691,24 @@ } } }, - "viaStoreWebsite" : { + "viaPlatformWebsiteDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{platform_store} veb saytı vasitəsilə" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Přes webové stránky {platform_store}" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Via the {platform_store} website" + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform} website ." } - }, - "nl" : { + } + } + }, + "viaStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Via de {platform_store} website" + "value" : "Via the {platform} website" } } } @@ -424264,28 +427716,10 @@ "viaStoreWebsiteDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website." + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website ." } } } @@ -427486,6 +430920,17 @@ } } }, + "warningIosVersionEndingSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support for iOS 15 has ended. Update to iOS 16 or later to continue receiving app updates." + } + } + } + }, "window" : { "extractionState" : "manual", "localizations" : { @@ -428926,10 +432371,22 @@ "yourCpuIsUnsupportedSSE42" : { "extractionState" : "manual", "localizations" : { + "az" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU-nuz Linux x64 əməliyyat sistemlərində {app_name}-un təsvirləri emal etməsi üçün tələb olunan SSE 4.2 təlimatlarını dəstəkləmir. Lütfən uyumlu bir CPU-ya keçin və ya fərqli bir əməliyyat sistemi istifadə edin." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Váš procesor nepodporuje instrukce SSE 4.2, které jsou vyžadovány aplikací {app_name} v operačních systémech Linux x64 pro zpracování obrázků. Proveďte upgrade na kompatibilní procesor nebo použijte jiný operační systém." + } + }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your CPU does not support SSE 4.2 instructions, which are required by Session on Linux x64 operating systems to process images. Please upgrade to a compatible CPU or use a different operating system." + "value" : "Your CPU does not support SSE 4.2 instructions, which are required by {app_name} on Linux x64 operating systems to process images. Please upgrade to a compatible CPU or use a different operating system." } } } @@ -428961,6 +432418,12 @@ "value" : "Your Recovery Password" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre mot de passe de récupération" + } + }, "nb" : { "stringUnit" : { "state" : "translated", @@ -428973,6 +432436,12 @@ "value" : "Je herstelwachtwoord" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoje hasło odzyskiwania" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -429020,12 +432489,24 @@ "value" : "Zoom Factor" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niveau de Zoom" + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Zoomfactor" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Współczynnik powiększenia" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -429073,12 +432554,24 @@ "value" : "Adjust the size of text and visual elements." } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajuster la taille du texte et des éléments visuels." + } + }, "nl" : { "stringUnit" : { "state" : "translated", "value" : "Pas de grootte van tekst en visuele elementen aan." } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostosuj wielkość tekstu i elementów wizualnych." + } + }, "ru" : { "stringUnit" : { "state" : "translated", diff --git a/Session/Meta/WebPImages/AnimatedProfileCTA.webp b/Session/Meta/WebPImages/AnimatedProfileCTA.webp new file mode 100644 index 0000000000..9d2ee88e15 Binary files /dev/null and b/Session/Meta/WebPImages/AnimatedProfileCTA.webp differ diff --git a/Session/Meta/WebPImages/AnimatedProfileCTAAnimationCropped.webp b/Session/Meta/WebPImages/AnimatedProfileCTAAnimationCropped.webp new file mode 100644 index 0000000000..099037c065 Binary files /dev/null and b/Session/Meta/WebPImages/AnimatedProfileCTAAnimationCropped.webp differ diff --git a/Session/Meta/WebPImages/GenericCTAAnimation.webp b/Session/Meta/WebPImages/GenericCTAAnimation.webp deleted file mode 100644 index 67ee0987a3..0000000000 Binary files a/Session/Meta/WebPImages/GenericCTAAnimation.webp and /dev/null differ diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c335c898bd..b60ed79b92 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -230,6 +230,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, threadVariant: threadVariant, identifier: threadId, category: .errorMessage, + groupingIdentifier: .threadId(threadId), body: "messageErrorDelivery".localized(), sound: notificationSettings.sound, userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), @@ -339,12 +340,12 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, switch shouldPresentNotification { case true: - let shouldGroupNotification: Bool = ( + let shouldDelayNotificationForBatching: Bool = ( content.threadVariant == .community && content.identifier == content.threadId ) - - if shouldGroupNotification { + + if shouldDelayNotificationForBatching { /// Only set a trigger for grouped notifications if we don't already have one if trigger == nil { trigger = UNTimeIntervalNotificationTrigger( diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 322bf5b1ee..5dda7496ea 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -409,13 +409,13 @@ extension Onboarding { .upsert(db) try Profile .filter(id: userSessionId.hexString) - .updateAll(db, Profile.Columns.lastNameUpdate.set(to: nil)) + .updateAll(db, Profile.Columns.profileLastUpdated.set(to: nil)) try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(displayName), displayPictureUpdate: .none, - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, using: dependencies ) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 0c5fa3c2cc..22bc293fc7 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -82,10 +82,8 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC // presentation type is `fullScreen` var navBarHeight: CGFloat { switch modalPresentationStyle { - case .fullScreen: - return navigationController?.navigationBar.frame.size.height ?? 0 - default: - return 0 + case .fullScreen: return (navigationController?.navigationBar.frame.size.height ?? 0) + default: return 0 } } @@ -115,7 +113,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC pageVCView.pin(.bottom, to: .bottom, of: view) let statusBarHeight: CGFloat = UIApplication.shared.statusBarFrame.size.height - let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - navBarHeight - TabBar.snHeight - statusBarHeight) + let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - (navigationController?.navigationBar.frame.size.height ?? 0) - TabBar.snHeight - statusBarHeight) let size: CGSize = CGSize(width: UIScreen.main.bounds.width, height: height) enterURLVC.constrainSize(to: size) scanQRCodePlaceholderVC.constrainSize(to: size) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 0eb37764ab..13b596b8ae 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -73,6 +73,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case purchaseProSubscription case manageProSubscriptions case restoreProSubscription + case requestRefund case proStatus case proIncomingMessages @@ -88,6 +89,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" + case .requestRefund: return "requestRefund" case .proStatus: return "proStatus" case .proIncomingMessages: return "proIncomingMessages" @@ -106,6 +108,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough + case .requestRefund: result.append(.requestRefund); fallthrough case .proStatus: result.append(.proStatus); fallthrough case .proIncomingMessages: result.append(.proIncomingMessages) @@ -116,7 +119,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } public enum DeveloperSettingsProEvent: Hashable { - case purchasedProduct([Product], Product?, String?, String?, UInt64?) + case purchasedProduct([Product], Product?, String?, String?, Transaction?) + case refundTransaction(Transaction.RefundRequestStatus) } // MARK: - Content @@ -128,7 +132,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchasedProduct: Product? let purchaseError: String? let purchaseStatus: String? - let purchaseTransactionId: String? + let purchaseTransaction: Transaction? + let refundRequestStatus: Transaction.RefundRequestStatus? let mockCurrentUserSessionPro: Bool let treatAllIncomingMessagesAsProMessages: Bool @@ -156,7 +161,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: nil, purchaseError: nil, purchaseStatus: nil, - purchaseTransactionId: nil, + purchaseTransaction: nil, + refundRequestStatus: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] @@ -176,18 +182,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var purchasedProduct: Product? = previousState.purchasedProduct var purchaseError: String? = previousState.purchaseError var purchaseStatus: String? = previousState.purchaseStatus - var purchaseTransactionId: String? = previousState.purchaseTransactionId + var purchaseTransaction: Transaction? = previousState.purchaseTransaction + var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus events.forEach { event in guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } switch eventValue { - case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let id): + case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let transaction): products = receivedProducts purchasedProduct = purchased purchaseError = error purchaseStatus = status - purchaseTransactionId = id.map { "\($0)" } + purchaseTransaction = transaction + + case .refundTransaction(let status): + refundRequestStatus = status } } @@ -197,7 +207,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: purchasedProduct, purchaseError: purchaseError, purchaseStatus: purchaseStatus, - purchaseTransactionId: purchaseTransactionId, + purchaseTransaction: purchaseTransaction, + refundRequestStatus: refundRequestStatus, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] ) @@ -243,9 +254,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold "N/A" ) let transactionId: String = ( - state.purchaseTransactionId.map { "\($0)" } ?? + state.purchaseTransaction.map { "\($0.id)" } ?? "N/A" ) + let refundStatus: String = { + switch state.refundRequestStatus { + case .success: return "Success (Does not mean approved)" + case .userCancelled: return "User Cancelled" + case .none: return "N/A" + @unknown default: return "N/A" + } + }() let subscriptions: SectionModel = SectionModel( model: .subscriptions, elements: [ @@ -287,6 +306,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold onTap: { [weak viewModel] in Task { await viewModel?.restoreSubscriptions() } } + ), + SessionCell.Info( + id: .requestRefund, + title: "Request Refund", + subtitle: """ + Request a refund for a Session Pro subscription via the App Store. + + Status:\(refundStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Request"), + isEnabled: (state.purchaseTransaction != nil), + onTap: { [weak viewModel] in + Task { await viewModel?.requestRefund() } + } ) ] ) @@ -381,7 +414,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let transaction = try verificationResult.payloadValue dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction.id) + value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction) ) await transaction.finish() @@ -421,7 +454,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { try await AppStore.showManageSubscriptions(in: scene) - print("AS") } catch { Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") @@ -436,4 +468,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") } } + + private func requestRefund() async { + guard let transaction: Transaction = await internalState.purchaseTransaction else { return } + guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return Log.error("[DevSettings] Unable to show manage subscriptions: Unable to get UIWindowScene") + } + + do { + let result = try await transaction.beginRefundRequest(in: scene) + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.refundTransaction(result) + ) + } + catch { + Log.error("[DevSettings] Unable to request refund: \(error)") + } + } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index b32ecb10ad..dd611e73eb 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -74,6 +74,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case proConfig case groupConfig + case shortenFileTTL case animationsEnabled case showStringKeys case truncatePubkeysInLogs @@ -95,7 +96,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case communityPollLimit - case versionBlindedID case scheduleLocalNotification @@ -114,7 +114,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: return "proConfig" case .groupConfig: return "groupConfig" - + + case .shortenFileTTL: return "shortenFileTTL" case .animationsEnabled: return "animationsEnabled" case .showStringKeys: return "showStringKeys" case .truncatePubkeysInLogs: return "truncatePubkeysInLogs" @@ -158,6 +159,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: result.append(.proConfig); fallthrough case .groupConfig: result.append(.groupConfig); fallthrough + case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough case .animationsEnabled: result.append(.animationsEnabled); fallthrough case .showStringKeys: result.append(.showStringKeys); fallthrough case .truncatePubkeysInLogs: result.append(.truncatePubkeysInLogs); fallthrough @@ -198,6 +200,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let developerMode: Bool let versionBlindedID: String? + let shortenFileTTL: Bool let animationsEnabled: Bool let showStringKeys: Bool let truncatePubkeysInLogs: Bool @@ -241,6 +244,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, cache.get(.developerModeEnabled) }, versionBlindedID: versionBlindedID, + shortenFileTTL: dependencies[feature: .shortenFileTTL], animationsEnabled: dependencies[feature: .animationsEnabled], showStringKeys: dependencies[feature: .showStringKeys], truncatePubkeysInLogs: dependencies[feature: .truncatePubkeysInLogs], @@ -334,6 +338,21 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let general: SectionModel = SectionModel( model: .general, elements: [ + SessionCell.Info( + id: .shortenFileTTL, + title: "Shorten File TTL", + subtitle: "Set the TTL for files in the cache to 1 minute", + trailingAccessory: .toggle( + current.shortenFileTTL, + oldValue: previous?.shortenFileTTL + ), + onTap: { [weak self] in + self?.updateFlag( + for: .shortenFileTTL, + to: !current.shortenFileTTL + ) + } + ), SessionCell.Info( id: .animationsEnabled, title: "Animations Enabled", @@ -766,7 +785,15 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, .copyAppGroupPath, .resetSnodeCache, .createMockContacts, .exportDatabase, .importDatabase, .advancedLogging, .resetAppReviewPrompt: break /// These are actions rather than values stored as "features" so no need to do anything - + + case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) + case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) + + case .shortenFileTTL: + guard dependencies.hasSet(feature: .shortenFileTTL) else { return } + + updateFlag(for: .shortenFileTTL, to: nil) + case .animationsEnabled: guard dependencies.hasSet(feature: .animationsEnabled) else { return } @@ -816,9 +843,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, dependencies.set(feature: .communityPollLimit, to: nil) forceRefresh(type: .databaseQuery) - case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) - case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) - case .forceSlowDatabaseQueries: guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { return } diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 4ab69ce0d1..4b3ddc2932 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -224,15 +224,25 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa guard let latestLogFilePath: String = await Log.logFilePath(using: dependencies), - let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController + let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController, + let sanitizedLogFilePath = try? dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening(filePath: latestLogFilePath) // Creates a copy of the log file with whitespaces on the filename removed else { return } let showShareSheet: () -> () = { let shareVC = UIActivityViewController( - activityItems: [ URL(fileURLWithPath: latestLogFilePath) ], + activityItems: [ + URL(fileURLWithPath: sanitizedLogFilePath) + ], applicationActivities: nil ) shareVC.completionWithItemsHandler = { _, success, _, _ in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if sanitizedLogFilePath.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + /// Deletes file copy of the log file + try? dependencies[singleton: .fileManager].removeItem(atPath: sanitizedLogFilePath) + } + UIActivityViewController.notifyIfNeeded(success, using: dependencies) onShareComplete?() } diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 66eaf0e948..0764aa3b77 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -232,9 +232,7 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ), confirmationInfo: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription" - .put(key: "session_foundation", value: Constants.session_foundation) - .localized()), + body: .text("callsVoiceAndVideoModalDescription".localized()), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index f8fa6a6a42..d725ccf4c4 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -269,7 +269,11 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + top: 0, + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( @@ -651,59 +655,136 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture(currentUrl: String?) { let iconName: String = "profile_placeholder" // stringlint:ignore + var hasSetNewProfilePicture: Bool = false + let body: ConfirmationModal.Info.Body = .image( + source: nil, + placeholder: currentUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } + .defaulting(to: Lucide.image(icon: .image, size: 40).map { image in + ImageDataManager.DataSource.image( + iconName, + image + .withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate) + .withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1)) + ) + }), + icon: (currentUrl != nil ? .pencil : .rightPlus), + style: .circular, + description: { + guard dependencies[feature: .sessionProEnabled] else { return nil } + return dependencies[cache: .libSession].isSessionPro ? + "proAnimatedDisplayPictureModalDescription" + .localized() + .addProBadge( + at: .leading, + font: .systemFont(ofSize: Values.smallFontSize), + textColor: .textSecondary, + proBadgeSize: .small + ): + "proAnimatedDisplayPicturesNonProModalDescription" + .localized() + .addProBadge( + at: .trailing, + font: .systemFont(ofSize: Values.smallFontSize), + textColor: .textSecondary, + proBadgeSize: .small + ) + }(), + accessibility: Accessibility( + identifier: "Upload", + label: "Upload" + ), + dataManager: dependencies[singleton: .imageDataManager], + onProBageTapped: { [weak self, dependencies] in + Task { @MainActor in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) + } + }, + onClick: { [weak self] onDisplayPictureSelected in + self?.onDisplayPictureSelected = { valueUpdate in + onDisplayPictureSelected(valueUpdate) + hasSetNewProfilePicture = true + } + self?.showPhotoLibraryForAvatar() + } + ) self.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( title: "profileDisplayPictureSet".localized(), - body: .image( - source: currentUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }, - placeholder: UIImage(named: iconName).map { - ImageDataManager.DataSource.image(iconName, $0) - }, - icon: .rightPlus, - style: .circular, - accessibility: Accessibility( - identifier: "Upload", - label: "Upload" - ), - dataManager: dependencies[singleton: .imageDataManager], - onClick: { [weak self] onDisplayPictureSelected in - self?.onDisplayPictureSelected = onDisplayPictureSelected - self?.showPhotoLibraryForAvatar() - } - ), + body: body, confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _, _, _): return (source?.imageData != nil) default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: .bool(currentUrl != nil), + cancelEnabled: (currentUrl != nil) ? .bool(true) : .afterChange { info in + switch info.body { + case .image(let source, _, _, _, _, _, _, _, _): return (source?.imageData != nil) + default: return false + } + }, hasCloseButton: true, dismissOnConfirm: false, - onConfirm: { [weak self] modal in + onConfirm: { [weak self, dependencies] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, _, _, _, _, _, _): guard let imageData: Data = source.imageData else { return } - + + let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(imageData) + guard ( + !isAnimatedImage || + dependencies[cache: .libSession].isSessionPro || + !dependencies[feature: .sessionProEnabled] + ) else { + Task { @MainActor in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) + } + return + } + self?.updateProfile( - displayPictureUpdate: .currentUserUploadImageData(imageData), + displayPictureUpdate: .currentUserUploadImageData(data: imageData, isReupload: false), onComplete: { [weak modal] in modal?.close() } ) - + default: modal.close() } }, onCancel: { [weak self] modal in - self?.updateProfile( - displayPictureUpdate: .currentUserRemove, - onComplete: { [weak modal] in modal?.close() } - ) + if hasSetNewProfilePicture { + modal.updateContent( + with: modal.info.with( + body: body, + cancelTitle: "remove".localized() + ) + ) + hasSetNewProfilePicture = false + } else { + self?.updateProfile( + displayPictureUpdate: .currentUserRemove, + onComplete: { [weak modal] in modal?.close() } + ) + } } ) ), diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index 0a2c4e9a9e..b2d25211fb 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -127,6 +127,9 @@ public class ScreenLockWindow { } } + /// Checks if app has been unlocked + public func checkIfScreenIsUnlocked() -> Bool { !isScreenLockLocked } + // MARK: - Functions private func determineDesiredUIState() -> ScreenLockViewController.State { diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 087091c28f..3e4502aa2e 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -96,7 +96,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa result.delegate = self result.sectionHeaderTopPadding = 0 result.rowHeight = UITableView.automaticDimension - result.estimatedRowHeight = 56 // Approximate size of an [{Icon} {Text}] SessionCell + result.estimatedRowHeight = UITableView.automaticDimension return result }() diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index 63025785d7..e1e17b647e 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -580,8 +580,6 @@ public extension SessionCell.AccessoryConfig { public let additionalProfile: Profile? public let additionalProfileIcon: ProfilePictureView.ProfileIcon - override public var shouldFitToEdge: Bool { true } - fileprivate init( id: String, size: ProfilePictureView.Size, diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index a3815587ae..6412cd1e42 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -39,6 +39,8 @@ extension SessionCell { minWidthConstraint.isActive = false fixedWidthConstraint.constant = AccessoryView.minWidth fixedWidthConstraint.isActive = false + + invalidateIntrinsicContentSize() } public func update( @@ -688,7 +690,7 @@ extension SessionCell { profilePictureView.pin(.leading, to: .leading, of: self) profilePictureView.pin(.trailing, to: .trailing, of: self) profilePictureView.pin(.bottom, to: .bottom, of: self).setting(priority: .defaultHigh) - fixedWidthConstraint.constant = size.viewSize + fixedWidthConstraint.constant = (size.viewSize) fixedWidthConstraint.isActive = true } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 768773ff44..0e78a0e704 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -217,12 +217,25 @@ public class SessionCell: UITableViewCell { public override func layoutSubviews() { super.layoutSubviews() + if titleLabel.preferredMaxLayoutWidth != titleLabel.bounds.width { + titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width + } + + if subtitleLabel.preferredMaxLayoutWidth != subtitleLabel.bounds.width { + subtitleLabel.preferredMaxLayoutWidth = subtitleLabel.bounds.width + } + + if expandableDescriptionLabel.preferredMaxLayoutWidth != expandableDescriptionLabel.bounds.width { + expandableDescriptionLabel.preferredMaxLayoutWidth = expandableDescriptionLabel.bounds.width + } + // Need to force the contentStackView to layout if needed as it might not have updated it's // sizing yet self.contentStackView.layoutIfNeeded() repositionExtraView(titleExtraView, for: titleLabel) repositionExtraView(subtitleExtraView, for: subtitleLabel) self.titleStackView.layoutIfNeeded() + self.layoutIfNeeded() } private func repositionExtraView(_ targetView: UIView?, for label: UILabel) { @@ -319,6 +332,8 @@ public class SessionCell: UITableViewCell { subtitleLabel.isHidden = true expandableDescriptionLabel.isHidden = true botSeparator.isHidden = true + + invalidateIntrinsicContentSize() } @MainActor public func update( diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 56b9102bba..a5275d714c 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -217,10 +217,21 @@ fileprivate class IP2Country: IP2CountryCacheType { guard nameCache["\(ip)-\(currentLocale)"] == nil else { return } + /// Code block checks if IP passed is unknown, not supported or blocked guard let ipAsInt: Int64 = IPv4.toInt(ip), - let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), - let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }), + let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }) + else { return } + + /// Get local index for the current locale + /// When index is not found it should fallback to english + var validLocaleStartIndex: Int? { + cache.countryLocationsLocaleCode.firstIndex(of: currentLocale) + ?? cache.countryLocationsLocaleCode.firstIndex(of: "en") + } + + guard + let localeStartIndex: Int = validLocaleStartIndex, let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex] }), diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 96fc52c505..7443b67958 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -12,6 +12,7 @@ import Network extension Permissions { @MainActor @discardableResult public static func requestCameraPermissionIfNeeded( presentingViewController: UIViewController? = nil, + useCustomDeniedAlert: Bool = false, using dependencies: Dependencies, onAuthorized: ((Bool) -> Void)? = nil ) -> Bool { @@ -22,8 +23,12 @@ extension Permissions { case .denied, .restricted: guard - let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController) - else { return false } + let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController), + !useCustomDeniedAlert + else { + onAuthorized?(false) + return false + } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -337,5 +342,61 @@ extension Permissions { ) } } + + // MARK: - Custom camera permission request dialog + public static func remindCameraAccessRequirement(using dependencies: Dependencies) { + /* + Only show when the folliwing conditions are true + - Remind me later is tapped when trying to enable camera on calls + - Not in background state + - Camera permission is not yet allowed + */ + guard + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls], + !dependencies[singleton: .appContext].isInBackground, + Permissions.camera == .denied + else { + return + } + + DispatchQueue.main.async { [dependencies] in + guard let controller = dependencies[singleton: .appContext].frontMostViewController else { + return + } + + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = false + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text( + "cameraAccessReminderMessage".localized(), + scrollMode: .never + ), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } + + public static func showEnableCameraAccessInstructions(using dependencies: Dependencies) { + DispatchQueue.main.async { + guard let controller = dependencies[singleton: .appContext].frontMostViewController + else { return } + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text("cameraAccessInstructions" + .localized()), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } } diff --git a/Session/Utilities/QRCode.swift b/Session/Utilities/QRCode.swift deleted file mode 100644 index 4a47dac08c..0000000000 --- a/Session/Utilities/QRCode.swift +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit - -enum QRCode { - /// Generates a QRCode for the give string - /// - /// **Note:** If the `hasBackground` value is true then the QRCode will be black and white and - /// the `withRenderingMode(.alwaysTemplate)` won't work correctly on some iOS versions (eg. iOS 16) - /// - /// stringlint:ignore_contents - static func generate(for string: String, hasBackground: Bool) -> UIImage { - let data = string.data(using: .utf8) - var qrCodeAsCIImage: CIImage - let filter1 = CIFilter(name: "CIQRCodeGenerator")! - filter1.setValue(data, forKey: "inputMessage") - qrCodeAsCIImage = filter1.outputImage! - - guard !hasBackground else { - let filter2 = CIFilter(name: "CIFalseColor")! - filter2.setValue(qrCodeAsCIImage, forKey: "inputImage") - filter2.setValue(CIColor(color: .black), forKey: "inputColor0") - filter2.setValue(CIColor(color: .white), forKey: "inputColor1") - qrCodeAsCIImage = filter2.outputImage! - - let scaledQRCodeAsCIImage = qrCodeAsCIImage.transformed(by: CGAffineTransform(scaleX: 6.4, y: 6.4)) - return UIImage(ciImage: scaledQRCodeAsCIImage) - } - - let filter2 = CIFilter(name: "CIColorInvert")! - filter2.setValue(qrCodeAsCIImage, forKey: "inputImage") - qrCodeAsCIImage = filter2.outputImage! - let filter3 = CIFilter(name: "CIMaskToAlpha")! - filter3.setValue(qrCodeAsCIImage, forKey: "inputImage") - qrCodeAsCIImage = filter3.outputImage! - - let scaledQRCodeAsCIImage = qrCodeAsCIImage.transformed(by: CGAffineTransform(scaleX: 6.4, y: 6.4)) - - // Note: It looks like some internal method was changed in iOS 16.0 where images - // generated from a CIImage don't have the same color information as normal images - // as a result tinting using the `alwaysTemplate` rendering mode won't work - to - // work around this we convert the image to data and then back into an image - let imageData: Data = UIImage(ciImage: scaledQRCodeAsCIImage).pngData()! - return UIImage(data: imageData)! - } -} - -import SwiftUI -import SessionUIKit - -struct QRCodeView: View { - let string: String - let hasBackground: Bool - let logo: String? - let themeStyle: UIUserInterfaceStyle - var backgroundThemeColor: ThemeValue { - switch themeStyle { - case .light: - return .backgroundSecondary - default: - return .textPrimary - } - } - var qrCodeThemeColor: ThemeValue { - switch themeStyle { - case .light: - return .textPrimary - default: - return .backgroundPrimary - } - } - - static private var cornerRadius: CGFloat = 10 - static private var logoSize: CGFloat = 66 - - var body: some View { - ZStack(alignment: .center) { - ZStack(alignment: .center) { - RoundedRectangle(cornerRadius: Self.cornerRadius) - .fill(themeColor: backgroundThemeColor) - - Image(uiImage: QRCode.generate(for: string, hasBackground: hasBackground)) - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: qrCodeThemeColor) - .scaledToFit() - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .padding(.vertical, Values.smallSpacing) - - if let logo = logo { - ZStack(alignment: .center) { - Rectangle() - .fill(themeColor: backgroundThemeColor) - - Image(logo) - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: qrCodeThemeColor) - .scaledToFit() - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .padding(.all, 4) - } - .frame( - width: Self.logoSize, - height: Self.logoSize - ) - } - } - .frame( - maxWidth: 400, - maxHeight: 400 - ) - } - .frame(maxWidth: .infinity) - } -} diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 5260915ddc..f95a65aa42 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -2,7 +2,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public enum SNMessagingKit { // Just to make the external API nice +public enum SNMessagingKit { public static let migrations: [Migration.Type] = [ _001_SUK_InitialSetupMigration.self, _002_SUK_SetupStandardJobs.self, @@ -47,7 +47,8 @@ public enum SNMessagingKit { // Just to make the external API nice _041_RenameTableSettingToKeyValueStore.self, _042_MoveSettingsToLibSession.self, _043_RenameAttachments.self, - _044_AddProMessageFlag.self + _044_AddProMessageFlag.self, + _045_LastProfileUpdateTimestamp.self ] public static func configure(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index a53031dd46..76f27c71dd 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -63,7 +63,8 @@ enum _028_GenerateInitialUserConfigDumps: Migration { try cache.updateProfile( displayName: (userProfile?["name"] ?? ""), displayPictureUrl: userProfile?["profilePictureUrl"], - displayPictureEncryptionKey: userProfile?["profileEncryptionKey"] + displayPictureEncryptionKey: userProfile?["profileEncryptionKey"], + isReuploadProfilePicture: false ) try LibSession.updateNoteToSelf( diff --git a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift new file mode 100644 index 0000000000..89163827fb --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift @@ -0,0 +1,21 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _045_LastProfileUpdateTimestamp: Migration { + static let identifier: String = "LastProfileUpdateTimestamp" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + try db.alter(table: "Profile") { t in + t.drop(column: "lastNameUpdate") + t.drop(column: "lastBlocksCommunityMessageRequests") + t.rename(column: "displayPictureLastUpdated", to: "profileLastUpdated") + } + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 26f06fbb0c..d9dd41f57e 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -21,15 +21,14 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet case id case name - case lastNameUpdate case nickname case displayPictureUrl case displayPictureEncryptionKey - case displayPictureLastUpdated + + case profileLastUpdated case blocksCommunityMessageRequests - case lastBlocksCommunityMessageRequests } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -38,9 +37,6 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). public let name: String - /// The timestamp (in seconds since epoch) that the name was last updated - public let lastNameUpdate: TimeInterval? - /// A custom name for the profile set by the current user public let nickname: String? @@ -52,37 +48,36 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// The key with which the profile is encrypted. public let displayPictureEncryptionKey: Data? - /// The timestamp (in seconds since epoch) that the profile picture was last updated - public let displayPictureLastUpdated: TimeInterval? + /// The timestamp (in seconds since epoch) that the profile was last updated + public let profileLastUpdated: TimeInterval? /// A flag indicating whether this profile has reported that it blocks community message requests public let blocksCommunityMessageRequests: Bool? - /// The timestamp (in seconds since epoch) that the `blocksCommunityMessageRequests` setting was last updated - public let lastBlocksCommunityMessageRequests: TimeInterval? + /// The Pro Proof for when this profile is updated + // TODO: Implement this when the structure of Session Pro Proof is determined + public let sessionProProof: String? // MARK: - Initialization public init( id: String, name: String, - lastNameUpdate: TimeInterval? = nil, nickname: String? = nil, displayPictureUrl: String? = nil, displayPictureEncryptionKey: Data? = nil, - displayPictureLastUpdated: TimeInterval? = nil, + profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - lastBlocksCommunityMessageRequests: TimeInterval? = nil + sessionProProof: String? = nil ) { self.id = id self.name = name - self.lastNameUpdate = lastNameUpdate self.nickname = nickname self.displayPictureUrl = displayPictureUrl self.displayPictureEncryptionKey = displayPictureEncryptionKey - self.displayPictureLastUpdated = displayPictureLastUpdated + self.profileLastUpdated = profileLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests + self.sessionProProof = sessionProProof } } @@ -104,13 +99,11 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { Profile( id: \(id), name: \(name), - lastNameUpdate: \(lastNameUpdate.map { "\($0)" } ?? "null"), nickname: \(nickname.map { "\($0)" } ?? "null"), displayPictureUrl: \(displayPictureUrl.map { "\"\($0)\"" } ?? "null"), displayPictureEncryptionKey: \(displayPictureEncryptionKey?.toHexString() ?? "null"), - displayPictureLastUpdated: \(displayPictureLastUpdated.map { "\($0)" } ?? "null"), - blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), - lastBlocksCommunityMessageRequests: \(lastBlocksCommunityMessageRequests.map { "\($0)" } ?? "null") + profileLastUpdated: \(profileLastUpdated.map { "\($0)" } ?? "null"), + blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null") ) """ } @@ -137,13 +130,11 @@ public extension Profile { self = Profile( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), - lastNameUpdate: try? container.decode(TimeInterval?.self, forKey: .lastNameUpdate), nickname: try? container.decode(String?.self, forKey: .nickname), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: displayPictureKey, - displayPictureLastUpdated: try? container.decode(TimeInterval?.self, forKey: .displayPictureLastUpdated), - blocksCommunityMessageRequests: try? container.decode(Bool?.self, forKey: .blocksCommunityMessageRequests), - lastBlocksCommunityMessageRequests: try? container.decode(TimeInterval?.self, forKey: .lastBlocksCommunityMessageRequests) + profileLastUpdated: try? container.decode(TimeInterval?.self, forKey: .profileLastUpdated), + blocksCommunityMessageRequests: try? container.decode(Bool?.self, forKey: .blocksCommunityMessageRequests) ) } @@ -152,13 +143,11 @@ public extension Profile { try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) - try container.encodeIfPresent(lastNameUpdate, forKey: .lastNameUpdate) try container.encodeIfPresent(nickname, forKey: .nickname) try container.encodeIfPresent(displayPictureUrl, forKey: .displayPictureUrl) try container.encodeIfPresent(displayPictureEncryptionKey, forKey: .displayPictureEncryptionKey) - try container.encodeIfPresent(displayPictureLastUpdated, forKey: .displayPictureLastUpdated) + try container.encodeIfPresent(profileLastUpdated, forKey: .profileLastUpdated) try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) - try container.encodeIfPresent(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } } @@ -172,9 +161,15 @@ public extension Profile { if let displayPictureEncryptionKey: Data = displayPictureEncryptionKey, - let displayPictureUrl: String = displayPictureUrl { + let displayPictureUrl: String = displayPictureUrl + { dataMessageProto.setProfileKey(displayPictureEncryptionKey) profileProto.setProfilePicture(displayPictureUrl) + // TODO: Add ProProof if needed + } + + if let profileLastUpdated: TimeInterval = profileLastUpdated { + profileProto.setLastUpdateSeconds(UInt64(profileLastUpdated)) } do { @@ -218,13 +213,12 @@ public extension Profile { return Profile( id: id, name: "", - lastNameUpdate: nil, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, + profileLastUpdated: nil, blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + sessionProProof: nil ) } @@ -434,13 +428,12 @@ public extension Profile { return Profile( id: id, name: (name ?? self.name), - lastNameUpdate: lastNameUpdate, nickname: (nickname ?? self.nickname), displayPictureUrl: (displayPictureUrl ?? self.displayPictureUrl), displayPictureEncryptionKey: displayPictureEncryptionKey, - displayPictureLastUpdated: displayPictureLastUpdated, + profileLastUpdated: profileLastUpdated, blocksCommunityMessageRequests: blocksCommunityMessageRequests, - lastBlocksCommunityMessageRequests: lastBlocksCommunityMessageRequests + sessionProProof: self.sessionProProof ) } } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index edae3c0108..8268b78dbd 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -63,7 +63,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) } } - .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?)> in + .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?, Date?)> in guard let filePath: String = try? dependencies[singleton: .displayPictureManager].path( for: (preparedDownload.destination.url?.absoluteString) @@ -75,15 +75,15 @@ public enum DisplayPictureDownloadJob: JobExecutor { throw DisplayPictureError.alreadyDownloaded(preparedDownload.destination.url) } - return preparedDownload.map { _, data in - (data, filePath, preparedDownload.destination.url) + return preparedDownload.map { info, data in + (data, filePath, preparedDownload.destination.url, Date.fromHTTPExpiresHeaders(info.headers["Expires"])) } } .flatMap { $0.send(using: dependencies) } .map { _, result in result } .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) - .flatMapStorageReadPublisher(using: dependencies) { (db: ObservingDatabase, result: (Data, String, URL?)) -> (Data, String, URL?) in + .flatMapStorageReadPublisher(using: dependencies) { (db: ObservingDatabase, result: (Data, String, URL?, Date?)) -> (Data, String, URL?, Date?) in /// Check to make sure this download is still a valid update guard details.isValidUpdate(db, using: dependencies) else { throw DisplayPictureError.updateNoLongerValid @@ -91,7 +91,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { return result } - .tryMap { (data: Data, filePath: String, downloadUrl: URL?) -> URL? in + .tryMap { (data: Data, filePath: String, downloadUrl: URL?, expires: Date?) -> (URL?, Date?) in guard let decryptedData: Data = { switch details.target { @@ -119,15 +119,16 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) } - return downloadUrl + return (downloadUrl, expires) } - .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, downloadUrl: URL?) in + .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, result: (downloadUrl: URL?, expires: Date?)) in /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe /// the `downloadUrl` changing) try writeChanges( db, details: details, - downloadUrl: downloadUrl, + downloadUrl: result.downloadUrl, + expires: result.expires, using: dependencies ) } @@ -144,6 +145,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { db, details: details, downloadUrl: downloadUrl, + expires: nil, using: dependencies ) }, @@ -180,6 +182,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { _ db: ObservingDatabase, details: Details, downloadUrl: URL?, + expires: Date?, using dependencies: Dependencies ) throws { switch details.target { @@ -190,11 +193,15 @@ public enum DisplayPictureDownloadJob: JobExecutor { db, Profile.Columns.displayPictureUrl.set(to: url), Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), - Profile.Columns.displayPictureLastUpdated.set(to: details.timestamp), + Profile.Columns.profileLastUpdated.set(to: details.timestamp), using: dependencies ) db.addProfileEvent(id: id, change: .displayPictureUrl(url)) db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) + + if dependencies[cache: .general].sessionId.hexString == id, let expires: Date = expires { + dependencies[defaults: .standard, key: .profilePictureExpiresDate] = expires + } case .group(let id, let url, let encryptionKey): _ = try? ClosedGroup @@ -257,7 +264,7 @@ extension DisplayPictureDownloadJob { public struct Details: Codable, Hashable { public let target: Target - public let timestamp: TimeInterval + public let timestamp: TimeInterval? // MARK: - Hashable @@ -270,7 +277,7 @@ extension DisplayPictureDownloadJob { // MARK: - Initialization - public init?(target: Target, timestamp: TimeInterval) { + public init?(target: Target, timestamp: TimeInterval?) { guard target.isValid else { return nil } self.target = { @@ -297,7 +304,7 @@ extension DisplayPictureDownloadJob { let key: Data = profile.displayPictureEncryptionKey, let details: Details = Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: (profile.displayPictureLastUpdated ?? 0) + timestamp: profile.profileLastUpdated ) else { return nil } @@ -341,11 +348,16 @@ extension DisplayPictureDownloadJob { case .profile(let id, let url, let encryptionKey): guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false } + /// If the data matches what is stored in the database then we should be fine to consider it valid (it may be that + /// we are re-downloading a profile due to some invalid state) + let dataMatches: Bool = ( + encryptionKey == latestProfile.displayPictureEncryptionKey && + url == latestProfile.displayPictureUrl + ) + return ( - timestamp >= (latestProfile.displayPictureLastUpdated ?? 0) || ( - encryptionKey == latestProfile.displayPictureEncryptionKey && - url == latestProfile.displayPictureUrl - ) + Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) || + dataMatches ) case .group(let id, let url,_): diff --git a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift index cf1f402590..6b1051be05 100644 --- a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift @@ -4,6 +4,7 @@ import Foundation import Combine import GRDB import SessionUtilitiesKit +import SessionUIKit // MARK: - Log.Category @@ -17,6 +18,7 @@ public enum UpdateProfilePictureJob: JobExecutor { public static let maxFailureCount: Int = -1 public static let requiresThreadId: Bool = false public static let requiresInteractionId: Bool = false + public static let maxTTL: TimeInterval = (14 * 24 * 60 * 60) public static func run( _ job: Job, @@ -31,11 +33,38 @@ public enum UpdateProfilePictureJob: JobExecutor { return deferred(job) // Don't need to do anything if it's not the main app } - // Only re-upload the profile picture if enough time has passed since the last upload - guard - let lastProfilePictureUpload: Date = dependencies[defaults: .standard, key: .lastProfilePictureUpload], - dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) - else { + let expirationDate: Date? = dependencies[defaults: .standard, key: .profilePictureExpiresDate] + let lastUploadDate: Date? = dependencies[defaults: .standard, key: .lastProfilePictureUpload] + let expired: Bool = (expirationDate.map({ dependencies.dateNow.timeIntervalSince($0) > 0 }) == true) + let exceededMaxTTL: Bool = (lastUploadDate.map({ dependencies.dateNow.timeIntervalSince($0) > Self.maxTTL }) == true) + + if (expired || exceededMaxTTL) { + /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` + let profile = dependencies.mutate(cache: .libSession) { $0.profile } + let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + .map { dependencies[singleton: .fileManager].contents(atPath: $0) } + .map { .currentUserUploadImageData(data: $0, isReupload: true)} + .defaulting(to: .none) + + Profile + .updateLocal( + displayPictureUpdate: displayPictureUpdate, + using: dependencies + ) + .subscribe(on: scheduler, using: dependencies) + .receive(on: scheduler, using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .failure(let error): failure(job, error, false) + case .finished: + Log.info(.cat, "Profile successfully updated") + success(job, false) + } + } + ) + } else { // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck // in a loop endlessly deferring the job if let jobId: Int64 = job.id { @@ -45,35 +74,14 @@ public enum UpdateProfilePictureJob: JobExecutor { .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) } } + + if expirationDate != nil { + Log.info(.cat, "Deferred as current picture hasn't expired") + } else { + Log.info(.cat, "Deferred as not enough time has passed since the last update") + } - Log.info(.cat, "Deferred as not enough time has passed since the last update") return deferred(job) } - - /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` - let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - .map { dependencies[singleton: .fileManager].contents(atPath: $0) } - .map { .currentUserUploadImageData($0) } - .defaulting(to: .none) - - Profile - .updateLocal( - displayPictureUpdate: displayPictureUpdate, - using: dependencies - ) - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .failure(let error): failure(job, error, false) - case .finished: - Log.info(.cat, "Profile successfully updated") - success(job, false) - } - } - ) } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 40a00940db..462ce0a98c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -24,6 +24,7 @@ internal extension LibSession { Profile.Columns.nickname, Profile.Columns.displayPictureUrl, Profile.Columns.displayPictureEncryptionKey, + Profile.Columns.profileLastUpdated, DisappearingMessagesConfiguration.Columns.isEnabled, DisappearingMessagesConfiguration.Columns.type, DisappearingMessagesConfiguration.Columns.durationSeconds @@ -36,8 +37,7 @@ internal extension LibSessionCacheType { func handleContactsUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - oldState: [ObservableKey: Any], - serverTimestampMs: Int64 + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .contacts(let conf) = config else { @@ -48,7 +48,6 @@ internal extension LibSessionCacheType { // actually a bug) let targetContactData: [String: ContactData] = try LibSession.extractContacts( from: conf, - serverTimestampMs: serverTimestampMs, using: dependencies ).filter { $0.key != userSessionId.hexString } @@ -62,24 +61,18 @@ internal extension LibSessionCacheType { // observation system can't differ between update calls which do and don't change anything) let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - let profileNameShouldBeUpdated: Bool = ( - !data.profile.name.isEmpty && - profile.name != data.profile.name && - (profile.lastNameUpdate ?? 0) < (data.profile.lastNameUpdate ?? 0) - ) - let profilePictureShouldBeUpdated: Bool = ( - ( - profile.displayPictureUrl != data.profile.displayPictureUrl || - profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey - ) && - (profile.displayPictureLastUpdated ?? 0) < (data.profile.displayPictureLastUpdated ?? 0) + let profileUpdated: Bool = Profile.shouldUpdateProfile( + data.profile.profileLastUpdated, + profile: profile, + using: dependencies ) - if - profileNameShouldBeUpdated || - profile.nickname != data.profile.nickname || - profilePictureShouldBeUpdated - { + if (profileUpdated || (profile.nickname != data.profile.nickname)) { + let profileNameShouldBeUpdated: Bool = ( + !data.profile.name.isEmpty && + profile.name != data.profile.name + ) + try profile.upsert(db) try Profile .filter(id: sessionId) @@ -89,9 +82,6 @@ internal extension LibSessionCacheType { (!profileNameShouldBeUpdated ? nil : Profile.Columns.name.set(to: data.profile.name) ), - (!profileNameShouldBeUpdated ? nil : - Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate) - ), (profile.nickname == data.profile.nickname ? nil : Profile.Columns.nickname.set(to: data.profile.nickname) ), @@ -101,8 +91,8 @@ internal extension LibSessionCacheType { (profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ? nil : Profile.Columns.displayPictureEncryptionKey.set(to: data.profile.displayPictureEncryptionKey) ), - (!profilePictureShouldBeUpdated ? nil : - Profile.Columns.displayPictureLastUpdated.set(to: data.profile.displayPictureLastUpdated) + (!profileUpdated ? nil : + Profile.Columns.profileLastUpdated.set(to: data.profile.profileLastUpdated) ) ].compactMap { $0 }, using: dependencies @@ -343,6 +333,9 @@ public extension LibSession { contact.set(\.nickname, to: info.nickname) contact.set(\.profile_pic.url, to: info.displayPictureUrl) contact.set(\.profile_pic.key, to: info.displayPictureEncryptionKey) + if let profileLastUpdated = info.profileLastUpdated { + contact.set(\.profile_updated, to: profileLastUpdated) + } // Attempts retrieval of the profile picture (will schedule a download if // needed via a throttled subscription on another thread to prevent blocking) @@ -512,19 +505,6 @@ internal extension LibSession { existingContactIds.contains($0.id) } - // Update the user profile first (if needed) - if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userSessionId.hexString }) { - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in - try cache.updateProfile( - displayName: updatedUserProfile.name, - displayPictureUrl: updatedUserProfile.displayPictureUrl, - displayPictureEncryptionKey: updatedUserProfile.displayPictureEncryptionKey - ) - } - } - } - try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .contacts, sessionId: userSessionId) { config in try LibSession @@ -740,6 +720,7 @@ extension LibSession { let nickname: String? let displayPictureUrl: String? let displayPictureEncryptionKey: Data? + let profileLastUpdated: Int64? let disappearingMessagesInfo: DisappearingMessageInfo? let priority: Int32? @@ -775,6 +756,7 @@ extension LibSession { nickname: profile?.nickname, displayPictureUrl: profile?.displayPictureUrl, displayPictureEncryptionKey: profile?.displayPictureEncryptionKey, + profileLastUpdated: profile?.profileLastUpdated.map({ Int64($0) }), disappearingMessagesInfo: disappearingMessagesConfig.map { DisappearingMessageInfo( isEnabled: $0.isEnabled, @@ -797,6 +779,7 @@ extension LibSession { nickname: String? = nil, displayPictureUrl: String? = nil, displayPictureEncryptionKey: Data? = nil, + profileLastUpdated: Int64? = nil, disappearingMessagesInfo: DisappearingMessageInfo? = nil, priority: Int32? = nil, created: TimeInterval? = nil @@ -810,6 +793,7 @@ extension LibSession { self.nickname = nickname self.displayPictureUrl = displayPictureUrl self.displayPictureEncryptionKey = displayPictureEncryptionKey + self.profileLastUpdated = profileLastUpdated self.disappearingMessagesInfo = disappearingMessagesInfo self.priority = priority self.created = created @@ -851,7 +835,6 @@ internal struct ContactData { internal extension LibSession { static func extractContacts( from conf: UnsafeMutablePointer?, - serverTimestampMs: Int64, using dependencies: Dependencies ) throws -> [String: ContactData] { var infiniteLoopGuard: Int = 0 @@ -875,11 +858,10 @@ internal extension LibSession { let profileResult: Profile = Profile( id: contactId, name: contact.get(\.name), - lastNameUpdate: (TimeInterval(serverTimestampMs) / 1000), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - displayPictureLastUpdated: (TimeInterval(serverTimestampMs) / 1000) + profileLastUpdated: TimeInterval(contact.profile_updated) ) let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration( threadId: contactId, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 8774ca003d..7c36349299 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -134,7 +134,7 @@ internal extension LibSessionCacheType { publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - sentTimestamp: TimeInterval(Double(serverTimestampMs) * 1000), + profileUpdateTimestamp: profile.profileLastUpdated, using: dependencies ) } @@ -522,14 +522,12 @@ internal extension LibSession { Profile( id: member.get(\.session_id), name: member.get(\.name), - lastNameUpdate: TimeInterval(Double(serverTimestampMs) / 1000), nickname: nil, displayPictureUrl: member.get(\.profile_pic.url, nullIfEmpty: true), displayPictureEncryptionKey: (member.get(\.profile_pic.url, nullIfEmpty: true) == nil ? nil : member.get(\.profile_pic.key) ), - displayPictureLastUpdated: TimeInterval(Double(serverTimestampMs) / 1000), - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: TimeInterval(member.profile_updated) ) ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index b67745e7f8..31ba81eb19 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -22,17 +22,18 @@ public extension LibSession { public extension LibSessionCacheType { var isSessionPro: Bool { - if dependencies.hasSet(feature: .mockCurrentUserSessionPro) { - return dependencies[feature: .mockCurrentUserSessionPro] - } - return false + guard dependencies[feature: .sessionProEnabled] else { return false } + return dependencies[feature: .mockCurrentUserSessionPro] } - func validateProProof(_ proProof: String?) -> Bool { - if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] - } - return false + func validateProProof(for message: Message?) -> Bool { + guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } + return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + } + + func validateProProof(for profile: Profile?) -> Bool { + guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } + return dependencies[feature: .treatAllIncomingMessagesAsProMessages] } func getProProof() -> String? { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index ce4d1986dd..2d69301415 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -761,6 +761,7 @@ public extension LibSession.Cache { let displayNameInMessage: String? = (visibleMessage?.sender != contactId ? nil : visibleMessage?.profile?.displayName?.nullIfEmpty ) + let profileLastUpdatedInMessage: TimeInterval? = visibleMessage?.profile?.updateTimestampSeconds let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { @@ -782,11 +783,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: String(cString: profileNamePtr), - lastNameUpdate: nil, nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - displayPictureLastUpdated: nil + profileLastUpdated: profileLastUpdatedInMessage ) } @@ -812,11 +812,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: (displayNameInMessage ?? member.get(\.name)), - lastNameUpdate: nil, nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - displayPictureLastUpdated: nil + profileLastUpdated: TimeInterval(member.get(\.profile_updated)) ) } @@ -838,11 +837,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: (displayNameInMessage ?? contact.get(\.name)), - lastNameUpdate: nil, nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - displayPictureLastUpdated: nil + profileLastUpdated: TimeInterval(contact.get( \.profile_updated)) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index c9724f15b1..ea081d9c1b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -39,8 +39,7 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleUserGroupsUpdate( _ db: ObservingDatabase, - in config: LibSession.Config?, - serverTimestampMs: Int64 + in config: LibSession.Config? ) throws { guard configNeedsDump(config) else { return } guard case .userGroups(let conf) = config else { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 8957a66c65..9d5c58efc2 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -11,7 +11,8 @@ internal extension LibSession { static let columnsRelatedToUserProfile: [Profile.Columns] = [ Profile.Columns.name, Profile.Columns.displayPictureUrl, - Profile.Columns.displayPictureEncryptionKey + Profile.Columns.displayPictureEncryptionKey, + Profile.Columns.profileLastUpdated ] static let syncedSettings: [String] = [ @@ -25,8 +26,7 @@ internal extension LibSessionCacheType { func handleUserProfileUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - oldState: [ObservableKey: Any], - serverTimestampMs: Int64 + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .userProfile(let conf) = config else { @@ -39,10 +39,12 @@ internal extension LibSessionCacheType { let profileName: String = String(cString: profileNamePtr) let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let profileLastUpdateTimestamp: TimeInterval = TimeInterval(user_profile_get_profile_updated(conf)) let updatedProfile: Profile = Profile( id: userSessionId.hexString, name: profileName, - displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl + displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl, + profileLastUpdated: profileLastUpdateTimestamp ) if let profile: Profile = oldState[.profile(userSessionId.hexString)] as? Profile { @@ -70,10 +72,11 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPic.get(\.key), - filePath: filePath + filePath: filePath, + sessionProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented ) }(), - sentTimestamp: TimeInterval(Double(serverTimestampMs) / 1000), + profileUpdateTimestamp: profileLastUpdateTimestamp, using: dependencies ) @@ -208,7 +211,8 @@ public extension LibSession.Cache { func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: nil) @@ -233,7 +237,13 @@ public extension LibSession.Cache { var profilePic: user_profile_pic = user_profile_pic() profilePic.set(\.url, to: displayPictureUrl) profilePic.set(\.key, to: displayPictureEncryptionKey) - user_profile_set_pic(conf, profilePic) + // FIXME: Add this back once `profile_update` is getting set again +// if isReuploadProfilePicture { +// user_profile_set_reupload_pic(conf, profilePic) +// } else { + user_profile_set_pic(conf, profilePic) +// } + try LibSessionError.throwIfNeeded(conf) /// Add a pending observation to notify any observers of the change once it's committed diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 217af7d5ae..93df1e6c7a 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -744,7 +744,7 @@ public extension LibSession { case .contacts(let conf): return try LibSession - .extractContacts(from: conf, serverTimestampMs: -1, using: dependencies) + .extractContacts(from: conf, using: dependencies) .reduce(into: [:]) { result, next in result[.contact(next.key)] = next.value.contact result[.profile(next.key)] = next.value.profile @@ -794,16 +794,14 @@ public extension LibSession { try handleUserProfileUpdate( db, in: config, - oldState: oldState, - serverTimestampMs: latestServerTimestampMs + oldState: oldState ) case .contacts: try handleContactsUpdate( db, in: config, - oldState: oldState, - serverTimestampMs: latestServerTimestampMs + oldState: oldState ) case .convoInfoVolatile: @@ -815,8 +813,7 @@ public extension LibSession { case .userGroups: try handleUserGroupsUpdate( db, - in: config, - serverTimestampMs: latestServerTimestampMs + in: config ) case .groupInfo: @@ -1041,10 +1038,12 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func set(_ key: Setting.EnumKey, _ value: T?) var displayName: String? { get } + func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws func canPerformChange( @@ -1184,7 +1183,12 @@ public extension LibSessionCacheType { } func updateProfile(displayName: String) throws { - try updateProfile(displayName: displayName, displayPictureUrl: nil, displayPictureEncryptionKey: nil) + try updateProfile( + displayName: displayName, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + isReuploadProfilePicture: false + ) } var profile: Profile { @@ -1319,7 +1323,8 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws {} func canPerformChange( diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 2f3867a06d..d3eccefb6f 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -10,7 +10,9 @@ public extension VisibleMessage { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + public let updateTimestampSeconds: TimeInterval? public let blocksCommunityMessageRequests: Bool? + public let sessionProProof: String? // MARK: - Initialization @@ -18,14 +20,18 @@ public extension VisibleMessage { displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil, - blocksCommunityMessageRequests: Bool? = nil + updateTimestampSeconds: TimeInterval? = nil, + blocksCommunityMessageRequests: Bool? = nil, + sessionProProof: String? = nil ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) self.displayName = displayName self.profileKey = (hasUrlAndKey ? profileKey : nil) self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) + self.updateTimestampSeconds = updateTimestampSeconds self.blocksCommunityMessageRequests = blocksCommunityMessageRequests + self.sessionProProof = sessionProProof } // MARK: - Proto Conversion @@ -40,7 +46,9 @@ public extension VisibleMessage { displayName: displayName, profileKey: proto.profileKey, profilePictureUrl: profileProto.profilePicture, - blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil) + updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), + sessionProProof: nil // TODO: Add Session Pro Proof to profile proto ) } @@ -60,6 +68,10 @@ public extension VisibleMessage { profileProto.setProfilePicture(profilePictureUrl) } + if let updateTimestampSeconds: TimeInterval = updateTimestampSeconds { + profileProto.setLastUpdateSeconds(UInt64(updateTimestampSeconds)) + } + dataMessageProto.setProfile(try profileProto.build()) return dataMessageProto } @@ -87,7 +99,9 @@ public extension VisibleMessage { return VMProfile( displayName: displayName, profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture + profilePictureUrl: profileProto.profilePicture, + updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), + sessionProProof: nil // TODO: Add Session Pro Proof to profile proto ) } @@ -106,6 +120,9 @@ public extension VisibleMessage { messageRequestResponseProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) } + if let updateTimestampSeconds: TimeInterval = updateTimestampSeconds { + profileProto.setLastUpdateSeconds(UInt64(updateTimestampSeconds)) + } do { messageRequestResponseProto.setProfile(try profileProto.build()) return try messageRequestResponseProto.build() @@ -122,7 +139,8 @@ public extension VisibleMessage { Profile( displayName: \(displayName ?? "null"), profileKey: \(profileKey?.description ?? "null"), - profilePictureUrl: \(profilePictureUrl ?? "null") + profilePictureUrl: \(profilePictureUrl ?? "null"), + UpdateTimestampSeconds: \(updateTimestampSeconds ?? 0) ) """ } diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 14aaa60e99..633e60c3a7 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -1296,6 +1296,9 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui if let _value = profilePicture { builder.setProfilePicture(_value) } + if hasLastUpdateSeconds { + builder.setLastUpdateSeconds(lastUpdateSeconds) + } return builder } @@ -1313,6 +1316,10 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui proto.profilePicture = valueParam } + @objc public func setLastUpdateSeconds(_ valueParam: UInt64) { + proto.lastUpdateSeconds = valueParam + } + @objc public func build() throws -> SNProtoLokiProfile { return try SNProtoLokiProfile.parseProto(proto) } @@ -1344,6 +1351,13 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui return proto.hasProfilePicture } + @objc public var lastUpdateSeconds: UInt64 { + return proto.lastUpdateSeconds + } + @objc public var hasLastUpdateSeconds: Bool { + return proto.hasLastUpdateSeconds + } + private init(proto: SessionProtos_LokiProfile) { self.proto = proto } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index b6fc8ff3bd..40f49dead2 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -619,12 +619,23 @@ struct SessionProtos_LokiProfile { /// Clears the value of `profilePicture`. Subsequent reads from it will return its default value. mutating func clearProfilePicture() {self._profilePicture = nil} + /// Timestamp of the last profile update + var lastUpdateSeconds: UInt64 { + get {return _lastUpdateSeconds ?? 0} + set {_lastUpdateSeconds = newValue} + } + /// Returns true if `lastUpdateSeconds` has been explicitly set. + var hasLastUpdateSeconds: Bool {return self._lastUpdateSeconds != nil} + /// Clears the value of `lastUpdateSeconds`. Subsequent reads from it will return its default value. + mutating func clearLastUpdateSeconds() {self._lastUpdateSeconds = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} fileprivate var _displayName: String? = nil fileprivate var _profilePicture: String? = nil + fileprivate var _lastUpdateSeconds: UInt64? = nil } struct SessionProtos_DataMessage { @@ -2321,6 +2332,7 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "displayName"), 2: .same(proto: "profilePicture"), + 3: .same(proto: "lastUpdateSeconds"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -2331,6 +2343,7 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self._displayName) }() case 2: try { try decoder.decodeSingularStringField(value: &self._profilePicture) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self._lastUpdateSeconds) }() default: break } } @@ -2347,12 +2360,16 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = self._profilePicture { try visitor.visitSingularStringField(value: v, fieldNumber: 2) } }() + try { if let v = self._lastUpdateSeconds { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: SessionProtos_LokiProfile, rhs: SessionProtos_LokiProfile) -> Bool { if lhs._displayName != rhs._displayName {return false} if lhs._profilePicture != rhs._profilePicture {return false} + if lhs._lastUpdateSeconds != rhs._lastUpdateSeconds {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 13ca774aec..a25e477759 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -115,6 +115,8 @@ message DataExtractionNotification { message LokiProfile { optional string displayName = 1; optional string profilePicture = 2; + + optional uint64 lastUpdateSeconds = 3; // Timestamp of the last profile update } message DataMessage { diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift index c543b0dd4b..8c85b12bd5 100644 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -99,7 +99,7 @@ public final class AttachmentUploader { return ( attachment, try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), + FileUploadResponse(id: fileId, expires: nil), endpoint: endpoint, using: dependencies ), @@ -127,7 +127,7 @@ public final class AttachmentUploader { return ( attachment, try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), + FileUploadResponse(id: fileId, expires: nil), endpoint: endpoint, using: dependencies ), diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index adbc379e5c..a0db18cc7b 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -37,6 +37,15 @@ extension String { } return result } + + // Remove whitesspaces and replace with "_" + public var replacingWhitespacesWithUnderscores: String { + let sanitizedFileNameComponents = components(separatedBy: .whitespaces) + + return sanitizedFileNameComponents + .filter { !$0.isEmpty } // Remove empty strings if multiple spaces were adjacent + .joined(separator: "_") // stringlint:ignore + } } extension SignalAttachmentError: LocalizedError { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 79ca54ca61..4de5e3bcb9 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -146,7 +146,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -250,7 +250,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -611,7 +611,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -628,7 +628,7 @@ extension MessageReceiver { name: $0, displayPictureUrl: profile.profilePictureUrl, displayPictureEncryptionKey: profile.profileKey, - displayPictureLastUpdated: (Double(sentTimestampMs) / 1000) + profileLastUpdated: (Double(sentTimestampMs) / 1000) ) } }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 1ad39c5a05..aa966d9d39 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -26,14 +26,12 @@ extension MessageReceiver { // Update profile if needed (want to do this regardless of whether the message exists or // not to ensure the profile info gets sync between a users devices at every chance) if let profile = message.profile { - let messageSentTimestamp: TimeInterval = TimeInterval(Double(message.sentTimestampMs ?? 0) / 1000) - try Profile.updateIfNeeded( db, publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - sentTimestamp: messageSentTimestamp, + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a5ddfb44bf..430b38e325 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -31,7 +31,8 @@ extension MessageReceiver { // Note: `message.sentTimestamp` is in ms (convert to TimeInterval before converting to // seconds to maintain the accuracy) - let messageSentTimestamp: TimeInterval = TimeInterval(Double(message.sentTimestampMs ?? 0) / 1000) + let messageSentTimestampMs: UInt64 = message.sentTimestampMs ?? 0 + let messageSentTimestamp: TimeInterval = TimeInterval(Double(messageSentTimestampMs) / 1000) let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] // Update profile if needed (want to do this regardless of whether the message exists or @@ -43,7 +44,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: messageSentTimestamp, + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -186,7 +187,7 @@ extension MessageReceiver { using: dependencies ) do { - let isProMessage: Bool = dependencies.mutate(cache: .libSession, { $0.validateProProof(message.proProof) }) + let isProMessage: Bool = dependencies.mutate(cache: .libSession, { $0.validateProProof(for: message) }) let processedMessageBody: String? = Self.truncateMessageTextIfNeeded( message.text, isProMessage: isProMessage, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 8defb0e98d..a0129669ac 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -38,7 +38,7 @@ extension MessageSender { } return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: displayPictureData) + .prepareAndUploadDisplayPicture(imageData: displayPictureData, compression: true) .mapError { error -> Error in error } .map { Optional($0) } .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 78ed522564..8752d80003 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -172,7 +172,8 @@ public final class MessageSender { VisibleMessage.VMProfile( displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated ) } } @@ -271,6 +272,7 @@ public final class MessageSender { displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated, blocksCommunityMessageRequests: !checkForCommunityMessageRequests ) } @@ -336,7 +338,8 @@ public final class MessageSender { VisibleMessage.VMProfile( displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated ) } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index b844d336dc..8e65f4550d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -351,7 +351,7 @@ public extension NotificationsManagerType { threadId: threadId, threadVariant: threadVariant ) - + /// Ensure we should be showing a notification for the thread try ensureWeShouldShowNotification( message: message, @@ -383,6 +383,10 @@ public extension NotificationsManagerType { } }(), category: .incomingMessage, + groupingIdentifier: (isMessageRequest ? + .messageRequest : + .threadId(threadId) + ), title: try notificationTitle( cat: cat, message: message, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift index 2557384fb4..33e11a2699 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift @@ -1,13 +1,32 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import UserNotifications +// Add more later if any push notification needs to be customly grouped +// Currently default grouping is via `threadId` +public enum NotificationGroupingType: Equatable { + case messageRequest + case threadId(String) + case none + + var key: String? { + switch self { + case .messageRequest: "message-request-grouping-identifier" + case .threadId(let indentifier): indentifier + case .none: nil + } + } +} + public struct NotificationContent { public let threadId: String? public let threadVariant: SessionThread.Variant? public let identifier: String public let category: NotificationCategory + public let groupingIdentifier: NotificationGroupingType public let title: String? public let body: String? public let delay: TimeInterval? @@ -22,6 +41,7 @@ public struct NotificationContent { threadVariant: SessionThread.Variant?, identifier: String, category: NotificationCategory, + groupingIdentifier: NotificationGroupingType = .none, title: String? = nil, body: String? = nil, delay: TimeInterval? = nil, @@ -33,6 +53,7 @@ public struct NotificationContent { self.threadVariant = threadVariant self.identifier = identifier self.category = category + self.groupingIdentifier = groupingIdentifier self.title = title self.body = body self.delay = delay @@ -53,6 +74,7 @@ public struct NotificationContent { threadVariant: threadVariant, identifier: identifier, category: category, + groupingIdentifier: groupingIdentifier, title: (title ?? self.title), body: (body ?? self.body), delay: self.delay, @@ -67,7 +89,10 @@ public struct NotificationContent { content.categoryIdentifier = category.identifier content.userInfo = userInfo - if let threadId: String = threadId { content.threadIdentifier = threadId } + if let groupIdentifier = groupingIdentifier.key { + content.threadIdentifier = groupIdentifier + } + if let title: String = title { content.title = title } if let body: String = body { content.body = body } diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index b6d6acbb89..9c01a8c2d9 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -62,6 +62,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case reactionInfo case cellType case authorName + case authorNameSuppressedId case senderName case canHaveProfile case shouldShowProfile @@ -153,6 +154,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value includes the author name information public let authorName: String + + /// This value includes the author name information with the `id` suppressed (if it was present) + public let authorNameSuppressedId: String /// This value will be used to populate the author label, if it's null then the label will be hidden /// @@ -250,6 +254,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: (reactionInfo ?? self.reactionInfo), cellType: self.cellType, authorName: self.authorName, + authorNameSuppressedId: self.authorNameSuppressedId, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, @@ -311,6 +316,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: self.reactionInfo, cellType: self.cellType, authorName: self.authorName, + authorNameSuppressedId: self.authorNameSuppressedId, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, @@ -371,6 +377,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nickname: nil, // Folded into 'authorName' within the Query suppressId: false // Show the id next to the author name if desired ) + let authorDisplayNameSuppressedId: String = Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: true // Exclude the id next to the author name + ) let shouldShowDateBeforeThisModel: Bool = { guard self.isTypingIndicator != true else { return false } guard self.variant != .infoCall else { return true } // Always show on calls @@ -495,6 +508,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: self.reactionInfo, cellType: cellType, authorName: authorDisplayName, + authorNameSuppressedId: authorDisplayNameSuppressedId, senderName: { // Only show for group threads guard isGroupThread else { return nil } @@ -750,6 +764,7 @@ public extension MessageViewModel { self.cellType = cellType self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -834,6 +849,7 @@ public extension MessageViewModel { self.cellType = .textOnlyMessage self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -992,6 +1008,7 @@ public extension MessageViewModel { -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), '' AS \(ViewModel.Columns.authorName), + '' AS \(ViewModel.Columns.authorNameSuppressedId), false AS \(ViewModel.Columns.canHaveProfile), false AS \(ViewModel.Columns.shouldShowProfile), false AS \(ViewModel.Columns.shouldShowDateHeader), diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a85fc4b1d2..562a846611 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -93,6 +93,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D case recentReactionEmoji case wasKickedFromGroup case groupIsDestroyed + case isContactApproved } public struct MessageInputState: Equatable { @@ -198,6 +199,9 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let wasKickedFromGroup: Bool? public let groupIsDestroyed: Bool? + /// Flag indicates that the contact's message request has been approved + public let isContactApproved: Bool? + // UI specific logic public var displayName: String { @@ -275,6 +279,13 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + if threadVariant == .community && threadCanWrite == false { + return MessageInputState( + allowedInputTypes: .none, + message: "permissionsWriteCommunity".localized() + ) + } + return MessageInputState( allowedInputTypes: (threadRequiresApproval == false && threadIsMessageRequest == false ? .all : @@ -595,6 +606,7 @@ public extension SessionThreadViewModel { self.recentReactionEmoji = nil self.wasKickedFromGroup = false self.groupIsDestroyed = false + self.isContactApproved = false } } @@ -672,7 +684,8 @@ public extension SessionThreadViewModel { currentUserSessionIds: currentUserSessionIds, recentReactionEmoji: recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed + groupIsDestroyed: groupIsDestroyed, + isContactApproved: isContactApproved ) } } @@ -2060,13 +2073,14 @@ public extension SessionThreadViewModel { /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 8 + let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), \(contact[.rowId]) AS \(ViewModel.Columns.rowId), \(contact[.id]) AS \(ViewModel.Columns.threadId), + \(contact[.isApproved]) AS \(ViewModel.Columns.isContactApproved), \(SessionThread.Variant.contact) AS \(ViewModel.Columns.threadVariant), IFNULL(\(thread[.creationDateTimestamp]), \(currentTimestamp)) AS \(ViewModel.Columns.threadCreationDateTimestamp), '' AS \(ViewModel.Columns.threadMemberNames), diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 394cb1fef0..fe72bb99f4 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -122,9 +122,11 @@ public final class AttachmentManager: Sendable, ThumbnailManager { ) }() } + + let sanitizedFileName = targetFilenameNoExtension.replacingWhitespacesWithUnderscores return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent(targetFilenameNoExtension) + .appendingPathComponent(sanitizedFileName) .appendingPathExtension(finalExtension) .path } @@ -151,6 +153,35 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return tmpPath } + public func createTemporaryFileForOpening(filePath: String) throws -> String { + /// Ensure the original file exists before generating a path for opening or trying to copy it + guard dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { + throw AttachmentError.invalidData + } + + let originalUrl: URL = URL(fileURLWithPath: filePath) + let fileName: String = originalUrl.deletingPathExtension().lastPathComponent + let fileExtension: String = originalUrl.pathExtension + + /// Removes white spaces on the filename and replaces it with _ + let filenameNoExtension = fileName + .replacingWhitespacesWithUnderscores + + let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(filenameNoExtension) + .appendingPathExtension(fileExtension) + .path + + /// If the file already exists then we should remove it as it may not be the same file + if dependencies[singleton: .fileManager].fileExists(atPath: tmpPath) { + try dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) + } + + try dependencies[singleton: .fileManager].copyItem(atPath: filePath, toPath: tmpPath) + + return tmpPath + } + public func resetStorage() { try? dependencies[singleton: .fileManager].removeItem( atPath: sharedDataAttachmentsDirPath() diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 6663fcf0f8..31f849baaa 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -25,38 +25,38 @@ public extension Log.Category { // MARK: - DisplayPictureManager public class DisplayPictureManager { - public typealias UploadResult = (downloadUrl: String, filePath: String, encryptionKey: Data) + public typealias UploadResult = (downloadUrl: String, filePath: String, encryptionKey: Data, expries: Date?) public enum Update { case none case contactRemove - case contactUpdateTo(url: String, key: Data, filePath: String) + case contactUpdateTo(url: String, key: Data, filePath: String, contactProProof: String?) case currentUserRemove - case currentUserUploadImageData(Data) - case currentUserUpdateTo(url: String, key: Data, filePath: String) + case currentUserUploadImageData(data: Data, isReupload: Bool) + case currentUserUpdateTo(url: String, key: Data, filePath: String, sessionProProof: String?) case groupRemove case groupUploadImageData(Data) case groupUpdateTo(url: String, key: Data, filePath: String) static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback, using: dependencies) + return from(profile.profilePictureUrl, key: profile.profileKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) } public static func from(_ profile: Profile, fallback: Update, using dependencies: Dependencies) -> Update { - return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, fallback: fallback, using: dependencies) + return from(profile.displayPictureUrl, key: profile.displayPictureEncryptionKey, contactProProof: profile.sessionProProof, fallback: fallback, using: dependencies) } - static func from(_ url: String?, key: Data?, fallback: Update, using dependencies: Dependencies) -> Update { + static func from(_ url: String?, key: Data?, contactProProof: String?, fallback: Update, using dependencies: Dependencies) -> Update { guard let url: String = url, let key: Data = key, let filePath: String = try? dependencies[singleton: .displayPictureManager].path(for: url) else { return fallback } - return .contactUpdateTo(url: url, key: key, filePath: filePath) + return .contactUpdateTo(url: url, key: key, filePath: filePath, contactProProof: contactProProof) } } @@ -182,7 +182,7 @@ public class DisplayPictureManager { // MARK: - Uploading - public func prepareAndUploadDisplayPicture(imageData: Data) -> AnyPublisher { + public func prepareAndUploadDisplayPicture(imageData: Data, compression: Bool) -> AnyPublisher { return Just(()) .setFailureType(to: DisplayPictureError.self) .tryMap { [dependencies] _ -> (Network.PreparedRequest, String, Data) in @@ -223,7 +223,7 @@ public class DisplayPictureManager { image = image.resized(toFillPixelSize: CGSize(width: DisplayPictureManager.maxDiameter, height: DisplayPictureManager.maxDiameter)) } - guard let data: Data = image.jpegData(compressionQuality: 0.95) else { + guard let data: Data = image.jpegData(compressionQuality: (compression ? 0.95 : 1.0)) else { Log.error(.displayPictureManager, "Updating service with profile failed.") throw DisplayPictureError.writeFailed } @@ -298,12 +298,13 @@ public class DisplayPictureManager { } .eraseToAnyPublisher() } - .tryMap { [dependencies] fileUploadResponse, temporaryFilePath, newEncryptionKey -> (String, String, Data) in + .tryMap { [dependencies] fileUploadResponse, temporaryFilePath, newEncryptionKey -> (String, Date?, String, Data) in let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id) + let expries: Date? = fileUploadResponse.expires.map { Date(timeIntervalSince1970: $0)} let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) try dependencies[singleton: .fileManager].moveItem(atPath: temporaryFilePath, toPath: finalFilePath) - return (downloadUrl, finalFilePath, newEncryptionKey) + return (downloadUrl, expries, finalFilePath, newEncryptionKey) } .mapError { error in Log.error(.displayPictureManager, "Updating service with profile failed with error: \(error).") @@ -314,7 +315,7 @@ public class DisplayPictureManager { default: return DisplayPictureError.uploadFailed } } - .map { [dependencies] downloadUrl, finalFilePath, newEncryptionKey -> UploadResult in + .map { [dependencies] downloadUrl, expires, finalFilePath, newEncryptionKey -> UploadResult in /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) Task(priority: .userInitiated) { await dependencies[singleton: .imageDataManager].load( @@ -323,7 +324,7 @@ public class DisplayPictureManager { } Log.verbose(.displayPictureManager, "Successfully uploaded avatar image.") - return (downloadUrl, finalFilePath, newEncryptionKey) + return (downloadUrl, finalFilePath, newEncryptionKey, expires) } .eraseToAnyPublisher() } diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 0075b91d78..4693cffae8 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -105,26 +105,30 @@ public class ExtensionHelper: ExtensionHelperType { private func read(from path: String) throws -> Data { /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends - guard - var encKey: [UInt8] = (try? dependencies[singleton: .keychain] - .getOrGenerateEncryptionKey( - forKey: .extensionEncryptionKey, - length: encryptionKeyLength, - cat: .cat - )).map({ Array($0) }) - else { throw ExtensionHelperError.noEncryptionKey } + guard var encKey: [UInt8] = (try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) else { + Log.error(.cat, "Failed to retrieve encryption key") + throw ExtensionHelperError.noEncryptionKey + } defer { encKey.resetBytes(in: 0.. UserMetadata? { guard let plaintext: Data = try? read(from: metadataPath) else { return nil } - return try? JSONDecoder(using: dependencies) - .decode(UserMetadata.self, from: plaintext) + do { + return try JSONDecoder(using: dependencies) + .decode(UserMetadata.self, from: plaintext) + } + catch { + Log.error(.cat, "Failed to parse UserMetadata") + return nil + } } // MARK: - Deduping diff --git a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift similarity index 70% rename from SessionMessagingKit/Utilities/Profile+CurrentUser.swift rename to SessionMessagingKit/Utilities/Profile+Updating.swift index 0796e4f761..8baf719cdd 100644 --- a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -78,12 +78,13 @@ public extension Profile { } } + let profileUpdateTimestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: TimeInterval(profileUpdateTimestampMs / 1000), using: dependencies ) Log.info(.profile, "Successfully updated user profile.") @@ -91,11 +92,12 @@ public extension Profile { .mapError { _ in DisplayPictureError.databaseChangesFailed } .eraseToAnyPublisher() - case .currentUserUploadImageData(let data): + case .currentUserUploadImageData(let data, let isReupload): return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data) + .prepareAndUploadDisplayPicture(imageData: data, compression: !isReupload) .mapError { $0 as Error } .flatMapStorageWritePublisher(using: dependencies, updates: { db, result in + let profileUpdateTimestamp: TimeInterval = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, @@ -103,12 +105,15 @@ public extension Profile { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - filePath: result.filePath + filePath: result.filePath, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() } ), - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: profileUpdateTimestamp, + isReuploadCurrentUserProfilePicture: isReupload, using: dependencies ) + dependencies[defaults: .standard, key: .profilePictureExpiresDate] = result.expries dependencies[defaults: .standard, key: .lastProfilePictureUpload] = dependencies.dateNow Log.info(.profile, "Successfully updated user profile.") }) @@ -120,7 +125,36 @@ public extension Profile { } .eraseToAnyPublisher() } - } + } + + /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if + /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if + /// they are newer that our cached version of the profile data + static func shouldUpdateProfile( + _ profileUpdateTimestamp: TimeInterval?, + profile: Profile, + using dependencies: Dependencies + ) -> Bool { + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + let targetProfile: Profile = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: profile.id) } ?? + profile + ) + let finalProfileUpdateTimestamp: TimeInterval = (profileUpdateTimestamp ?? 0) + let finalCachedProfileUpdateTimestamp: TimeInterval = (targetProfile.profileLastUpdated ?? 0) + + /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update + /// + /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` + /// rather than `null` + guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { + return true + } + + /// Otherwise we should only accept the update if it's newer than our cached value + return (finalProfileUpdateTimestamp > finalCachedProfileUpdateTimestamp) + } static func updateIfNeeded( _ db: ObservingDatabase, @@ -128,36 +162,24 @@ public extension Profile { displayNameUpdate: DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update, blocksCommunityMessageRequests: Bool? = nil, - sentTimestamp: TimeInterval, + profileUpdateTimestamp: TimeInterval?, + isReuploadCurrentUserProfilePicture: Bool = false, using dependencies: Dependencies ) throws { let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString) let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) var profileChanges: [ConfigColumnAssignment] = [] - /// There were some bugs (somewhere) where some of these timestamps valid could be in seconds or milliseconds so we need to try to - /// detect this and convert it to proper seconds (if we don't then we will never update the profile) - func convertToSections(_ maybeValue: Double?) -> TimeInterval { - guard let value: Double = maybeValue else { return 0 } - - if value > 9_000_000_000_000 { // Microseconds - return (value / 1_000_000) - } else if value > 9_000_000_000 { // Milliseconds - return (value / 1000) - } - - return TimeInterval(value) // Seconds + guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { + return } // Name - // FIXME: This 'lastNameUpdate' approach is buggy - we should have a timestamp on the ConvoInfoVolatile - switch (displayNameUpdate, isCurrentUser, (sentTimestamp > convertToSections(profile.lastNameUpdate))) { - case (.none, _, _): break - case (.currentUserUpdate(let name), true, _), (.contactUpdate(let name), false, true): + switch (displayNameUpdate, isCurrentUser) { + case (.none, _): break + case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): guard let name: String = name, !name.isEmpty, name != profile.name else { break } - profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) - if profile.name != name { profileChanges.append(Profile.Columns.name.set(to: name)) db.addProfileEvent(id: publicKey, change: .name(name)) @@ -168,9 +190,8 @@ public extension Profile { } // Blocks community message requests flag - if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > convertToSections(profile.lastBlocksCommunityMessageRequests) { + if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests { profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) - profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp)) } // Profile picture & profile key @@ -180,8 +201,6 @@ public extension Profile { preconditionFailure("Invalid options for this function") case (.contactRemove, false), (.currentUserRemove, true): - profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) - if profile.displayPictureEncryptionKey != nil { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) } @@ -191,8 +210,8 @@ public extension Profile { db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) } - case (.contactUpdateTo(let url, let key, let filePath), false), - (.currentUserUpdateTo(let url, let key, let filePath), true): + case (.contactUpdateTo(let url, let key, let filePath, let proProof), false), + (.currentUserUpdateTo(let url, let key, let filePath, let proProof), true): /// If we have already downloaded the image then no need to download it again (the database records will be updated /// once the download completes) if !dependencies[singleton: .fileManager].fileExists(atPath: filePath) { @@ -203,7 +222,7 @@ public extension Profile { shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: sentTimestamp + timestamp: profileUpdateTimestamp ) ), canStartJob: dependencies[singleton: .appContext].isMainApp @@ -218,16 +237,18 @@ public extension Profile { if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) } - - profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) } + // TODO: Handle Pro Proof update + /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } - // Persist any changes + /// Persist any changes if !profileChanges.isEmpty { + profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) + try profile.upsert(db) try Profile @@ -237,6 +258,21 @@ public extension Profile { profileChanges, using: dependencies ) + + /// We don't automatically update the current users profile data when changed in the database so need to manually + /// trigger the update + if isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile, sessionId: dependencies[cache: .general].sessionId) { _ in + try cache.updateProfile( + displayName: updatedProfile.name, + displayPictureUrl: updatedProfile.displayPictureUrl, + displayPictureEncryptionKey: updatedProfile.displayPictureEncryptionKey, + isReuploadProfilePicture: isReuploadCurrentUserProfilePicture + ) + } + } + } } } } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index fff1736d22..6a0ddff983 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -48,13 +48,27 @@ public extension ProfilePictureView { ) switch (explicitPath, publicKey.isEmpty, threadVariant) { - case (.some(let path), _, _): + // TODO: Deal with this case later when implement group related Pro features + case (.some(let path), _, .legacyGroup), (.some(let path), _, .group): fallthrough + case (.some(let path), _, .community): /// If we are given an explicit `displayPictureUrl` then only use that return (Info( source: .url(URL(fileURLWithPath: path)), + animationBehaviour: .generic(true), icon: profileIcon ), nil) + case (.some(let path), _, _): + /// If we are given an explicit `displayPictureUrl` then only use that + return ( + Info( + source: .url(URL(fileURLWithPath: path)), + animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), + icon: profileIcon + ), + nil + ) + case (_, _, .community): return ( Info( @@ -62,9 +76,10 @@ public extension ProfilePictureView { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) case .list: return .image("SessionWhite24", #imageLiteral(resourceName: "SessionWhite24")) - case .hero: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) + case .hero, .modal: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) } }(), + animationBehaviour: .generic(true), inset: UIEdgeInsets( top: 12, left: 12, @@ -101,7 +116,11 @@ public extension ProfilePictureView { }() return ( - Info(source: source, icon: profileIcon), + Info( + source: source, + animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), + icon: profileIcon + ), additionalProfile .map { other in let source: ImageDataManager.DataSource = { @@ -120,11 +139,16 @@ public extension ProfilePictureView { return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) }() - return Info(source: source, icon: additionalProfileIcon) + return Info( + source: source, + animationBehaviour: ProfilePictureView.animationBehaviour(from: other, using: dependencies), + icon: additionalProfileIcon + ) } .defaulting( to: Info( source: .image("ic_user_round_fill", UIImage(named: "ic_user_round_fill")), + animationBehaviour: .generic(false), renderingMode: .alwaysTemplate, themeTintColor: .white, inset: UIEdgeInsets( @@ -156,7 +180,29 @@ public extension ProfilePictureView { return ImageDataManager.DataSource.url(URL(fileURLWithPath: path)) }() - return (Info(source: source, icon: profileIcon), nil) + return ( + Info( + source: source, + animationBehaviour: ProfilePictureView.animationBehaviour(from: profile, using: dependencies), + icon: profileIcon), + nil + ) + } + } +} + +public extension ProfilePictureView { + static func animationBehaviour(from profile: Profile?, using dependencies: Dependencies) -> Info.AnimationBehaviour { + guard dependencies[feature: .sessionProEnabled] else { return .generic(true) } + + switch profile { + case .none: return .generic(false) + + case .some(let profile) where profile.id == dependencies[cache: .general].sessionId.hexString: + return .currentUser(dependencies[singleton: .sessionProState]) + + case .some(let profile): + return .contact(dependencies.mutate(cache: .libSession, { $0.validateProProof(for: profile) })) } } } diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index feb5e96ffe..17e0ea1b7e 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -34,4 +34,29 @@ public class SessionProState: SessionProManagerType { self.isSessionProSubject.send(true) completion?(true) } + + @discardableResult @MainActor public func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { + return false + } + beforePresented?() + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: variant, + dataManager: dependencies[singleton: .imageDataManager], + dismissType: dismissType, + afterClosed: afterClosed + ) + ) + presenting?(sessionProModal) + + return true + } } diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 8cdfca3459..1389d05588 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -23,7 +23,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } + initialSetup: { + $0.defaultInitialSetup() + $0.when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) } + .thenReturn(nil) + } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), @@ -587,7 +591,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil + profileLastUpdated: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -705,7 +709,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567890 + profileLastUpdated: 1234567890 ) mockStorage.write { db in _ = try Profile.deleteAll(db) @@ -754,7 +758,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { .updateAll( db, Profile.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -777,7 +781,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -791,7 +795,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { .updateAll( db, Profile.Columns.displayPictureUrl.set(to: "testUrl"), - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -814,7 +818,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -827,7 +831,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Profile .updateAll( db, - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -859,7 +863,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -874,7 +878,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 56ab349c08..84c3252952 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -882,12 +882,20 @@ fileprivate extension LibSessionUtilSpec { expect(pushData5.pointee.seqno).to(equal(3)) expect(pushData6.pointee.seqno).to(equal(3)) - // They should have resolved the conflict to the same thing: - expect(String(cString: user_profile_get_name(conf)!)).to(equal("Nibbler")) - expect(String(cString: user_profile_get_name(conf2)!)).to(equal("Nibbler")) - // (Note that they could have also both resolved to "Raz" here, but the hash of the serialized - // message just happens to have a higher hash -- and thus gets priority -- for this particular - // test). + // They should have resolved the conflict to the same thing - since the configs set + // a timestamp to the current time when modifying the profile (and we don't have a + // mechanism via the C API to set it directly, we can do this directly in the C++ but + // not here) we don't actually know whether "Nibbler" or "Raz" will win here so instead + // the best we can do is insure they match each other, and that they match one of the options + let confNamePtr: UnsafePointer? = user_profile_get_name(conf) + let conf2NamePtr: UnsafePointer? = user_profile_get_name(conf2) + try require(confNamePtr).toNot(beNil()) + try require(conf2NamePtr).toNot(beNil()) + let confName: String = String(cString: confNamePtr!) + let conf2Name: String = String(cString: conf2NamePtr!) + expect(Set(["Nibbler", "Raz"])).to(contain(confName)) + expect(Set(["Nibbler", "Raz"])).to(contain(conf2Name)) + expect(confName).to(equal(conf2Name)) // Since only one of them set a profile pic there should be no conflict there: let pic3: user_profile_pic = user_profile_get_pic(conf) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index f0933e8d92..9d2d2d4cb3 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -196,6 +196,7 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(defaults: .appGroup, in: dependencies) var mockAppGroupDefaults: MockUserDefaults! = MockUserDefaults( initialSetup: { defaults in defaults.when { $0.bool(forKey: .any) }.thenReturn(false) + defaults.when { $0.object(forKey: .any) }.thenReturn(nil) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index e42508adee..9c0ec8387c 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -68,7 +68,7 @@ class MessageReceiverGroupsSpec: QuickSpec { initialSetup: { network in network .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", expires: nil))) network .when { $0.getSwarm(for: .any) } .thenReturn([ @@ -404,7 +404,8 @@ class MessageReceiverGroupsSpec: QuickSpec { displayName: "TestName", profileKey: Data((0.., LibSessionCacheType { mockNoReturn(generics: [T.self], args: [key, value]) } - func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?) throws { - try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey]) + func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?, isReuploadProfilePicture: Bool) throws { + try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) } func canPerformChange( diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index 4b3304b3dd..9e8e46a5b3 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -12,11 +12,18 @@ public extension Network.FileServer { requestAndPathBuildTimeout: TimeInterval? = nil, using dependencies: Dependencies ) throws -> Network.PreparedRequest { + var headers: [HTTPHeader: String] = [:] + + if dependencies[feature: .shortenFileTTL] { + headers = [.fileCustomTTL : "60"] + } + return try Network.PreparedRequest( request: Request( endpoint: .file, destination: .serverUpload( server: FileServer.fileServer, + headers: headers, x25519PublicKey: FileServer.fileServerPublicKey, fileName: nil ), diff --git a/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift new file mode 100644 index 0000000000..5edb20c045 --- /dev/null +++ b/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +extension HTTPHeader { + static let fileCustomTTL: HTTPHeader = "X-FS-TTL" +} diff --git a/SessionNetworkingKit/Models/FileUploadResponse.swift b/SessionNetworkingKit/Models/FileUploadResponse.swift index 0f7b328d84..9d28838d92 100644 --- a/SessionNetworkingKit/Models/FileUploadResponse.swift +++ b/SessionNetworkingKit/Models/FileUploadResponse.swift @@ -4,9 +4,11 @@ import Foundation public struct FileUploadResponse: Codable { public let id: String + public let expires: TimeInterval? - public init(id: String) { + public init(id: String, expires: TimeInterval?) { self.id = id + self.expires = expires } } @@ -20,12 +22,16 @@ extension FileUploadResponse { // that and convert the value to a string so we can be consistent (SOGS is able to handle // an array of Strings for the `files` param when posting a message just fine) if let intValue: Int64 = try? container.decode(Int64.self, forKey: .id) { - self = FileUploadResponse(id: "\(intValue)") + self = FileUploadResponse( + id: "\(intValue)", + expires: try? container.decode(TimeInterval?.self, forKey: .expires) + ) return } self = FileUploadResponse( - id: try container.decode(String.self, forKey: .id) + id: try container.decode(String.self, forKey: .id), + expires: try? container.decode(TimeInterval?.self, forKey: .expires) ) } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 54fc5cd9a9..0e3e03e4b9 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -49,7 +49,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Log.info(.cat, "didReceive called with requestId: \(request.identifier).") /// Create the context if we don't have it (needed before _any_ interaction with the database) - if !dependencies[singleton: .appContext].isValid { + if !dependencies.has(singleton: .appContext) || !dependencies[singleton: .appContext].isValid { dependencies.set(singleton: .appContext, to: NotificationServiceExtensionContext(using: dependencies)) Dependencies.setIsRTLRetriever(requiresMainThread: false) { NotificationServiceExtensionContext.determineDeviceRTL() diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 0d421c8caf..d4ced9d635 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -40,7 +40,10 @@ final class SimplifiedConversationCell: UITableViewCell { }() private lazy var profilePictureView: ProfilePictureView = { - let view: ProfilePictureView = ProfilePictureView(size: .list, dataManager: nil) + let view: ProfilePictureView = ProfilePictureView( + size: .list, + dataManager: nil + ) view.translatesAutoresizingMaskIntoConstraints = false return view diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index c9b824b196..662bb23e3b 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -236,7 +236,8 @@ class DatabaseSpec: QuickSpec { "utilitiesKit.RenameTableSettingToKeyValueStore", "messagingKit.MoveSettingsToLibSession", "messagingKit.RenameAttachments", - "messagingKit.AddProMessageFlag" + "messagingKit.AddProMessageFlag", + "LastProfileUpdateTimestamp" ])) } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index f060ee7e98..d60698f19d 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -609,13 +609,11 @@ class OnboardingSpec: AsyncSpec { Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", - lastNameUpdate: 1234567890, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, - blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: 1234567890, + blocksCommunityMessageRequests: nil ) ])) } @@ -649,13 +647,11 @@ class OnboardingSpec: AsyncSpec { Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", - lastNameUpdate: nil, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, - blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil ) )) } @@ -665,16 +661,36 @@ class OnboardingSpec: AsyncSpec { let result: [ConfigDump]? = mockStorage.read { db in try ConfigDump.fetchAll(db) } - let expectedData: Data? = Data(base64Encoded: "ZDE6IWkxZTE6JDEwNDpkMTojaTFlMTomZDE6K2ktMWUxOm4xNjpUZXN0Q29tcGxldGVOYW1lZTE6PGxsaTBlMzI66hc7V77KivGMNRmnu/acPnoF0cBJ+pVYNB2Ou0iwyWVkZWVlMTo9ZDE6KzA6MTpuMDplZTE6KGxlMTopbGUxOipkZTE6K2RlZQ==") + try require(result).to(haveCount(1)) - expect(result).to(equal([ + /// Since the `UserProfile` data is not deterministic then the best we can do is compare the `ConfigDump` + /// without it's data to ensure everything else is correct, then check that the dump data contains expected values + let resultData: Data = result![0].data + let resultWithoutData: ConfigDump = ConfigDump( + variant: result![0].variant, + sessionId: result![0].sessionId.hexString, + data: Data(), + timestampMs: result![0].timestampMs + ) + var resultDataString: String = "" + + for i in (0.. ())? = nil, onCancel: ((ConfirmationModal) -> ())? = nil, afterClosed: (() -> ())? = nil @@ -808,7 +858,7 @@ public extension ConfirmationModal { confirmTitle: self.confirmTitle, confirmStyle: self.confirmStyle, confirmEnabled: self.confirmEnabled, - cancelTitle: self.cancelTitle, + cancelTitle: (cancelTitle ?? self.cancelTitle), cancelStyle: self.cancelStyle, cancelEnabled: self.cancelEnabled, hasCloseButton: self.hasCloseButton, @@ -1009,8 +1059,10 @@ public extension ConfirmationModal.Info { placeholder: ImageDataManager.DataSource?, icon: ProfilePictureView.ProfileIcon = .none, style: ImageStyle, + description: NSAttributedString?, accessibility: Accessibility?, dataManager: ImageDataManagerType, + onProBageTapped: (() -> Void)?, onClick: (@MainActor (@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void) ) @@ -1045,12 +1097,13 @@ public extension ConfirmationModal.Info { lhsOptions == rhsOptions ) - case (.image(let lhsSource, let lhsPlaceholder, let lhsIcon, let lhsStyle, let lhsAccessibility, _, _), .image(let rhsSource, let rhsPlaceholder, let rhsIcon, let rhsStyle, let rhsAccessibility, _, _)): + case (.image(let lhsSource, let lhsPlaceholder, let lhsIcon, let lhsStyle, let lhsShowPro, let lhsAccessibility, _, _, _), .image(let rhsSource, let rhsPlaceholder, let rhsIcon, let rhsStyle, let rhsShowPro, let rhsAccessibility, _, _, _)): return ( lhsSource == rhsSource && lhsPlaceholder == rhsPlaceholder && lhsIcon == rhsIcon && lhsStyle == rhsStyle && + lhsShowPro == rhsShowPro && lhsAccessibility == rhsAccessibility ) @@ -1078,11 +1131,12 @@ public extension ConfirmationModal.Info { warning.hash(into: &hasher) options.hash(into: &hasher) - case .image(let source, let placeholder, let icon, let style, let accessibility, _, _): + case .image(let source, let placeholder, let icon, let style, let showPro, let accessibility, _, _, _): source.hash(into: &hasher) placeholder.hash(into: &hasher) icon.hash(into: &hasher) style.hash(into: &hasher) + showPro.hash(into: &hasher) accessibility.hash(into: &hasher) case .inputConfirmation(let explanation, let textToConfirm): diff --git a/SessionUIKit/Components/Modals & Toast/Modal.swift b/SessionUIKit/Components/Modals & Toast/Modal.swift index 9f8738dfbb..af3eeb0423 100644 --- a/SessionUIKit/Components/Modals & Toast/Modal.swift +++ b/SessionUIKit/Components/Modals & Toast/Modal.swift @@ -151,11 +151,19 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { public static func createButton(title: String, titleColor: ThemeValue) -> UIButton { let result: UIButton = UIButton() result.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.titleLabel?.numberOfLines = 0 + result.titleLabel?.textAlignment = .center result.setTitle(title, for: .normal) result.setThemeTitleColor(titleColor, for: .normal) result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal) result.setThemeBackgroundColor(.highlighted(.alert_buttonBackground), for: .highlighted) result.set(.height, to: Values.alertButtonHeight) + result.contentEdgeInsets = UIEdgeInsets( + top: 0, + left: Values.mediumSpacing, + bottom: 0, + right: Values.mediumSpacing + ) return result } diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 34669ea476..8310756f6c 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -2,10 +2,18 @@ import UIKit import Combine +import Lucide public final class ProfilePictureView: UIView { public struct Info { + public enum AnimationBehaviour { + case generic(Bool) // For communities and when Pro is not enabled + case contact(Bool) + case currentUser(SessionProManagerType) + } + let source: ImageDataManager.DataSource? + let animationBehaviour: AnimationBehaviour let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? let inset: UIEdgeInsets @@ -15,6 +23,7 @@ public final class ProfilePictureView: UIView { public init( source: ImageDataManager.DataSource?, + animationBehaviour: AnimationBehaviour, renderingMode: UIImage.RenderingMode? = nil, themeTintColor: ThemeValue? = nil, inset: UIEdgeInsets = .zero, @@ -23,6 +32,7 @@ public final class ProfilePictureView: UIView { forcedBackgroundColor: ForcedThemeValue? = nil ) { self.source = source + self.animationBehaviour = animationBehaviour self.renderingMode = renderingMode self.themeTintColor = themeTintColor self.inset = inset @@ -37,12 +47,14 @@ public final class ProfilePictureView: UIView { case message case list case hero + case modal public var viewSize: CGFloat { switch self { case .navigation, .message: return 26 case .list: return 46 case .hero: return 110 + case .modal: return 90 } } @@ -51,6 +63,7 @@ public final class ProfilePictureView: UIView { case .navigation, .message: return 26 case .list: return 46 case .hero: return 80 + case .modal: return 90 } } @@ -59,6 +72,7 @@ public final class ProfilePictureView: UIView { case .navigation, .message: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 + case .modal: return 90 } } @@ -67,6 +81,7 @@ public final class ProfilePictureView: UIView { case .navigation, .message: return 10 // Intentionally not a multiple of 4 case .list: return 16 case .hero: return 24 + case .modal: return 24 // Shouldn't be used } } } @@ -76,6 +91,7 @@ public final class ProfilePictureView: UIView { case crown case rightPlus case letter(Character, Bool) + case pencil func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { @@ -91,12 +107,13 @@ public final class ProfilePictureView: UIView { var isLeadingAligned: Bool { switch self { case .none, .crown, .letter: return true - case .rightPlus: return false + case .rightPlus, .pencil: return false } } } private var dataManager: ImageDataManagerType? + private var disposables: Set = Set() public var size: Size { didSet { widthConstraint.constant = (customWidth ?? size.viewSize) @@ -403,6 +420,7 @@ public final class ProfilePictureView: UIView { case .crown: imageView.image = UIImage(systemName: "crown.fill") + imageView.contentMode = .scaleAspectFit imageView.themeTintColor = .dynamicForPrimary( .green, use: .profileIcon_greenPrimaryColor, @@ -414,6 +432,7 @@ public final class ProfilePictureView: UIView { case .rightPlus: imageView.image = UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)) + imageView.contentMode = .scaleAspectFit imageView.themeTintColor = .black backgroundView.themeBackgroundColor = .primary imageView.isHidden = false @@ -424,18 +443,32 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = (dangerMode ? .danger : .textPrimary) label.isHidden = false label.text = "\(character)" + + case .pencil: + imageView.image = Lucide.image(icon: .pencil, size: 14)?.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.themeTintColor = .black + backgroundView.themeBackgroundColor = .primary + imageView.isHidden = false + label.isHidden = true + } } // MARK: - Content private func prepareForReuse() { + /// Reset the disposables in case this was called with different data/ + disposables = Set() + imageView.image = nil + imageView.shouldAnimateImage = false imageView.contentMode = .scaleAspectFill imageContainerView.clipsToBounds = clipsToBounds imageContainerView.themeBackgroundColor = .backgroundSecondary additionalImageContainerView.isHidden = true additionalImageView.image = nil + additionalImageView.shouldAnimateImage = false additionalImageContainerView.clipsToBounds = clipsToBounds imageViewTopConstraint.isActive = false @@ -479,7 +512,8 @@ public final class ProfilePictureView: UIView { case (.some(let source), .some(let renderingMode)) where source.directImage != nil: imageView.image = source.directImage?.withRenderingMode(renderingMode) - case (.some(let source), _): imageView.loadImage(source) + case (.some(let source), _): + imageView.loadImage(source) default: imageView.image = nil } @@ -498,6 +532,8 @@ public final class ProfilePictureView: UIView { } } + startAnimationIfNeeded(for: info, with: imageView) + // Check if there is a second image (if not then set the size and finish) guard let additionalInfo: Info = additionalInfo else { imageViewWidthConstraint.constant = size.imageSize @@ -551,6 +587,8 @@ public final class ProfilePictureView: UIView { } } + startAnimationIfNeeded(for: additionalInfo, with: additionalImageView) + imageViewTopConstraint.isActive = true imageViewLeadingConstraint.isActive = true imageViewCenterXConstraint.isActive = false @@ -567,6 +605,25 @@ public final class ProfilePictureView: UIView { ) additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } + + private func startAnimationIfNeeded(for info: Info, with targetImageView: SessionImageView) { + switch info.animationBehaviour { + case .generic(let enableAnimation), .contact(let enableAnimation): + targetImageView.shouldAnimateImage = enableAnimation + + case .currentUser(let currentUserSessionProState): + targetImageView.shouldAnimateImage = currentUserSessionProState.isSessionProSubject.value + currentUserSessionProState.isSessionProPublisher + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { [weak targetImageView] isPro in + targetImageView?.shouldAnimateImage = isPro + } + ) + .store(in: &disposables) + } + } } import SwiftUI @@ -592,7 +649,10 @@ public struct ProfilePictureSwiftUI: UIViewRepresentable { } public func makeUIView(context: Context) -> ProfilePictureView { - ProfilePictureView(size: size, dataManager: dataManager) + ProfilePictureView( + size: size, + dataManager: dataManager + ) } public func updateUIView(_ profilePictureView: ProfilePictureView, context: Context) { diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 35e0b5837e..c5cce36801 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -44,7 +44,7 @@ public class SessionProBadge: UIView { public init(size: Size) { self.size = size - super.init(frame: .zero) + super.init(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) self.setupView() } @@ -76,4 +76,14 @@ public class SessionProBadge: UIView { self.set(.width, to: self.size.width) self.set(.height, to: self.size.height) } + + public func toImage() -> UIImage { + self.proImageView.frame = CGRect( + x: (size.width - size.proFontWidth) / 2, + y: (size.height - size.proFontHeight) / 2, + width: size.proFontWidth, + height: size.proFontHeight + ) + return self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + } } diff --git a/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift index 5eab839233..3b95c7392a 100644 --- a/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift +++ b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift @@ -7,11 +7,19 @@ public enum ViewPosition: String, Sendable { case top case bottom case none - + case topLeft + case topRight + case bottomLeft + case bottomRight + var opposite: ViewPosition { switch self { case .top: return .bottom case .bottom: return .top + case .topLeft: return .bottomRight + case .topRight: return .bottomLeft + case .bottomLeft: return .topRight + case .bottomRight: return .topLeft default: return .none } } @@ -23,7 +31,6 @@ struct ArrowCapsule: Shape { func path(in rect: CGRect) -> Path { let height = rect.size.height - let maxX = rect.maxX let minX = rect.minX let maxY = rect.maxY @@ -31,36 +38,60 @@ struct ArrowCapsule: Shape { let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) let actualArrowPosition: ViewPosition = self.arrowLength > 0 ? self.arrowPosition : .none + let arrowOffSet: CGFloat = 30 - triangleSideLength + height / 2 var path = Path() - path.move(to: CGPoint(x: minX + height/2, y: minY)) + // 1. Start at top-left arc start point + path.move(to: CGPoint(x: minX + height / 2, y: minY)) - if actualArrowPosition == .top { - path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + // 2. Top edge (arrow if needed) + if actualArrowPosition == .topLeft { + path.addLine(to: CGPoint(x: minX + arrowOffSet, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet, position: .topLeft) + } else if actualArrowPosition == .topRight { + path.addLine(to: CGPoint(x: maxX - arrowOffSet - triangleSideLength, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .topRight) + } else if actualArrowPosition == .top { + path.addLine(to: CGPoint(x: rect.midX - triangleSideLength / 2, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .top) } - path.addLine(to: CGPoint(x: maxX - height/2, y: minY)) + path.addLine(to: CGPoint(x: maxX - height / 2, y: minY)) + + // 3. Right corner path.addArc( - center: CGPoint(x: maxX - height/2, y: minY + height/2), - radius: height/2, + center: CGPoint(x: maxX - height / 2, y: minY + height / 2), + radius: height / 2, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 90), clockwise: false ) - if actualArrowPosition == .bottom { - path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + + // 4. Bottom edge (arrow if needed) + if actualArrowPosition == .bottomRight { + path.addLine(to: CGPoint(x: maxX - arrowOffSet, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottomRight) + } else if actualArrowPosition == .bottomLeft { + path.addLine(to: CGPoint(x: minX + arrowOffSet + triangleSideLength, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottomLeft) + } else if actualArrowPosition == .bottom { + path.addLine(to: CGPoint(x: rect.midX + triangleSideLength / 2, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottom) } - path.addLine(to: CGPoint(x: minX + height/2, y: maxY)) + path.addLine(to: CGPoint(x: minX + height / 2, y: maxY)) + + // 5. Left corner path.addArc( - center: CGPoint(x: minX + height/2, y: maxY - height/2), - radius: height/2, + center: CGPoint(x: minX + height / 2, y: maxY - height / 2), + radius: height / 2, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 270), clockwise: false ) + return path } - func trianglePointsFor(arrowPosition: ViewPosition, rect: CGRect, triangleSideLength: CGFloat) -> (CGPoint, CGPoint, CGPoint) { + func trianglePointsFor(arrowPosition: ViewPosition, rect: CGRect, triangleSideLength: CGFloat, offset: CGFloat) -> (CGPoint, CGPoint, CGPoint) { switch arrowPosition { case .top: return ( @@ -74,6 +105,30 @@ struct ArrowCapsule: Shape { CGPoint(x: rect.midX, y: rect.maxY + arrowLength), CGPoint(x: rect.midX - triangleSideLength / 2, y: rect.maxY) ) + case .topLeft: + return ( + CGPoint(x: rect.minX + offset, y: rect.minY), + CGPoint(x: rect.minX + offset + triangleSideLength / 2, y: rect.minY - arrowLength), + CGPoint(x: rect.minX + offset + triangleSideLength, y: rect.minY) + ) + case .topRight: + return ( + CGPoint(x: rect.maxX - offset - triangleSideLength, y: rect.minY), + CGPoint(x: rect.maxX - offset - triangleSideLength / 2, y: rect.minY - arrowLength), + CGPoint(x: rect.maxX - offset, y: rect.minY) + ) + case .bottomLeft: + return ( + CGPoint(x: rect.minX - offset - triangleSideLength, y: rect.maxY), + CGPoint(x: rect.minX - offset - triangleSideLength / 2, y: rect.maxY + arrowLength), + CGPoint(x: rect.minX - offset, y: rect.maxY) + ) + case .bottomRight: + return ( + CGPoint(x: rect.maxX - offset, y: rect.maxY), + CGPoint(x: rect.maxX - offset - triangleSideLength / 2, y: rect.maxY + arrowLength), + CGPoint(x: rect.maxX - offset - triangleSideLength, y: rect.maxY) + ) default: return ( CGPoint.zero, @@ -83,11 +138,12 @@ struct ArrowCapsule: Shape { } } - func makeArrow(path: inout Path, rect: CGRect, triangleSideLength: CGFloat, position: ViewPosition) -> Path { + func makeArrow(path: inout Path, rect: CGRect, triangleSideLength: CGFloat, offset: CGFloat, position: ViewPosition) -> Path { let points = self.trianglePointsFor( arrowPosition: position, rect: rect, - triangleSideLength: triangleSideLength + triangleSideLength: triangleSideLength, + offset: offset ) path.addLine(to: points.0) diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift new file mode 100644 index 0000000000..b9af52e625 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -0,0 +1,62 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct LightBox: View { + @EnvironmentObject var host: HostWrapper + + public var title: String? + public var itemsToShare: [UIImage] = [] + public var content: () -> Content + + public var body: some View { + NavigationView { + content() + .navigationTitle(title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + self.host.controller?.dismiss(animated: true) + } label: { + Image(systemName: "chevron.left") + .foregroundColor(themeColor: .textPrimary) + } + } + } + .safeAreaInset(edge: .bottom) { + HStack { + Button { + share() + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 20)) + .foregroundColor(themeColor: .textPrimary) + } + + Spacer() + } + .padding() + .backgroundColor(themeColor: .backgroundSecondary) + } + } + } + + private func share() { + let shareVC: UIActivityViewController = UIActivityViewController( + activityItems: itemsToShare, + applicationActivities: nil + ) + + if UIDevice.current.isIPad { + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = self.host.controller?.view + shareVC.popoverPresentationController?.sourceRect = (self.host.controller?.view.bounds ?? UIScreen.main.bounds) + } + + self.host.controller?.present( + shareVC, + animated: true + ) + } +} diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index b0951567de..2fa2c8ee75 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -6,7 +6,7 @@ public struct Modal_SwiftUI: View where Content: View { let host: HostWrapper let dismissType: Modal.DismissType let afterClosed: (() -> Void)? - let content: (@escaping () -> Void) -> Content + let content: (@escaping ((() -> Void)?) -> Void) -> Content let cornerRadius: CGFloat = 11 let shadowRadius: CGFloat = 10 @@ -16,11 +16,21 @@ public struct Modal_SwiftUI: View where Content: View { public var body: some View { ZStack { + // Background + Rectangle() + .fill(.ultraThinMaterial) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onTapGesture { close() } + + // Modal VStack { Spacer() VStack(spacing: 0) { - content{ close() } + content { internalAfterClosed in + close(internalAfterClosed) + } } .backgroundColor(themeColor: .alert_background) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) @@ -40,8 +50,6 @@ public struct Modal_SwiftUI: View where Content: View { maxWidth: .infinity, maxHeight: .infinity ) - .background(.ultraThinMaterial) - .onTapGesture { close() } .gesture( DragGesture(minimumDistance: 20, coordinateSpace: .global) .onEnded { value in @@ -50,14 +58,11 @@ public struct Modal_SwiftUI: View where Content: View { } } ) - .onDisappear { - afterClosed?() - } } // MARK: - Dismiss Logic - private func close() { + private func close(_ internalAfterClosed: (() -> Void)? = nil) { // Recursively dismiss all modals (ie. find the first modal presented by a non-modal // and get that to dismiss it's presented view controller) var targetViewController: UIViewController? = host.controller @@ -70,7 +75,13 @@ public struct Modal_SwiftUI: View where Content: View { } } - targetViewController?.presentingViewController?.dismiss(animated: true) + targetViewController?.presentingViewController?.dismiss( + animated: true, + completion: { + afterClosed?() + internalAfterClosed?() + } + ) } } diff --git a/SessionUIKit/Components/SwiftUI/PopoverView.swift b/SessionUIKit/Components/SwiftUI/PopoverView.swift index 5e0cf83942..b3b97ccb60 100644 --- a/SessionUIKit/Components/SwiftUI/PopoverView.swift +++ b/SessionUIKit/Components/SwiftUI/PopoverView.swift @@ -73,9 +73,9 @@ internal struct PopoverOffset: ViewModifier { var originBounds: CGRect var position: ViewPosition var arrowLength: CGFloat - + func body(content: Content) -> some View { - return content + content .offset( x: self.offsetXFor( position: position, @@ -90,36 +90,41 @@ internal struct PopoverOffset: ViewModifier { arrowLength: arrowLength ) ) - } - + func offsetXFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { - var offsetX: CGFloat = 0 + let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) + let arrowOffSet: CGFloat = 30 - triangleSideLength + frame.size.height / 2 switch position { case .top, .bottom: - offsetX = originBounds.minX + (originBounds.size.width - frame.size.width) / 2 + // Center horizontally + return originBounds.minX + (originBounds.size.width - frame.size.width) / 2 + case .topLeft, .bottomLeft: + // Align right + return originBounds.maxX - frame.size.width + arrowOffSet - triangleSideLength / 2 + case .topRight, .bottomRight: + // Align left + return originBounds.minX - arrowOffSet + triangleSideLength / 2 case .none: - offsetX = 0 + return 0 } - - return offsetX } - - func offsetYFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat)->CGFloat { - var offsetY:CGFloat = 0 + + func offsetYFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { switch position { - case .top: - offsetY = originBounds.minY - frame.size.height - arrowLength - case .bottom: - offsetY = originBounds.minY + originBounds.size.height + arrowLength + case .top, .topLeft, .topRight: + // Position above origin + arrow + return originBounds.minY - frame.size.height - arrowLength + case .bottom, .bottomLeft, .bottomRight: + // Position below origin + arrow + return originBounds.maxY + arrowLength case .none: - offsetY = 0 + return 0 } - - return offsetY } } + public struct AnchorView: ViewModifier { let viewId: String diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 01e48b7034..6b4ed4802c 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -8,7 +8,7 @@ public struct ProCTAModal: View { public enum Variant { case generic case longerMessages - case animatedProfileImage + case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) case groupLimit(isAdmin: Bool) @@ -20,7 +20,7 @@ public struct ProCTAModal: View { case .longerMessages: return "HigherCharLimitCTA.webp" case .animatedProfileImage: - return "session_pro_modal_background_animated_profile_image" + return "AnimatedProfileCTA.webp" case .morePinnedConvos: return "PinnedConversationsCTA.webp" case .groupLimit(let isAdmin): @@ -30,11 +30,23 @@ public struct ProCTAModal: View { // stringlint:ignore_contents public var animatedAvatarImageURL: URL? { switch self { - case .generic: - return Bundle.main.url(forResource: "GenericCTAAnimation", withExtension: "webp") + case .generic, .animatedProfileImage: + return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp") default: return nil } } + /// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the + /// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size + /// of the modal. + public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { + switch self { + case .generic: + return (1313.5, 753) + case .animatedProfileImage: + return (690, 363) + default: return (0, 0) + } + } public var subtitle: String { switch self { @@ -47,10 +59,12 @@ public struct ProCTAModal: View { return "proCallToActionLongerMessages" .put(key: "app_pro", value: Constants.app_pro) .localized() - case .animatedProfileImage: - return "proAnimatedDisplayPictureCallToActionDescription" - .put(key: "app_pro", value: Constants.app_pro) - .localized() + case .animatedProfileImage(let isSessionProActivated): + return isSessionProActivated ? + "proAnimatedDisplayPicture".localized() : + "proAnimatedDisplayPictureCallToActionDescription" + .put(key: "app_pro", value: Constants.app_pro) + .localized() case .morePinnedConvos(let isGrandfathered): return isGrandfathered ? "proCallToActionPinnedConversations" @@ -105,6 +119,7 @@ public struct ProCTAModal: View { } @EnvironmentObject var host: HostWrapper + @State var proCTAImageHeight: CGFloat = 0 private var delegate: SessionProManagerType? private let variant: ProCTAModal.Variant @@ -137,29 +152,45 @@ public struct ProCTAModal: View { afterClosed: afterClosed ) { close in VStack(spacing: 0) { + // Background images ZStack { - SessionAsyncImage( - source: ( - variant.animatedAvatarImageURL.map { .url($0) } ?? - .image( - variant.backgroundImageName, - UIImage(named: variant.backgroundImageName) ?? - UIImage() + if let animatedAvatarImageURL = variant.animatedAvatarImageURL { + GeometryReader { geometry in + let size: CGFloat = geometry.size.width / 1522.0 * 187.0 + let scale: CGFloat = geometry.size.width / 1522.0 + SessionAsyncImage( + source: .url(animatedAvatarImageURL), + dataManager: dataManager, + content: { image in + image + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(width: size, height: size) + }, + placeholder: { + if let data = try? Data(contentsOf: animatedAvatarImageURL) { + Image(uiImage: UIImage(data: data) ?? UIImage()) + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(width: size, height: size) + } else { + EmptyView() + } + } ) - ), - dataManager: dataManager, - content: { image in - image - .resizable() - .aspectRatio((1522.0/1258.0), contentMode: .fit) - .frame(maxWidth: .infinity) - }, - placeholder: { - ThemeColor(.alert_background) - .aspectRatio((1522.0/1258.0), contentMode: .fit) - .frame(maxWidth: .infinity) + .padding(.leading, variant.animatedAvatarImagePadding.leading * scale) + .padding(.top, variant.animatedAvatarImagePadding.top * scale) + .onAppear { + proCTAImageHeight = geometry.size.width / 1522.0 * 1258.0 + } } - ) + .frame(height: proCTAImageHeight) + } + + Image(uiImage: UIImage(named: variant.backgroundImageName) ?? UIImage()) + .resizable() + .aspectRatio((1522.0/1258.0), contentMode: .fit) + .frame(maxWidth: .infinity) } .backgroundColor(themeColor: .primary) .overlay(alignment: .bottom, content: { @@ -180,82 +211,119 @@ public struct ProCTAModal: View { maxWidth: .infinity, alignment: .bottom ) - + // Content VStack(spacing: Values.largeSpacing) { // Title - HStack(spacing: Values.smallSpacing) { - Text("upgradeTo".localized()) - .font(.system(size: Values.largeFontSize)) - .bold() - .foregroundColor(themeColor: .textPrimary) - - SessionProBadge_SwiftUI(size: .large) + if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) + + Text("proActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } + } else { + HStack(spacing: Values.smallSpacing) { + Text("upgradeTo".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + + SessionProBadge_SwiftUI(size: .large) + } } + // Description, Subtitle - VStack(spacing: Values.smallSpacing) { + VStack(spacing: 0) { + if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { + HStack(spacing: Values.verySmallSpacing) { + Text("proAlreadyPurchased".localized()) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + + SessionProBadge_SwiftUI(size: .small) + } + } + Text(variant.subtitle) - .font(.system(size: Values.smallFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } + // Benefits - VStack(alignment: .leading, spacing: Values.mediumSmallSpacing) { - ForEach( - 0.. { get } var isSessionProPublisher: AnyPublisher { get } func upgradeToPro(completion: ((_ result: Bool) -> Void)?) + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool +} + +// MARK: - Convenience +public extension SessionProManagerType { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: beforePresented, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: nil, + afterClosed: nil, + presenting: presenting + ) + } } struct ProCTAModal_Previews: PreviewProvider { diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift new file mode 100644 index 0000000000..03c310b66b --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct QRCodeView: View { + let qrCodeImage: UIImage? + let themeStyle: UIUserInterfaceStyle + var backgroundThemeColor: ThemeValue { + switch themeStyle { + case .light: + return .backgroundSecondary + default: + return .textPrimary + } + } + var qrCodeThemeColor: ThemeValue { + switch themeStyle { + case .light: + return .textPrimary + default: + return .backgroundPrimary + } + } + + static private var cornerRadius: CGFloat = 10 + static private var logoSize: CGFloat = 66 + + public init( + qrCodeImage: UIImage?, + themeStyle: UIUserInterfaceStyle + ) { + self.qrCodeImage = qrCodeImage + self.themeStyle = themeStyle + } + + public init( + string: String, + hasBackground: Bool, + logo: String?, + themeStyle: UIUserInterfaceStyle + ) { + self.qrCodeImage = QRCode.generate(for: string, hasBackground: hasBackground, iconName: logo) + self.themeStyle = themeStyle + } + + public var body: some View { + ZStack(alignment: .center) { + ZStack(alignment: .center) { + RoundedRectangle(cornerRadius: Self.cornerRadius) + .fill(themeColor: backgroundThemeColor) + + if let qrCodeImage: UIImage = self.qrCodeImage { + Image(uiImage: qrCodeImage) + .resizable() + .renderingMode(.template) + .foregroundColor(themeColor: qrCodeThemeColor) + .scaledToFit() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + .padding(.vertical, Values.smallSpacing) + } + } + .frame( + maxWidth: 400, + maxHeight: 400 + ) + } + .frame(maxWidth: .infinity) + } +} diff --git a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift new file mode 100644 index 0000000000..2700be3d17 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct Seperator_SwiftUI: View { + + public let title: String + + public var body: some View { + HStack(spacing: 0) { + Line(color: .textSecondary, lineWidth: Values.separatorThickness) + + Text(title) + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .fixedSize() + .padding(.horizontal, 30) + .padding(.vertical, 6) + .background( + Capsule() + .stroke(themeColor: .textSecondary, lineWidth: Values.separatorThickness) + ) + + Line(color: .textSecondary, lineWidth: Values.separatorThickness) + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift new file mode 100644 index 0000000000..b54bd7a0df --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -0,0 +1,438 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide +import Combine + +public struct UserProfileModal: View { + @EnvironmentObject var host: HostWrapper + @State private var isProfileImageToggled: Bool = true + @State private var isProfileImageExpanding: Bool = false + @State private var isSessionIdCopied: Bool = false + @State private var isShowingTooltip: Bool = false + @State private var tooltipContentFrame: CGRect = CGRect.zero + + private let tooltipViewId: String = "UserProfileModalToolTip" // stringlint:ignore + private let coordinateSpaceName: String = "UserProfileModal" // stringlint:ignore + + private var info: Info + private var dataManager: ImageDataManagerType + let dismissType: Modal.DismissType + let afterClosed: (() -> Void)? + + private var tooltipText: ThemedAttributedString { + if info.sessionId == nil { + return "tooltipBlindedIdCommunities" + .localizedFormatted(baseFont: Fonts.Body.smallRegular) + } else { + return "tooltipAccountIdVisible" + .put(key: "name", value: (info.displayName ?? "")) + .localizedFormatted(baseFont: Fonts.Body.smallRegular) + } + } + + public init( + info: Info, + dataManager: ImageDataManagerType, + dismissType: Modal.DismissType = .recursive, + afterClosed: (() -> Void)? = nil + ) { + self.info = info + self.dataManager = dataManager + self.dismissType = dismissType + self.afterClosed = afterClosed + } + + public var body: some View { + Modal_SwiftUI( + host: host, + dismissType: dismissType, + afterClosed: afterClosed + ) { close in + ZStack(alignment: .topTrailing) { + // Closed button + Button { + close(nil) + } label: { + AttributedText(Lucide.Icon.x.attributedString(size: 20)) + .font(.system(size: 20)) + .foregroundColor(themeColor: .textPrimary) + } + .frame(width: 24, height: 24) + + VStack(spacing: Values.mediumSpacing) { + // Profile Image & QR Code + let scale: CGFloat = isProfileImageExpanding ? (190.0 / 90) : 1 + if isProfileImageToggled { + ZStack(alignment: .topTrailing) { + ZStack { + ProfilePictureSwiftUI( + size: .modal, + info: info.profileInfo, + dataManager: self.dataManager + ) + .scaleEffect(scale, anchor: .topLeading) + .onTapGesture { + withAnimation { + self.isProfileImageExpanding.toggle() + } + } + } + .frame( + width: ProfilePictureView.Size.modal.viewSize * scale, + height: ProfilePictureView.Size.modal.viewSize * scale, + alignment: .center + ) + + if info.sessionId != nil { + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) + ZStack { + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: buttonSize, height: buttonSize) + + if let icon: UIImage = Lucide.image(icon: .qrCode, size: iconSize) { + Image(uiImage: icon) + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: iconSize, height: iconSize) + } + } + .padding(.trailing, isProfileImageExpanding ? 28 : 4) + .onTapGesture { + withAnimation { + self.isProfileImageToggled.toggle() + } + } + } + } + .padding(.top, 12) + .padding(.vertical, 5) + .padding(.horizontal, 10) + } else { + ZStack(alignment: .topTrailing) { + if let qrCodeImage = info.qrCodeImage { + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .accessibility( + Accessibility( + identifier: "QR code", + label: "QR code" + ) + ) + .aspectRatio(1, contentMode: .fit) + .frame(width: 190, height: 190) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .onTapGesture { + showQRCodeLightBox() + } + + Image("ic_user_round_fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: 18, height: 18) + .background( + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: 33, height: 33) + ) + .onTapGesture { + withAnimation { + self.isProfileImageToggled.toggle() + } + } + } + } + .padding(.top, 12) + } + + // Display name & Nickname (ProBadge) + if let displayName: String = info.displayName { + VStack(spacing: Values.smallSpacing) { + HStack(spacing: Values.smallSpacing) { + Text(displayName) + .font(.Headings.H6) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + + if info.isProUser { + SessionProBadge_SwiftUI(size: .large) + .onTapGesture { + info.onProBadgeTapped?() + } + } + } + + if let contactDisplayName: String = info.contactDisplayName, contactDisplayName != displayName { + Text("(\(contactDisplayName))") // stringlint:ignroe + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + } + } + } + + // Account Id | Blinded Id (Tooltips) + let (title, hexEncodedId): (String, String) = { + switch (info.sessionId, info.blindedId) { + case (.some(let sessionId), .none): + return ("accountId".localized(), sessionId) + case (.some(let sessionId), .some): + return ("accountId".localized(), sessionId.splitIntoLines(charactersForLines: [23, 23, 20])) + case (.none, .some(let blindedId)): + return ("blindedId".localized(), blindedId) + case (.none, .none): + return ("", "") // Shouldn't happen + } + }() + + Seperator_SwiftUI(title: title) + + ZStack(alignment: .top) { + if info.blindedId != nil { + HStack { + Spacer() + + Button { + withAnimation { + isShowingTooltip.toggle() + } + } label: { + Image(systemName: "questionmark.circle") + .font(.Body.extraLargeRegular) + .foregroundColor(themeColor: .textPrimary) + } + .anchorView(viewId: tooltipViewId) + } + } + + Text(hexEncodedId) + .font(isIPhone5OrSmaller ? .Display.base : .Display.large) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, info.blindedId == nil ? 0 : Values.largeSpacing) + } + + // Buttons + if let sessionId = info.sessionId { + HStack(spacing: Values.mediumSpacing) { + Button { + close(info.onStartThread) + } label: { + Text("message".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + } + .buttonStyle(PlainButtonStyle()) + + Button { + copySessionId(sessionId) + } label: { + Text(isSessionIdCopied ? "copied".localized() : "copy".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + } + .disabled(isSessionIdCopied) + .buttonStyle(PlainButtonStyle()) + } + .padding(.bottom, 12) + } else { + if !info.isMessageRequestsEnabled, let displayName: String = info.displayName { + AttributedText( + "messageRequestsTurnedOff" + .put(key: "name", value: displayName) + .localizedFormatted(Fonts.Body.smallRegular) + ) + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + } + + GeometryReader { geometry in + HStack { + Button { + close(info.onStartThread) + } label: { + Text("message".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_text : .disabled)) + .overlay( + Capsule() + .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) + .frame( + width: (geometry.size.width - Values.mediumSpacing) / 2, + height: Values.smallButtonHeight + ) + ) + } + .disabled(!info.isMessageRequestsEnabled) + .buttonStyle(PlainButtonStyle()) + } + .frame( + width: geometry.size.width, + height: geometry.size.height, + alignment: .center + ) + } + .frame(height: Values.largeButtonHeight) + .padding(.bottom, 12) + } + } + } + .padding(Values.mediumSpacing) + } + .popoverView( + content: { + ZStack { + AttributedText(tooltipText) + .font(.Body.smallRegular) + .multilineTextAlignment(.center) + .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + .frame(maxWidth: 260) + } + .overlay( + GeometryReader { geometry in + Color.clear // Invisible overlay + .onAppear { + self.tooltipContentFrame = geometry.frame(in: .global) + } + } + ) + }, + backgroundThemeColor: .toast_background, + isPresented: $isShowingTooltip, + frame: $tooltipContentFrame, + position: .topLeft, + viewId: tooltipViewId + ) + .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { + guard self.isShowingTooltip else { + return + } + + withAnimation(.spring()) { + self.isShowingTooltip = false + } + } + } + + private func copySessionId(_ sessionId: String) { + guard !isSessionIdCopied else { return } + + UIPasteboard.general.string = sessionId + + // Ensure we are on the main thread just in case + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + // 4 seconds delay + the animation duration above + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4) + .milliseconds(250)) { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + } + } + } + + private func showQRCodeLightBox() { + guard let qrCodeImage: UIImage = info.qrCodeImage else { return } + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithTintAndBackground( + image: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle, + size: CGSize(width: 400, height: 400), + insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + ) + ] + ) { + VStack { + Spacer() + + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + + Spacer() + } + .backgroundColor(themeColor: .newConversation_background) + }, + customizedNavigationBackground: .backgroundSecondary + ) + viewController.modalPresentationStyle = .fullScreen + self.host.controller?.present(viewController, animated: true) + } +} + +public extension UserProfileModal { + struct Info { + let sessionId: String? + let blindedId: String? + let qrCodeImage: UIImage? + let profileInfo: ProfilePictureView.Info + let displayName: String? + let contactDisplayName: String? + let isProUser: Bool + let isMessageRequestsEnabled: Bool + let onStartThread: (() -> Void)? + let onProBadgeTapped: (() -> Void)? + + public init( + sessionId: String?, + blindedId: String?, + qrCodeImage: UIImage?, + profileInfo: ProfilePictureView.Info, + displayName: String?, + contactDisplayName: String?, + isProUser: Bool, + isMessageRequestsEnabled: Bool, + onStartThread: (() -> Void)?, + onProBadgeTapped: (() -> Void)? + ) { + self.sessionId = sessionId + self.blindedId = blindedId + self.qrCodeImage = qrCodeImage + self.profileInfo = profileInfo + self.displayName = displayName + self.contactDisplayName = contactDisplayName + self.isProUser = isProUser + self.isMessageRequestsEnabled = isMessageRequestsEnabled + self.onStartThread = onStartThread + self.onProBadgeTapped = onProBadgeTapped + } + } +} diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift index fee96e74f8..05e8bd925a 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -190,8 +190,8 @@ extension SessionNetworkScreen { @Binding var isShowingTooltip: Bool @State var tooltipContentFrame: CGRect = CGRect.zero - let tooltipViewId: String = "tooltip" // stringlint:ignore - let scaleRatio: CGFloat = max(UIScreen.main.bounds.width / 390, 1.0) + let tooltipViewId: String = "SessionNetworkScreenToolTip" // stringlint:ignore + let scaleRatio: CGFloat = max(UIScreen.main.bounds.width / 390, 1.0) var body: some View { HStack( diff --git a/SessionUIKit/Utilities/Date+Utilities.swift b/SessionUIKit/Utilities/Date+Utilities.swift index 689977cf39..f7e1a02057 100644 --- a/SessionUIKit/Utilities/Date+Utilities.swift +++ b/SessionUIKit/Utilities/Date+Utilities.swift @@ -48,6 +48,14 @@ public extension Date { var formattedForBanner: String { return Date.localTimeAndDateFormatter.string(from: self) } + + static func fromHTTPExpiresHeaders(_ expiresValue: String?) -> Date? { + guard let expiresValue else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE',' dd MMM yyyy HH:mm:ss zzz" + return formatter.date(from: expiresValue) + } } // MARK: - Formatters diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift new file mode 100644 index 0000000000..a5df2c5dc8 --- /dev/null +++ b/SessionUIKit/Utilities/QRCode.swift @@ -0,0 +1,121 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public enum QRCode { + /// Generates a QRCode with a logo in the middle for the give string + /// + /// **Note:** If the `hasBackground` value is true then the QRCode will be black and white and + /// the `withRenderingMode(.alwaysTemplate)` won't work correctly on some iOS versions (eg. iOS 16) + /// + /// stringlint:ignore_contents + public static func generate(for string: String, hasBackground: Bool, iconName: String?) -> UIImage { + // 1. Create QR code data + guard let data = string.data(using: .utf8), + let qrFilter = CIFilter(name: "CIQRCodeGenerator") else { + return UIImage() + } + + qrFilter.setValue(data, forKey: "inputMessage") + qrFilter.setValue("H", forKey: "inputCorrectionLevel") // High error correction for embedded icon + guard var qrCIImage = qrFilter.outputImage else { return UIImage() } + + // 2. Optional coloring + if hasBackground { + if let colorFilter = CIFilter(name: "CIFalseColor") { + colorFilter.setValue(qrCIImage, forKey: "inputImage") + colorFilter.setValue(CIColor(color: .black), forKey: "inputColor0") + colorFilter.setValue(CIColor(color: .white), forKey: "inputColor1") + qrCIImage = colorFilter.outputImage ?? qrCIImage + } + } else { + if let invertFilter = CIFilter(name: "CIColorInvert"), + let maskFilter = CIFilter(name: "CIMaskToAlpha") { + invertFilter.setValue(qrCIImage, forKey: "inputImage") + maskFilter.setValue(invertFilter.outputImage, forKey: "inputImage") + qrCIImage = maskFilter.outputImage ?? qrCIImage + } + } + + // 3. Scale CIImage to high resolution + let scaleX: CGFloat = 10.0 + let scaleTransform = CGAffineTransform(scaleX: scaleX, y: scaleX) + let scaledCIImage = qrCIImage.transformed(by: scaleTransform) + let qrUIImage = UIImage(ciImage: scaledCIImage) + + // 4. Draw final image + let size = qrUIImage.size + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + qrUIImage.draw(in: CGRect(origin: .zero, size: size)) + + // 5. Add icon with white background + 4pt padding + if + let iconName = iconName, + let icon: UIImage = UIImage(named: iconName) + { + let iconPercent: CGFloat = 0.25 + let iconSize = size.width * iconPercent + let iconRect = CGRect( + x: (size.width - iconSize) / 2, + y: (size.height - iconSize) / 2, + width: iconSize, + height: iconSize + ) + + // Clear the area under the icon + if let ctx = UIGraphicsGetCurrentContext() { + ctx.clear(iconRect) + } + + // Draw the icon over the transparent hole + icon.draw(in: iconRect) + } + + let finalImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return finalImage ?? qrUIImage + } + + static func qrCodeImageWithTintAndBackground( + image: UIImage, + themeStyle: UIUserInterfaceStyle, + size: CGSize? = nil, + insets: UIEdgeInsets = .zero + ) -> UIImage { + var backgroundColor: UIColor { + switch themeStyle { + case .light: return .classicDark1 + default: return .white + } + } + var tintColor: UIColor { + switch themeStyle { + case .light: return .white + default: return .classicDark1 + } + } + + let outputSize = size ?? image.size + let renderer = UIGraphicsImageRenderer(size: outputSize) + + return renderer.image { context in + // Fill background + backgroundColor.setFill() + context.fill(CGRect(origin: .zero, size: outputSize)) + + // Apply tint using template rendering + tintColor.setFill() + let templateImage = image.withRenderingMode(.alwaysTemplate) + + let imageRect = CGRect( + x: insets.left, + y: insets.top, + width: outputSize.width - insets.left - insets.right, + height: outputSize.height - insets.top - insets.bottom + ) + + templateImage.draw(in: imageRect) + } + } +} diff --git a/SessionUIKit/Utilities/String+SessionProBadge.swift b/SessionUIKit/Utilities/String+SessionProBadge.swift new file mode 100644 index 0000000000..15f36582cc --- /dev/null +++ b/SessionUIKit/Utilities/String+SessionProBadge.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension String { + enum SessionProBadgePosition { + case leading, trailing + } + + func addProBadge( + at postion: SessionProBadgePosition, + font: UIFont, + textColor: ThemeValue = .textPrimary, + proBadgeSize: SessionProBadge.Size, + spacing: String = " " + ) -> NSMutableAttributedString { + let image: UIImage = SessionProBadge(size: proBadgeSize).toImage() + let base = NSMutableAttributedString() + let attachment = NSTextAttachment() + attachment.image = image + + // Vertical alignment tweak to align to baseline + let cap = font.capHeight + let dy = (cap - image.size.height) / 2 + attachment.bounds = CGRect(x: 0, y: dy, width: image.size.width, height: image.size.height) + + switch postion { + case .leading: + base.append(NSAttributedString(attachment: attachment)) + base.append(NSAttributedString(string: spacing)) + base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + case .trailing: + base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + base.append(NSAttributedString(string: spacing)) + base.append(NSAttributedString(attachment: attachment)) + } + + return base + } +} diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 05250f2ce5..3181285787 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -25,3 +25,19 @@ extension String { return boundingBox.width } } + +public extension String { + func splitIntoLines(charactersForLines: [Int]) -> String { + var result: [String] = [] + var start = self.startIndex + + for count in charactersForLines { + let end = self.index(start, offsetBy: count, limitedBy: self.endIndex) ?? self.endIndex + let line = String(self[start.. UIImage? { + let originalSize = self.size + let diameter = max(originalSize.width, originalSize.height) * 2 + let newSize = CGSize(width: diameter, height: diameter) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let renderedImage = renderer.image { context in + let ctx = context.cgContext + + // Draw the circular background + let circleRect = CGRect(origin: .zero, size: newSize) + ctx.setFillColor(backgroundColor.cgColor) + ctx.fillEllipse(in: circleRect) + + // Draw the original image centered + let imageOrigin = CGPoint( + x: (newSize.width - originalSize.width) / 2, + y: (newSize.height - originalSize.height) / 2 + ) + self.draw(at: imageOrigin) + } + + return renderedImage + } } diff --git a/SessionUIKit/Utilities/UIView+Utilities.swift b/SessionUIKit/Utilities/UIView+Utilities.swift index b5518ad1f0..28a13ba074 100644 --- a/SessionUIKit/Utilities/UIView+Utilities.swift +++ b/SessionUIKit/Utilities/UIView+Utilities.swift @@ -3,7 +3,7 @@ import UIKit public extension UIView { - func toImage(isOpaque: Bool, scale: CGFloat) -> UIImage? { + func toImage(isOpaque: Bool, scale: CGFloat) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = scale format.opaque = isOpaque diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 4c6578106a..8308765f4e 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -117,6 +117,12 @@ public class Dependencies { // MARK: - Instance management + public func has(singleton: SingletonConfig) -> Bool { + let key: DependencyStorage.Key = DependencyStorage.Key.Variant.singleton.key(singleton.identifier) + + return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) + } + public func warmCache(cache: CacheConfig) { _ = getOrCreate(cache) } diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 0e512347e0..55998cbed9 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -94,6 +94,10 @@ public extension FeatureStorage { identifier: "treatAllIncomingMessagesAsProMessages" ) + static let shortenFileTTL: FeatureConfig = Dependencies.create( + identifier: "shortenFileTTL" + ) + static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 67b529d8ec..64dc28534c 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -192,8 +192,7 @@ public enum Log { else { return logFiles[0] } // The file is too small so lets create a temp file to share instead - let tempDirectory: String = NSTemporaryDirectory() - let tempFilePath: String = URL(fileURLWithPath: tempDirectory) + let tempFilePath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) .appendingPathComponent(URL(fileURLWithPath: logFiles[1]).lastPathComponent) .path diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 968b66c2e4..0bcf0e5087 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -180,12 +180,18 @@ public extension UserDefaults.BoolKey { /// Idicates whether app review prompt was ignored or no iteraction was done to dismiss it (closed app) static let didActionAppReviewPrompt: UserDefaults.BoolKey = "didActionAppReviewPrompt" + + /// Indicates wheter the user should be reminded to grant camera permission for calls + static let shouldRemindGrantingCameraPermissionForCalls: UserDefaults.BoolKey = "shouldRemindGrantingCameraPermissionForCalls" } public extension UserDefaults.DateKey { /// The date/time when the users profile picture was last uploaded to the server (used to rate-limit re-uploading) static let lastProfilePictureUpload: UserDefaults.DateKey = "lastProfilePictureUpload" + /// The date/time when the users profile picture expires on the server + static let profilePictureExpiresDate: UserDefaults.DateKey = "profilePictureExpiresDate" + /// The date/time when any open group last had a successful poll (used as a fallback date/time if the open group hasn't been polled /// this session) static let lastOpen: UserDefaults.DateKey = "lastOpen" diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 8a2b9917ad..e3d4039eb8 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -638,31 +638,22 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.approvalDelegate?.attachmentApprovalDidCancel(self) } - // MARK: - Session Pro CTA - - @discardableResult @MainActor func showSessionProCTAIfNeeded() -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionPro) else { - return false - } - self.hideInputAccessoryView() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") - } - ) - ) - present(sessionProModal, animated: true, completion: nil) - - return true - } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded() else { return } + guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -689,7 +680,22 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { - guard !showSessionProCTAIfNeeded() else { return } + guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } + self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 3aee88512b..05984e01f0 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -219,11 +219,13 @@ public class AttachmentPrepViewController: OWSViewController { if attachment.isVideo || attachment.isAudio { let playButtonSize: CGFloat = Values.scaleFromIPhone5(70) + let playButtonVerticalOffset = attachment.isAudio ? 0 : -AttachmentPrepViewController.verticalCenterOffset + NSLayoutConstraint.activate([ playButton.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), playButton.centerYAnchor.constraint( equalTo: contentContainerView.centerYAnchor, - constant: -AttachmentPrepViewController.verticalCenterOffset + constant: playButtonVerticalOffset ), playButton.widthAnchor.constraint(equalToConstant: playButtonSize), playButton.heightAnchor.constraint(equalToConstant: playButtonSize),