diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index f3b1c82806..2731c09df5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -149,6 +149,10 @@ 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */; }; 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BC2D5195F30058F244 /* KeyValueStore.swift */; }; + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */; }; 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */; }; 942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */; }; @@ -170,8 +174,11 @@ 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 */; }; + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; + 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9479981B2DD44AC5008F5CD5 /* ThreadNotificationSettingsViewModel.swift */; }; @@ -184,7 +191,6 @@ 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 */; }; @@ -215,6 +221,10 @@ 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -243,7 +253,6 @@ B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B877E24226CA12910007970A /* CallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24126CA12910007970A /* CallVC.swift */; }; B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; @@ -386,7 +395,6 @@ C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */; }; C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */; }; C3D90A5C25773A25002C9DF5 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; D2179CFC16BB0B3A0006F3AB /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */; }; D2179CFE16BB0B480006F3AB /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; }; D221A08E169C9E5E00537ABF /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08D169C9E5E00537ABF /* UIKit.framework */; }; @@ -443,7 +451,6 @@ FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */; }; FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */; }; FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; @@ -1541,6 +1548,8 @@ 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupAdminCTA.webp; sourceTree = ""; }; + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupNonAdminCTA.webp; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; @@ -1563,6 +1572,8 @@ 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 = ""; }; + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.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 = ""; }; @@ -1583,7 +1594,6 @@ 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 = ""; }; @@ -1609,6 +1619,10 @@ 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; + 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+Utilities.swift"; sourceTree = ""; }; + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+Attributes.swift"; 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; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -1642,7 +1656,6 @@ B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; B877E24126CA12910007970A /* CallVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVC.swift; sourceTree = ""; }; B877E24526CA13BA0007970A /* CallVC+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallVC+Camera.swift"; sourceTree = ""; }; - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Interaction.swift"; sourceTree = ""; }; 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 = ""; }; @@ -1842,7 +1855,6 @@ FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = ""; }; FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _017_EmojiReacts.swift; sourceTree = ""; }; FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; @@ -2735,6 +2747,7 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */, FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, @@ -2746,7 +2759,6 @@ FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, @@ -2868,6 +2880,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 94D716812E8FA19D008294EE /* AttributedLabel.swift */, 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, @@ -2930,6 +2943,8 @@ 94CD963F2E1BABE90097754D /* WebPImages */ = { isa = PBXGroup; children = ( + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */, + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */, 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */, 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */, 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */, @@ -3354,7 +3369,6 @@ isa = PBXGroup; children = ( 94B6BB012E3AE85800E718BB /* QRCode.swift */, - 949D91212E822D520074F595 /* String+SessionProBadge.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, @@ -3371,6 +3385,7 @@ FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, C33100272559000A00070591 /* UIView+Utilities.swift */, + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -3382,7 +3397,9 @@ 94CD96282E1B855E0097754D /* Input View */, 942256932C23F8DD00C0FDBF /* SwiftUI */, B8B5BCEB2394D869003823C9 /* SessionButton.swift */, + C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */, + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */, FD52090228B4680F006098F6 /* RadioButton.swift */, B8BB82B02390C37000BA5194 /* SearchBar.swift */, B8BB82B82394911B00BA5194 /* Separator.swift */, @@ -3390,7 +3407,7 @@ B8BB82B423947F2D00BA5194 /* SNTextField.swift */, C3C3CF8824D8EED300E1CCE7 /* SNTextView.swift */, 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */, - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */, + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */, C38EF3EE255B6DF6007E1867 /* GradientView.swift */, FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */, C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */, @@ -4641,13 +4658,13 @@ FD71164028E2C83000B47552 /* Views */ = { isa = PBXGroup; children = ( + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */, 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */, FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */, FD37EA0A28AB12E2003AE748 /* SessionCell.swift */, FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */, FD71164528E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift */, 7B71A98E2925E2A600E54854 /* SessionFooterView.swift */, - C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, ); path = Views; sourceTree = ""; @@ -5823,6 +5840,8 @@ 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5893,6 +5912,8 @@ 45A2F005204473A3002E978A /* NewMessage.aifc in Resources */, 45B74A882044AAB600CD42F8 /* aurora.aifc in Resources */, 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 7B0EFDF4275490EA00FFAAE7 /* ringing.mp3 in Resources */, 45B74A852044AAB600CD42F8 /* bamboo.aifc in Resources */, 45B74A782044AAB600CD42F8 /* bamboo-quiet.aifc in Resources */, @@ -6245,6 +6266,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, @@ -6262,7 +6284,6 @@ 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */, - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, C331FFE72558FB0000070591 /* SNTextField.swift in Sources */, 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, @@ -6270,8 +6291,10 @@ C331FFE32558FB0000070591 /* TabBar.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */, FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */, @@ -6296,7 +6319,8 @@ 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, - 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */, + FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, @@ -6323,6 +6347,7 @@ 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, @@ -6663,6 +6688,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */, FDD23AE62E458CAA0057E853 /* _023_SplitSnodeReceivedMessageInfo.swift in Sources */, 7B81682828B310D50069F315 /* _015_HomeQueryOptimisationIndexes.swift in Sources */, @@ -6853,7 +6879,6 @@ 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 */, @@ -6899,7 +6924,6 @@ FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */, FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */, 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */, - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, @@ -7015,7 +7039,6 @@ 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, - C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, 7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */, FD78EA0B2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */, 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */, @@ -7072,6 +7095,7 @@ 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */, FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */, + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */, FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */, FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */, 7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */, @@ -7112,6 +7136,7 @@ FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */, FDE754B12C9B96B4002A2623 /* TurnServerInfo.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, FD71162E28E168C700B47552 /* SettingsViewModel.swift in Sources */, ); diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 8a4817f5dd..c3378090e6 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -300,7 +300,11 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa font: .title, accessibility: Accessibility( identifier: "Contact" - ) + ), + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo( memberInfo.value.statusDescription, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index dd98833b78..c122b2b28c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -66,7 +66,7 @@ extension ContextMenuVC { static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(systemName: "arrow.triangle.2.circlepath"), + icon: Lucide.image(icon: .repeat2, size: 24), title: (cellViewModel.state == .failedToSync ? "resync".localized() : "resend".localized() @@ -77,17 +77,17 @@ extension ContextMenuVC { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_reply"), + icon: Dependencies.isRTL ? Lucide.image(icon: .reply, size: 24)?.flippedHorizontally() : Lucide.image(icon: .reply, size: 24), title: "reply".localized(), shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" ) { completion in delegate?.reply(cellViewModel, completion: completion) } } - static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, forMessageInfoScreen: Bool) -> Action { return Action( - icon: UIImage(named: "ic_copy"), - title: "copy".localized(), + icon: Lucide.image(icon: .copy, size: 24), + title: forMessageInfoScreen ? "messageCopy".localized() : "copy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy text" ) { completion in delegate?.copy(cellViewModel, completion: completion) } @@ -95,7 +95,7 @@ extension ContextMenuVC { static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_copy"), + icon: Lucide.image(icon: .copy, size: 24), title: "accountIDCopy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy Session ID" @@ -118,7 +118,7 @@ extension ContextMenuVC { static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_download"), + icon: Lucide.image(icon: .arrowDownToLine, size: 24), title: "save".localized(), feedback: "saved".localized(), accessibilityLabel: "Save attachment" @@ -251,7 +251,8 @@ extension ContextMenuVC { }() let canCopySessionId: Bool = ( cellViewModel.variant == .standardIncoming && - cellViewModel.threadVariant != .community + cellViewModel.threadVariant != .community && + !forMessageInfoScreen ) let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], @@ -291,7 +292,7 @@ extension ContextMenuVC { let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), (viewModelCanReply(cellViewModel, using: dependencies) ? Action.reply(cellViewModel, delegate) : nil), - (canCopy ? Action.copy(cellViewModel, delegate) : nil), + (canCopy ? Action.copy(cellViewModel, delegate, forMessageInfoScreen: forMessageInfoScreen) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), (canDelete ? Action.delete(cellViewModel, delegate) : nil), diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 768cbb59c1..1206ba0643 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -143,7 +143,7 @@ final class ContextMenuVC: UIViewController { emojiBarBackgroundView.pin(to: emojiBar) emojiBar.addSubview(emojiPlusButton) - emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing) + emojiPlusButton.pin(.trailing, to: .trailing, of: emojiBar, withInset: -Values.smallSpacing) emojiPlusButton.center(.vertical, in: emojiBar) let emojiBarStackView = UIStackView( @@ -156,8 +156,8 @@ final class ContextMenuVC: UIViewController { emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing) emojiBarStackView.isLayoutMarginsRelativeArrangement = true emojiBar.addSubview(emojiBarStackView) - emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) - emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton) + emojiBarStackView.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) + emojiBarStackView.pin(.trailing, to: .leading, of: emojiPlusButton) // Hide the emoji bar if we have no emoji actions emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty @@ -188,10 +188,10 @@ final class ContextMenuVC: UIViewController { timestampLabel.center(.vertical, in: snapshot) if cellViewModel.variant == .standardOutgoing { - timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) + timestampLabel.pin(.trailing, to: .leading, of: snapshot, withInset: -Values.smallSpacing) } else { - timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) + timestampLabel.pin(.leading, to: .trailing, of: snapshot, withInset: Values.smallSpacing) } view.addSubview(fallbackTimestampLabel) @@ -199,14 +199,14 @@ final class ContextMenuVC: UIViewController { fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight) if cellViewModel.variant == .standardOutgoing { - fallbackTimestampLabel.textAlignment = .right - fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing) - fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .left : .right + fallbackTimestampLabel.pin(.trailing, to: .leading, of: menuView, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.pin(.leading, to: .leading, of: view, withInset: Values.mediumSpacing) } else { - fallbackTimestampLabel.textAlignment = .left - fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing) - fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .right : .left + fallbackTimestampLabel.pin(.leading, to: .trailing, of: menuView, withInset: Values.mediumSpacing) + fallbackTimestampLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.mediumSpacing) } // Constrains @@ -219,9 +219,15 @@ final class ContextMenuVC: UIViewController { self.timestampLabel.isHidden = { switch cellViewModel.variant { case .standardOutgoing: + if Dependencies.isRTL { + return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) + } return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) default: + if Dependencies.isRTL { + return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) + } return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) } }() @@ -234,15 +240,18 @@ final class ContextMenuVC: UIViewController { switch cellViewModel.variant { case .standardOutgoing, .standardOutgoingDeleted, .standardOutgoingDeletedLocally: - menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) - emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) + let inset: CGFloat = Dependencies.isRTL ? -targetFrame.minX : -(UIScreen.main.bounds.width - targetFrame.maxX) + menuView.pin(.trailing, to: .trailing, of: view, withInset: inset) + emojiBar.pin(.trailing, to: .trailing, of: view, withInset: inset) case .standardIncoming, .standardIncomingDeleted, .standardIncomingDeletedLocally: - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) - emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) + emojiBar.pin(.leading, to: .leading, of: view, withInset: inset) default: // Should generally only be the 'delete' action - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) } // Tap gesture @@ -281,10 +290,7 @@ final class ContextMenuVC: UIViewController { initialSpringVelocity: 0.6, options: .curveEaseInOut, animations: { [weak self] in - self?.snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x) - self?.snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y) - self?.snapshot.set(.width, to: targetFrame.width) - self?.snapshot.set(.height, to: targetFrame.height) + self?.snapshot.frame = targetFrame self?.snapshot.superview?.setNeedsLayout() self?.snapshot.superview?.layoutIfNeeded() }, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index a683e730fd..d446cfdfd7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -652,7 +652,7 @@ extension ConversationVC: for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), isSessionPro: viewModel.isCurrentUserSessionPro ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isCurrentUserSessionPro) + showModalForMessagesExceedingCharacterLimit(viewModel.isCurrentUserSessionPro) return } @@ -663,7 +663,7 @@ extension ConversationVC: ) } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { + @MainActor func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in @@ -1667,6 +1667,11 @@ extension ConversationVC: dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } ) + let isCurrentUser: Bool = (viewModel.threadData.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } + return ( (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), profile?.displayName(for: .contact, ignoringNickname: true) @@ -2355,6 +2360,14 @@ extension ConversationVC: let messageInfoViewController = MessageInfoViewController( actions: actions, messageViewModel: finalCellViewModel, + threadCanWrite: (viewModel.threadData.threadCanWrite == true), + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + }, using: viewModel.dependencies ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in @@ -2906,11 +2919,14 @@ extension ConversationVC: func startVoiceMessageRecording() { // Request permission if needed - Permissions.requestMicrophonePermissionIfNeeded(using: viewModel.dependencies) { [weak self] in - DispatchQueue.main.async { - self?.cancelVoiceMessageRecording() + Permissions.requestMicrophonePermissionIfNeeded( + using: viewModel.dependencies, + onNotGranted: { [weak self] in + DispatchQueue.main.async { + self?.cancelVoiceMessageRecording() + } } - } + ) // Keep screen on UIApplication.shared.isIdleTimerDisabled = false diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7ec260b5b3..3b269b50b3 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -461,7 +461,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa titleView.initialSetup( with: self.viewModel.initialThreadVariant, isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true) + isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), + isSessionPro: self.viewModel.threadData.isSessionPro(using: self.viewModel.dependencies) ) // Constraints @@ -796,6 +797,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa with: updatedThreadData.displayName, isNoteToSelf: updatedThreadData.threadIsNoteToSelf, isMessageRequest: (updatedThreadData.threadIsMessageRequest == true), + isSessionPro: updatedThreadData.isSessionPro(using: viewModel.dependencies), threadVariant: updatedThreadData.threadVariant, mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 75549db6f3..8a9b56c87f 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -746,7 +746,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), linkPreviewUrl: linkPreviewDraft?.urlString, - isProMessage: dependencies[cache: .libSession].isSessionPro, + isProMessage: (text.defaulting(to: "").utf16.count > LibSession.CharacterLimit), using: dependencies ) var optimisticAttachments: [Attachment]? @@ -784,6 +784,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold body: interaction.body, expiresStartedAtMs: interaction.expiresStartedAtMs, expiresInSeconds: interaction.expiresInSeconds, + isProMessage: interaction.isProMessage, isSenderModeratorOrAdmin: { switch threadData.threadVariant { case .group, .legacyGroup: diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 38fd63053b..0c9ecf6b51 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -189,7 +189,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index adb0ce5294..a6dcfacca5 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -86,6 +86,7 @@ final class LinkPreviewView: UIView { }() var bodyTappableLabel: TappableLabel? + var bodyTappableLabelHeight: CGFloat = 0 // MARK: - Initialization @@ -222,18 +223,22 @@ final class LinkPreviewView: UIView { bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() } if let cellViewModel: MessageViewModel = cellViewModel { - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + let (bodyTappableLabel, height) = VisibleMessageCell.getBodyTappableLabel( for: cellViewModel, with: maxWidth, textColor: (bodyLabelTextColor ?? .textPrimary), searchText: lastSearchText, delegate: delegate, using: dependencies - ).label + ) self.bodyTappableLabel = bodyTappableLabel + self.bodyTappableLabelHeight = height bodyTappableLabelContainer.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.leading, to: .leading, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.top, to: .top, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyTappableLabelContainer, withInset: -12) + bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyTappableLabelContainer) } if state is LinkPreview.DraftState { diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index be5ab7daa5..0ae01b0688 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import Lucide final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 @@ -87,8 +88,6 @@ final class QuoteView: UIView { let mainStackView = UIStackView(arrangedSubviews: []) mainStackView.axis = .horizontal mainStackView.spacing = smallSpacing - mainStackView.isLayoutMarginsRelativeArrangement = true - mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) mainStackView.alignment = .center mainStackView.setCompressionResistance(.vertical, to: .required) @@ -160,13 +159,13 @@ final class QuoteView: UIView { bodyLabel.lineBreakMode = .byTruncatingTail bodyLabel.numberOfLines = 2 - let targetThemeColor: ThemeValue = { + let (targetThemeColor, proBadgeThemeColor): (ThemeValue, ThemeValue) = { switch mode { case .regular: return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText + (.messageBubble_outgoingText, .white) : + (.messageBubble_incomingText, .primary) ) - case .draft: return .textPrimary + case .draft: return (.textPrimary, .primary) } }() bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) @@ -198,7 +197,10 @@ final class QuoteView: UIView { .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) // Label stack view - let authorLabel = UILabel() + let authorLabel = SessionLabelWithProBadge( + proBadgeSize: .mini, + proBadgeThemeBackgroundColor: proBadgeThemeColor + ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.text = { guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } @@ -207,6 +209,7 @@ final class QuoteView: UIView { return Profile.displayNameNoFallback( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) } @@ -214,13 +217,15 @@ final class QuoteView: UIView { return Profile.displayName( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) }() authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail - authorLabel.isHidden = (authorLabel.text == nil) authorLabel.numberOfLines = 1 + authorLabel.isHidden = (authorLabel.text == nil) + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } authorLabel.setCompressionResistance(.vertical, to: .required) let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) @@ -239,7 +244,7 @@ final class QuoteView: UIView { if mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) - cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal) + cancelButton.setImage(Lucide.image(icon: .x, size: 24)?.withRenderingMode(.alwaysTemplate), for: .normal) cancelButton.themeTintColor = .textPrimary cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize) @@ -247,6 +252,8 @@ final class QuoteView: UIView { mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) + mainStackView.isLayoutMarginsRelativeArrangement = true + mainStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 1) } } diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index b6b64b2ef3..5f11dded10 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -125,9 +125,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - private lazy var authorLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var authorLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge(proBadgeSize: .mini) result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.isProBadgeHidden = true result.setContentHugging(.vertical, to: .required) result.setCompressionResistance(.vertical, to: .required) @@ -341,11 +342,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) contentHStackTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) - // Author label - authorLabel.isHidden = (cellViewModel.senderName == nil) - authorLabel.text = cellViewModel.senderName - authorLabel.themeTextColor = .textPrimary - let isGroupThread: Bool = ( cellViewModel.threadVariant == .community || cellViewModel.threadVariant == .legacyGroup || @@ -391,6 +387,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string bubbleView.isAccessibilityElement = true + // Author label + authorLabel.isHidden = (cellViewModel.senderName == nil) + authorLabel.text = cellViewModel.authorNameSuppressedId + authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) + authorLabel.themeTextColor = .textPrimary + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId) } + // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity .scaledBy( @@ -545,10 +548,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { for: cellViewModel, cellWidth: tableSize.width ) - 2 * inset) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + let linkPreviewView: LinkPreviewView = LinkPreviewView( maxWidth: maxWidth, using: dependencies @@ -567,10 +576,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { using: dependencies ) self.linkPreviewView = linkPreviewView - bubbleView.addSubview(linkPreviewView) - linkPreviewView.pin(to: bubbleView, withInset: 0) + stackView.addArrangedSubview(linkPreviewView) + readMoreButton.themeTextColor = bodyLabelTextColor + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) + self.bodayTappableLabelHeightConstraint = linkPreviewView.bodyTappableLabel?.set( + .height, + to: (shouldExpanded ? linkPreviewView.bodyTappableLabelHeight : min(linkPreviewView.bodyTappableLabelHeight, maxHeight)) + ) + if ((linkPreviewView.bodyTappableLabelHeight - maxHeight >= lineHeight) && !shouldExpanded) { + stackView.addArrangedSubview(readMoreButton) + readMoreButton.isHidden = false + readMoreButton.transform = CGAffineTransform(translationX: inset, y: 0) + } + + bubbleView.addSubview(stackView) + stackView.pin([UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top], to: bubbleView) + stackView.pin(.bottom, to: .bottom, of: bubbleView, withInset: -inset) snContentView.addArrangedSubview(bubbleBackgroundView) self.bodyTappableLabel = linkPreviewView.bodyTappableLabel + self.bodyTappableLabelHeight = linkPreviewView.bodyTappableLabelHeight + case .openGroupInvitation: let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( @@ -629,7 +654,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -678,6 +703,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellWidth: tableSize.width ) - 2 * inset ) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight switch (cellViewModel.quotedInfo, cellViewModel.body) { /// Both quote and body @@ -719,7 +745,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -754,7 +780,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) bodyTappableLabel.numberOfLines = shouldExpanded ? 0 : VisibleMessageCell.maxNumberOfLinesAfterTruncation - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight > UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -1191,7 +1217,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { + public static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { return CGFloat(maxNumberOfLinesAfterTruncation) * UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)).lineHeight } @@ -1395,7 +1421,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return attributedText } - static func getBodyTappableLabel( + public static func getBodyTappableLabel( for cellViewModel: MessageViewModel, with availableWidth: CGFloat, textColor: ThemeValue, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0c9e9f599c..43718f2822 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1,5 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import SwiftUI import Foundation import PhotosUI import Combine @@ -12,7 +13,7 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionNetworkingKit -class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { +class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() @@ -31,6 +32,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob }, using: dependencies ) + private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) + // TODO: Refactor this with SessionThreadViewModel + private var threadViewModelSubject: CurrentValueSubject // MARK: - Initialization @@ -44,36 +48,41 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob self.threadId = threadId self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch + self.threadViewModelSubject = CurrentValueSubject(nil) + self.profileImageStatus = (previous: nil, current: .normal) } // MARK: - Config - - enum NavState { - case standard - case editing + enum ProfileImageStatus: Equatable { + case normal + case expanded + case qrCode } enum NavItem: Equatable { case edit - case cancel - case done } public enum Section: SessionTableSection { case conversationInfo + case sessionId + case sessionIdNoteToSelf case content case adminActions case destructiveActions public var title: String? { switch self { - case .adminActions: return "adminSettings".localized() + case .sessionId: return "accountId".localized() + case .sessionIdNoteToSelf: return "accountIdYours".localized() + case .adminActions: return "adminSettings".localized() default: return nil } } public var style: SessionTableSectionStyle { switch self { + case .sessionId, .sessionIdNoteToSelf: return .titleSeparator case .destructiveActions: return .padding case .adminActions: return .titleRoundedContent default: return .none @@ -83,6 +92,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob public enum TableItem: Differentiable { case avatar + case qrCode case displayName case contactName case threadDescription @@ -111,6 +121,47 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case debugDeleteAttachmentsBeforeNow } + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject + .map { [weak self] threadViewModel -> [SessionNavItem] in + guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } + + let currentUserIsClosedGroupAdmin: Bool = ( + [.legacyGroup, .group].contains(threadViewModel.threadVariant) && + threadViewModel.currentUserIsClosedGroupAdmin == true + ) + + let canEditDisplayName: Bool = ( + threadViewModel.threadIsNoteToSelf != true && + ( + threadViewModel.threadVariant == .contact || + currentUserIsClosedGroupAdmin + ) + ) + + guard canEditDisplayName else { return [] } + + return [ + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Nickname", + action: { [weak self] in + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + } + ) + ] + } + .eraseToAnyPublisher() + // MARK: - Content private struct State: Equatable { @@ -126,7 +177,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in + .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in let userSessionId: SessionId = dependencies[cache: .general].sessionId var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) @@ -135,14 +186,21 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + self?.threadViewModelSubject.send(threadViewModel) + return State( threadViewModel: threadViewModel, disappearingMessagesConfig: disappearingMessagesConfig ) } - .compactMap { [weak self] current -> [SectionModel]? in self?.content(current) } + .compactMap { [weak self] current -> [SectionModel]? in + self?.content( + current, + profileImageStatus: self?.profileImageStatus + ) + } - private func content(_ current: State) -> [SectionModel] { + private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted // so dismiss the screen guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { @@ -167,16 +225,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob isGroup && threadViewModel.currentUserIsClosedGroupAdmin == true ) - let canEditDisplayName: Bool = ( - threadViewModel.threadIsNoteToSelf != true && ( - threadViewModel.threadVariant == .contact || - currentUserIsClosedGroupAdmin - ) - ) let isThreadHidden: Bool = ( threadViewModel.threadShouldBeVisible != true && threadViewModel.threadPinnedPriority == LibSession.hiddenPriority ) + let showThreadPubkey: Bool = ( threadViewModel.threadVariant == .contact || ( threadViewModel.threadVariant == .group && @@ -184,81 +237,89 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) ) // MARK: - Conversation Info + let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - SessionCell.Info( - id: .avatar, - accessory: .profile( - id: threadViewModel.id, - size: .hero, - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - profileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } - - // If we already have a display picture then the main profile gets the icon - return (threadViewModel.threadDisplayPictureUrl != nil ? .rightPlus : .none) - }(), - additionalProfile: threadViewModel.additionalProfile, - additionalProfileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } + (profileImageStatus?.current == .qrCode ? + SessionCell.Info( + id: .qrCode, + accessory: .qrCode( + for: threadViewModel.getQRCodeString(), + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + onTapView: { [weak self] targetView in + let didTapProfileIcon: Bool = !(targetView is UIImageView) - // No display picture means the dual-profile so the additionalProfile gets the icon - return .rightPlus - }(), - accessibility: nil - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding( - leading: 0, - bottom: Values.smallSpacing + if didTapProfileIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) + self?.forceRefresh(type: .postDatabaseQuery) + } else { + self?.showQRCodeLightBox(for: threadViewModel) + } + } + ) + : + SessionCell.Info( + id: .avatar, + accessory: .profile( + id: threadViewModel.id, + size: (profileImageStatus?.current == .expanded ? .expanded : .hero), + threadVariant: threadViewModel.threadVariant, + displayPictureUrl: threadViewModel.threadDisplayPictureUrl, + profile: threadViewModel.profile, + profileIcon: (threadViewModel.threadIsNoteToSelf || threadVariant == .group ? .none : .qrCode), + additionalProfile: threadViewModel.additionalProfile, + accessibility: nil ), - backgroundStyle: .noBackground - ), - onTap: { [weak self] in - switch (threadViewModel.threadVariant, threadViewModel.threadDisplayPictureUrl, currentUserIsClosedGroupAdmin) { - case (.contact, _, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - case (.group, _, true): - self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), + backgroundStyle: .noBackground + ), + onTapView: { [weak self] targetView in + let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) - case (_, .some, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - default: break + if didTapQRCodeIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) + } else { + self?.profileImageStatus = ( + previous: profileImageStatus?.current, + current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + ) + } + self?.forceRefresh(type: .postDatabaseQuery) } - - } + ) ), SessionCell.Info( id: .displayName, title: SessionCell.TextInfo( threadViewModel.displayName, font: .titleLarge, - alignment: .center - ), - trailingAccessory: (!canEditDisplayName ? nil : - .icon( - .pencil, - size: .small, - customTint: .textSecondary - ) + alignment: .center, + trailingImage: { + guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) + }() ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: (!canEditDisplayName ? nil : IconSize.small.size), bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.smallSpacing } + guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } return Values.largeSpacing @@ -271,15 +332,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob identifier: "Username", label: threadViewModel.displayName ), - onTap: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + onTapView: { [weak self, threadId, dependencies] targetView in + guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + return + } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + let proCTAModalVariant: ProCTAModal.Variant = { + switch threadViewModel.threadVariant { + case .group: + return .groupLimit( + isAdmin: currentUserIsClosedGroupAdmin, + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + proBadgeImage: SessionProBadge(size: .mini).toImage(using: dependencies) + ) + default: return .generic + } + }() + + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAModalVariant, + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } ), @@ -295,7 +378,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob tintColor: .textSecondary, customPadding: SessionCell.Padding( top: 0, - bottom: 0 + bottom: Values.largeSpacing ), backgroundStyle: .noBackground ) @@ -324,34 +407,37 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob label: threadDescription ) ) - }, - - (!showThreadPubkey ? nil : - SessionCell.Info( - id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoSmall, - alignment: .center, - interaction: .copy - ), - styling: SessionCell.StyleInfo( - customPadding: SessionCell.Padding( - top: Values.smallSpacing, - bottom: Values.largeSpacing - ), - backgroundStyle: .noBackground - ), - accessibility: Accessibility( - identifier: "Session ID", - label: threadViewModel.id - ) + } + ].compactMap { $0 } + ) + + // MARK: - Session Id + + let sessionIdSection: SectionModel = SectionModel( + model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), + elements: [ + SessionCell.Info( + id: .sessionId, + subtitle: SessionCell.TextInfo( + threadViewModel.id, + font: .monoLarge, + alignment: .center, + interaction: .copy + ), + styling: SessionCell.StyleInfo( + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + accessibility: Accessibility( + identifier: "Session ID", + label: threadViewModel.id ) ) - ].compactMap { $0 } + ] ) // MARK: - Users kicked from groups + guard !currentUserKickedFromGroup else { return [ conversationInfoSection, @@ -398,6 +484,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } // MARK: - Standard Actions + let standardActionsSection: SectionModel = SectionModel( model: .content, elements: [ @@ -608,7 +695,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) ].compactMap { $0 } ) + // MARK: - Admin Actions + let adminActionsSection: SectionModel? = ( !currentUserIsClosedGroupAdmin ? nil : SectionModel( @@ -688,7 +777,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ].compactMap { $0 } ) ) + // MARK: - Destructive Actions + let destructiveActionsSection: SectionModel = SectionModel( model: .destructiveActions, elements: [ @@ -1122,6 +1213,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob return [ conversationInfoSection, + (!showThreadPubkey ? nil : sessionIdSection), standardActionsSection, adminActionsSection, destructiveActionsSection @@ -1130,24 +1222,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob // MARK: - Functions - private func viewDisplayPicture(threadViewModel: SessionThreadViewModel) { - guard - let fileUrl: String = threadViewModel.threadDisplayPictureUrl, - let path: String = try? dependencies[singleton: .displayPictureManager].path(for: fileUrl) - else { return } - - let navController: UINavigationController = StyledNavigationController( - rootViewController: ProfilePictureVC( - imageSource: .url(URL(fileURLWithPath: path)), - title: threadViewModel.displayName, - using: dependencies - ) - ) - navController.modalPresentationStyle = .fullScreen - - self.transitionToScreen(navController, transitionType: .present) - } - private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { guard let name: String = threadViewModel.openGroupName, @@ -2038,4 +2112,45 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case (.community, _), (.legacyGroup, false), (.group, false): return nil } } + + private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { + let qrCodeImage: UIImage = QRCode.generate( + for: threadViewModel.getQRCodeString(), + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + .withRenderingMode(.alwaysTemplate) + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithBackground( + image: qrCodeImage, + 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.transitionToScreen(viewController, transitionType: .present) + } } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 81c07987e0..090b46e07a 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -27,14 +27,18 @@ final class ConversationTitleView: UIView { private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self) private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self) - private lazy var titleLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var titleLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .medium, + withStretchingSpacer: false + ) result.accessibilityIdentifier = "Conversation header name" result.accessibilityLabel = "Conversation header name" result.isAccessibilityElement = true - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.font = Fonts.Headings.H5 result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -48,6 +52,7 @@ final class ConversationTitleView: UIView { let result = UIStackView(arrangedSubviews: [ titleLabel, labelCarouselView ]) result.axis = .vertical result.alignment = .center + result.spacing = 2 return result }() @@ -80,12 +85,14 @@ final class ConversationTitleView: UIView { public func initialSetup( with threadVariant: SessionThread.Variant, isNoteToSelf: Bool, - isMessageRequest: Bool + isMessageRequest: Bool, + isSessionPro: Bool ) { self.update( with: " ", isNoteToSelf: isNoteToSelf, isMessageRequest: isMessageRequest, + isSessionPro: isSessionPro, threadVariant: threadVariant, mutedUntilTimestamp: nil, onlyNotifyForMentions: false, @@ -113,6 +120,7 @@ final class ConversationTitleView: UIView { with name: String, isNoteToSelf: Bool, isMessageRequest: Bool, + isSessionPro: Bool, threadVariant: SessionThread.Variant, mutedUntilTimestamp: TimeInterval?, onlyNotifyForMentions: Bool, @@ -130,12 +138,8 @@ final class ConversationTitleView: UIView { self.titleLabel.text = name self.titleLabel.accessibilityLabel = name - self.titleLabel.font = .boldSystemFont( - ofSize: (shouldHaveSubtitle ? - Values.largeFontSize : - Values.veryLargeFontSize - ) - ) + self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) + self.titleLabel.isProBadgeHidden = !isSessionPro self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 1cf6362d53..d56d28ff63 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -324,7 +324,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: self.viewModel.state.serviceNetwork, forceOffline: self.viewModel.state.forceOffline ) - setUpNavBarSessionHeading() + setUpNavBarSessionHeading(currentUserSessionProState: viewModel.dependencies[singleton: .sessionProState]) // Banner stack view view.addSubview(bannersStackView) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 76cdb79c27..b49b9926e4 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -5,22 +5,60 @@ import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit import SessionMessagingKit +import Lucide struct MessageInfoScreen: View { @EnvironmentObject var host: HostWrapper @State var index = 1 @State var feedbackMessage: String? = nil + @State var isExpanded: Bool = false static private let cornerRadius: CGFloat = 17 var actions: [ContextMenuVC.Action] var messageViewModel: MessageViewModel + let threadCanWrite: Bool + let onStartThread: (@MainActor () -> Void)? let dependencies: Dependencies - var isMessageFailed: Bool { - return [.failed, .failedToSync].contains(messageViewModel.state) + let isMessageFailed: Bool + let isCurrentUser: Bool + let profileInfo: ProfilePictureView.Info? + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + public init( + actions: [ContextMenuVC.Action], + messageViewModel: MessageViewModel, + threadCanWrite: Bool, + onStartThread: (@MainActor () -> Void)?, + using dependencies: Dependencies + ) { + self.actions = actions + self.messageViewModel = messageViewModel + self.threadCanWrite = threadCanWrite + self.onStartThread = onStartThread + self.dependencies = dependencies + + self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) + self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) + self.profileInfo = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), + using: dependencies + ).info + + (self.proFeatures, self.proCTAVariant) = getProFeaturesInfo() } - private var isCurrentUser: Bool { (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) } var body: some View { ZStack (alignment: .topLeading) { @@ -83,7 +121,6 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) } } - .padding(.top, -Values.smallSpacing) .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) } @@ -184,7 +221,7 @@ struct MessageInfoScreen: View { ) { InfoBlock(title: "attachmentsFileId".localized()) { Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: URL(string: $0)?.strippingQueryAndFragment?.absoluteString) } ?? "") - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -193,7 +230,7 @@ struct MessageInfoScreen: View { ) { InfoBlock(title: "attachmentsFileType".localized()) { Text(attachment.contentType) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -201,7 +238,7 @@ struct MessageInfoScreen: View { InfoBlock(title: "attachmentsFileSize".localized()) { Text(Format.fileSize(attachment.byteCount)) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -216,7 +253,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsResolution".localized()) { Text(resolution) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -228,7 +265,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsDuration".localized()) { Text(duration) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -256,53 +293,80 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.mediumSpacing ) { - InfoBlock(title: "sent".localized()) { - Text(messageViewModel.dateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) - .foregroundColor(themeColor: .textPrimary) - } - - InfoBlock(title: "received".localized()) { - Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) + // Pro feature message + if proFeatures.count > 0 { + VStack( + alignment: .leading, + spacing: Values.mediumSpacing + ) { + HStack(spacing: Values.verySmallSpacing) { + SessionProBadge_SwiftUI(size: .small) + Text("message".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + .onTapGesture { + showSessionProCTAIfNeeded() + } + + Text( + "proMessageInfoFeatures" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + ) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) + + VStack( + alignment: .leading, + spacing: Values.smallSpacing + ) { + ForEach(self.proFeatures, id: \.self) { feature in + HStack(spacing: Values.smallSpacing) { + AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17)) + .font(.system(size: 17)) + .foregroundColor(themeColor: .primary) + + Text(feature) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + } + } + } } if isMessageFailed { let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { Text(failureText) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .danger) } + } else { + InfoBlock(title: "sent".localized()) { + Text(messageViewModel.dateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + + InfoBlock(title: "received".localized()) { + Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } } InfoBlock(title: "from".localized()) { HStack( spacing: 10 ) { - let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), - using: dependencies - ) - let size: ProfilePictureView.Size = .list - - if let info: ProfilePictureView.Info = info { + if let info: ProfilePictureView.Info = self.profileInfo { ProfilePictureSwiftUI( size: size, info: info, - additionalInfo: additionalInfo, + additionalInfo: nil, dataManager: dependencies[singleton: .imageDataManager] ) .frame( @@ -316,24 +380,47 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.verySmallSpacing ) { - if isCurrentUser { - Text("you".localized()) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) - } - else if !messageViewModel.authorName.isEmpty { - Text(messageViewModel.authorName) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) + HStack(spacing: Values.verySmallSpacing) { + if isCurrentUser { + Text("you".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + else if !messageViewModel.authorNameSuppressedId.isEmpty { + Text(messageViewModel.authorNameSuppressedId) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + + if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { + SessionProBadge_SwiftUI(size: .small) + .onTapGesture { + showSessionProCTAIfNeeded() + } + } } + Text(messageViewModel.authorId) - .font(.spaceMono(size: Values.smallFontSize)) - .foregroundColor(themeColor: .textPrimary) + .font(.Display.base) + .foregroundColor( + themeColor: { + if + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) + { + return .textSecondary + } + else { + return .textPrimary + } + }() + ) } } } + .onTapGesture { + showUserProfileModal() + } } .frame( maxWidth: .infinity, @@ -383,8 +470,7 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) .frame(width: 26, height: 26) Text(actions[index].title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Headings.H8) .foregroundColor(themeColor: tintColor) } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -419,6 +505,144 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } + private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } + + if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { + proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) + } + + if ( + messageViewModel.isProMessage && + messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit || + dependencies[feature: .messageFeatureLongMessage] + ) { + proFeatures.append("proIncreasedMessageLengthFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) + } + + if ( + ImageDataManager.isAnimatedImage(profileInfo?.source) || + dependencies[feature: .messageFeatureAnimatedAvatar] + ) { + proFeatures.append("proAnimatedDisplayPictureFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) + } + + return (proFeatures, proCTAVariant) + } + + private func showSessionProCTAIfNeeded() { + guard dependencies[feature: .sessionProEnabled] && (!dependencies[cache: .libSession].isSessionPro) else { + return + } + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: proCTAVariant, + dataManager: dependencies[singleton: .imageDataManager] + ) + ) + self.host.controller?.present(sessionProModal, animated: true) + } + + func showUserProfileModal() { + guard threadCanWrite 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: messageViewModel.authorId)) != .blinded25 else { return } + + guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: .none, + using: dependencies + ).info else { + return + } + + let (sessionId, blindedId): (String?, String?) = { + guard + (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15, + let openGroupServer: String = messageViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = messageViewModel.threadOpenGroupPublicKey + else { + return (messageViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: messageViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + return (lookup?.sessionId, messageViewModel.authorId.truncated(prefix: 10, suffix: 10)) + }() + + 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 messageViewModel.threadVariant == .community else { return true } + return messageViewModel.profile?.blocksCommunityMessageRequests != true + }() + + let (displayName, contactDisplayName): (String?, String?) = { + guard let sessionId: String = sessionId else { + return (messageViewModel.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) } + ) + + let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } + + return ( + (profile?.displayName(for: .contact) ?? messageViewModel.authorNameSuppressedId), + profile?.displayName(for: .contact, ignoringNickname: true) + ) + }() + + 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: messageViewModel.profile) }), + isMessageRequestsEnabled: isMessasgeRequestsEnabled, + onStartThread: self.onStartThread, + onProBadgeTapped: self.showSessionProCTAIfNeeded + ), + dataManager: dependencies[singleton: .imageDataManager] + ) + ) + self.host.controller?.present(userProfileModal, animated: true, completion: nil) + } + private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( for: messageViewModel.threadId, @@ -438,8 +662,11 @@ struct MessageInfoScreen: View { } } +// MARK: - MessageBubble + struct MessageBubble: View { @State private var maxWidth: CGFloat? + @State private var isExpanded: Bool = false static private let cornerRadius: CGFloat = 18 static private let inset: CGFloat = 12 @@ -462,6 +689,15 @@ struct MessageBubble: View { cellWidth: UIScreen.main.bounds.width ) - 2 * Self.inset ) + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: messageViewModel) + let height: CGFloat = VisibleMessageCell.getBodyTappableLabel( + for: messageViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: nil, + delegate: nil, + using: dependencies + ).height VStack( alignment: .leading, @@ -524,9 +760,20 @@ struct MessageBubble: View { searchText: nil, using: dependencies ) { - AttributedText(bodyText) + AttributedLabel(bodyText, maxWidth: maxWidth) + .padding(.horizontal, Self.inset) + .padding(.top, Self.inset) + .frame( + maxHeight: (isExpanded ? .infinity : maxHeight) + ) + } + + if (maxHeight < height && !isExpanded) { + Text("messageBubbleReadMore".localized()) + .bold() + .font(.system(size: Values.smallFontSize)) .foregroundColor(themeColor: bodyLabelTextColor) - .padding(.all, Self.inset) + .padding(.horizontal, Self.inset) } } else { @@ -535,6 +782,7 @@ struct MessageBubble: View { if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ // TODO: Playback Info and check if playing function is needed VoiceMessageView_SwiftUI(attachment: attachment) + .padding(.top, Self.inset) } case .audio, .genericAttachment: if let attachment: Attachment = messageViewModel.attachments?.first { @@ -544,6 +792,7 @@ struct MessageBubble: View { textColor: bodyLabelTextColor ) .modifier(MaxWidthEqualizer.notify) + .padding(.top, Self.inset) .frame( width: maxWidth, alignment: .leading @@ -553,10 +802,16 @@ struct MessageBubble: View { } } } + .padding(.bottom, Self.inset) + .onTapGesture { + self.isExpanded = true + } } } } +// MARK: - InfoBlock + struct InfoBlock: View where Content: View { let title: String let content: () -> Content @@ -569,8 +824,7 @@ struct InfoBlock: View where Content: View { spacing: Values.verySmallSpacing ) { Text(self.title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) self.content() } @@ -581,16 +835,22 @@ struct InfoBlock: View where Content: View { } } +// MARK: - MessageInfoViewController + final class MessageInfoViewController: SessionHostingViewController { init( actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, + threadCanWrite: Bool, + onStartThread: (() -> Void)?, using dependencies: Dependencies ) { let messageInfoView = MessageInfoScreen( actions: actions, messageViewModel: messageViewModel, - dependencies: dependencies + threadCanWrite: threadCanWrite, + onStartThread: onStartThread, + using: dependencies ) super.init(rootView: messageInfoView) @@ -608,6 +868,8 @@ final class MessageInfoViewController: SessionHostingViewController [SectionModel] { DeveloperSettingsProViewModel.sections( @@ -150,7 +166,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .updateScreen(DeveloperSettingsProViewModel.self), .feature(.mockCurrentUserSessionPro), - .feature(.treatAllIncomingMessagesAsProMessages) + .feature(.allUsersSessionPro), + .feature(.messageFeatureProBadge), + .feature(.messageFeatureLongMessage), + .feature(.messageFeatureAnimatedAvatar) ] static func initialState(using dependencies: Dependencies) -> State { @@ -165,7 +184,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold refundRequestStatus: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } } @@ -210,7 +233,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransaction: purchaseTransaction, refundRequestStatus: refundRequestStatus, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } @@ -342,26 +368,73 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold feature: .mockCurrentUserSessionPro, to: !state.mockCurrentUserSessionPro ) + dependencies[singleton: .sessionProState].isSessionProSubject.send(!state.mockCurrentUserSessionPro) } ), SessionCell.Info( - id: .proIncomingMessages, - title: "All Pro Incoming Messages", + id: .allUsersSessionPro, + title: "Everyone is a Pro", subtitle: """ Treat all incoming messages as Pro messages. + Treat all contacts, groups as Session Pro. """, trailingAccessory: .toggle( - state.treatAllIncomingMessagesAsProMessages, - oldValue: previousState.treatAllIncomingMessagesAsProMessages + state.allUsersSessionPro, + oldValue: previousState.allUsersSessionPro ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .treatAllIncomingMessagesAsProMessages, - to: !state.treatAllIncomingMessagesAsProMessages + feature: .allUsersSessionPro, + to: !state.allUsersSessionPro ) } ) - ] + ].appending( + contentsOf: !state.allUsersSessionPro ? [] : [ + SessionCell.Info( + id: .messageFeatureProBadge, + title: .init("Message Feature: Pro Badge", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureProBadge, + oldValue: previousState.messageFeatureProBadge + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureProBadge, + to: !state.messageFeatureProBadge + ) + } + ), + SessionCell.Info( + id: .messageFeatureLongMessage, + title: .init("Message Feature: Long Message", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureLongMessage, + oldValue: previousState.messageFeatureLongMessage + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureLongMessage, + to: !state.messageFeatureLongMessage + ) + } + ), + SessionCell.Info( + id: .messageFeatureAnimatedAvatar, + title: .init("Message Feature: Animated Avatar", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureAnimatedAvatar, + oldValue: previousState.messageFeatureAnimatedAvatar + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureAnimatedAvatar, + to: !state.messageFeatureAnimatedAvatar + ) + } + ) + ] + ) ) return [general, subscriptions, features] @@ -373,7 +446,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let features: [FeatureConfig] = [ .sessionProEnabled, .mockCurrentUserSessionPro, - .treatAllIncomingMessagesAsProMessages + .allUsersSessionPro ] features.forEach { feature in @@ -390,8 +463,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: .mockCurrentUserSessionPro, to: nil) } - if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { - dependencies.set(feature: .treatAllIncomingMessagesAsProMessages, to: nil) + if dependencies.hasSet(feature: .allUsersSessionPro) { + dependencies.set(feature: .allUsersSessionPro, to: nil) } } diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 66eaf0e948..9d85494286 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -505,9 +505,11 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription" - .put(key: "session_foundation", value: Constants.session_foundation) - .localized()), + body: .text( + "callsVoiceAndVideoModalDescription" + .put(key: "session_foundation", value: Constants.session_foundation) + .localized() + ), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, diff --git a/Session/Settings/RecoveryPasswordScreen.swift b/Session/Settings/RecoveryPasswordScreen.swift index 1fd3034852..989a75a93b 100644 --- a/Session/Settings/RecoveryPasswordScreen.swift +++ b/Session/Settings/RecoveryPasswordScreen.swift @@ -91,19 +91,25 @@ struct RecoveryPasswordScreen: View { self.showQRCode.toggle() } } label: { - Text("recoveryPasswordView".localized()) - .bold() - .font(.system(size: Values.verySmallFontSize)) - .foregroundColor(themeColor: .textPrimary) - .frame( - maxWidth: Self.buttonWidth, - maxHeight: Values.mediumSmallButtonHeight, - alignment: .center - ) - .overlay( - Capsule() - .stroke(themeColor: .textPrimary) - ) + HStack { + Spacer() + + Text("recoveryPasswordView".localized()) + .bold() + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .frame( + maxHeight: Values.mediumSmallButtonHeight, + alignment: .center + ) + .padding(.horizontal, Values.mediumSmallSpacing) + .overlay( + Capsule() + .stroke(themeColor: .textPrimary) + ) + + Spacer() + } } } .frame(maxWidth: .infinity) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 7a3e349321..278f21f9c0 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -35,7 +35,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl @MainActor init(using dependencies: Dependencies) { self.dependencies = dependencies - self.internalState = State.initialState(userSessionId: dependencies[cache: .general].sessionId) + self.internalState = State.initialState( + userSessionId: dependencies[cache: .general].sessionId, + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) bindState() } @@ -44,6 +47,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl enum NavItem: Equatable { case close + case edit case qrCode } @@ -51,8 +55,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case profileInfo case sessionId - case donationAndCommunity - case network + case sessionProAndCommunity + case donationAndnetwork case settings case helpAndData @@ -68,7 +72,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl var style: SessionTableSectionStyle { switch self { case .sessionId: return .titleSeparator - case .donationAndCommunity, .network, .settings, .helpAndData: return .padding + case .sessionProAndCommunity, .donationAndnetwork, .settings, .helpAndData: return .padding default: return .none } } @@ -81,9 +85,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case sessionId case idActions - case donate + case sessionPro case inviteAFriend + case donate case path case sessionNetwork @@ -114,7 +119,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = [ SessionNavItem( id: .qrCode, - image: UIImage(named: "QRCode")? + image: Lucide.image(icon: .qrCode, size: 24)? .withRenderingMode(.alwaysTemplate), style: .plain, accessibilityIdentifier: "View QR code", @@ -125,6 +130,24 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl viewController.setNavBarTitle("qrCode".localized()) self?.transitionToScreen(viewController) } + ), + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Profile Name", + action: { [weak self] in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.transitionToScreen( + ConfirmationModal( + info: self.updateDisplayName(current: self.internalState.profile.displayName()) + ), + transitionType: .present + ) + } + } ) ] @@ -133,6 +156,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile + let isSessionPro: Bool let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -147,15 +171,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .profile(userSessionId.hexString), .feature(.serviceNetwork), .feature(.forceOffline), + .feature(.mockCurrentUserSessionPro), .setting(.developerModeEnabled), .setting(.hideRecoveryPasswordPermanently) + // TODO: [PRO] Need to observe changes to the users pro status ] } - static func initialState(userSessionId: SessionId) -> State { + static func initialState(userSessionId: SessionId, isSessionPro: Bool) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), + isSessionPro: isSessionPro, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -189,6 +216,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile + var isSessionPro: Bool = previousState.isSessionPro var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -249,12 +277,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl forceOffline = updatedValue } + else if event.key == .feature(.mockCurrentUserSessionPro) { + guard let updatedValue: Bool = event.value as? Bool else { return } + + isSessionPro = updatedValue + } } /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, + isSessionPro: isSessionPro, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -276,7 +310,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch (state.serviceNetwork, state.forceOffline) { case (.testnet, false): return .letter("T", false) // stringlint:ignore case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .none + default: return (state.profile.displayPictureUrl?.isEmpty == false) ? .pencil : .rightPlus } }() ), @@ -302,18 +336,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl title: SessionCell.TextInfo( state.profile.displayName(), font: .titleLarge, - alignment: .center - ), - trailingAccessory: .icon( - .pencil, - size: .small, - customTint: .textSecondary + alignment: .center, + trailingImage: (state.isSessionPro ? + ("ProBadge", { SessionProBadge(size: .medium).toImage(using: viewModel.dependencies) }) : + nil + ) ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: IconSize.small.size, bottom: Values.mediumSpacing, interItem: 0 ), @@ -384,20 +416,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let donationAndCommunity: SectionModel = SectionModel( - model: .donationAndCommunity, + let sessionProAndCommunity: SectionModel = SectionModel( + model: .sessionProAndCommunity, elements: [ SessionCell.Info( - id: .donate, - leadingAccessory: .icon( - .heart, - customTint: .sessionButton_border - ), - title: "donate".localized(), - styling: SessionCell.StyleInfo( - tintColor: .sessionButton_border - ), - onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + id: .sessionPro, + leadingAccessory: .proBadge(size: .small), + title: Constants.app_pro, + onTap: { [weak viewModel] in + // TODO: Implement + } ), SessionCell.Info( id: .inviteAFriend, @@ -421,9 +449,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let network: SectionModel = SectionModel( - model: .network, + let donationAndNetwork: SectionModel = SectionModel( + model: .donationAndnetwork, elements: [ + SessionCell.Info( + id: .donate, + leadingAccessory: .icon( + .heart, + customTint: .sessionButton_border + ), + title: "donate".localized(), + onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + ), SessionCell.Info( id: .path, leadingAccessory: .custom( @@ -441,9 +478,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .withRenderingMode(.alwaysTemplate) ), title: Constants.network_name, - trailingAccessory: .custom( - info: NewTagView.Info() - ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionNetworkScreen( @@ -596,7 +630,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl elements: helpAndDataElements ) - return [profileInfo, sessionId, donationAndCommunity, network, settings, helpAndData] + return [profileInfo, sessionId, sessionProAndCommunity, donationAndNetwork, settings, helpAndData] } public lazy var footerView: AnyPublisher = Just(VersionFooterView( @@ -644,6 +678,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self?.updatedName != current }, cancelStyle: .alert_text, + hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in guard @@ -700,7 +735,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .leading, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ): "proAnimatedDisplayPicturesNonProModalDescription" .localized() @@ -708,7 +744,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .trailing, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ) }(), accessibility: Accessibility( @@ -767,7 +804,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch modal.info.body { case .image(.some(let source), _, _, let style, _, _, _, _, _): let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) - guard ( !isAnimatedImage || dependencies[cache: .libSession].isSessionPro || @@ -857,7 +893,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl return .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, isReupload: false ) } diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 2e3d122f45..531395ed97 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -2,8 +2,10 @@ import UIKit import SessionUIKit +import Combine public class BaseVC: UIViewController { + private var disposables: Set = Set() public var onViewWillAppear: ((UIViewController) -> Void)? public var onViewWillDisappear: ((UIViewController) -> Void)? public var onViewDidDisappear: ((UIViewController) -> Void)? @@ -81,16 +83,36 @@ public class BaseVC: UIViewController { navigationItem.titleView = container } - internal func setUpNavBarSessionHeading() { + internal func setUpNavBarSessionHeading(currentUserSessionProState: SessionProManagerType) { let headingImageView = UIImageView( image: UIImage(named: "SessionHeading")? .withRenderingMode(.alwaysTemplate) ) headingImageView.themeTintColor = .textPrimary headingImageView.contentMode = .scaleAspectFit - headingImageView.set(.width, to: 150) + headingImageView.set(.width, to: 140) headingImageView.set(.height, to: Values.mediumFontSize) - navigationItem.titleView = headingImageView + let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) + sessionProBadge.isHidden = !currentUserSessionProState.isSessionProSubject.value + + let stackView: UIStackView = UIStackView( + arrangedSubviews: MainAppContext.determineDeviceRTL() ? [ sessionProBadge, headingImageView ] : [ headingImageView, sessionProBadge ] + ) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 0 + + currentUserSessionProState.isSessionProPublisher + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { [weak sessionProBadge] isPro in + sessionProBadge?.isHidden = !isPro + } + ) + .store(in: &disposables) + + navigationItem.titleView = stackView } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index dc3aecf9d1..3065e50df8 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -26,11 +26,15 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC dataManager: nil ) - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .small, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -300,6 +304,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) } public func updateForMessageSearchResult( @@ -328,6 +333,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) snippetLabel.themeAttributedText = getHighlightedSnippet( content: Interaction.previewText( variant: (cellViewModel.interactionVariant ?? .standardIncoming), @@ -378,6 +384,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC textColor: .textPrimary, using: dependencies ) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) switch cellViewModel.threadVariant { case .contact, .community: bottomLabelStackView.isHidden = true @@ -440,6 +447,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC using: dependencies ) displayNameLabel.text = cellViewModel.displayName + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay if cellViewModel.threadContactIsTyping == true { @@ -597,7 +605,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: "messageSnippetGroup" .put(key: "author", value: authorName) .put(key: "message_snippet", value: "") - .localized(), + .localizedDeformatted(), attributes: [ .themeForegroundColor: textColor ] )) } @@ -607,7 +615,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC case .infoGroupCurrentUserErrorLeaving: return "groupLeaveErrorFailed" .put(key: "group_name", value: cellViewModel.displayName) - .localized() + .localizedDeformatted() default: return Interaction.previewText( @@ -619,7 +627,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC attachmentCount: cellViewModel.interactionAttachmentCount, isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), using: dependencies - ) + ).localizedDeformatted() } }() diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 3e4502aa2e..d0dcd68d58 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -558,7 +558,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa guard info.isEnabled else { return } // Get the view that was tapped (for presenting on iPad) - let tappedView: UIView? = { + let tappedView: UIView? = { () -> UIView? in guard let cell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell else { return nil } @@ -567,6 +567,15 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa let touchLocation: UITouch? = cell.lastTouchLocation cell.lastTouchLocation = nil + if + info.title?.trailingImage != nil, + let localPoint: CGPoint = touchLocation?.location(in: cell.titleLabel), + cell.titleLabel.bounds.contains(localPoint), + cell.titleLabel.isPointOnTrailingAttachment(localPoint) == true + { + return SessionProBadge(size: .large) + } + switch (info.leadingAccessory, info.trailingAccessory) { case (_, is SessionCell.AccessoryConfig.HighlightingBackgroundLabel): return (!cell.trailingAccessoryView.isHidden ? cell.trailingAccessoryView : cell) @@ -582,7 +591,10 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa return cell.trailingAccessoryView.touchedView(touchLocation) - case (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _): + case + (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _), + (is SessionCell.AccessoryConfig.DisplayPicture, _), + (is SessionCell.AccessoryConfig.QRCode, _): guard let touchLocation: UITouch = touchLocation, !cell.leadingAccessoryView.isHidden diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index e1e17b647e..e618758e95 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -35,6 +35,28 @@ public extension SessionCell { // MARK: - DSL public extension SessionCell.Accessory { + static func qrCode( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.QRCode( + for: string, + hasBackground: hasBackground, + logo: logo, + themeStyle: themeStyle + ) + } + + static func proBadge( + size: SessionProBadge.Size + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.ProBadge( + proBadgeSize: size + ) + } + static func icon( _ icon: Lucide.Icon, size: IconSize = .medium, @@ -220,6 +242,80 @@ public extension SessionCell.Accessory { // stringlint:ignore_contents public extension SessionCell.AccessoryConfig { + // MARK: - QRCode + + class QRCode: SessionCell.Accessory { + override public var viewIdentifier: String { + "qr-code" + } + + public let string: String + public let hasBackground: Bool + public let logo: String? + public let themeStyle: UIUserInterfaceStyle + + fileprivate init( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) { + self.string = string + self.hasBackground = hasBackground + self.logo = logo + self.themeStyle = themeStyle + + super.init(accessibility: Accessibility(identifier: "Session QRCode")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + string.hash(into: &hasher) + hasBackground.hash(into: &hasher) + logo?.hash(into: &hasher) + themeStyle.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: QRCode = other as? QRCode else { return false } + + return ( + string == rhs.string && + hasBackground == rhs.hasBackground && + logo == rhs.logo && + themeStyle == rhs.themeStyle + ) + } + } + + // MARK: - Pro Badge + + class ProBadge: SessionCell.Accessory { + override public var viewIdentifier: String { + "pro-badge" + } + + public let proBadgeSize: SessionProBadge.Size + + fileprivate init(proBadgeSize: SessionProBadge.Size) { + self.proBadgeSize = proBadgeSize + super.init(accessibility: Accessibility(identifier: "Session Pro Badge")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + proBadgeSize.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: ProBadge = other as? ProBadge else { return false } + + return (proBadgeSize == rhs.proBadgeSize) + } + } + // MARK: - Icon class Icon: SessionCell.Accessory { diff --git a/Session/Shared/Types/SessionCell+Info.swift b/Session/Shared/Types/SessionCell+Info.swift index cd32ff0e8a..0d499b8752 100644 --- a/Session/Shared/Types/SessionCell+Info.swift +++ b/Session/Shared/Types/SessionCell+Info.swift @@ -126,7 +126,8 @@ public extension SessionCell.Info { isEnabled: Bool = true, accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (@MainActor () -> Void)? = nil + onTap: (@MainActor () -> Void)? = nil, + onTapView: (@MainActor (UIView?) -> Void)? = nil ) { self.id = id self.position = position @@ -140,7 +141,7 @@ public extension SessionCell.Info { self.accessibility = accessibility self.confirmationInfo = confirmationInfo self.onTap = onTap - self.onTapView = nil + self.onTapView = onTapView } // leadingAccessory, trailingAccessory diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 8df10aa317..6eb1048924 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -18,6 +18,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? + let trailingImage: (id: String, imageGenerator: (() -> UIImage))? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -30,6 +31,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, + trailingImage: (id: String, imageGenerator: (() -> UIImage))? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text @@ -38,6 +40,7 @@ public extension SessionCell { self.editingPlaceholder = editingPlaceholder self.interaction = interaction self.accessibility = accessibility + self.trailingImage = trailingImage self.extraViewGenerator = extraViewGenerator } @@ -50,6 +53,7 @@ public extension SessionCell { interaction.hash(into: &hasher) editingPlaceholder.hash(into: &hasher) accessibility.hash(into: &hasher) + trailingImage?.id.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -59,7 +63,8 @@ public extension SessionCell { lhs.textAlignment == rhs.textAlignment && lhs.interaction == rhs.interaction && lhs.editingPlaceholder == rhs.editingPlaceholder && - lhs.accessibility == rhs.accessibility + lhs.accessibility == rhs.accessibility && + lhs.trailingImage?.id == rhs.trailingImage?.id ) } } @@ -110,16 +115,14 @@ public extension SessionCell { var font: UIFont { switch self { case .title: return .boldSystemFont(ofSize: 16) - case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium) + case .titleLarge: return Fonts.Headings.H4 case .titleRegular: return .systemFont(ofSize: 16) case .subtitle: return .systemFont(ofSize: 14) case .subtitleBold: return .boldSystemFont(ofSize: 14) case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) - case .monoLarge: return Fonts.spaceMono( - ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) - ) + case .monoLarge: return Fonts.Display.extraLarge } } } diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index ad543a2ea0..6928938b09 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -149,8 +149,15 @@ class UserListViewModel: SessionTableVie profile: userInfo.profile, profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) ), - title: title, - subtitle: userInfo.itemDescription(using: dependencies), + title: SessionCell.TextInfo( + title, + font: .title, + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() + ), + subtitle: SessionCell.TextInfo(userInfo.itemDescription(using: dependencies), font: .subtitle), trailingAccessory: trailingAccessory, styling: SessionCell.StyleInfo( subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 6412cd1e42..1b23ef69d8 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -94,12 +94,21 @@ extension SessionCell { // MARK: - Interaction func touchedView(_ touch: UITouch) -> UIView { - switch (currentContentView, currentContentView?.subviews.first) { - case (let label as SessionHighlightingBackgroundLabel, _), - (_, let label as SessionHighlightingBackgroundLabel): + switch (currentContentView, currentContentView?.subviews.first, currentContentView?.subviews.last) { + case (let label as SessionHighlightingBackgroundLabel, _, _), + (_, let label as SessionHighlightingBackgroundLabel, _): let localPoint: CGPoint = touch.location(in: label) return (label.bounds.contains(localPoint) ? label : self) + case (let profilePictureView as ProfilePictureView, _, _): + let localPoint: CGPoint = touch.location(in: profilePictureView) + + return profilePictureView.getTouchedView(from: localPoint) + + case (_, let qrCodeImageView as UIImageView , .some(let profileIcon)): + let localPoint: CGPoint = touch.location(in: profileIcon) + + return (profileIcon.bounds.contains(localPoint) ? profileIcon : qrCodeImageView) default: return self } @@ -157,6 +166,12 @@ extension SessionCell { using dependencies: Dependencies ) -> UIView? { switch accessory { + case is SessionCell.AccessoryConfig.QRCode: + return createQRCodeView() + + case is SessionCell.AccessoryConfig.ProBadge: + return SessionProBadge(size: .small) + case is SessionCell.AccessoryConfig.Icon: return createIconView(using: dependencies) @@ -194,6 +209,12 @@ extension SessionCell { private func layout(view: UIView?, accessory: Accessory) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + layoutQRCodeView(view) + + case let accessory as SessionCell.AccessoryConfig.ProBadge: + layoutProBadgeView(view, size: accessory.proBadgeSize) + case let accessory as SessionCell.AccessoryConfig.Icon: layoutIconView( view, @@ -243,6 +264,12 @@ extension SessionCell { using dependencies: Dependencies ) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + configureQRCodeView(view, accessory) + + case let accessory as SessionCell.AccessoryConfig.ProBadge: + configureProBadgeView(view, tintColor: .primary) + case let accessory as SessionCell.AccessoryConfig.Icon: configureIconView(view, accessory, tintColor: tintColor) @@ -286,6 +313,87 @@ extension SessionCell { } } + // MARK: -- QRCode + + private func createQRCodeView() -> UIView { + let result: UIView = UIView() + result.layer.cornerRadius = 10 + + let qrCodeImageView: UIImageView = UIImageView() + qrCodeImageView.contentMode = .scaleAspectFit + + result.addSubview(qrCodeImageView) + qrCodeImageView.pin(to: result, withInset: Values.smallSpacing) + result.set(.width, to: 190) + result.set(.height, to: 190) + + let iconImageView: UIImageView = UIImageView( + image: UIImage(named: "ic_user_round_fill")? + .withRenderingMode(.alwaysTemplate) + ) + iconImageView.contentMode = .scaleAspectFit + iconImageView.set(.width, to: 18) + iconImageView.set(.height, to: 18) + iconImageView.themeTintColor = .black + + let iconBackgroudView: UIView = UIView() + iconBackgroudView.themeBackgroundColor = .primary + iconBackgroudView.set(.width, to: 33) + iconBackgroudView.set(.height, to: 33) + iconBackgroudView.layer.cornerRadius = 16.5 + iconBackgroudView.layer.masksToBounds = true + + iconBackgroudView.addSubview(iconImageView) + iconImageView.center(in: iconBackgroudView) + + result.addSubview(iconBackgroudView) + iconBackgroudView.pin(.top, to: .top, of: result, withInset: -10) + iconBackgroudView.pin(.trailing, to: .trailing, of: result, withInset: 17) + + return result + } + + private func layoutQRCodeView(_ view: UIView?) { + guard let view: UIView = view else { return } + + view.pin(to: self) + fixedWidthConstraint.constant = 190 + fixedWidthConstraint.isActive = true + } + + private func configureQRCodeView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.QRCode) { + guard + let backgroundView: UIView = view, + let qrCodeImageView: UIImageView = view?.subviews.first as? UIImageView + else { return } + + let backgroundThemeColor: ThemeValue = (accessory.themeStyle == .light ? .backgroundSecondary : .textPrimary) + let qrCodeThemeColor: ThemeValue = (accessory.themeStyle == .light ? .textPrimary : .backgroundPrimary) + let qrCodeImage: UIImage = QRCode + .generate(for: accessory.string, hasBackground: accessory.hasBackground, iconName: accessory.logo) + .withRenderingMode(.alwaysTemplate) + + qrCodeImageView.image = qrCodeImage + qrCodeImageView.themeTintColor = qrCodeThemeColor + backgroundView.themeBackgroundColor = backgroundThemeColor + } + + // MARK: -- Pro Badge + + private func layoutProBadgeView(_ view: UIView?, size: SessionProBadge.Size) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.size = size + badgeView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + badgeView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + badgeView.pin(.top, to: .top, of: self) + badgeView.pin(.bottom, to: .bottom, of: self) + } + + private func configureProBadgeView(_ view: UIView?, tintColor: ThemeValue) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.themeBackgroundColor = tintColor + } + // MARK: -- Icon private func createIconView(using dependencies: Dependencies) -> SessionImageView { @@ -616,6 +724,8 @@ extension SessionCell { radioBorderView.center(.vertical, in: self) radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) minWidthConstraint.isActive = true + + view.pin(to: self) } private func configureHighlightingBackgroundLabelAndRadioView( diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 0e78a0e704..8a2aa511ef 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -94,7 +94,7 @@ public class SessionCell: UITableViewCell { return result }() - fileprivate let titleLabel: SRCopyableLabel = { + public let titleLabel: SRCopyableLabel = { let result: SRCopyableLabel = SRCopyableLabel() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false @@ -542,6 +542,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) + titleLabel.attachTrailing(info.title?.trailingImage?.imageGenerator) subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) subtitleLabel.font = info.subtitle?.font subtitleLabel.themeTextColor = info.styling.subtitleTintColor diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift new file mode 100644 index 0000000000..81f7ccae2b --- /dev/null +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -0,0 +1,63 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + +public extension SessionProBadge.Size{ + // stringlint:ignore_contents + var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } +} + +public extension SessionProBadge { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "\(self.size.cacheKey).\(themePrimaryColor)" // stringlint:ignore + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} + +public extension String { + enum SessionProBadgePosition { + case leading, trailing + } + + func addProBadge( + at postion: SessionProBadgePosition, + font: UIFont, + textColor: ThemeValue = .textPrimary, + proBadgeSize: SessionProBadge.Size, + spacing: String = " ", + using dependencies: Dependencies + ) -> ThemedAttributedString { + let base = ThemedAttributedString() + switch postion { + case .leading: + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + case .trailing: + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) + } + + return base + } +} diff --git a/Session/Utilities/MentionUtilities+Attributes.swift b/Session/Utilities/MentionUtilities+Attributes.swift new file mode 100644 index 0000000000..08169965d6 --- /dev/null +++ b/Session/Utilities/MentionUtilities+Attributes.swift @@ -0,0 +1,100 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + + +public extension MentionUtilities { + static func highlightMentions( + in string: String, + currentUserSessionIds: Set, + location: MentionLocation, + textColor: ThemeValue, + attributes: [NSAttributedString.Key: Any], + displayNameRetriever: (String, Bool) -> String?, + using dependencies: Dependencies + ) -> ThemedAttributedString { + let (string, mentions) = getMentions( + in: string, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ) + + let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) + let result = ThemedAttributedString(string: string, attributes: attributes) + let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) + // Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { + if mention.isCurrentUser && location == .incomingMessage { + // Build the rendered chip image + let image = HighlightMentionView( + mentionText: (result.string as NSString).substring(with: mention.range), + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * sizeDiff), + backgroundPadding: (3 * sizeDiff) + ).toImage(using: dependencies) + + let attachment = NSTextAttachment() + let offsetY = (mentionFont.capHeight - image.size.height) / 2 + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: image.size.width, + height: image.size.height + ) + + let attachmentString = NSMutableAttributedString(attachment: attachment) + + // Replace the mention text with the image attachment + result.replaceCharacters(in: mention.range, with: attachmentString) + + let insertIndex = mention.range.location + attachmentString.length + if insertIndex < result.length { + result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) + } + continue + } + + result.addAttribute(.font, value: mentionFont, range: mention.range) + + var targetColor: ThemeValue = textColor + switch location { + case .incomingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .outgoingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .outgoingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .incomingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .quoteDraft, .styleFree: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) + } + + result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) + } + + return result + } +} + +public extension HighlightMentionView { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "Mention.CurrentUser.\(themePrimaryColor)" // stringlint:ignore + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift index a2059109dc..18adfe2c99 100644 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -49,7 +49,8 @@ public extension MentionUtilities { threadVariant: threadVariant, using: dependencies ) - } + }, + using: dependencies ) } } diff --git a/Session/Utilities/UILabel+Interaction.swift b/Session/Utilities/UILabel+Interaction.swift deleted file mode 100644 index 4c7a5e92c5..0000000000 --- a/Session/Utilities/UILabel+Interaction.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -extension UILabel { - - func characterIndex(for point: CGPoint) -> Int { - let textStorage = NSTextStorage(attributedString: attributedText!) - let layoutManager = NSLayoutManager() - textStorage.addLayoutManager(layoutManager) - let textContainer = NSTextContainer(size: bounds.size) - textContainer.lineFragmentPadding = 0 - textContainer.maximumNumberOfLines = numberOfLines - textContainer.lineBreakMode = lineBreakMode - layoutManager.addTextContainer(textContainer) - return layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - } -} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 35f4225f80..6faf2e0234 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -55,8 +55,9 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet public let blocksCommunityMessageRequests: Bool? /// The Pro Proof for when this profile is updated - // TODO: Implement this when the structure of Session Pro Proof is determined + // TODO: Implement these when the structure of Session Pro Proof is determined public let sessionProProof: String? + public var showProBadge: Bool? // MARK: - Initialization @@ -68,7 +69,8 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet displayPictureEncryptionKey: Data? = nil, profileLastUpdated: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil, - sessionProProof: String? = nil + sessionProProof: String? = nil, + showProBadge: Bool? = nil ) { self.id = id self.name = name @@ -78,6 +80,7 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet self.profileLastUpdated = profileLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests self.sessionProProof = sessionProProof + self.showProBadge = showProBadge } } @@ -190,10 +193,11 @@ public extension Profile { _ db: ObservingDatabase, id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, customFallback: String? = nil ) -> String { let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) return (existingDisplayName ?? (customFallback ?? id)) } @@ -201,10 +205,11 @@ public extension Profile { static func displayNameNoFallback( _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact + threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false ) -> String? { return (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) } // MARK: - Fetch or Create @@ -218,7 +223,8 @@ public extension Profile { displayPictureEncryptionKey: nil, profileLastUpdated: nil, blocksCommunityMessageRequests: nil, - sessionProProof: nil + sessionProProof: nil, + showProBadge: nil ) } @@ -241,13 +247,14 @@ public extension Profile { static func displayName( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, customFallback: String? = nil, using dependencies: Dependencies ) -> String { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var displayName: String? dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant) }, + retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break @@ -264,12 +271,13 @@ public extension Profile { static func displayNameNoFallback( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, using dependencies: Dependencies ) -> String? { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var displayName: String? dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant) }, + retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 32608ed6e7..e7acb1067f 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -113,7 +113,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: displayPictureUrl.absoluteString, key: displayPictureEncryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, isReupload: true ), using: dependencies @@ -173,7 +173,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { displayPictureUpdate: .currentUserUpdateTo( url: result.downloadUrl, key: result.encryptionKey, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() }, isReupload: true ), using: dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index ebb82f02d3..73f287b5a2 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -72,7 +72,7 @@ internal extension LibSessionCacheType { return .contactUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - contactProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented + contactProProof: getContanctProProof(for: sessionId) // TODO: double check if this is needed after Pro Proof is implemented ) }(), nicknameUpdate: .set(to: data.profile.nickname), diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 31ba81eb19..4fe20e752b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -28,18 +28,51 @@ public extension LibSessionCacheType { func validateProProof(for message: Message?) -> Bool { guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } func validateProProof(for profile: Profile?) -> Bool { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } - func getProProof() -> String? { + func validateSessionProState(for threadId: String?) -> Bool { + guard let threadId = threadId, dependencies[feature: .sessionProEnabled] else { return false } + let threadVariant = dependencies[singleton: .storage].read { db in + try SessionThread + .select(SessionThread.Columns.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + } + guard threadVariant != .community else { return false } + if threadId == dependencies[cache: .general].sessionId.hexString { + return dependencies[feature: .mockCurrentUserSessionPro] + } else { + return dependencies[feature: .allUsersSessionPro] + } + } + + func shouldShowProBadge(for profile: Profile?) -> Bool { + guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } + return ( + dependencies[feature: .allUsersSessionPro] && + dependencies[feature: .messageFeatureProBadge] || + (profile.showProBadge == true) + ) + } + + func getCurrentUserProProof() -> String? { guard isSessionPro else { return nil } return "" } + + func getContanctProProof(for sessionId: String) -> String? { + guard dependencies[feature: .allUsersSessionPro] else { + return nil + } + return "" + } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 94a9b3d35b..c8d324f46d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -56,7 +56,7 @@ internal extension LibSessionCacheType { return .currentUserUpdateTo( url: displayPictureUrl, key: displayPictureEncryptionKey, - sessionProProof: getProProof(), // TODO: double check if this is needed after Pro Proof is implemented + sessionProProof: getCurrentUserProProof(), // TODO: double check if this is needed after Pro Proof is implemented isReupload: false ) }(), diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 1680c1d75f..5dc79f6026 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -26,7 +26,7 @@ extension MessageSender { message: VisibleMessage.from( db, interaction: interaction, - proProof: dependencies.mutate(cache: .libSession, { $0.getProProof() }) + proProof: dependencies.mutate(cache: .libSession, { $0.getCurrentUserProProof() }) ), threadId: threadId, interactionId: interactionId, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 1b6164f6c2..71d534d69e 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -58,6 +58,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case rawBody case expiresStartedAtMs case expiresInSeconds + case isProMessage case state case hasBeenReadByRecipient @@ -141,6 +142,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let rawBody: String? public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? + public let isProMessage: Bool public let state: Interaction.State public let hasBeenReadByRecipient: Bool @@ -252,6 +254,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: state.or(self.state), hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), @@ -468,6 +471,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: self.state, hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: self.mostRecentFailureText, @@ -755,6 +759,7 @@ public extension MessageViewModel { self.rawBody = nil self.expiresStartedAtMs = nil self.expiresInSeconds = nil + self.isProMessage = false self.state = .sent self.hasBeenReadByRecipient = false @@ -806,6 +811,7 @@ public extension MessageViewModel { body: String?, expiresStartedAtMs: Double?, expiresInSeconds: TimeInterval?, + isProMessage: Bool, state: Interaction.State = .sending, isSenderModeratorOrAdmin: Bool, currentUserProfile: Profile, @@ -838,6 +844,7 @@ public extension MessageViewModel { self.rawBody = body self.expiresStartedAtMs = expiresStartedAtMs self.expiresInSeconds = expiresInSeconds + self.isProMessage = isProMessage self.state = state self.hasBeenReadByRecipient = false @@ -952,7 +959,7 @@ public extension MessageViewModel { let linkPreview: TypedTableAlias = TypedTableAlias() let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) - let numColumnsBeforeLinkedRecords: Int = 24 + let numColumnsBeforeLinkedRecords: Int = 25 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT @@ -978,6 +985,7 @@ public extension MessageViewModel { \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), + \(interaction[.isProMessage]), \(interaction[.state]), (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasBeenReadByRecipient), \(interaction[.mostRecentFailureText]), diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 90e986ca60..a1ed006575 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -345,6 +345,31 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + public func isSessionPro(using dependencies: Dependencies) -> Bool { + guard threadIsNoteToSelf == false && threadVariant != .community else { + return false + } + return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} + } + + public func getQRCodeString() -> String { + switch self.threadVariant { + case .contact, .legacyGroup, .group: + return self.threadId + + case .community: + guard + let urlString: String = LibSession.communityUrlFor( + server: self.openGroupServer, + roomToken: self.openGroupRoomToken, + publicKey: self.openGroupPublicKey + ) + else { return "" } + + return urlString + } + } + // MARK: - Marking as Read public enum ReadTarget { diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 1806635be0..66517115fb 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -42,7 +42,7 @@ public extension ProfilePictureView { additionalProfile: Profile? = nil, additionalProfileIcon: ProfileIcon = .none, using dependencies: Dependencies - ) -> (Info?, Info?) { + ) -> (info: Info?, additionalInfo: Info?) { let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( for: displayPictureUrl ) @@ -77,7 +77,7 @@ public extension ProfilePictureView { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) case .list: return .image("SessionWhite24", #imageLiteral(resourceName: "SessionWhite24")) - case .hero, .modal: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) + case .hero, .modal, .expanded: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) } }(), animationBehaviour: .generic(true), diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index 17e0ea1b7e..e90ce744f4 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -20,7 +20,7 @@ public class SessionProState: SessionProManagerType { public var isSessionProSubject: CurrentValueSubject public var isSessionProPublisher: AnyPublisher { isSessionProSubject - .filter { $0 } + .compactMap { $0 } .eraseToAnyPublisher() } @@ -42,7 +42,13 @@ public class SessionProState: SessionProManagerType { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { + let shouldShowProCTA: Bool = { + guard dependencies[feature: .sessionProEnabled] else { return false } + if case .groupLimit = variant { return true } + return !dependencies[feature: .mockCurrentUserSessionPro] + }() + + guard shouldShowProCTA else { return false } beforePresented?() diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index d4ced9d635..7d3c4d4047 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -49,11 +49,15 @@ final class SimplifiedConversationCell: UITableViewCell { return view }() - private lazy var displayNameLabel: UILabel = { - let result = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge( + proBadgeSize: .mini, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -100,6 +104,7 @@ final class SimplifiedConversationCell: UITableViewCell { using: dependencies ) displayNameLabel.text = cellViewModel.displayName + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) self.isAccessibilityElement = true self.accessibilityIdentifier = "Contact" diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 77514dedf8..b1c9946e0b 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -241,20 +241,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { expect(item?.title?.text).to(equal("TestUser")) } - // MARK: ---- has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -270,7 +262,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) @@ -455,21 +447,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -584,21 +567,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -626,7 +600,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) diff --git a/SessionUIKit/Components/HighlightMentionBackgroundView.swift b/SessionUIKit/Components/HighlightMentionBackgroundView.swift deleted file mode 100644 index aad6caff04..0000000000 --- a/SessionUIKit/Components/HighlightMentionBackgroundView.swift +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit - -public extension NSAttributedString.Key { - static let currentUserMentionBackgroundColor: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundColor") - static let currentUserMentionBackgroundCornerRadius: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundCornerRadius") - static let currentUserMentionBackgroundPadding: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundPadding") -} - -public class HighlightMentionBackgroundView: UIView { - weak var targetLabel: UILabel? - var maxPadding: CGFloat = 0 - - init(targetLabel: UILabel) { - self.targetLabel = targetLabel - - super.init(frame: .zero) - - self.isOpaque = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Functions - - public func calculateMaxPadding(for attributedText: NSAttributedString) -> CGFloat { - var allMentionRadii: [CGFloat?] = [] - let path: CGMutablePath = CGMutablePath() - path.addRect(CGRect( - x: 0, - y: 0, - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - )) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - lines.forEach { line in - let runs: [CTRun] = line.ctruns - - runs.forEach { run in - let attributes: NSDictionary = CTRunGetAttributes(run) - allMentionRadii.append( - attributes - .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat - ) - } - } - - let maxRadii: CGFloat? = allMentionRadii - .compactMap { $0 } - .max() - - return (maxRadii ?? 0) - } - - // MARK: - Drawing - - override public func draw(_ rect: CGRect) { - guard - let targetLabel: UILabel = self.targetLabel, - let attributedText: NSAttributedString = targetLabel.attributedText, - let context = UIGraphicsGetCurrentContext() - else { return } - - // Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left - context.textMatrix = .identity - context.translateBy(x: 0, y: bounds.size.height) - context.scaleBy(x: 1.0, y: -1.0) - - // Note: Calculations MUST happen based on the 'targetLabel' size as this class has extra padding - // which can result in calculations being off - let path = CGMutablePath() - let size = targetLabel.sizeThatFits(CGSize(width: targetLabel.bounds.width, height: .greatestFiniteMagnitude)) - path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - var origins = [CGPoint](repeating: .zero, count: lines.count) - CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) - - var currentMentionBounds: CGRect? = nil // Store mention bounding box - var lastMentionBackgroundColor: UIColor = .clear - var lastMentionBackgroundCornerRadius: CGFloat = 0 - - for lineIndex in 0.. Void)?, diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index d1beb7ff9e..9d48ab664f 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -12,7 +12,7 @@ public final class ProfilePictureView: UIView { case currentUser(SessionProManagerType) } - let source: ImageDataManager.DataSource? + public let source: ImageDataManager.DataSource? let animationBehaviour: AnimationBehaviour let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? @@ -51,6 +51,7 @@ public final class ProfilePictureView: UIView { case list case hero case modal + case expanded public var viewSize: CGFloat { switch self { @@ -58,6 +59,7 @@ public final class ProfilePictureView: UIView { case .list: return 46 case .hero: return 110 case .modal: return 90 + case .expanded: return 190 } } @@ -65,17 +67,18 @@ public final class ProfilePictureView: UIView { switch self { case .navigation, .message: return 26 case .list: return 46 - case .hero: return 80 + case .hero: return 90 case .modal: return 90 + case .expanded: return 190 } } public var multiImageSize: CGFloat { switch self { - case .navigation, .message: return 18 // Shouldn't be used + case .navigation, .message, .modal: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 - case .modal: return 90 + case .expanded: return 140 } } @@ -85,6 +88,7 @@ public final class ProfilePictureView: UIView { case .list: return 16 case .hero: return 24 case .modal: return 24 // Shouldn't be used + case .expanded: return 33 } } } @@ -95,10 +99,11 @@ public final class ProfilePictureView: UIView { case rightPlus case letter(Character, Bool) case pencil + case qrCode func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { - case (.crown, .navigation), (.crown, .message): return 1 + case (.crown, .navigation), (.crown, .message): return 2 case (.crown, .list): return 3 case (.crown, .hero): return 5 @@ -109,8 +114,8 @@ public final class ProfilePictureView: UIView { var isLeadingAligned: Bool { switch self { - case .none, .crown, .letter: return true - case .rightPlus, .pencil: return false + case .none, .letter: return true + case .rightPlus, .pencil, .crown, .qrCode: return false } } } @@ -162,6 +167,8 @@ public final class ProfilePictureView: UIView { private var profileIconBottomConstraint: NSLayoutConstraint! private var profileIconBackgroundLeadingAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundTrailingAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundTopAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundBottomAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundWidthConstraint: NSLayoutConstraint! private var profileIconBackgroundHeightConstraint: NSLayoutConstraint! private var additionalProfileIconTopConstraint: NSLayoutConstraint! @@ -348,11 +355,14 @@ public final class ProfilePictureView: UIView { profileIconLabel.pin(to: profileIconBackgroundView) profileIconBackgroundLeadingAlignConstraint = profileIconBackgroundView.pin(.leading, to: .leading, of: imageContainerView) profileIconBackgroundTrailingAlignConstraint = profileIconBackgroundView.pin(.trailing, to: .trailing, of: imageContainerView) - profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) + profileIconBackgroundTopAlignConstraint = profileIconBackgroundView.pin(.top, to: .top, of: imageContainerView) + profileIconBackgroundBottomAlignConstraint = profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) profileIconBackgroundWidthConstraint = profileIconBackgroundView.set(.width, to: size.iconSize) profileIconBackgroundHeightConstraint = profileIconBackgroundView.set(.height, to: size.iconSize) profileIconBackgroundLeadingAlignConstraint.isActive = false profileIconBackgroundTrailingAlignConstraint.isActive = false + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = false additionalProfileIconTopConstraint = additionalProfileIconImageView.pin( .top, @@ -411,7 +421,7 @@ public final class ProfilePictureView: UIView { label.isHidden = true case .crown: - imageView.image = UIImage(systemName: "crown.fill") + imageView.image = UIImage(named: "ic_crown")?.withRenderingMode(.alwaysTemplate) imageView.contentMode = .scaleAspectFit imageView.themeTintColor = .dynamicForPrimary( .green, @@ -421,6 +431,8 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .profileIcon_background imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .rightPlus: imageView.image = UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)) @@ -429,12 +441,16 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .letter(let character, let dangerMode): label.themeTextColor = (dangerMode ? .textPrimary : .backgroundPrimary) backgroundView.themeBackgroundColor = (dangerMode ? .danger : .textPrimary) label.isHidden = false label.text = "\(character)" + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false case .pencil: imageView.image = Lucide.image(icon: .pencil, size: 14)?.withRenderingMode(.alwaysTemplate) @@ -443,7 +459,19 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true + case .qrCode: + imageView.image = Lucide.image(icon: .qrCode, size: (size == .expanded ? 20 : 14))?.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.themeTintColor = .black + backgroundView.themeBackgroundColor = .primary + imageView.isHidden = false + label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false + trailingAlignConstraint.constant = (size == .expanded ? -8 : 0) } } @@ -680,6 +708,13 @@ public final class ProfilePictureView: UIView { .store(in: &disposables) } } + + public func getTouchedView(from localPoint: CGPoint) -> UIView { + if profileIconBackgroundView.frame.contains(localPoint) { + return profileIconBackgroundView + } + return self + } } import SwiftUI diff --git a/Session/Shared/Views/SRCopyableLabel.swift b/SessionUIKit/Components/SRCopyableLabel.swift similarity index 84% rename from Session/Shared/Views/SRCopyableLabel.swift rename to SessionUIKit/Components/SRCopyableLabel.swift index 7c9476ccc2..1b908b944a 100644 --- a/Session/Shared/Views/SRCopyableLabel.swift +++ b/SessionUIKit/Components/SRCopyableLabel.swift @@ -7,7 +7,7 @@ import UIKit -@objc class SRCopyableLabel : UILabel { +@objc public class SRCopyableLabel : UILabel { override public var canBecomeFirstResponder: Bool { return true } @@ -29,7 +29,7 @@ import UIKit )) } - override func copy(_ sender: Any?) { + public override func copy(_ sender: Any?) { UIPasteboard.general.string = text UIMenuController.shared.hideMenu(from: self) } @@ -42,7 +42,7 @@ import UIKit } } - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { return (action == #selector(copy(_:))) } } diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index a4ccd6296d..0b07f3c1ea 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -25,7 +25,7 @@ public final class Separator: UIView { private lazy var titleLabel: UILabel = { let result = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) + result.font = Fonts.Body.baseRegular result.themeTextColor = .textSecondary result.textAlignment = .center @@ -69,8 +69,8 @@ public final class Separator: UIView { addSubview(titleLabel) titleLabel.pin(.top, to: .top, of: roundedLine, withInset: 6) - titleLabel.pin(.leading, to: .leading, of: roundedLine, withInset: 10) - titleLabel.pin(.trailing, to: .trailing, of: roundedLine, withInset: -10) + titleLabel.pin(.leading, to: .leading, of: roundedLine, withInset: 30) + titleLabel.pin(.trailing, to: .trailing, of: roundedLine, withInset: -30) titleLabel.pin(.bottom, to: .bottom, of: roundedLine, withInset: -6) roundedLine.pin(.top, to: .top, of: self) diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift new file mode 100644 index 0000000000..d8565bdae6 --- /dev/null +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -0,0 +1,156 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public class SessionLabelWithProBadge: UIView { + public var font: UIFont { + get { label.font } + set { + label.font = newValue + extraLabel.font = newValue + } + } + + public var text: String? { + get { label.text } + set { + guard label.text != newValue else { return } + label.text = newValue + } + } + + public var extraText: String? { + get { extraLabel.text } + set { + guard extraLabel.text != newValue else { return } + extraLabel.text = newValue + extraLabel.isHidden = !(newValue?.isEmpty == false) + } + } + + public var themeAttributedText: ThemedAttributedString? { + get { label.themeAttributedText } + set { + guard label.themeAttributedText != newValue else { return } + label.themeAttributedText = newValue + } + } + + public var extraThemeAttributedText: ThemedAttributedString? { + get { extraLabel.themeAttributedText } + set { + guard extraLabel.themeAttributedText != newValue else { return } + extraLabel.themeAttributedText = newValue + } + } + + public var themeTextColor: ThemeValue? { + get { label.themeTextColor } + set { + label.themeTextColor = newValue + extraLabel.themeTextColor = newValue + } + } + + public var textAlignment: NSTextAlignment { + get { label.textAlignment } + set { + label.textAlignment = newValue + extraLabel.textAlignment = newValue + } + } + + public var lineBreakMode: NSLineBreakMode { + get { label.lineBreakMode } + set { + label.lineBreakMode = newValue + extraLabel.lineBreakMode = newValue + } + } + + public var numberOfLines: Int { + get { label.numberOfLines } + set { + label.numberOfLines = newValue + extraLabel.numberOfLines = newValue + } + } + + public var isProBadgeHidden: Bool { + get { sessionProBadge.isHidden } + set { sessionProBadge.isHidden = newValue } + } + + public override var isUserInteractionEnabled: Bool { + get { super.isUserInteractionEnabled } + set { + super.isUserInteractionEnabled = newValue + label.isUserInteractionEnabled = newValue + extraLabel.isUserInteractionEnabled = newValue + } + } + + private let proBadgeSize: SessionProBadge.Size + private let proBadgeThemeBackgroundColor: ThemeValue + private let withStretchingSpacer: Bool + + // MARK: - UI Components + + private let label: SRCopyableLabel = SRCopyableLabel() + private let extraLabel: UILabel = UILabel() + + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: proBadgeSize) + result.themeBackgroundColor = proBadgeThemeBackgroundColor + result.isHidden = true + + return result + }() + + private lazy var stackView: UIStackView = { + let result: UIStackView = UIStackView( + arrangedSubviews: + [ + label, + sessionProBadge, + extraLabel, + withStretchingSpacer ? UIView.hStretchingSpacer() : nil + ] + .compactMap { $0 } + ) + result.axis = .horizontal + result.spacing = { + switch proBadgeSize { + case .mini: return 3 + default: return 4 + } + }() + result.alignment = .center + + return result + }() + + // MARK: - Initialization + + public init( + proBadgeSize: SessionProBadge.Size, + proBadgeThemeBackgroundColor: ThemeValue = .primary, + withStretchingSpacer: Bool = true + ) { + self.proBadgeSize = proBadgeSize + self.proBadgeThemeBackgroundColor = proBadgeThemeBackgroundColor + self.withStretchingSpacer = withStretchingSpacer + + super.init(frame: .zero) + self.addSubview(stackView) + stackView.pin(to: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func sizeThatFits(_ size: CGSize) -> CGSize { + return label.sizeThatFits(size) + } +} diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index c5cce36801..47bd9141d8 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -4,48 +4,66 @@ import UIKit public class SessionProBadge: UIView { public enum Size { - case small, large + case mini, small, medium, large - var width: CGFloat { + public var width: CGFloat { switch self { - case .small: return 40 + case .mini: return 24 + case .small: return 32 + case .medium: return 40 case .large: return 52 } } - var height: CGFloat { + public var height: CGFloat { switch self { - case .small: return 18 + case .mini: return 11 + case .small: return 14.5 + case .medium: return 18 case .large: return 26 } } - var cornerRadius: CGFloat { + public var cornerRadius: CGFloat { switch self { - case .small: return 4 + case .mini: return 2.5 + case .small: return 3.5 + case .medium: return 4 case .large: return 6 } } - var proFontHeight: CGFloat { + public var proFontHeight: CGFloat { switch self { - case .small: return 7 + case .mini: return 5 + case .small: return 6 + case .medium: return 7 case .large: return 11 } } - var proFontWidth: CGFloat { + public var proFontWidth: CGFloat { switch self { - case .small: return 28 + case .mini: return 17 + case .small: return 24 + case .medium: return 28 case .large: return 40 } } } - private let size: Size + public var size: Size { + didSet { + widthConstraint.constant = size.width + heightConstraint.constant = size.height + proImageWidthConstraint.constant = size.proFontWidth + proImageHeightConstraint.constant = size.proFontHeight + self.layer.cornerRadius = size.cornerRadius + } + } // MARK: - Initialization public init(size: Size) { self.size = size super.init(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) - self.setupView() + setUpViewHierarchy() } public override init(frame: CGRect) { @@ -57,6 +75,7 @@ public class SessionProBadge: UIView { } // MARK: - UI + private lazy var proImageView: UIImageView = { let result: UIImageView = UIImageView(image: UIImage(named: "session_pro")) result.contentMode = .scaleAspectFit @@ -64,26 +83,28 @@ public class SessionProBadge: UIView { return result }() - private func setupView() { + private var widthConstraint: NSLayoutConstraint! + private var heightConstraint: NSLayoutConstraint! + private var proImageWidthConstraint: NSLayoutConstraint! + private var proImageHeightConstraint: NSLayoutConstraint! + + private func setUpViewHierarchy() { self.addSubview(proImageView) - proImageView.set(.height, to: self.size.proFontHeight) - proImageView.set(.width, to: self.size.proFontWidth) + proImageHeightConstraint = proImageView.set(.height, to: self.size.proFontHeight) + proImageWidthConstraint = proImageView.set(.width, to: self.size.proFontWidth) proImageView.center(in: self) self.themeBackgroundColor = .primary self.clipsToBounds = true self.layer.cornerRadius = self.size.cornerRadius - self.set(.width, to: self.size.width) - self.set(.height, to: self.size.height) - } - - public func toImage() -> UIImage { + widthConstraint = self.set(.width, to: self.size.width) + heightConstraint = self.set(.height, to: self.size.height) + 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/AttributedLabel.swift b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift new file mode 100644 index 0000000000..968af2033b --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct AttributedLabel: UIViewRepresentable { + public typealias UIViewType = UILabel + + let themedAttributedString: ThemedAttributedString? + let maxWidth: CGFloat? + + public init(_ themedAttributedString: ThemedAttributedString?, maxWidth: CGFloat? = nil) { + self.themedAttributedString = themedAttributedString + self.maxWidth = maxWidth + } + + public func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.themeAttributedText = themedAttributedString + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return label + } + + public func updateUIView(_ label: UILabel, context: Context) { + label.themeAttributedText = themedAttributedString + if let maxWidth = maxWidth { + label.preferredMaxLayoutWidth = maxWidth + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift index b9af52e625..fbcee7e8c5 100644 --- a/SessionUIKit/Components/SwiftUI/LightBox.swift +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -9,6 +9,12 @@ public struct LightBox: View { public var itemsToShare: [UIImage] = [] public var content: () -> Content + public init(title: String? = nil, itemsToShare: [UIImage], content: @escaping () -> Content) { + self.title = title + self.itemsToShare = itemsToShare + self.content = content + } + public var body: some View { NavigationView { content() diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 80a6c44825..0594460bec 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -10,7 +10,7 @@ public struct ProCTAModal: View { case longerMessages case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) - case groupLimit(isAdmin: Bool) + case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) // stringlint:ignore_contents public var backgroundImageName: String { @@ -23,8 +23,13 @@ public struct ProCTAModal: View { return "AnimatedProfileCTA.webp" case .morePinnedConvos: return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin): - return isAdmin ? "" : "" + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (false, false): + return "GroupNonAdminCTA.webp" + default: + return "GroupAdminCTA.webp" + } } } // stringlint:ignore_contents @@ -40,10 +45,8 @@ public struct ProCTAModal: View { /// of the modal. public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { switch self { - case .generic: - return (1313.5, 753) - case .animatedProfileImage: - return (690, 363) + case .generic: return (1293, 743) + case .animatedProfileImage: return (690, 363) default: return (0, 0) } } @@ -71,18 +74,24 @@ public struct ProCTAModal: View { .put(key: "app_pro", value: Constants.app_pro) .localized() } - return "proCallToActionPinnedConversationsMoreThan" .put(key: "app_pro", value: Constants.app_pro) .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager .localized() - case .groupLimit: - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localized() - } + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (_, true): + return "proGroupActivatedDescription".localized() + case (true, false): + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localized() + case (false, false): + return "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro" // TODO: Localised + } + } } public var benefits: [String] { @@ -111,13 +120,16 @@ public struct ProCTAModal: View { "proFeatureListLargerGroups".localized(), "proFeatureListLoadsMore".localized() ] - case .groupLimit(let isAdmin): - return !isAdmin ? [] : - [ - "proFeatureListLargerGroups".localized(), - "proFeatureListLongerMessages".localized(), - "proFeatureListLoadsMore".localized() - ] + case .groupLimit(let isAdmin, let isSessionProActivated, _): + switch (isAdmin, isSessionProActivated) { + case (true, false): + return [ + "proFeatureListLargerGroups".localized(), + "proFeatureListLongerMessages".localized(), + "proFeatureListLoadsMore".localized() + ] + default: return [] + } } } } @@ -160,7 +172,7 @@ public struct ProCTAModal: View { ZStack { if let animatedAvatarImageURL = variant.animatedAvatarImageURL { GeometryReader { geometry in - let size: CGFloat = geometry.size.width / 1522.0 * 187.0 + let size: CGFloat = geometry.size.width / 1522.0 * 135 let scale: CGFloat = geometry.size.width / 1522.0 SessionAsyncImage( source: .url(animatedAvatarImageURL), @@ -226,6 +238,14 @@ public struct ProCTAModal: View { .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) } + } else if case .groupLimit(_, let isSessionProActivated, _) = variant, isSessionProActivated { + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) + + Text("proGroupActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } } else { HStack(spacing: Values.smallSpacing) { Text("upgradeTo".localized()) @@ -244,15 +264,26 @@ public struct ProCTAModal: View { .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) - SessionProBadge_SwiftUI(size: .small) + SessionProBadge_SwiftUI(size: .medium) } } - Text(variant.subtitle) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textSecondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + if + case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant, + isSessionProActivated + { + (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))")) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(variant.subtitle) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } } // Benefits @@ -284,7 +315,7 @@ public struct ProCTAModal: View { // Buttons let onlyShowCloseButton: Bool = { - if case .groupLimit(let isAdmin) = variant, !isAdmin { return true } + if case .groupLimit(let isAdmin, let isSessionProActivated, _) = variant, (!isAdmin || isSessionProActivated) { return true } if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { return true } return false }() diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index 03c310b66b..a3422a53c0 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -23,7 +23,6 @@ public struct QRCodeView: View { } static private var cornerRadius: CGFloat = 10 - static private var logoSize: CGFloat = 66 public init( qrCodeImage: UIImage?, diff --git a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift index 94b482f009..6d0e239115 100644 --- a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift @@ -4,15 +4,17 @@ import SwiftUI public struct SessionProBadge_SwiftUI: View { private let size: SessionProBadge.Size + private let themeBackgroundColor: ThemeValue - public init(size: SessionProBadge.Size) { + public init(size: SessionProBadge.Size, themeBackgroundColor: ThemeValue = .primary) { self.size = size + self.themeBackgroundColor = themeBackgroundColor } public var body: some View { ZStack { RoundedRectangle(cornerRadius: size.cornerRadius) - .fill(themeColor: .primary) + .fill(themeColor: themeBackgroundColor) Image("session_pro") .resizable() diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index b54bd7a0df..1939c19c16 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -73,7 +73,7 @@ public struct UserProfileModal: View { ) .scaleEffect(scale, anchor: .topLeading) .onTapGesture { - withAnimation { + withAnimation(.easeInOut(duration: 0.1)) { self.isProfileImageExpanding.toggle() } } @@ -85,7 +85,7 @@ public struct UserProfileModal: View { ) if info.sessionId != nil { - let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (24, 14) ZStack { Circle() .foregroundColor(themeColor: .primary) @@ -366,9 +366,8 @@ public struct UserProfileModal: View { let viewController = SessionHostingViewController( rootView: LightBox( itemsToShare: [ - QRCode.qrCodeImageWithTintAndBackground( + QRCode.qrCodeImageWithBackground( image: qrCodeImage, - themeStyle: ThemeManager.currentTheme.interfaceStyle, size: CGSize(width: 400, height: 400), insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) ) diff --git a/SessionUIKit/Components/TappableLabel.swift b/SessionUIKit/Components/TappableLabel.swift index f750fca998..f79ef2e9e7 100644 --- a/SessionUIKit/Components/TappableLabel.swift +++ b/SessionUIKit/Components/TappableLabel.swift @@ -15,9 +15,8 @@ public protocol TappableLabelDelegate: AnyObject { public class TappableLabel: UILabel { public private(set) var links: [String: NSRange] = [:] - private lazy var highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView(targetLabel: self) private(set) var layoutManager = NSLayoutManager() - private(set) var textContainer = NSTextContainer(size: CGSize.zero) + public private(set) var textContainer = NSTextContainer(size: CGSize.zero) private(set) var textStorage = NSTextStorage() { didSet { textStorage.addLayoutManager(layoutManager) @@ -36,12 +35,6 @@ public class TappableLabel: UILabel { textStorage = NSTextStorage(attributedString: attributedText) findLinksAndRange(attributeString: attributedText) - highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView - .calculateMaxPadding(for: attributedText) - highlightedMentionBackgroundView.frame = self.bounds.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) } } @@ -84,27 +77,36 @@ public class TappableLabel: UILabel { // MARK: - Layout - public override func didMoveToSuperview() { - super.didMoveToSuperview() - - // Note: Because we want the 'highlight' content to appear behind the label we need - // to add the 'highlightedMentionBackgroundView' below it in the view hierarchy - // - // In order to try and avoid adding even more complexity to UI components which use - // this 'TappableLabel' we are going some view hierarchy manipulation and forcing - // these elements to maintain the same superview - highlightedMentionBackgroundView.removeFromSuperview() - superview?.insertSubview(highlightedMentionBackgroundView, belowSubview: self) - } - public override func layoutSubviews() { super.layoutSubviews() textContainer.size = bounds.size - highlightedMentionBackgroundView.frame = self.frame.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) + + if preferredMaxLayoutWidth != bounds.width { + preferredMaxLayoutWidth = bounds.width + invalidateIntrinsicContentSize() + } + } + + public override var intrinsicContentSize: CGSize { + // Compute layout with the current/expected width + let width = preferredMaxLayoutWidth > 0 ? preferredMaxLayoutWidth : bounds.width + let targetWidth = (width > 0) ? width : UIScreen.main.bounds.width + + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) // forces layout + let used = layoutManager.usedRect(for: textContainer) + + // Ceil to avoid fractional sizes causing extra lines/clipping + return CGSize(width: ceil(used.width), height: ceil(used.height)) + } + + public override func sizeThatFits(_ size: CGSize) -> CGSize { + let targetWidth = size.width > 0 ? size.width : UIScreen.main.bounds.width + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) + let used = layoutManager.usedRect(for: textContainer) + return CGSize(width: min(ceil(used.width), targetWidth), height: ceil(used.height)) } // MARK: - Functions diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 9c07b2d702..a13d429b3e 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -9,10 +9,6 @@ public extension NSAttributedString.Key { .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, .themeStrikethroughColor ] - internal static let keysToIgnoreValidation: Set = [ - .currentUserMentionBackgroundColor, .currentUserMentionBackgroundCornerRadius, .currentUserMentionBackgroundPadding - ] - static let themeForegroundColor = NSAttributedString.Key("org.getsession.themeForegroundColor") static let themeBackgroundColor = NSAttributedString.Key("org.getsession.themeBackgroundColor") static let themeStrokeColor = NSAttributedString.Key("org.getsession.themeStrokeColor") @@ -34,37 +30,62 @@ public extension NSAttributedString.Key { // MARK: - ThemedAttributedString public class ThemedAttributedString: Equatable, Hashable { - internal let value: NSMutableAttributedString + internal var value: NSMutableAttributedString { + if let image = imageAttachmentGenerator?() { + let attachment = NSTextAttachment(image: image) + if let font = imageAttachmentReferenceFont { + attachment.bounds = CGRect( + x: 0, + y: font.capHeight / 2 - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) + } + + return NSMutableAttributedString(attachment: attachment) + } + return attributedString + } public var string: String { value.string } public var length: Int { value.length } + internal var imageAttachmentGenerator: (() -> UIImage?)? + internal var imageAttachmentReferenceFont: UIFont? + internal var attributedString: NSMutableAttributedString public init() { - self.value = NSMutableAttributedString() + self.attributedString = NSMutableAttributedString() } public init(attributedString: ThemedAttributedString) { - self.value = attributedString.value + self.attributedString = attributedString.attributedString + self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator } public init(attributedString: NSAttributedString) { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.value = NSMutableAttributedString(attributedString: attributedString) + self.attributedString = NSMutableAttributedString(attributedString: attributedString) } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(string: string, attributes: attributes) + self.attributedString = NSMutableAttributedString(string: string, attributes: attributes) } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(attachment: attachment) + self.attributedString = NSMutableAttributedString(attachment: attachment) + } + + public init(imageAttachmentGenerator: @escaping (() -> UIImage?), referenceFont: UIFont?) { + self.attributedString = NSMutableAttributedString() + self.imageAttachmentGenerator = imageAttachmentGenerator + self.imageAttachmentReferenceFont = referenceFont } required init?(coder: NSCoder) { @@ -89,7 +110,7 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributes ?? [:]) #endif - value.append(NSAttributedString(string: string, attributes: attributes)) + self.attributedString.append(NSAttributedString(string: string, attributes: attributes)) return self } @@ -97,23 +118,23 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) } public func append(_ attributedString: ThemedAttributedString) { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) return self } public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) return self } @@ -122,7 +143,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) } public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { @@ -130,7 +151,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) return self } @@ -139,7 +160,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) } public func addingAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) -> ThemedAttributedString { @@ -147,12 +168,16 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) return self } public func boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], context: NSStringDrawingContext?) -> CGRect { - return value.boundingRect(with: size, options: options, context: context) + return self.attributedString.boundingRect(with: size, options: options, context: context) + } + + public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { + self.attributedString.replaceCharacters(in: range, with: attributedString) } // MARK: - Convenience @@ -162,8 +187,7 @@ public class ThemedAttributedString: Equatable, Hashable { for (key, value) in attributes { guard key.originalKey == nil && - NSAttributedString.Key.themedKeys.contains(key) == false && - NSAttributedString.Key.keysToIgnoreValidation.contains(key) == false + NSAttributedString.Key.themedKeys.contains(key) == false else { continue } if value is ThemeValue { diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 2f71b024b9..1ed75194df 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -764,8 +764,8 @@ public extension ImageDataManager { // MARK: - ImageDataManager.isAnimatedImage public extension ImageDataManager { - static func isAnimatedImage(_ source: ImageDataManager.DataSource) -> Bool { - guard let imageSource: CGImageSource = source.createImageSource() else { return false } + static func isAnimatedImage(_ source: ImageDataManager.DataSource?) -> Bool { + guard let source, let imageSource: CGImageSource = source.createImageSource() else { return false } return (CGImageSourceGetCount(imageSource) > 1) } diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 58b19576b5..844df5a58d 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -66,82 +66,13 @@ public enum MentionUtilities { currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? ) -> String { - /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant - return highlightMentions( + let (string, _) = getMentions( in: string, currentUserSessionIds: currentUserSessionIds, - location: .styleFree, - textColor: .black, - attributes: [:], displayNameRetriever: displayNameRetriever ) - .string - .deformatted() - } - - public static func highlightMentions( - in string: String, - currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - displayNameRetriever: (String, Bool) -> String? - ) -> ThemedAttributedString { - let (string, mentions) = getMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: displayNameRetriever - ) - - let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) - let result: ThemedAttributedString = ThemedAttributedString(string: string, attributes: attributes) - mentions.forEach { mention in - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) - - if mention.isCurrentUser && location == .incomingMessage { - // Note: The designs don't match with the dynamic sizing so these values need to be calculated - // to maintain a "rounded rect" effect rather than a "pill" effect - result.addAttribute(.currentUserMentionBackgroundCornerRadius, value: (8 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundPadding, value: (3 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundColor, value: ThemeValue.primary, range: mention.range) - - // Only add the additional kern if the mention isn't at the end of the string (otherwise this - // would crash due to an index out of bounds exception) - if mention.range.upperBound < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: mention.range.upperBound, length: 1)) - } - } - - var targetColor: ThemeValue = textColor - - switch (location, mention.isCurrentUser) { - // 1 - Incoming messages where the mention is for the current user - case (.incomingMessage, true): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 2 - Incoming messages where the mention is for another user - case (.incomingMessage, false): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 3 - Outgoing messages - case (.outgoingMessage, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 4 - Mentions in quotes - case (.outgoingQuote, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case (.incomingQuote, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 5 - Mentions in quote drafts - case (.quoteDraft, _), (.styleFree, _): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) - } - - result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) - } - return result + return string } } diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index a5df2c5dc8..ee7482b0b5 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -53,13 +53,16 @@ public enum QRCode { let iconName = iconName, let icon: UIImage = UIImage(named: iconName) { - let iconPercent: CGFloat = 0.25 - let iconSize = size.width * iconPercent + let iconPercent: CGFloat = 0.2 + let iconSize: CGSize = CGSize( + width: size.width * iconPercent, + height: icon.size.height * (size.width * iconPercent) / icon.size.width + ) let iconRect = CGRect( - x: (size.width - iconSize) / 2, - y: (size.height - iconSize) / 2, - width: iconSize, - height: iconSize + x: (size.width - iconSize.width) / 2, + y: (size.height - iconSize.height) / 2, + width: iconSize.width, + height: iconSize.height ) // Clear the area under the icon @@ -77,24 +80,13 @@ public enum QRCode { return finalImage ?? qrUIImage } - static func qrCodeImageWithTintAndBackground( + public static func qrCodeImageWithBackground( 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 - } - } + var backgroundColor: UIColor = .white + var tintColor: UIColor = .classicDark1 let outputSize = size ?? image.size let renderer = UIGraphicsImageRenderer(size: outputSize) diff --git a/SessionUIKit/Utilities/String+SessionProBadge.swift b/SessionUIKit/Utilities/String+SessionProBadge.swift deleted file mode 100644 index 15f36582cc..0000000000 --- a/SessionUIKit/Utilities/String+SessionProBadge.swift +++ /dev/null @@ -1,40 +0,0 @@ -// 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/UIImage+Utilities.swift b/SessionUIKit/Utilities/UIImage+Utilities.swift index 0c2e894c15..09806d8ad4 100644 --- a/SessionUIKit/Utilities/UIImage+Utilities.swift +++ b/SessionUIKit/Utilities/UIImage+Utilities.swift @@ -87,4 +87,9 @@ public extension UIImage { return renderedImage } + + func flippedHorizontally() -> UIImage? { + guard let cgImage = self.cgImage else { return nil } + return UIImage(cgImage: cgImage, scale: scale, orientation: .upMirrored) + } } diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift new file mode 100644 index 0000000000..ef6476a079 --- /dev/null +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UILabel { + /// Appends a rendered snapshot of `view` as an inline image attachment. + func attachTrailing(_ imageGenerator: (() -> UIImage?)?, spacing: String = " ") { + guard let imageGenerator else { return } + + let base = ThemedAttributedString() + if let existing = attributedText, existing.length > 0 { + base.append(existing) + } else if let t = text { + base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) + } + + base.append(NSAttributedString(string: spacing)) + base.append(ThemedAttributedString(imageAttachmentGenerator: imageGenerator, referenceFont: font)) + + themeAttributedText = base + numberOfLines = 0 + lineBreakMode = .byWordWrapping + } + + /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment at the end of the string. + /// Works with multi-line labels, alignment, and truncation. + func isPointOnTrailingAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { + guard let attributed = attributedText, attributed.length > 0 else { return false } + + // Reuse the general function but also ensure the attachment range ends at string end. + // We re-run the minimal parts to get the effectiveRange. + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + textContainer.maximumNumberOfLines = numberOfLines + textContainer.lineBreakMode = lineBreakMode + + let textStorage = NSTextStorage(attributedString: attributed) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + layoutManager.ensureLayout(for: textContainer) + + let glyphRange = layoutManager.glyphRange(for: textContainer) + if glyphRange.length == 0 { return false } + let textBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + var textOrigin = CGPoint.zero + switch textAlignment { + case .center: textOrigin.x = (bounds.width - textBounds.width) / 2.0 + case .right: textOrigin.x = bounds.width - textBounds.width + case .natural where effectiveUserInterfaceLayoutDirection == .rightToLeft: + textOrigin.x = bounds.width - textBounds.width + default: break + } + + let pt = CGPoint(x: point.x - textOrigin.x, y: point.y - textOrigin.y) + if !textBounds.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) { return false } + + let idx = layoutManager.characterIndex(for: pt, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + guard idx < attributed.length else { return false } + + var range = NSRange(location: 0, length: 0) + guard attributed.attribute(.attachment, at: idx, effectiveRange: &range) is NSTextAttachment, + NSMaxRange(range) == attributed.length else { + return false + } + + let attGlyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + let attRect = layoutManager.boundingRect(forGlyphRange: attGlyphRange, in: textContainer) + return attRect.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) + } +} diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 7500f0dca4..04ce1e090f 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -94,8 +94,20 @@ public extension FeatureStorage { identifier: "mockCurrentUserSessionPro" ) - static let treatAllIncomingMessagesAsProMessages: FeatureConfig = Dependencies.create( - identifier: "treatAllIncomingMessagesAsProMessages" + static let allUsersSessionPro: FeatureConfig = Dependencies.create( + identifier: "allUsersSessionPro" + ) + + static let messageFeatureProBadge: FeatureConfig = Dependencies.create( + identifier: "messageFeatureProBadge" + ) + + static let messageFeatureLongMessage: FeatureConfig = Dependencies.create( + identifier: "messageFeatureLongMessage" + ) + + static let messageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( + identifier: "messageFeatureAnimatedAvatar" ) static let shortenFileTTL: FeatureConfig = Dependencies.create( diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 1c7f764c4e..cef2c6f3fb 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -14,6 +14,15 @@ public extension Cache { ) } +public extension Cache { + static let generalUI: CacheConfig = Dependencies.create( + identifier: "generalUI", + createInstance: { dependencies in General.UICache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - General.Cache public enum General { @@ -58,6 +67,18 @@ public enum General { self.ed25519SecretKey = ed25519SecretKey } } + + public class UICache: GeneralUICacheType { + private let cache: NSCache = NSCache() + + public func cache(_ image: UIImage, for key: String) { + cache.setObject(image, forKey: key as NSString) + } + + public func get(for key: String) -> UIImage? { + return cache.object(forKey: key as NSString) + } + } } // MARK: - GeneralCacheType @@ -82,3 +103,14 @@ public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { func setSecretKey(ed25519SecretKey: [UInt8]) } + +// MARK: Cache.GeneralUI + +/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way +public protocol ImmutableGeneralUICacheType: ImmutableCacheType { + func get(for key: String) -> UIImage? +} + +public protocol GeneralUICacheType: ImmutableGeneralUICacheType, MutableCacheType { + func cache(_ image: UIImage, for key: String) +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 0f9a2c430c..4ff96a1a59 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -90,7 +90,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result