diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 918924bf05..cd344a81ee 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -171,6 +171,7 @@ 942256A12C23F90700C0FDBF /* CustomTopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */; }; 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; + 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -198,6 +199,10 @@ 94AAB1602E24C97400A6FA18 /* AnimatedProfileCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; 94B6BAF62E30A88800E718BB /* SessionProState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAF52E30A88800E718BB /* SessionProState.swift */; }; + 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */; }; + 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */; }; + 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB012E3AE85800E718BB /* QRCode.swift */; }; + 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */; }; 94B6BB062E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; 94B6BB072E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; @@ -250,7 +255,6 @@ B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3F255A580C00E217F9 /* String+SSK.swift */; }; B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; - B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; @@ -1554,6 +1558,7 @@ 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTopTabBar.swift; sourceTree = ""; }; 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; + 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; @@ -1586,6 +1591,10 @@ 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTA.webp; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; 94B6BAF52E30A88800E718BB /* SessionProState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProState.swift; sourceTree = ""; }; + 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModal.swift; sourceTree = ""; }; + 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; + 94B6BB012E3AE85800E718BB /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; + 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Seperator+SwiftUI.swift"; sourceTree = ""; }; 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = AnimatedProfileCTAAnimationCropped.webp; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProBadge.swift; sourceTree = ""; }; @@ -1635,7 +1644,6 @@ B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; - B886B4A82398BA1500211ABE /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; B88FA7B726045D100049422F /* SOGSAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSAPI.swift; sourceTree = ""; }; B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSuggestionGrid.swift; sourceTree = ""; }; B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; @@ -2731,7 +2739,6 @@ FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - B886B4A82398BA1500211ABE /* QRCode.swift */, FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, @@ -2858,10 +2865,14 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 942BA9402E4487EE007C4595 /* LightBox.swift */, + 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, + 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */, 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */, 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */, 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */, + 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */, 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */, FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, @@ -3344,6 +3355,7 @@ C331FFAE2558FA7700070591 /* Utilities */ = { isa = PBXGroup; children = ( + 94B6BB012E3AE85800E718BB /* QRCode.swift */, 949D91212E822D520074F595 /* String+SessionProBadge.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, @@ -6274,6 +6286,7 @@ 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, + 94B6BB002E3AE83C00E718BB /* QRCodeView.swift in Sources */, FDB3DA882E24810C00148F8D /* SessionAsyncImage.swift in Sources */, 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */, 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */, @@ -6286,11 +6299,13 @@ FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, + 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, + 94B6BB042E3B208C00E718BB /* Seperator+SwiftUI.swift in Sources */, FD8A5B222DC0489C004C689B /* AdaptiveHStack.swift in Sources */, FD42ECD02E289261002D03EA /* ThemeLinearGradient.swift in Sources */, FDE754BE2C9BA16C002A2623 /* UIButtonConfiguration+Utilities.swift in Sources */, @@ -6300,7 +6315,9 @@ FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, 942256942C23F8DD00C0FDBF /* ActivityView.swift in Sources */, FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, + 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, + 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, FD71162028D97ABC00B47552 /* UIImage+Utilities.swift in Sources */, 947D7FE72D51837200E8E413 /* ArrowCapsule.swift in Sources */, @@ -6921,7 +6938,6 @@ FD860CBE2D6E7DAA00BBE29C /* DeveloperSettingsViewModel+Testing.swift in Sources */, FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */, 7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */, - B886B4A92398BA1500211ABE /* QRCode.swift in Sources */, 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */, FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index bda6c2aa00..3f1c928c60 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -237,30 +237,6 @@ extension ConversationVC: return true } - // MARK: - Session Pro CTA - - @discardableResult @MainActor func showSessionProCTAIfNeeded() -> Bool { - let dependencies: Dependencies = viewModel.dependencies - guard dependencies[feature: .sessionProEnabled] && (!viewModel.isSessionPro) else { - return false - } - self.hideInputAccessoryView() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") - } - ) - ) - present(sessionProModal, animated: true, completion: nil) - - return true - } - // MARK: - UIGestureRecognizerDelegate func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true @@ -543,14 +519,28 @@ extension ConversationVC: } @MainActor func handleCharacterLimitLabelTapped() { - guard !showSessionProCTAIfNeeded() else { return } + guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) - let limit: Int = (viewModel.isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) + let limit: Int = (viewModel.isCurrentUserSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -621,9 +611,9 @@ extension ConversationVC: @MainActor func handleSendButtonTapped() { guard LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isSessionPro) + showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isCurrentUserSessionPro) return } @@ -635,7 +625,21 @@ extension ConversationVC: } @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded() else { return } + guard !viewModel.dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -1564,6 +1568,113 @@ extension ConversationVC: reply(cellViewModel, completion: nil) } + func showUserProfileModal(for cellViewModel: MessageViewModel) { + guard viewModel.threadData.threadCanWrite == true else { return } + // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) + guard (try? SessionId.Prefix(from: cellViewModel.authorId)) != .blinded25 else { return } + + let dependencies: Dependencies = viewModel.dependencies + + let (info, _) = ProfilePictureView.getProfilePictureInfo( + size: .hero, + publicKey: cellViewModel.authorId, + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: cellViewModel.profile, + using: dependencies + ) + + guard let profileInfo: ProfilePictureView.Info = info else { return } + + let (sessionId, blindedId): (String?, String?) = { + guard + (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey + else { + return (cellViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: cellViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) + } + return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) + }() + + let (displayName, contactDisplayName): (String?, String?) = { + guard let sessionId: String = sessionId else { + return (cellViewModel.authorName, nil) + } + + let profile: Profile? = dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId)} + + return ( + (profile?.displayName(for: .contact) ?? cellViewModel.authorName), + profile?.displayName(for: .contact, ignoringNickname: true) + ) + }() + + let qrCodeImage: UIImage? = { + guard let sessionId: String = sessionId else { return nil } + return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore + }() + + let isMessasgeRequestsEnabled: Bool = { + guard cellViewModel.threadVariant == .community else { return true } + return cellViewModel.profile?.blocksCommunityMessageRequests != true + }() + + self.hideInputAccessoryView() + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModal( + info: .init( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: displayName, + contactDisplayName: contactDisplayName, + isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), + isMessageRequestsEnabled: isMessasgeRequestsEnabled, + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + }, + onProBadgeTapped: { [weak self, dependencies] in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .generic, + dismissType: .single, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "") + }, + presenting: { modal in + dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true) + } + ) + } + ), + dataManager: dependencies[singleton: .imageDataManager], + afterClosed: { [weak self] in + self?.showInputAccessoryView() + } + ) + ) + present(userProfileModal, animated: true, completion: nil) + } + func startThread( with sessionId: String, openGroupServer: String?, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 804521bd4d..82b8507cf7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -72,7 +72,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private var markAsReadPublisher: AnyPublisher? public let dependencies: Dependencies - public var isSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } + public var isCurrentUserSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index cbb7caa696..a4e2cd8585 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -145,7 +145,7 @@ protocol MessageCellDelegate: ReactionDelegate { func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) func openUrl(_ urlString: String) func handleReplyButtonTapped(for cellViewModel: MessageViewModel) - func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) + func showUserProfileModal(for cellViewModel: MessageViewModel) func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) func handleReadMoreButtonTapped(_ cell: UITableViewCell, for cellViewModel: MessageViewModel) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 20d64ebed9..977da2a226 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1035,25 +1035,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { - // For open groups only attempt to start a conversation if the author has a blinded id - guard cellViewModel.threadVariant != .community else { - // FIXME: Add in support for opening a conversation with a 'blinded25' id - guard (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15 else { return } - - delegate?.startThread( - with: cellViewModel.authorId, - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey - ) - return - } - - delegate?.startThread( - with: cellViewModel.authorId, - openGroupServer: nil, - openGroupPublicKey: nil - ) + if + ( + profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || + authorLabel.bounds.contains(authorLabel.convert(location, from: self)) + ), + cellViewModel.shouldShowProfile + { + delegate?.showUserProfileModal(for: cellViewModel) } else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json b/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json deleted file mode 100644 index 6364b3c6db..0000000000 --- a/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "profile_placeholder.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "profile_placeholder@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "profile_placeholder@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png deleted file mode 100644 index cf0843dcf1..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png and /dev/null differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png deleted file mode 100644 index 02649edd92..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png and /dev/null differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png deleted file mode 100644 index 3a6241122e..0000000000 Binary files a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png and /dev/null differ diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 9daf4ee913..52ae1b611a 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -692,8 +692,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl label: "Upload" ), dataManager: dependencies[singleton: .imageDataManager], - onProBageTapped: { [weak self] in - self?.showSessionProCTAIfNeeded() + onProBageTapped: { [weak self, dependencies] in + Task { @MainActor in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) + } }, onClick: { [weak self] onDisplayPictureSelected in self?.onDisplayPictureSelected = { valueUpdate in @@ -736,7 +745,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dependencies[cache: .libSession].isSessionPro || !dependencies[feature: .sessionProEnabled] ) else { - self?.showSessionProCTAIfNeeded() + Task { @MainActor in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) + } return } @@ -770,21 +788,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) } - @discardableResult func showSessionProCTAIfNeeded() -> Bool { - guard dependencies[feature: .sessionProEnabled] else { return false } - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro - ), - dataManager: dependencies[singleton: .imageDataManager] - ) - ) - self.transitionToScreen(sessionProModal, transitionType: .present) - return true - } - @MainActor private func showPhotoLibraryForAvatar() { Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in DispatchQueue.main.async { diff --git a/Session/Utilities/QRCode.swift b/Session/Utilities/QRCode.swift deleted file mode 100644 index 4a47dac08c..0000000000 --- a/Session/Utilities/QRCode.swift +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit - -enum QRCode { - /// Generates a QRCode for the give string - /// - /// **Note:** If the `hasBackground` value is true then the QRCode will be black and white and - /// the `withRenderingMode(.alwaysTemplate)` won't work correctly on some iOS versions (eg. iOS 16) - /// - /// stringlint:ignore_contents - static func generate(for string: String, hasBackground: Bool) -> UIImage { - let data = string.data(using: .utf8) - var qrCodeAsCIImage: CIImage - let filter1 = CIFilter(name: "CIQRCodeGenerator")! - filter1.setValue(data, forKey: "inputMessage") - qrCodeAsCIImage = filter1.outputImage! - - guard !hasBackground else { - let filter2 = CIFilter(name: "CIFalseColor")! - filter2.setValue(qrCodeAsCIImage, forKey: "inputImage") - filter2.setValue(CIColor(color: .black), forKey: "inputColor0") - filter2.setValue(CIColor(color: .white), forKey: "inputColor1") - qrCodeAsCIImage = filter2.outputImage! - - let scaledQRCodeAsCIImage = qrCodeAsCIImage.transformed(by: CGAffineTransform(scaleX: 6.4, y: 6.4)) - return UIImage(ciImage: scaledQRCodeAsCIImage) - } - - let filter2 = CIFilter(name: "CIColorInvert")! - filter2.setValue(qrCodeAsCIImage, forKey: "inputImage") - qrCodeAsCIImage = filter2.outputImage! - let filter3 = CIFilter(name: "CIMaskToAlpha")! - filter3.setValue(qrCodeAsCIImage, forKey: "inputImage") - qrCodeAsCIImage = filter3.outputImage! - - let scaledQRCodeAsCIImage = qrCodeAsCIImage.transformed(by: CGAffineTransform(scaleX: 6.4, y: 6.4)) - - // Note: It looks like some internal method was changed in iOS 16.0 where images - // generated from a CIImage don't have the same color information as normal images - // as a result tinting using the `alwaysTemplate` rendering mode won't work - to - // work around this we convert the image to data and then back into an image - let imageData: Data = UIImage(ciImage: scaledQRCodeAsCIImage).pngData()! - return UIImage(data: imageData)! - } -} - -import SwiftUI -import SessionUIKit - -struct QRCodeView: View { - let string: String - let hasBackground: Bool - let logo: String? - let themeStyle: UIUserInterfaceStyle - var backgroundThemeColor: ThemeValue { - switch themeStyle { - case .light: - return .backgroundSecondary - default: - return .textPrimary - } - } - var qrCodeThemeColor: ThemeValue { - switch themeStyle { - case .light: - return .textPrimary - default: - return .backgroundPrimary - } - } - - static private var cornerRadius: CGFloat = 10 - static private var logoSize: CGFloat = 66 - - var body: some View { - ZStack(alignment: .center) { - ZStack(alignment: .center) { - RoundedRectangle(cornerRadius: Self.cornerRadius) - .fill(themeColor: backgroundThemeColor) - - Image(uiImage: QRCode.generate(for: string, hasBackground: hasBackground)) - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: qrCodeThemeColor) - .scaledToFit() - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .padding(.vertical, Values.smallSpacing) - - if let logo = logo { - ZStack(alignment: .center) { - Rectangle() - .fill(themeColor: backgroundThemeColor) - - Image(logo) - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: qrCodeThemeColor) - .scaledToFit() - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .padding(.all, 4) - } - .frame( - width: Self.logoSize, - height: Self.logoSize - ) - } - } - .frame( - maxWidth: 400, - maxHeight: 400 - ) - } - .frame(maxWidth: .infinity) - } -} diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index feb5e96ffe..17e0ea1b7e 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -34,4 +34,29 @@ public class SessionProState: SessionProManagerType { self.isSessionProSubject.send(true) completion?(true) } + + @discardableResult @MainActor public func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { + return false + } + beforePresented?() + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: variant, + dataManager: dependencies[singleton: .imageDataManager], + dismissType: dismissType, + afterClosed: afterClosed + ) + ) + presenting?(sessionProModal) + + return true + } } diff --git a/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift index 5eab839233..3b95c7392a 100644 --- a/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift +++ b/SessionUIKit/Components/SwiftUI/ArrowCapsule.swift @@ -7,11 +7,19 @@ public enum ViewPosition: String, Sendable { case top case bottom case none - + case topLeft + case topRight + case bottomLeft + case bottomRight + var opposite: ViewPosition { switch self { case .top: return .bottom case .bottom: return .top + case .topLeft: return .bottomRight + case .topRight: return .bottomLeft + case .bottomLeft: return .topRight + case .bottomRight: return .topLeft default: return .none } } @@ -23,7 +31,6 @@ struct ArrowCapsule: Shape { func path(in rect: CGRect) -> Path { let height = rect.size.height - let maxX = rect.maxX let minX = rect.minX let maxY = rect.maxY @@ -31,36 +38,60 @@ struct ArrowCapsule: Shape { let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) let actualArrowPosition: ViewPosition = self.arrowLength > 0 ? self.arrowPosition : .none + let arrowOffSet: CGFloat = 30 - triangleSideLength + height / 2 var path = Path() - path.move(to: CGPoint(x: minX + height/2, y: minY)) + // 1. Start at top-left arc start point + path.move(to: CGPoint(x: minX + height / 2, y: minY)) - if actualArrowPosition == .top { - path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + // 2. Top edge (arrow if needed) + if actualArrowPosition == .topLeft { + path.addLine(to: CGPoint(x: minX + arrowOffSet, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet, position: .topLeft) + } else if actualArrowPosition == .topRight { + path.addLine(to: CGPoint(x: maxX - arrowOffSet - triangleSideLength, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .topRight) + } else if actualArrowPosition == .top { + path.addLine(to: CGPoint(x: rect.midX - triangleSideLength / 2, y: minY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .top) } - path.addLine(to: CGPoint(x: maxX - height/2, y: minY)) + path.addLine(to: CGPoint(x: maxX - height / 2, y: minY)) + + // 3. Right corner path.addArc( - center: CGPoint(x: maxX - height/2, y: minY + height/2), - radius: height/2, + center: CGPoint(x: maxX - height / 2, y: minY + height / 2), + radius: height / 2, startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 90), clockwise: false ) - if actualArrowPosition == .bottom { - path = self.makeArrow(path: &path, rect:rect, triangleSideLength: triangleSideLength, position: actualArrowPosition) + + // 4. Bottom edge (arrow if needed) + if actualArrowPosition == .bottomRight { + path.addLine(to: CGPoint(x: maxX - arrowOffSet, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottomRight) + } else if actualArrowPosition == .bottomLeft { + path.addLine(to: CGPoint(x: minX + arrowOffSet + triangleSideLength, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottomLeft) + } else if actualArrowPosition == .bottom { + path.addLine(to: CGPoint(x: rect.midX + triangleSideLength / 2, y: maxY)) + path = self.makeArrow(path: &path, rect: rect, triangleSideLength: triangleSideLength, offset: arrowOffSet,position: .bottom) } - path.addLine(to: CGPoint(x: minX + height/2, y: maxY)) + path.addLine(to: CGPoint(x: minX + height / 2, y: maxY)) + + // 5. Left corner path.addArc( - center: CGPoint(x: minX + height/2, y: maxY - height/2), - radius: height/2, + center: CGPoint(x: minX + height / 2, y: maxY - height / 2), + radius: height / 2, startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 270), clockwise: false ) + return path } - func trianglePointsFor(arrowPosition: ViewPosition, rect: CGRect, triangleSideLength: CGFloat) -> (CGPoint, CGPoint, CGPoint) { + func trianglePointsFor(arrowPosition: ViewPosition, rect: CGRect, triangleSideLength: CGFloat, offset: CGFloat) -> (CGPoint, CGPoint, CGPoint) { switch arrowPosition { case .top: return ( @@ -74,6 +105,30 @@ struct ArrowCapsule: Shape { CGPoint(x: rect.midX, y: rect.maxY + arrowLength), CGPoint(x: rect.midX - triangleSideLength / 2, y: rect.maxY) ) + case .topLeft: + return ( + CGPoint(x: rect.minX + offset, y: rect.minY), + CGPoint(x: rect.minX + offset + triangleSideLength / 2, y: rect.minY - arrowLength), + CGPoint(x: rect.minX + offset + triangleSideLength, y: rect.minY) + ) + case .topRight: + return ( + CGPoint(x: rect.maxX - offset - triangleSideLength, y: rect.minY), + CGPoint(x: rect.maxX - offset - triangleSideLength / 2, y: rect.minY - arrowLength), + CGPoint(x: rect.maxX - offset, y: rect.minY) + ) + case .bottomLeft: + return ( + CGPoint(x: rect.minX - offset - triangleSideLength, y: rect.maxY), + CGPoint(x: rect.minX - offset - triangleSideLength / 2, y: rect.maxY + arrowLength), + CGPoint(x: rect.minX - offset, y: rect.maxY) + ) + case .bottomRight: + return ( + CGPoint(x: rect.maxX - offset, y: rect.maxY), + CGPoint(x: rect.maxX - offset - triangleSideLength / 2, y: rect.maxY + arrowLength), + CGPoint(x: rect.maxX - offset - triangleSideLength, y: rect.maxY) + ) default: return ( CGPoint.zero, @@ -83,11 +138,12 @@ struct ArrowCapsule: Shape { } } - func makeArrow(path: inout Path, rect: CGRect, triangleSideLength: CGFloat, position: ViewPosition) -> Path { + func makeArrow(path: inout Path, rect: CGRect, triangleSideLength: CGFloat, offset: CGFloat, position: ViewPosition) -> Path { let points = self.trianglePointsFor( arrowPosition: position, rect: rect, - triangleSideLength: triangleSideLength + triangleSideLength: triangleSideLength, + offset: offset ) path.addLine(to: points.0) diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift new file mode 100644 index 0000000000..b9af52e625 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -0,0 +1,62 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct LightBox: View { + @EnvironmentObject var host: HostWrapper + + public var title: String? + public var itemsToShare: [UIImage] = [] + public var content: () -> Content + + public var body: some View { + NavigationView { + content() + .navigationTitle(title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + self.host.controller?.dismiss(animated: true) + } label: { + Image(systemName: "chevron.left") + .foregroundColor(themeColor: .textPrimary) + } + } + } + .safeAreaInset(edge: .bottom) { + HStack { + Button { + share() + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 20)) + .foregroundColor(themeColor: .textPrimary) + } + + Spacer() + } + .padding() + .backgroundColor(themeColor: .backgroundSecondary) + } + } + } + + private func share() { + let shareVC: UIActivityViewController = UIActivityViewController( + activityItems: itemsToShare, + applicationActivities: nil + ) + + if UIDevice.current.isIPad { + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = self.host.controller?.view + shareVC.popoverPresentationController?.sourceRect = (self.host.controller?.view.bounds ?? UIScreen.main.bounds) + } + + self.host.controller?.present( + shareVC, + animated: true + ) + } +} diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index b0951567de..2fa2c8ee75 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -6,7 +6,7 @@ public struct Modal_SwiftUI: View where Content: View { let host: HostWrapper let dismissType: Modal.DismissType let afterClosed: (() -> Void)? - let content: (@escaping () -> Void) -> Content + let content: (@escaping ((() -> Void)?) -> Void) -> Content let cornerRadius: CGFloat = 11 let shadowRadius: CGFloat = 10 @@ -16,11 +16,21 @@ public struct Modal_SwiftUI: View where Content: View { public var body: some View { ZStack { + // Background + Rectangle() + .fill(.ultraThinMaterial) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onTapGesture { close() } + + // Modal VStack { Spacer() VStack(spacing: 0) { - content{ close() } + content { internalAfterClosed in + close(internalAfterClosed) + } } .backgroundColor(themeColor: .alert_background) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) @@ -40,8 +50,6 @@ public struct Modal_SwiftUI: View where Content: View { maxWidth: .infinity, maxHeight: .infinity ) - .background(.ultraThinMaterial) - .onTapGesture { close() } .gesture( DragGesture(minimumDistance: 20, coordinateSpace: .global) .onEnded { value in @@ -50,14 +58,11 @@ public struct Modal_SwiftUI: View where Content: View { } } ) - .onDisappear { - afterClosed?() - } } // MARK: - Dismiss Logic - private func close() { + private func close(_ internalAfterClosed: (() -> Void)? = nil) { // Recursively dismiss all modals (ie. find the first modal presented by a non-modal // and get that to dismiss it's presented view controller) var targetViewController: UIViewController? = host.controller @@ -70,7 +75,13 @@ public struct Modal_SwiftUI: View where Content: View { } } - targetViewController?.presentingViewController?.dismiss(animated: true) + targetViewController?.presentingViewController?.dismiss( + animated: true, + completion: { + afterClosed?() + internalAfterClosed?() + } + ) } } diff --git a/SessionUIKit/Components/SwiftUI/PopoverView.swift b/SessionUIKit/Components/SwiftUI/PopoverView.swift index 5e0cf83942..b3b97ccb60 100644 --- a/SessionUIKit/Components/SwiftUI/PopoverView.swift +++ b/SessionUIKit/Components/SwiftUI/PopoverView.swift @@ -73,9 +73,9 @@ internal struct PopoverOffset: ViewModifier { var originBounds: CGRect var position: ViewPosition var arrowLength: CGFloat - + func body(content: Content) -> some View { - return content + content .offset( x: self.offsetXFor( position: position, @@ -90,36 +90,41 @@ internal struct PopoverOffset: ViewModifier { arrowLength: arrowLength ) ) - } - + func offsetXFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { - var offsetX: CGFloat = 0 + let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) + let arrowOffSet: CGFloat = 30 - triangleSideLength + frame.size.height / 2 switch position { case .top, .bottom: - offsetX = originBounds.minX + (originBounds.size.width - frame.size.width) / 2 + // Center horizontally + return originBounds.minX + (originBounds.size.width - frame.size.width) / 2 + case .topLeft, .bottomLeft: + // Align right + return originBounds.maxX - frame.size.width + arrowOffSet - triangleSideLength / 2 + case .topRight, .bottomRight: + // Align left + return originBounds.minX - arrowOffSet + triangleSideLength / 2 case .none: - offsetX = 0 + return 0 } - - return offsetX } - - func offsetYFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat)->CGFloat { - var offsetY:CGFloat = 0 + + func offsetYFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { switch position { - case .top: - offsetY = originBounds.minY - frame.size.height - arrowLength - case .bottom: - offsetY = originBounds.minY + originBounds.size.height + arrowLength + case .top, .topLeft, .topRight: + // Position above origin + arrow + return originBounds.minY - frame.size.height - arrowLength + case .bottom, .bottomLeft, .bottomRight: + // Position below origin + arrow + return originBounds.maxY + arrowLength case .none: - offsetY = 0 + return 0 } - - return offsetY } } + public struct AnchorView: ViewModifier { let viewId: String diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index f4196cd942..6b4ed4802c 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -219,15 +219,13 @@ public struct ProCTAModal: View { SessionProBadge_SwiftUI(size: .large) Text("proActivated".localized()) - .font(.system(size: Values.largeFontSize)) - .bold() + .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) } } else { HStack(spacing: Values.smallSpacing) { Text("upgradeTo".localized()) - .font(.system(size: Values.largeFontSize)) - .bold() + .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) SessionProBadge_SwiftUI(size: .large) @@ -239,7 +237,7 @@ public struct ProCTAModal: View { if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { HStack(spacing: Values.verySmallSpacing) { Text("proAlreadyPurchased".localized()) - .font(.system(size: Values.smallFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) SessionProBadge_SwiftUI(size: .small) @@ -247,7 +245,7 @@ public struct ProCTAModal: View { } Text(variant.subtitle) - .font(.system(size: Values.smallFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -273,7 +271,7 @@ public struct ProCTAModal: View { } Text(variant.benefits[index]) - .font(.system(size: Values.smallFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } } @@ -291,10 +289,10 @@ public struct ProCTAModal: View { GeometryReader { geometry in HStack { Button { - close() + close(nil) } label: { Text("close".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) } .frame( @@ -321,11 +319,11 @@ public struct ProCTAModal: View { if result { afterUpgrade?() } - close() + close(nil) } } label: { Text("theContinue".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .sessionButton_primaryFilledText) .framing( maxWidth: .infinity, @@ -340,10 +338,10 @@ public struct ProCTAModal: View { // Cancel Button Button { - close() + close(nil) } label: { Text("cancel".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) .framing( maxWidth: .infinity, @@ -369,6 +367,44 @@ public protocol SessionProManagerType: AnyObject { var isSessionProSubject: CurrentValueSubject { get } var isSessionProPublisher: AnyPublisher { get } func upgradeToPro(completion: ((_ result: Bool) -> Void)?) + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool +} + +// MARK: - Convenience +public extension SessionProManagerType { + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: beforePresented, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @discardableResult @MainActor func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: nil, + afterClosed: nil, + presenting: presenting + ) + } } struct ProCTAModal_Previews: PreviewProvider { diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift new file mode 100644 index 0000000000..03c310b66b --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -0,0 +1,72 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct QRCodeView: View { + let qrCodeImage: UIImage? + let themeStyle: UIUserInterfaceStyle + var backgroundThemeColor: ThemeValue { + switch themeStyle { + case .light: + return .backgroundSecondary + default: + return .textPrimary + } + } + var qrCodeThemeColor: ThemeValue { + switch themeStyle { + case .light: + return .textPrimary + default: + return .backgroundPrimary + } + } + + static private var cornerRadius: CGFloat = 10 + static private var logoSize: CGFloat = 66 + + public init( + qrCodeImage: UIImage?, + themeStyle: UIUserInterfaceStyle + ) { + self.qrCodeImage = qrCodeImage + self.themeStyle = themeStyle + } + + public init( + string: String, + hasBackground: Bool, + logo: String?, + themeStyle: UIUserInterfaceStyle + ) { + self.qrCodeImage = QRCode.generate(for: string, hasBackground: hasBackground, iconName: logo) + self.themeStyle = themeStyle + } + + public var body: some View { + ZStack(alignment: .center) { + ZStack(alignment: .center) { + RoundedRectangle(cornerRadius: Self.cornerRadius) + .fill(themeColor: backgroundThemeColor) + + if let qrCodeImage: UIImage = self.qrCodeImage { + Image(uiImage: qrCodeImage) + .resizable() + .renderingMode(.template) + .foregroundColor(themeColor: qrCodeThemeColor) + .scaledToFit() + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + .padding(.vertical, Values.smallSpacing) + } + } + .frame( + maxWidth: 400, + maxHeight: 400 + ) + } + .frame(maxWidth: .infinity) + } +} diff --git a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift new file mode 100644 index 0000000000..2700be3d17 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift @@ -0,0 +1,27 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct Seperator_SwiftUI: View { + + public let title: String + + public var body: some View { + HStack(spacing: 0) { + Line(color: .textSecondary, lineWidth: Values.separatorThickness) + + Text(title) + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .fixedSize() + .padding(.horizontal, 30) + .padding(.vertical, 6) + .background( + Capsule() + .stroke(themeColor: .textSecondary, lineWidth: Values.separatorThickness) + ) + + Line(color: .textSecondary, lineWidth: Values.separatorThickness) + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift new file mode 100644 index 0000000000..b54bd7a0df --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -0,0 +1,438 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide +import Combine + +public struct UserProfileModal: View { + @EnvironmentObject var host: HostWrapper + @State private var isProfileImageToggled: Bool = true + @State private var isProfileImageExpanding: Bool = false + @State private var isSessionIdCopied: Bool = false + @State private var isShowingTooltip: Bool = false + @State private var tooltipContentFrame: CGRect = CGRect.zero + + private let tooltipViewId: String = "UserProfileModalToolTip" // stringlint:ignore + private let coordinateSpaceName: String = "UserProfileModal" // stringlint:ignore + + private var info: Info + private var dataManager: ImageDataManagerType + let dismissType: Modal.DismissType + let afterClosed: (() -> Void)? + + private var tooltipText: ThemedAttributedString { + if info.sessionId == nil { + return "tooltipBlindedIdCommunities" + .localizedFormatted(baseFont: Fonts.Body.smallRegular) + } else { + return "tooltipAccountIdVisible" + .put(key: "name", value: (info.displayName ?? "")) + .localizedFormatted(baseFont: Fonts.Body.smallRegular) + } + } + + public init( + info: Info, + dataManager: ImageDataManagerType, + dismissType: Modal.DismissType = .recursive, + afterClosed: (() -> Void)? = nil + ) { + self.info = info + self.dataManager = dataManager + self.dismissType = dismissType + self.afterClosed = afterClosed + } + + public var body: some View { + Modal_SwiftUI( + host: host, + dismissType: dismissType, + afterClosed: afterClosed + ) { close in + ZStack(alignment: .topTrailing) { + // Closed button + Button { + close(nil) + } label: { + AttributedText(Lucide.Icon.x.attributedString(size: 20)) + .font(.system(size: 20)) + .foregroundColor(themeColor: .textPrimary) + } + .frame(width: 24, height: 24) + + VStack(spacing: Values.mediumSpacing) { + // Profile Image & QR Code + let scale: CGFloat = isProfileImageExpanding ? (190.0 / 90) : 1 + if isProfileImageToggled { + ZStack(alignment: .topTrailing) { + ZStack { + ProfilePictureSwiftUI( + size: .modal, + info: info.profileInfo, + dataManager: self.dataManager + ) + .scaleEffect(scale, anchor: .topLeading) + .onTapGesture { + withAnimation { + self.isProfileImageExpanding.toggle() + } + } + } + .frame( + width: ProfilePictureView.Size.modal.viewSize * scale, + height: ProfilePictureView.Size.modal.viewSize * scale, + alignment: .center + ) + + if info.sessionId != nil { + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) + ZStack { + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: buttonSize, height: buttonSize) + + if let icon: UIImage = Lucide.image(icon: .qrCode, size: iconSize) { + Image(uiImage: icon) + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: iconSize, height: iconSize) + } + } + .padding(.trailing, isProfileImageExpanding ? 28 : 4) + .onTapGesture { + withAnimation { + self.isProfileImageToggled.toggle() + } + } + } + } + .padding(.top, 12) + .padding(.vertical, 5) + .padding(.horizontal, 10) + } else { + ZStack(alignment: .topTrailing) { + if let qrCodeImage = info.qrCodeImage { + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .accessibility( + Accessibility( + identifier: "QR code", + label: "QR code" + ) + ) + .aspectRatio(1, contentMode: .fit) + .frame(width: 190, height: 190) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .onTapGesture { + showQRCodeLightBox() + } + + Image("ic_user_round_fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(themeColor: .black) + .frame(width: 18, height: 18) + .background( + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: 33, height: 33) + ) + .onTapGesture { + withAnimation { + self.isProfileImageToggled.toggle() + } + } + } + } + .padding(.top, 12) + } + + // Display name & Nickname (ProBadge) + if let displayName: String = info.displayName { + VStack(spacing: Values.smallSpacing) { + HStack(spacing: Values.smallSpacing) { + Text(displayName) + .font(.Headings.H6) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + + if info.isProUser { + SessionProBadge_SwiftUI(size: .large) + .onTapGesture { + info.onProBadgeTapped?() + } + } + } + + if let contactDisplayName: String = info.contactDisplayName, contactDisplayName != displayName { + Text("(\(contactDisplayName))") // stringlint:ignroe + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + } + } + } + + // Account Id | Blinded Id (Tooltips) + let (title, hexEncodedId): (String, String) = { + switch (info.sessionId, info.blindedId) { + case (.some(let sessionId), .none): + return ("accountId".localized(), sessionId) + case (.some(let sessionId), .some): + return ("accountId".localized(), sessionId.splitIntoLines(charactersForLines: [23, 23, 20])) + case (.none, .some(let blindedId)): + return ("blindedId".localized(), blindedId) + case (.none, .none): + return ("", "") // Shouldn't happen + } + }() + + Seperator_SwiftUI(title: title) + + ZStack(alignment: .top) { + if info.blindedId != nil { + HStack { + Spacer() + + Button { + withAnimation { + isShowingTooltip.toggle() + } + } label: { + Image(systemName: "questionmark.circle") + .font(.Body.extraLargeRegular) + .foregroundColor(themeColor: .textPrimary) + } + .anchorView(viewId: tooltipViewId) + } + } + + Text(hexEncodedId) + .font(isIPhone5OrSmaller ? .Display.base : .Display.large) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, info.blindedId == nil ? 0 : Values.largeSpacing) + } + + // Buttons + if let sessionId = info.sessionId { + HStack(spacing: Values.mediumSpacing) { + Button { + close(info.onStartThread) + } label: { + Text("message".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + } + .buttonStyle(PlainButtonStyle()) + + Button { + copySessionId(sessionId) + } label: { + Text(isSessionIdCopied ? "copied".localized() : "copy".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + } + .disabled(isSessionIdCopied) + .buttonStyle(PlainButtonStyle()) + } + .padding(.bottom, 12) + } else { + if !info.isMessageRequestsEnabled, let displayName: String = info.displayName { + AttributedText( + "messageRequestsTurnedOff" + .put(key: "name", value: displayName) + .localizedFormatted(Fonts.Body.smallRegular) + ) + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + } + + GeometryReader { geometry in + HStack { + Button { + close(info.onStartThread) + } label: { + Text("message".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_text : .disabled)) + .overlay( + Capsule() + .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) + .frame( + width: (geometry.size.width - Values.mediumSpacing) / 2, + height: Values.smallButtonHeight + ) + ) + } + .disabled(!info.isMessageRequestsEnabled) + .buttonStyle(PlainButtonStyle()) + } + .frame( + width: geometry.size.width, + height: geometry.size.height, + alignment: .center + ) + } + .frame(height: Values.largeButtonHeight) + .padding(.bottom, 12) + } + } + } + .padding(Values.mediumSpacing) + } + .popoverView( + content: { + ZStack { + AttributedText(tooltipText) + .font(.Body.smallRegular) + .multilineTextAlignment(.center) + .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + .frame(maxWidth: 260) + } + .overlay( + GeometryReader { geometry in + Color.clear // Invisible overlay + .onAppear { + self.tooltipContentFrame = geometry.frame(in: .global) + } + } + ) + }, + backgroundThemeColor: .toast_background, + isPresented: $isShowingTooltip, + frame: $tooltipContentFrame, + position: .topLeft, + viewId: tooltipViewId + ) + .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { + guard self.isShowingTooltip else { + return + } + + withAnimation(.spring()) { + self.isShowingTooltip = false + } + } + } + + private func copySessionId(_ sessionId: String) { + guard !isSessionIdCopied else { return } + + UIPasteboard.general.string = sessionId + + // Ensure we are on the main thread just in case + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + // 4 seconds delay + the animation duration above + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4) + .milliseconds(250)) { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + } + } + } + + private func showQRCodeLightBox() { + guard let qrCodeImage: UIImage = info.qrCodeImage else { return } + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithTintAndBackground( + image: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle, + size: CGSize(width: 400, height: 400), + insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + ) + ] + ) { + VStack { + Spacer() + + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + + Spacer() + } + .backgroundColor(themeColor: .newConversation_background) + }, + customizedNavigationBackground: .backgroundSecondary + ) + viewController.modalPresentationStyle = .fullScreen + self.host.controller?.present(viewController, animated: true) + } +} + +public extension UserProfileModal { + struct Info { + let sessionId: String? + let blindedId: String? + let qrCodeImage: UIImage? + let profileInfo: ProfilePictureView.Info + let displayName: String? + let contactDisplayName: String? + let isProUser: Bool + let isMessageRequestsEnabled: Bool + let onStartThread: (() -> Void)? + let onProBadgeTapped: (() -> Void)? + + public init( + sessionId: String?, + blindedId: String?, + qrCodeImage: UIImage?, + profileInfo: ProfilePictureView.Info, + displayName: String?, + contactDisplayName: String?, + isProUser: Bool, + isMessageRequestsEnabled: Bool, + onStartThread: (() -> Void)?, + onProBadgeTapped: (() -> Void)? + ) { + self.sessionId = sessionId + self.blindedId = blindedId + self.qrCodeImage = qrCodeImage + self.profileInfo = profileInfo + self.displayName = displayName + self.contactDisplayName = contactDisplayName + self.isProUser = isProUser + self.isMessageRequestsEnabled = isMessageRequestsEnabled + self.onStartThread = onStartThread + self.onProBadgeTapped = onProBadgeTapped + } + } +} diff --git a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift index fee96e74f8..05e8bd925a 100644 --- a/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift +++ b/SessionUIKit/Screens/Settings/SessionNetworkScreen/SessionNetworkScreen.swift @@ -190,8 +190,8 @@ extension SessionNetworkScreen { @Binding var isShowingTooltip: Bool @State var tooltipContentFrame: CGRect = CGRect.zero - let tooltipViewId: String = "tooltip" // stringlint:ignore - let scaleRatio: CGFloat = max(UIScreen.main.bounds.width / 390, 1.0) + let tooltipViewId: String = "SessionNetworkScreenToolTip" // stringlint:ignore + let scaleRatio: CGFloat = max(UIScreen.main.bounds.width / 390, 1.0) var body: some View { HStack( diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift new file mode 100644 index 0000000000..a5df2c5dc8 --- /dev/null +++ b/SessionUIKit/Utilities/QRCode.swift @@ -0,0 +1,121 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public enum QRCode { + /// Generates a QRCode with a logo in the middle for the give string + /// + /// **Note:** If the `hasBackground` value is true then the QRCode will be black and white and + /// the `withRenderingMode(.alwaysTemplate)` won't work correctly on some iOS versions (eg. iOS 16) + /// + /// stringlint:ignore_contents + public static func generate(for string: String, hasBackground: Bool, iconName: String?) -> UIImage { + // 1. Create QR code data + guard let data = string.data(using: .utf8), + let qrFilter = CIFilter(name: "CIQRCodeGenerator") else { + return UIImage() + } + + qrFilter.setValue(data, forKey: "inputMessage") + qrFilter.setValue("H", forKey: "inputCorrectionLevel") // High error correction for embedded icon + guard var qrCIImage = qrFilter.outputImage else { return UIImage() } + + // 2. Optional coloring + if hasBackground { + if let colorFilter = CIFilter(name: "CIFalseColor") { + colorFilter.setValue(qrCIImage, forKey: "inputImage") + colorFilter.setValue(CIColor(color: .black), forKey: "inputColor0") + colorFilter.setValue(CIColor(color: .white), forKey: "inputColor1") + qrCIImage = colorFilter.outputImage ?? qrCIImage + } + } else { + if let invertFilter = CIFilter(name: "CIColorInvert"), + let maskFilter = CIFilter(name: "CIMaskToAlpha") { + invertFilter.setValue(qrCIImage, forKey: "inputImage") + maskFilter.setValue(invertFilter.outputImage, forKey: "inputImage") + qrCIImage = maskFilter.outputImage ?? qrCIImage + } + } + + // 3. Scale CIImage to high resolution + let scaleX: CGFloat = 10.0 + let scaleTransform = CGAffineTransform(scaleX: scaleX, y: scaleX) + let scaledCIImage = qrCIImage.transformed(by: scaleTransform) + let qrUIImage = UIImage(ciImage: scaledCIImage) + + // 4. Draw final image + let size = qrUIImage.size + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + qrUIImage.draw(in: CGRect(origin: .zero, size: size)) + + // 5. Add icon with white background + 4pt padding + if + let iconName = iconName, + let icon: UIImage = UIImage(named: iconName) + { + let iconPercent: CGFloat = 0.25 + let iconSize = size.width * iconPercent + let iconRect = CGRect( + x: (size.width - iconSize) / 2, + y: (size.height - iconSize) / 2, + width: iconSize, + height: iconSize + ) + + // Clear the area under the icon + if let ctx = UIGraphicsGetCurrentContext() { + ctx.clear(iconRect) + } + + // Draw the icon over the transparent hole + icon.draw(in: iconRect) + } + + let finalImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return finalImage ?? qrUIImage + } + + static func qrCodeImageWithTintAndBackground( + image: UIImage, + themeStyle: UIUserInterfaceStyle, + size: CGSize? = nil, + insets: UIEdgeInsets = .zero + ) -> UIImage { + var backgroundColor: UIColor { + switch themeStyle { + case .light: return .classicDark1 + default: return .white + } + } + var tintColor: UIColor { + switch themeStyle { + case .light: return .white + default: return .classicDark1 + } + } + + let outputSize = size ?? image.size + let renderer = UIGraphicsImageRenderer(size: outputSize) + + return renderer.image { context in + // Fill background + backgroundColor.setFill() + context.fill(CGRect(origin: .zero, size: outputSize)) + + // Apply tint using template rendering + tintColor.setFill() + let templateImage = image.withRenderingMode(.alwaysTemplate) + + let imageRect = CGRect( + x: insets.left, + y: insets.top, + width: outputSize.width - insets.left - insets.right, + height: outputSize.height - insets.top - insets.bottom + ) + + templateImage.draw(in: imageRect) + } + } +} diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 05250f2ce5..3181285787 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -25,3 +25,19 @@ extension String { return boundingBox.width } } + +public extension String { + func splitIntoLines(charactersForLines: [Int]) -> String { + var result: [String] = [] + var start = self.startIndex + + for count in charactersForLines { + let end = self.index(start, offsetBy: count, limitedBy: self.endIndex) ?? self.endIndex + let line = String(self[start.. Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionPro) else { - return false - } - self.hideInputAccessoryView() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") - } - ) - ) - present(sessionProModal, animated: true, completion: nil) - - return true - } - @MainActor func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded() else { return } + guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -689,7 +680,22 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { - guard !showSessionProCTAIfNeeded() else { return } + guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .longerMessages, + beforePresented: { [weak self] in + self?.hideInputAccessoryView() + }, + afterClosed: { [weak self] in + self?.showInputAccessoryView() + self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") + }, + presenting: { [weak self] modal in + self?.present(modal, animated: true) + } + ) else { + return + } + self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info(