From cb4dca1e3b514e0ec9b50708f318c138109b30ca Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Fri, 25 Jul 2025 06:32:03 +0000 Subject: [PATCH 01/26] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 99 ++++++++++++++++--- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 0a4a817c36..7cf59bded7 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -172552,7 +172552,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your Display Name is visible to users, groups and communities you interact with." + "value" : "Your Display Name is visible to users, groups, and communities you interact with." } }, "eo" : { @@ -186860,7 +186860,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "You've been using {app_name} for a little
while, how’s it going? We’d really
appreciate hearing your thoughts." + "value" : "You've been using {app_name} for a little while, how’s it going? We’d really appreciate hearing your thoughts." } } } @@ -193418,7 +193418,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Sorry to hear your {app_name} experience
hasn’t been ideal. We'd be grateful if you
could take a moment to share your
thoughts in a brief survey" + "value" : "Sorry to hear your {app_name} experience hasn’t been ideal. We'd be grateful if you could take a moment to share your thoughts in a brief survey" } } } @@ -291894,12 +291894,6 @@ "value" : "Trieu un sobrenom per a {name}. Això us apareixerà a les vostres converses individuals i de grup." } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vyberte přezdívku pro {name}. Zobrazí se ve vašich konverzacích jeden na jednoho a ve skupinových." - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -350826,6 +350820,17 @@ } } }, + "proAnimatedDisplayPictureFeature" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animated Display Picture" + } + } + } + }, "proAnimatedDisplayPictureModalDescription" : { "extractionState" : "manual", "localizations" : { @@ -350872,6 +350877,17 @@ } } }, + "proBadge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Badge" + } + } + } + }, "proCallToActionLongerMessages" : { "extractionState" : "manual", "localizations" : { @@ -353936,6 +353952,61 @@ } } }, + "proGroupActivated" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group Activated" + } + } + } + }, + "proGroupActivatedDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This group has expanded capacity! It can support up to 300 members because a group admin has" + } + } + } + }, + "proIncreasedAttachmentSizeFeature" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Increased Attachment Size" + } + } + } + }, + "proIncreasedMessageLengthFeature" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Increased Message Length" + } + } + } + }, + "proMessageInfoFeatures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This message used the following Session Pro features:" + } + } + } + }, "promote" : { "extractionState" : "manual", "localizations" : { @@ -355926,7 +355997,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Want to use {app_name} to its fullest potential? Upgrade to {app_pro} to gain access to exclusive perks and features" + "value" : "Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience." } } } @@ -359791,7 +359862,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "We're glad you're enjoying {app_name}, if
you have a moment, rating us in the
{storevariant} helps others discover
private, secure messaging!" + "value" : "We're glad you're enjoying {app_name}, if you have a moment, rating us in the {storevariant} helps others discover private, secure messaging!" } } } @@ -375489,7 +375560,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "It looks like you've already reviewed
{app_name} recently, thanks for your
feedback!" + "value" : "It looks like you've already reviewed {app_name} recently, thanks for your feedback!" } } } @@ -401994,7 +402065,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Account ID of {name} is visible
based on your previous interactions" + "value" : "The Account ID of {name} is visible based on your previous interactions" } } } @@ -402005,7 +402076,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Blinded IDs are used in communities
to reduce spam and increase privacy" + "value" : "Blinded IDs are used in communities to reduce spam and increase privacy" } } } From f42d1671a5454eae99884b05f142c1047b070e0f Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 31 Jul 2025 17:12:03 +1000 Subject: [PATCH 02/26] wip: user profile modal implementation --- Session.xcodeproj/project.pbxproj | 20 +- .../ConversationVC+Interaction.swift | 37 ++ .../Message Cells/MessageCell.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 19 +- .../Contents.json | 23 -- .../profile_placeholder.png | Bin 2114 -> 0 bytes .../profile_placeholder@2x.png | Bin 4042 -> 0 bytes .../profile_placeholder@3x.png | Bin 5969 -> 0 bytes .../Components/SwiftUI/ProCTAModal.swift | 18 +- .../Components/SwiftUI/QRCodeView.swift | 64 +--- .../SwiftUI/Seperator+SwiftUI.swift | 26 ++ .../Components/SwiftUI/UserProfileModel.swift | 361 ++++++++++++++++++ .../SessionNetworkScreen.swift | 4 +- SessionUIKit/Utilities/QRCode.swift | 46 +++ .../Utilities/SwiftUI+Utilities.swift | 6 +- 15 files changed, 517 insertions(+), 109 deletions(-) delete mode 100644 Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json delete mode 100644 Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png delete mode 100644 Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png delete mode 100644 Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png rename Session/Utilities/QRCode.swift => SessionUIKit/Components/SwiftUI/QRCodeView.swift (51%) create mode 100644 SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift create mode 100644 SessionUIKit/Components/SwiftUI/UserProfileModel.swift create mode 100644 SessionUIKit/Utilities/QRCode.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 742f90920e..5577b17434 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -206,6 +206,10 @@ 94AAB1622E28742300A6FA18 /* _027_AddProfileProProof.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB1612E28742200A6FA18 /* _027_AddProfileProProof.swift */; }; 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 /* UserProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModel.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 */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; 94CD95BB2E08D9E00097754D /* SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */; }; 94CD95BD2E09083C0097754D /* LibSession+Pro.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CD95BC2E0908340097754D /* LibSession+Pro.swift */; }; @@ -260,7 +264,6 @@ 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 */; }; B886B4A72398B23E00211ABE /* (null) in Sources */ = {isa = PBXBuildFile; }; - 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 */; }; @@ -1538,6 +1541,10 @@ 94AAB1612E28742200A6FA18 /* _027_AddProfileProProof.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _027_AddProfileProProof.swift; 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 /* UserProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModel.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 = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionProBadge.swift; sourceTree = ""; }; 94CD95BC2E0908340097754D /* LibSession+Pro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+Pro.swift"; sourceTree = ""; }; @@ -1587,7 +1594,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 /* OpenGroupAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupAPI.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 = ""; }; @@ -2628,7 +2634,6 @@ C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, - B886B4A82398BA1500211ABE /* QRCode.swift */, FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, @@ -2753,10 +2758,13 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, + 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */, 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */, 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */, 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */, + 94B6BAFD2E39F50E00E718BB /* UserProfileModel.swift */, 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */, FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, @@ -3240,6 +3248,7 @@ C331FFAE2558FA7700070591 /* Utilities */ = { isa = PBXGroup; children = ( + 94B6BB012E3AE85800E718BB /* QRCode.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD2272E32C35134B004D8A6C /* Data+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, @@ -5986,6 +5995,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 */, @@ -6002,6 +6012,7 @@ 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 */, @@ -6011,7 +6022,9 @@ FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, 942256942C23F8DD00C0FDBF /* ActivityView.swift in Sources */, FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, + 94B6BAFE2E39F51800E718BB /* UserProfileModel.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 */, @@ -6597,7 +6610,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 */, FDEF57262C3CF05F00131302 /* (null) in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 327632b5db..4ac28b1e4b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1503,6 +1503,43 @@ extension ConversationVC: reply(cellViewModel, completion: nil) } + func showUserProfileModal(for cellViewModel: MessageViewModel) { + let dependencies: Dependencies = viewModel.dependencies + + let (info, _) = ProfilePictureView.getProfilePictureInfo( + size: .hero, + publicKey: cellViewModel.authorId, + threadVariant: cellViewModel.threadVariant, + displayPictureFilename: nil, + profile: cellViewModel.profile, + using: dependencies + ) + + guard let profileInfo: ProfilePictureView.Info = info else { return } + + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModel( + info: .init( + sessionId: cellViewModel.authorId, + blindedId: cellViewModel.authorId, + profileInfo: profileInfo, + displayName: cellViewModel.authorName, + nickname: cellViewModel.profile?.displayName( + for: cellViewModel.threadVariant, + ignoringNickname: true + ), + isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey, + onStartThread: self.startThread + ), + dataManager: dependencies[singleton: .imageDataManager], + sessionProState: dependencies[singleton: .sessionProState] + ) + ) + present(userProfileModal, animated: true, completion: nil) + } + func startThread( with sessionId: String, openGroupServer: String?, diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 39730a9d6c..aeac9a41b9 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -104,7 +104,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 140556473c..764c101068 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1008,24 +1008,7 @@ 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 - ) + 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 cf0843dcf1180e6ee19003cbe367f465f9648eff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2114 zcmV-I2)*}-P)b{fy5a@?f@x^%`ZE<#+H%A&%R zt;S4>Fd;lDn29`Y3={!X$b;>C|7BM>o$e%^ZtqU7-;B7^0|bA4ul;U!>7WRin3(A0 zL*mn?Pv+;(pTFUAv!kQK#B+S#w7vY4pUd*&r+l8}^S6d!WcYlWIi-l`pb$}lef;?G zHVcq)5UEzOe9LrpcCOu-%P4cvV2?G-Q8U!O=uC2 ztkX`mUpI)cN;W~$y`vU_5N5{v<+vJ0qEl|d~ z1sUranPqnAM^qy<2?&?HxVU)1fq8Ld)dIGFcnVJZkJYaH#@j^B~%2H84uJax@OSbg_ zmuC~~bqkeVSGJIrL$hD0b-6V-ICyq+bo7VY3tVHMjC+4k{WJuk;*{gzw{G#!)rDES z$J7=N^vZF*BaSXCUH2DiqcC~g{MKpQ>}G)#gJ%n};FOh11mvt+&;7h8L1|OcI4MdZ zK^4ryBf~U2JWL<*ALu-PPAA7F;&^s;M!B;bH4sQB*Q^(8U6l=g(?l z_toQ0^!N9d{7frCNGY<_iVGn_pB49xjg2Mt_VzNkW3|A$q#67%{6^yZFVkdA`5|8w0Kv%qIKH262Bro6o`$V(cN?T{SX-Mx%p<3{J=w7SYbw-;|tO= z$G?B6Ntv3OqN(qu3itQ-_Gx2dgM3g(Cc*+`^3Vb-=*yum3oaKWaNUE01KQl&6xKI0 zGb5~VbZnH~Y`*bR0rX1E(#nOkxG!SZ+}vEjW$FQlw6?Y;To)hio#Y+ycu#kauM&#g zIKhXsDx?#n0k}#%i3jleDABBm1SOImI2#M2i^=$Bk_P(v`ot0Ll712Ryq=0AzfX!p zqLPqGWI#g!Z$dk`u(oC?Tm*lV*|r9*bGDKIy+@))MjDhHv?NR z4UO}ZE8Pbzo=~h-N}Hj|Gj!#)4VZg>_wQ{kcVl2mdz^Um0XBE%N&B>ee;K75cT^bEmjhsee zfr^ol5n)AeNnCV7*Th;_L)5#eyRKbm1*~!Y-n?*W#MyG_EZnj5ED{TZFt8|yqskbF zfFfJPM_APS{Cq|66!W$uSLiV`)q1_WS@g`80|!IG^I^J8jmu-1cc zPOPWb#S{-6Ap7p@>|9g#W40%suqD(6mtzB={r)b{= z`xIZyQuv43%NraK-50|sT0>XV(nnx~6;c~VcL8A?-|BA^LhK3KFr zULqi@(C+7LN@JhGb*&s{WGr_`P#cim-QAsadcyUxCRf@&QUIfqMuS}!(|KKY^>AJ8 z`u_d<)4{<(jK1$sYx5J=eHphW%dQv3I$5sfzu-^d7PTN4opItQyI#8D%mi*4E@EF5 zYBBIPvv?IZ6{;wpXc`*EY%7&EvS6OA>a|oQScu4lb1;SKOc&6HT==3}146W>cd7#kZ~mJ9D17L+QeG-}=X*Y^&Va)XM7 z+u;Kv3~t>m?eFg|lMj6FV2Pb9u3o*mCSziw>|%*Mr;|Ls^)@}$0qvX6@+Z4cuL(x( zgkFD}(AweHxX3~IUcCqjcjd|fv2I!SF6TjR#El(55>Z-Ykir$-gF-;=$ADYl)94@? zW?INbK!yS)^nXSmS9+)T5529+G7;O@($@Kyk=wpE*FPh01A<;$TugAhOvp8Rrd)c4 se~(dA7O7^2oX?1@-T%gU*n-*l56;rdG;@S6?f?J)07*qoM6N<$f(;e$9RL6T 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 02649edd922560db938432934f255b15f30713f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4042 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 z 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 diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 5650afbab4..617b8c6383 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -198,15 +198,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) @@ -218,7 +216,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) @@ -226,7 +224,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) @@ -252,7 +250,7 @@ public struct ProCTAModal: View { } Text(variant.benefits[index]) - .font(.system(size: Values.smallFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } } @@ -273,7 +271,7 @@ public struct ProCTAModal: View { close() } label: { Text("close".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) } .frame( @@ -304,7 +302,7 @@ public struct ProCTAModal: View { } } label: { Text("theContinue".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .sessionButton_primaryFilledText) .framing( maxWidth: .infinity, @@ -322,7 +320,7 @@ public struct ProCTAModal: View { close() } label: { Text("cancel".localized()) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.baseRegular) .foregroundColor(themeColor: .textPrimary) .framing( maxWidth: .infinity, diff --git a/Session/Utilities/QRCode.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift similarity index 51% rename from Session/Utilities/QRCode.swift rename to SessionUIKit/Components/SwiftUI/QRCodeView.swift index 4a47dac08c..a385870ac8 100644 --- a/Session/Utilities/QRCode.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -1,54 +1,8 @@ -// 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)! - } -} +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import SwiftUI -import SessionUIKit -struct QRCodeView: View { +public struct QRCodeView: View { let string: String let hasBackground: Bool let logo: String? @@ -73,7 +27,19 @@ struct QRCodeView: View { static private var cornerRadius: CGFloat = 10 static private var logoSize: CGFloat = 66 - var body: some View { + public init( + string: String, + hasBackground: Bool, + logo: String?, + themeStyle: UIUserInterfaceStyle + ) { + self.string = string + self.hasBackground = hasBackground + self.logo = logo + self.themeStyle = themeStyle + } + + public var body: some View { ZStack(alignment: .center) { ZStack(alignment: .center) { RoundedRectangle(cornerRadius: Self.cornerRadius) diff --git a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift new file mode 100644 index 0000000000..66a04e2332 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift @@ -0,0 +1,26 @@ +// 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) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .overlay( + Capsule() + .stroke(themeColor: .textSecondary) + ) + + Line(color: .textSecondary, lineWidth: Values.separatorThickness) + } + } +} diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift new file mode 100644 index 0000000000..3a8c4d3bae --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -0,0 +1,361 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Lucide +import Combine + +public struct UserProfileModel: 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 = "UserProfileModelToolTip" // stringlint:ignore + private let coordinateSpaceName: String = "UserProfileModel" // stringlint:ignore + + private var info: Info + private var dataManager: ImageDataManagerType + private var sessionProState: SessionProManagerType + let dismissType: Modal.DismissType + let afterClosed: (() -> Void)? + + // TODO: Localised + private var tooltipText: String { + if info.sessionId == nil { + return "Blinded IDs are used in communities to reduce spam and increase privacy" + } else { + return "The Account ID of {name} is visible based on your previous interactions" + } + } + + public init( + info: Info, + dataManager: ImageDataManagerType, + sessionProState: SessionProManagerType, + dismissType: Modal.DismissType = .recursive, + afterClosed: (() -> Void)? = nil + ) { + self.info = info + self.dataManager = dataManager + self.sessionProState = sessionProState + 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() + } label: { + AttributedText(Lucide.Icon.x.attributedString(size: 12)) + .font(.system(size: 12)) + .foregroundColor(themeColor: .textPrimary) + } + .frame(width: 24, height: 24) + .padding(8) + + VStack(spacing: 0) { + // Profile Image & QR Code + if isProfileImageToggled { + ZStack(alignment: .topTrailing) { + ProfilePictureSwiftUI( + size: .hero, + info: info.profileInfo, + dataManager: self.dataManager, + sessionProState: self.sessionProState + ) + .frame( + width: ProfilePictureView.Size.hero.viewSize, + height: ProfilePictureView.Size.hero.viewSize, + alignment: .center + ) + + if let sessionId = info.sessionId { + Button { + withAnimation { + self.isProfileImageToggled.toggle() + } + } label: { + AttributedText(Lucide.Icon.qrCode.attributedString(size: 12)) + .font(.system(size: 12)) + .foregroundColor(themeColor: .black) + .background( + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: 20, height: 20) + ) + } + } + } + .scaleEffect(isProfileImageExpanding ? 2 : 1) + } else { + ZStack(alignment: .topTrailing) { + if let sessionId = info.sessionId { + QRCodeView( + string: sessionId, + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .accessibility( + Accessibility( + identifier: "QR code", + label: "QR code" + ) + ) + .aspectRatio(1, contentMode: .fit) + .frame(width: 190, height: 190) + .padding(.top, 10) + .padding(.trailing, 17) + .onTapGesture { + withAnimation { + self.isProfileImageExpanding.toggle() + } + } + + Button { + withAnimation { + self.isProfileImageToggled.toggle() + } + } label: { + 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) + ) + } + } + } + } + + // Display name & Nickname (ProBadge) + HStack(spacing: Values.smallSpacing) { + Text(info.displayName) + .font(.Headings.H6) + .foregroundColor(themeColor: .textPrimary) + + if info.isProUser { + SessionProBadge_SwiftUI(size: .large) + } + } + + // Account Id | Blinded Id (Tooltips) + let (title, hexEncodedId): (String, String) = { + switch (info.sessionId, info.blindedId) { + case (.some(let sessionId), _): return ("accountId".localized(), sessionId) + case (.none, .some(let blindedId)): return ("blindedId".localized(), blindedId) + default : 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) + } + + // Buttons + if let sessionId = info.sessionId { + HStack(spacing: Values.mediumSpacing) { + Button { + info.onStartThread?(sessionId, info.openGroupServer, info.openGroupPublicKey) + } 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() + } label: { + Text(isSessionIdCopied ? "copied".localized() : "copy".localized()) + .font(.Body.baseBold) + .foregroundColor(themeColor: .sessionButton_text) + } + .disabled(isSessionIdCopied) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + .buttonStyle(PlainButtonStyle()) + } + } else { + let isMessageButtonEnabled: Bool = (info.onStartThread != nil) + + if !isMessageButtonEnabled { + AttributedText("messageRequestsTurnedOff" + .put(key: "name", value: info.displayName) + .localizedFormatted(Fonts.Body.smallRegular) + ) + .font(.Body.smallRegular) + .foregroundColor(themeColor: .textSecondary) + } + + GeometryReader { geometry in + HStack { + Button { + if let blindedId = info.blindedId { + info.onStartThread?(blindedId, info.openGroupServer, info.openGroupPublicKey) + } + } label: { + Text("message".localized()) + .font(.system(size: Values.mediumFontSize)) + .foregroundColor(themeColor: (isMessageButtonEnabled ? .sessionButton_text : .disabled)) + } + .disabled(!isMessageButtonEnabled) + .frame( + width: (geometry.size.width - Values.mediumSpacing) / 2, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: (isMessageButtonEnabled ? .sessionButton_border : .disabled)) + ) + .buttonStyle(PlainButtonStyle()) + } + .frame( + width: geometry.size.width, + height: geometry.size.height, + alignment: .center + ) + } + .frame(height: Values.largeButtonHeight) + } + } + } + .padding(Values.mediumSpacing) + .popoverView( + content: { + ZStack { + Text(tooltipText) + .font(.Body.smallRegular) + .multilineTextAlignment(.center) + .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + } + .overlay( + GeometryReader { geometry in + Color.clear // Invisible overlay + .onAppear { + self.tooltipContentFrame = geometry.frame(in: .global) + } + } + ) + }, + backgroundThemeColor: .toast_background, + isPresented: $isShowingTooltip, + frame: $tooltipContentFrame, + position: .top, + viewId: tooltipViewId + ) + } + .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { + guard self.isShowingTooltip else { + return + } + + withAnimation(.spring()) { + self.isShowingTooltip = false + } + } + } + + private func copySessionId() { + UIPasteboard.general.string = info.sessionId + + // Ensure we are on the main thread just in case + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4250)) { + withAnimation(.easeInOut(duration: 0.25)) { + isSessionIdCopied.toggle() + } + } + } + } +} + +public extension UserProfileModel { + struct Info { + let sessionId: String? + let blindedId: String? + let profileInfo: ProfilePictureView.Info + let displayName: String + let nickname: String? + let isProUser: Bool + let openGroupServer: String? + let openGroupPublicKey: String? + let onStartThread: ((String, String?, String?) -> Void)? + + public init( + sessionId: String?, + blindedId: String?, + profileInfo: ProfilePictureView.Info, + displayName: String, + nickname: String?, + isProUser: Bool, + openGroupServer: String?, + openGroupPublicKey: String?, + onStartThread: ((String, String?, String?) -> Void)? + ) { + self.sessionId = sessionId + self.blindedId = blindedId + self.profileInfo = profileInfo + self.displayName = displayName + self.nickname = nickname + self.isProUser = isProUser + self.openGroupServer = openGroupServer + self.openGroupPublicKey = openGroupPublicKey + self.onStartThread = onStartThread + } + } +} 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..9539142021 --- /dev/null +++ b/SessionUIKit/Utilities/QRCode.swift @@ -0,0 +1,46 @@ +// Copyright © 2025 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)! + } +} diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 1f83f60164..8df425efcc 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -92,15 +92,17 @@ public struct MaxWidthEqualizer: ViewModifier { public struct Line: View { let color: ThemeValue + let lineWidth: CGFloat - public init(color: ThemeValue) { + public init(color: ThemeValue, lineWidth: CGFloat = 1) { self.color = color + self.lineWidth = lineWidth } public var body: some View { Rectangle() .fill(themeColor: color) - .frame(height: 1) + .frame(height: lineWidth) } } From be2f24857c041e954da97eaf8ae1eb9beb50ec64 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 1 Aug 2025 11:04:29 +1000 Subject: [PATCH 03/26] wip: user profile modal implementation --- Session/Conversations/ConversationVC+Interaction.swift | 2 +- SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift | 7 ++++--- SessionUIKit/Components/SwiftUI/UserProfileModel.swift | 9 ++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4ac28b1e4b..0383a3b8ce 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1509,7 +1509,7 @@ extension ConversationVC: let (info, _) = ProfilePictureView.getProfilePictureInfo( size: .hero, publicKey: cellViewModel.authorId, - threadVariant: cellViewModel.threadVariant, + threadVariant: .contact, // Always show the display picture in 'contact' mode displayPictureFilename: nil, profile: cellViewModel.profile, using: dependencies diff --git a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift index 66a04e2332..2700be3d17 100644 --- a/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Seperator+SwiftUI.swift @@ -13,11 +13,12 @@ public struct Seperator_SwiftUI: View { Text(title) .font(.Body.smallRegular) .foregroundColor(themeColor: .textSecondary) - .padding(.horizontal, 10) + .fixedSize() + .padding(.horizontal, 30) .padding(.vertical, 6) - .overlay( + .background( Capsule() - .stroke(themeColor: .textSecondary) + .stroke(themeColor: .textSecondary, lineWidth: Values.separatorThickness) ) Line(color: .textSecondary, lineWidth: Values.separatorThickness) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 3a8c4d3bae..fd2db6714b 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -55,14 +55,13 @@ public struct UserProfileModel: View { Button { close() } label: { - AttributedText(Lucide.Icon.x.attributedString(size: 12)) - .font(.system(size: 12)) + AttributedText(Lucide.Icon.x.attributedString(size: 20)) + .font(.system(size: 20)) .foregroundColor(themeColor: .textPrimary) } .frame(width: 24, height: 24) - .padding(8) - VStack(spacing: 0) { + VStack(spacing: Values.mediumSpacing) { // Profile Image & QR Code if isProfileImageToggled { ZStack(alignment: .topTrailing) { @@ -78,7 +77,7 @@ public struct UserProfileModel: View { alignment: .center ) - if let sessionId = info.sessionId { + if info.sessionId != nil { Button { withAnimation { self.isProfileImageToggled.toggle() From 9706d9a1df2f5a893627f46e6ef7469ec77412c2 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 4 Aug 2025 15:15:12 +1000 Subject: [PATCH 04/26] fix some ui issues for upm --- .../ConversationVC+Interaction.swift | 1 + .../ProfilePictureView+Convenience.swift | 2 +- .../Components/ProfilePictureView.swift | 7 +- .../Components/SwiftUI/Modal+SwiftUI.swift | 8 +- .../Components/SwiftUI/ProCTAModal.swift | 6 +- .../Components/SwiftUI/UserProfileModel.swift | 145 +++++++++++------- SessionUIKit/Utilities/String+Utilities.swift | 16 ++ 7 files changed, 120 insertions(+), 65 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 0383a3b8ce..be9e622aa0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1531,6 +1531,7 @@ extension ConversationVC: isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), openGroupServer: cellViewModel.threadOpenGroupServer, openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey, + isMessageRequestsEnabled: false, onStartThread: self.startThread ), dataManager: dependencies[singleton: .imageDataManager], diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 49d2196575..1b0cac3d55 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -67,7 +67,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: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) + case .hero, .userProfileModal: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) } }(), shouldAnimated: true, diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index f12c6b0312..fc40e12dd0 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -44,12 +44,14 @@ public final class ProfilePictureView: UIView { case message case list case hero + case userProfileModal public var viewSize: CGFloat { switch self { case .navigation, .message: return 26 case .list: return 46 case .hero: return 110 + case .userProfileModal: return 90 } } @@ -57,7 +59,7 @@ public final class ProfilePictureView: UIView { switch self { case .navigation, .message: return 26 case .list: return 46 - case .hero: return 90 + case .hero, .userProfileModal: return 90 } } @@ -65,7 +67,7 @@ public final class ProfilePictureView: UIView { switch self { case .navigation, .message: return 18 // Shouldn't be used case .list: return 32 - case .hero: return 90 + case .hero, .userProfileModal: return 90 } } @@ -74,6 +76,7 @@ public final class ProfilePictureView: UIView { case .navigation, .message: return 10 // Intentionally not a multiple of 4 case .list: return 16 case .hero: return 24 + case .userProfileModal: return 24 // Shouldn't be used } } } diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index b0951567de..33795596d9 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 @@ -20,7 +20,7 @@ public struct Modal_SwiftUI: View where Content: View { Spacer() VStack(spacing: 0) { - content{ close() } + content { completion in close(completion: completion) } } .backgroundColor(themeColor: .alert_background) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) @@ -57,7 +57,7 @@ public struct Modal_SwiftUI: View where Content: View { // MARK: - Dismiss Logic - private func close() { + private func close(completion: (() -> 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 +70,7 @@ public struct Modal_SwiftUI: View where Content: View { } } - targetViewController?.presentingViewController?.dismiss(animated: true) + targetViewController?.presentingViewController?.dismiss(animated: true, completion: completion) } } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 617b8c6383..9f4ed0dfca 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -268,7 +268,7 @@ public struct ProCTAModal: View { GeometryReader { geometry in HStack { Button { - close() + close(nil) } label: { Text("close".localized()) .font(.Body.baseRegular) @@ -298,7 +298,7 @@ public struct ProCTAModal: View { if result { afterUpgrade?() } - close() + close(nil) } } label: { Text("theContinue".localized()) @@ -317,7 +317,7 @@ public struct ProCTAModal: View { // Cancel Button Button { - close() + close(nil) } label: { Text("cancel".localized()) .font(.Body.baseRegular) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index fd2db6714b..8c5db656c4 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -21,12 +21,14 @@ public struct UserProfileModel: View { let dismissType: Modal.DismissType let afterClosed: (() -> Void)? - // TODO: Localised - private var tooltipText: String { + private var tooltipText: ThemedAttributedString { if info.sessionId == nil { - return "Blinded IDs are used in communities to reduce spam and increase privacy" + return "tooltipBlindedIdCommunities" + .localizedFormatted(baseFont: Fonts.Body.smallRegular) } else { - return "The Account ID of {name} is visible based on your previous interactions" + return "tooltipAccountIdVisible" + .put(key: "name", value: info.displayName) + .localizedFormatted(baseFont: Fonts.Body.smallRegular) } } @@ -53,7 +55,7 @@ public struct UserProfileModel: View { ZStack(alignment: .topTrailing) { // Closed button Button { - close() + close(nil) } label: { AttributedText(Lucide.Icon.x.attributedString(size: 20)) .font(.system(size: 20)) @@ -63,38 +65,51 @@ public struct UserProfileModel: View { VStack(spacing: Values.mediumSpacing) { // Profile Image & QR Code + let scale: CGFloat = isProfileImageExpanding ? (190.0 / 90) : 1 if isProfileImageToggled { ZStack(alignment: .topTrailing) { - ProfilePictureSwiftUI( - size: .hero, - info: info.profileInfo, - dataManager: self.dataManager, - sessionProState: self.sessionProState - ) + ZStack { + ProfilePictureSwiftUI( + size: .userProfileModal, + info: info.profileInfo, + dataManager: self.dataManager, + sessionProState: self.sessionProState + ) + .scaleEffect(scale, anchor: .topLeading) + } .frame( - width: ProfilePictureView.Size.hero.viewSize, - height: ProfilePictureView.Size.hero.viewSize, + width: ProfilePictureView.Size.userProfileModal.viewSize * scale, + height: ProfilePictureView.Size.userProfileModal.viewSize * scale, alignment: .center ) if info.sessionId != nil { + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) Button { withAnimation { self.isProfileImageToggled.toggle() } } label: { - AttributedText(Lucide.Icon.qrCode.attributedString(size: 12)) - .font(.system(size: 12)) + AttributedText(Lucide.Icon.qrCode.attributedString(size: iconSize, baselineOffset: 0)) + .font(.system(size: iconSize)) .foregroundColor(themeColor: .black) .background( Circle() .foregroundColor(themeColor: .primary) - .frame(width: 20, height: 20) + .frame(width: buttonSize, height: buttonSize) ) } + .padding(.trailing, isProfileImageExpanding ? 28 : 4) + } + } + .padding(.top, 12) + .padding(.vertical, 5) + .padding(.horizontal, 10) + .onTapGesture { + withAnimation { + self.isProfileImageExpanding.toggle() } } - .scaleEffect(isProfileImageExpanding ? 2 : 1) } else { ZStack(alignment: .topTrailing) { if let sessionId = info.sessionId { @@ -112,8 +127,8 @@ public struct UserProfileModel: View { ) .aspectRatio(1, contentMode: .fit) .frame(width: 190, height: 190) - .padding(.top, 10) - .padding(.trailing, 17) + .padding(.vertical, 5) + .padding(.horizontal, 10) .onTapGesture { withAnimation { self.isProfileImageExpanding.toggle() @@ -139,6 +154,7 @@ public struct UserProfileModel: View { } } } + .padding(.top, 12) } // Display name & Nickname (ProBadge) @@ -155,8 +171,12 @@ public struct UserProfileModel: View { // Account Id | Blinded Id (Tooltips) let (title, hexEncodedId): (String, String) = { switch (info.sessionId, info.blindedId) { - case (.some(let sessionId), _): return ("accountId".localized(), sessionId) - case (.none, .some(let blindedId)): return ("blindedId".localized(), 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) default : return ("", "") // Shouldn't happen } }() @@ -184,6 +204,10 @@ public struct UserProfileModel: View { Text(hexEncodedId) .font(isIPhone5OrSmaller ? .Display.base : .Display.large) .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + .lineLimit(info.blindedId == nil ? 0 : 1) + .truncationMode(.middle) + .padding(.horizontal, info.blindedId == nil ? 0 : Values.largeSpacing) } // Buttons @@ -225,9 +249,7 @@ public struct UserProfileModel: View { .buttonStyle(PlainButtonStyle()) } } else { - let isMessageButtonEnabled: Bool = (info.onStartThread != nil) - - if !isMessageButtonEnabled { + if !info.isMessageRequestsEnabled { AttributedText("messageRequestsTurnedOff" .put(key: "name", value: info.displayName) .localizedFormatted(Fonts.Body.smallRegular) @@ -239,22 +261,32 @@ public struct UserProfileModel: View { GeometryReader { geometry in HStack { Button { - if let blindedId = info.blindedId { - info.onStartThread?(blindedId, info.openGroupServer, info.openGroupPublicKey) - } + let hexEncodedId: String = { + switch (info.sessionId, info.blindedId) { + case (.some(let sessionId), _): return sessionId + case (.none, .some(let blindedId)): return blindedId + default : return "" // Shouldn't happen + } + }() + + info.onStartThread?( + hexEncodedId, + info.openGroupServer, + info.openGroupPublicKey + ) } label: { Text("message".localized()) .font(.system(size: Values.mediumFontSize)) - .foregroundColor(themeColor: (isMessageButtonEnabled ? .sessionButton_text : .disabled)) + .foregroundColor(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_text : .disabled)) } - .disabled(!isMessageButtonEnabled) + .disabled(!info.isMessageRequestsEnabled) .frame( width: (geometry.size.width - Values.mediumSpacing) / 2, height: Values.smallButtonHeight ) .overlay( Capsule() - .stroke(themeColor: (isMessageButtonEnabled ? .sessionButton_border : .disabled)) + .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) ) .buttonStyle(PlainButtonStyle()) } @@ -269,32 +301,32 @@ public struct UserProfileModel: View { } } .padding(Values.mediumSpacing) - .popoverView( - content: { - ZStack { - Text(tooltipText) - .font(.Body.smallRegular) - .multilineTextAlignment(.center) - .foregroundColor(themeColor: .textPrimary) - .padding(.horizontal, Values.mediumSpacing) - .padding(.vertical, Values.smallSpacing) - } - .overlay( - GeometryReader { geometry in - Color.clear // Invisible overlay - .onAppear { - self.tooltipContentFrame = geometry.frame(in: .global) - } - } - ) - }, - backgroundThemeColor: .toast_background, - isPresented: $isShowingTooltip, - frame: $tooltipContentFrame, - position: .top, - viewId: tooltipViewId - ) } + .popoverView( + content: { + ZStack { + AttributedText(tooltipText) + .font(.Body.smallRegular) + .multilineTextAlignment(.center) + .foregroundColor(themeColor: .textPrimary) + .padding(.horizontal, Values.mediumSpacing) + .padding(.vertical, Values.smallSpacing) + } + .overlay( + GeometryReader { geometry in + Color.clear // Invisible overlay + .onAppear { + self.tooltipContentFrame = geometry.frame(in: .global) + } + } + ) + }, + backgroundThemeColor: .toast_background, + isPresented: $isShowingTooltip, + frame: $tooltipContentFrame, + position: .top, + viewId: tooltipViewId + ) .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { guard self.isShowingTooltip else { return @@ -333,6 +365,7 @@ public extension UserProfileModel { let isProUser: Bool let openGroupServer: String? let openGroupPublicKey: String? + let isMessageRequestsEnabled: Bool let onStartThread: ((String, String?, String?) -> Void)? public init( @@ -344,6 +377,7 @@ public extension UserProfileModel { isProUser: Bool, openGroupServer: String?, openGroupPublicKey: String?, + isMessageRequestsEnabled: Bool, onStartThread: ((String, String?, String?) -> Void)? ) { self.sessionId = sessionId @@ -354,6 +388,7 @@ public extension UserProfileModel { self.isProUser = isProUser self.openGroupServer = openGroupServer self.openGroupPublicKey = openGroupPublicKey + self.isMessageRequestsEnabled = isMessageRequestsEnabled self.onStartThread = onStartThread } } diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 05250f2ce5..742d856a91 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 + var line = String(self[start.. Date: Tue, 5 Aug 2025 10:06:40 +1000 Subject: [PATCH 05/26] fix issue on tap gesture and ui --- .../ConversationVC+Interaction.swift | 28 +++++++-- .../Components/SwiftUI/Modal+SwiftUI.swift | 10 +++- .../Components/SwiftUI/UserProfileModel.swift | 59 ++++++++++--------- 3 files changed, 61 insertions(+), 36 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index be9e622aa0..a49a3c1280 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1504,6 +1504,10 @@ extension ConversationVC: } 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( @@ -1517,11 +1521,21 @@ extension ConversationVC: guard let profileInfo: ProfilePictureView.Info = info else { return } + let (sessionId, blindedId): (String?, String?) = { + guard (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15 else { + return (cellViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].read { db in + try? BlindedIdLookup.fetchOne(db, id: cellViewModel.authorId) + } + return (lookup?.sessionId, cellViewModel.authorId) + }() + let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModel( info: .init( - sessionId: cellViewModel.authorId, - blindedId: cellViewModel.authorId, + sessionId: sessionId, + blindedId: blindedId, profileInfo: profileInfo, displayName: cellViewModel.authorName, nickname: cellViewModel.profile?.displayName( @@ -1529,10 +1543,14 @@ extension ConversationVC: ignoringNickname: true ), isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), - openGroupServer: cellViewModel.threadOpenGroupServer, - openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey, isMessageRequestsEnabled: false, - onStartThread: self.startThread + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + } ), dataManager: dependencies[singleton: .imageDataManager], sessionProState: dependencies[singleton: .sessionProState] diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index 33795596d9..c43cb6f7a9 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -16,6 +16,14 @@ 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() @@ -40,8 +48,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 diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 8c5db656c4..b697962f4f 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -76,6 +76,11 @@ public struct UserProfileModel: View { sessionProState: self.sessionProState ) .scaleEffect(scale, anchor: .topLeading) + .onTapGesture { + withAnimation { + self.isProfileImageExpanding.toggle() + } + } } .frame( width: ProfilePictureView.Size.userProfileModal.viewSize * scale, @@ -105,11 +110,6 @@ public struct UserProfileModel: View { .padding(.top, 12) .padding(.vertical, 5) .padding(.horizontal, 10) - .onTapGesture { - withAnimation { - self.isProfileImageExpanding.toggle() - } - } } else { ZStack(alignment: .topTrailing) { if let sessionId = info.sessionId { @@ -205,8 +205,7 @@ public struct UserProfileModel: View { .font(isIPhone5OrSmaller ? .Display.base : .Display.large) .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.center) - .lineLimit(info.blindedId == nil ? 0 : 1) - .truncationMode(.middle) + .shouldTruncate(info.sessionId == nil) .padding(.horizontal, info.blindedId == nil ? 0 : Values.largeSpacing) } @@ -214,7 +213,7 @@ public struct UserProfileModel: View { if let sessionId = info.sessionId { HStack(spacing: Values.mediumSpacing) { Button { - info.onStartThread?(sessionId, info.openGroupServer, info.openGroupPublicKey) + close(info.onStartThread) } label: { Text("message".localized()) .font(.Body.baseBold) @@ -261,19 +260,7 @@ public struct UserProfileModel: View { GeometryReader { geometry in HStack { Button { - let hexEncodedId: String = { - switch (info.sessionId, info.blindedId) { - case (.some(let sessionId), _): return sessionId - case (.none, .some(let blindedId)): return blindedId - default : return "" // Shouldn't happen - } - }() - - info.onStartThread?( - hexEncodedId, - info.openGroupServer, - info.openGroupPublicKey - ) + close(info.onStartThread) } label: { Text("message".localized()) .font(.system(size: Values.mediumFontSize)) @@ -363,10 +350,8 @@ public extension UserProfileModel { let displayName: String let nickname: String? let isProUser: Bool - let openGroupServer: String? - let openGroupPublicKey: String? let isMessageRequestsEnabled: Bool - let onStartThread: ((String, String?, String?) -> Void)? + let onStartThread: (() -> Void)? public init( sessionId: String?, @@ -375,10 +360,8 @@ public extension UserProfileModel { displayName: String, nickname: String?, isProUser: Bool, - openGroupServer: String?, - openGroupPublicKey: String?, isMessageRequestsEnabled: Bool, - onStartThread: ((String, String?, String?) -> Void)? + onStartThread: (() -> Void)? ) { self.sessionId = sessionId self.blindedId = blindedId @@ -386,10 +369,28 @@ public extension UserProfileModel { self.displayName = displayName self.nickname = nickname self.isProUser = isProUser - self.openGroupServer = openGroupServer - self.openGroupPublicKey = openGroupPublicKey self.isMessageRequestsEnabled = isMessageRequestsEnabled self.onStartThread = onStartThread } } } + +struct ConditionalTruncation: ViewModifier { + let shouldTruncate: Bool + + func body(content: Content) -> some View { + if shouldTruncate { + content + .lineLimit(1) + .truncationMode(.middle) + } else { + content + } + } +} + +extension View { + func shouldTruncate(_ condition: Bool) -> some View { + modifier(ConditionalTruncation(shouldTruncate: condition)) + } +} From 275d1c16126c1bfa4c18e64f0aa499c17a088ce6 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 14:30:52 +1000 Subject: [PATCH 06/26] fix tooltip ui --- .../ConversationVC+Interaction.swift | 19 ++-- .../Components/SwiftUI/ArrowCapsule.swift | 88 +++++++++++++++---- .../Components/SwiftUI/PopoverView.swift | 43 +++++---- .../Components/SwiftUI/UserProfileModel.swift | 18 ++-- 4 files changed, 120 insertions(+), 48 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index a49a3c1280..ccf43bf820 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -234,7 +234,7 @@ extension ConversationVC: // MARK: - Session Pro CTA - @discardableResult func showSessionProCTAIfNeeded() -> Bool { + @discardableResult func showSessionProCTAIfNeeded(_ variant: ProCTAModal.Variant) -> Bool { let dependencies: Dependencies = viewModel.dependencies guard dependencies[feature: .sessionProEnabled] && (!viewModel.isSessionPro) else { return false @@ -243,7 +243,7 @@ extension ConversationVC: let sessionProModal: ModalHostingViewController = ModalHostingViewController( modal: ProCTAModal( delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, + variant: variant, dataManager: dependencies[singleton: .imageDataManager], afterClosed: { [weak self] in self?.showInputAccessoryView() @@ -527,7 +527,7 @@ extension ConversationVC: } func handleCharacterLimitLabelTapped() { - guard !showSessionProCTAIfNeeded() else { return } + guard !showSessionProCTAIfNeeded(.longerMessages) else { return } self.hideInputAccessoryView() let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( @@ -619,7 +619,7 @@ extension ConversationVC: } func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded() else { return } + guard !showSessionProCTAIfNeeded(.longerMessages) else { return } self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -1504,10 +1504,10 @@ extension ConversationVC: } 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 } - +// 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( @@ -1550,6 +1550,9 @@ extension ConversationVC: openGroupServer: cellViewModel.threadOpenGroupServer, openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey ) + }, + onProBadgeTapped: { [weak self] in + self?.showSessionProCTAIfNeeded(.generic) } ), dataManager: dependencies[singleton: .imageDataManager], 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/PopoverView.swift b/SessionUIKit/Components/SwiftUI/PopoverView.swift index 5e0cf83942..5cf911e780 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 / 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 + case .topRight, .bottomRight: + // Align left + return originBounds.minX - arrowOffSet 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/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index b697962f4f..a6f2dcfdca 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -165,6 +165,9 @@ public struct UserProfileModel: View { if info.isProUser { SessionProBadge_SwiftUI(size: .large) + .onTapGesture { + info.onProBadgeTapped?() + } } } @@ -230,7 +233,7 @@ public struct UserProfileModel: View { .buttonStyle(PlainButtonStyle()) Button { - copySessionId() + copySessionId(sessionId) } label: { Text(isSessionIdCopied ? "copied".localized() : "copy".localized()) .font(.Body.baseBold) @@ -255,6 +258,7 @@ public struct UserProfileModel: View { ) .font(.Body.smallRegular) .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) } GeometryReader { geometry in @@ -298,6 +302,7 @@ public struct UserProfileModel: View { .foregroundColor(themeColor: .textPrimary) .padding(.horizontal, Values.mediumSpacing) .padding(.vertical, Values.smallSpacing) + .frame(maxWidth: 260) } .overlay( GeometryReader { geometry in @@ -311,7 +316,7 @@ public struct UserProfileModel: View { backgroundThemeColor: .toast_background, isPresented: $isShowingTooltip, frame: $tooltipContentFrame, - position: .top, + position: .topLeft, viewId: tooltipViewId ) .onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) { @@ -325,8 +330,8 @@ public struct UserProfileModel: View { } } - private func copySessionId() { - UIPasteboard.general.string = info.sessionId + private func copySessionId(_ sessionId: String) { + UIPasteboard.general.string = sessionId // Ensure we are on the main thread just in case DispatchQueue.main.async { @@ -352,6 +357,7 @@ public extension UserProfileModel { let isProUser: Bool let isMessageRequestsEnabled: Bool let onStartThread: (() -> Void)? + let onProBadgeTapped: (() -> Void)? public init( sessionId: String?, @@ -361,7 +367,8 @@ public extension UserProfileModel { nickname: String?, isProUser: Bool, isMessageRequestsEnabled: Bool, - onStartThread: (() -> Void)? + onStartThread: (() -> Void)?, + onProBadgeTapped: (() -> Void)? ) { self.sessionId = sessionId self.blindedId = blindedId @@ -371,6 +378,7 @@ public extension UserProfileModel { self.isProUser = isProUser self.isMessageRequestsEnabled = isMessageRequestsEnabled self.onStartThread = onStartThread + self.onProBadgeTapped = onProBadgeTapped } } } From 6c4994abd5658918ac732100f57c83458c638e0d Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 15:11:26 +1000 Subject: [PATCH 07/26] implement is message requests off for community upm --- Session/Conversations/ConversationVC+Interaction.swift | 7 ++++++- SessionUIKit/Components/SwiftUI/PopoverView.swift | 6 +++--- SessionUIKit/Components/SwiftUI/UserProfileModel.swift | 2 ++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ccf43bf820..dd76dd66ba 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1531,6 +1531,11 @@ extension ConversationVC: return (lookup?.sessionId, cellViewModel.authorId) }() + let isMessasgeRequestsEnabled: Bool = { + guard cellViewModel.threadVariant == .community else { return true } + return cellViewModel.profile?.blocksCommunityMessageRequests != true + }() + let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModel( info: .init( @@ -1543,7 +1548,7 @@ extension ConversationVC: ignoringNickname: true ), isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), - isMessageRequestsEnabled: false, + isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: { [weak self] in self?.startThread( with: cellViewModel.authorId, diff --git a/SessionUIKit/Components/SwiftUI/PopoverView.swift b/SessionUIKit/Components/SwiftUI/PopoverView.swift index 5cf911e780..b3b97ccb60 100644 --- a/SessionUIKit/Components/SwiftUI/PopoverView.swift +++ b/SessionUIKit/Components/SwiftUI/PopoverView.swift @@ -94,17 +94,17 @@ internal struct PopoverOffset: ViewModifier { func offsetXFor(position: ViewPosition, frame: CGRect, originBounds: CGRect, arrowLength: CGFloat) -> CGFloat { let triangleSideLength : CGFloat = arrowLength / CGFloat(sqrt(0.75)) - let arrowOffSet: CGFloat = 30 + triangleSideLength / 2 + let arrowOffSet: CGFloat = 30 - triangleSideLength + frame.size.height / 2 switch position { case .top, .bottom: // Center horizontally return originBounds.minX + (originBounds.size.width - frame.size.width) / 2 case .topLeft, .bottomLeft: // Align right - return originBounds.maxX - frame.size.width + arrowOffSet + return originBounds.maxX - frame.size.width + arrowOffSet - triangleSideLength / 2 case .topRight, .bottomRight: // Align left - return originBounds.minX - arrowOffSet + return originBounds.minX - arrowOffSet + triangleSideLength / 2 case .none: return 0 } diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index a6f2dcfdca..17c06e4993 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -250,6 +250,7 @@ public struct UserProfileModel: View { ) .buttonStyle(PlainButtonStyle()) } + .padding(.bottom, 12) } else { if !info.isMessageRequestsEnabled { AttributedText("messageRequestsTurnedOff" @@ -288,6 +289,7 @@ public struct UserProfileModel: View { ) } .frame(height: Values.largeButtonHeight) + .padding(.bottom, 12) } } } From 0398779828ce6c81a52113a9a1745933ad42118f Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 15:16:06 +1000 Subject: [PATCH 08/26] minor fix --- Session/Conversations/ConversationVC+Interaction.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index dd76dd66ba..b72c497544 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1504,10 +1504,10 @@ extension ConversationVC: } 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 } -// + 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( From bcf71e3f8a12011cdb6d0c637215f84e0c270ff7 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 16:11:41 +1000 Subject: [PATCH 09/26] fix tap gesture issues --- .../Components/SwiftUI/UserProfileModel.swift | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 17c06e4993..6b8f4a7194 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -90,21 +90,20 @@ public struct UserProfileModel: View { if info.sessionId != nil { let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) - Button { - withAnimation { - self.isProfileImageToggled.toggle() + AttributedText(Lucide.Icon.qrCode.attributedString(size: iconSize, baselineOffset: 0)) + .font(.system(size: iconSize)) + .foregroundColor(themeColor: .black) + .background( + Circle() + .foregroundColor(themeColor: .primary) + .frame(width: buttonSize, height: buttonSize) + ) + .padding(.trailing, isProfileImageExpanding ? 28 : 4) + .onTapGesture { + withAnimation { + self.isProfileImageToggled.toggle() + } } - } label: { - AttributedText(Lucide.Icon.qrCode.attributedString(size: iconSize, baselineOffset: 0)) - .font(.system(size: iconSize)) - .foregroundColor(themeColor: .black) - .background( - Circle() - .foregroundColor(themeColor: .primary) - .frame(width: buttonSize, height: buttonSize) - ) - } - .padding(.trailing, isProfileImageExpanding ? 28 : 4) } } .padding(.top, 12) @@ -135,23 +134,22 @@ public struct UserProfileModel: View { } } - Button { - withAnimation { - self.isProfileImageToggled.toggle() + 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() + } } - } label: { - 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) - ) - } } } .padding(.top, 12) From faf9d827eb5b4ad2694a56fe206794051286c0f7 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 17:15:15 +1000 Subject: [PATCH 10/26] WIP: update QRCode generation --- .../Components/SwiftUI/QRCodeView.swift | 44 +++++------ .../Components/SwiftUI/UserProfileModel.swift | 5 +- SessionUIKit/Utilities/QRCode.swift | 74 +++++++++++++++++++ 3 files changed, 98 insertions(+), 25 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index a385870ac8..62a6ec6130 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -45,7 +45,7 @@ public struct QRCodeView: View { RoundedRectangle(cornerRadius: Self.cornerRadius) .fill(themeColor: backgroundThemeColor) - Image(uiImage: QRCode.generate(for: string, hasBackground: hasBackground)) + Image(uiImage: QRCode.generate(for: string, hasBackground: hasBackground, iconName: logo)) .resizable() .renderingMode(.template) .foregroundColor(themeColor: qrCodeThemeColor) @@ -56,27 +56,27 @@ public struct QRCodeView: View { ) .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 - ) - } +// 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, diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 6b8f4a7194..be9ca4f0b8 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -11,6 +11,7 @@ public struct UserProfileModel: View { @State private var isSessionIdCopied: Bool = false @State private var isShowingTooltip: Bool = false @State private var tooltipContentFrame: CGRect = CGRect.zero + @State private var isShowingLightBoxForQRCode: Bool = false private let tooltipViewId: String = "UserProfileModelToolTip" // stringlint:ignore private let coordinateSpaceName: String = "UserProfileModel" // stringlint:ignore @@ -129,9 +130,7 @@ public struct UserProfileModel: View { .padding(.vertical, 5) .padding(.horizontal, 10) .onTapGesture { - withAnimation { - self.isProfileImageExpanding.toggle() - } + // TODO: } Image("ic_user_round_fill") diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 9539142021..40e6afef3a 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -43,4 +43,78 @@ enum QRCode { let imageData: Data = UIImage(ciImage: scaledQRCodeAsCIImage).pngData()! return UIImage(data: imageData)! } + + /// 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 + 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 + } } From dae5cf838f55bf2274a0f24d66198cb1ed6f1622 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 5 Aug 2025 17:15:38 +1000 Subject: [PATCH 11/26] clean --- .../Components/SwiftUI/QRCodeView.swift | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index 62a6ec6130..394aa0be8f 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -55,28 +55,6 @@ public struct QRCodeView: View { 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, From 61da31bfc5ba9c2ea41436f562d0c15338edd1f5 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 7 Aug 2025 17:15:09 +1000 Subject: [PATCH 12/26] wip: lightbox to show and share qr code --- Session.xcodeproj/project.pbxproj | 6 ++- .../Components/SwiftUI/LightBox.swift | 46 +++++++++++++++++++ .../Components/SwiftUI/UserProfileModel.swift | 27 ++++++++++- SessionUIKit/Utilities/QRCode.swift | 41 ----------------- 4 files changed, 76 insertions(+), 44 deletions(-) create mode 100644 SessionUIKit/Components/SwiftUI/LightBox.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 9a6f85d995..c133c93177 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -173,6 +173,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 */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -1547,6 +1548,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 = ""; }; 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 = ""; }; @@ -2708,8 +2710,6 @@ FD37E9D828A230F2003AE748 /* TraitObservingWindow.swift */, C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - B84664F4235022F30083A1CD /* MentionUtilities.swift */, - B886B4A82398BA1500211ABE /* QRCode.swift */, FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, @@ -2836,6 +2836,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, 94AAB1522E1F8AD900A6FA18 /* ShineButton.swift */, @@ -6124,6 +6125,7 @@ FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, + 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift new file mode 100644 index 0000000000..b528fa18fc --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -0,0 +1,46 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct LightBox: View { + @Binding var isPresented: Bool + + public var title: String? + public var itemsToShare: [Any] = [] + public var content: () -> Content + + public var body: some View { + NavigationView { + content() + .navigationTitle(title ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + isPresented.toggle() + } label: { + Image(systemName: "chevron.left") + .foregroundColor(themeColor: .textPrimary) + } + } + + ToolbarItem(placement: .bottomBar) { + HStack { + Button { + share() + } label: { + Image(systemName: "square.and.arrow.up") + .foregroundColor(themeColor: .textPrimary) + } + + Spacer() + } + } + } + } + } + + private func share() { + + } +} diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index f729683cf2..5dc1beae8b 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -130,7 +130,7 @@ public struct UserProfileModel: View { .padding(.vertical, 5) .padding(.horizontal, 10) .onTapGesture { - // TODO: + isShowingLightBoxForQRCode.toggle() } Image("ic_user_round_fill") @@ -327,6 +327,31 @@ public struct UserProfileModel: View { self.isShowingTooltip = false } } + .fullScreenCover(isPresented: $isShowingLightBoxForQRCode) { + LightBox(isPresented: $isShowingTooltip) { + VStack { + Spacer() + + if let sessionId = info.sessionId { + QRCodeView( + string: sessionId, + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + + Spacer() + } + .backgroundColor(themeColor: .backgroundSecondary) + } + + } } private func copySessionId(_ sessionId: String) { diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 40e6afef3a..f9a8250a7a 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -3,47 +3,6 @@ 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)! - } - /// 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 From d91e122314b6c50f0ecf2fec885691310c454439 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 8 Aug 2025 15:09:42 +1000 Subject: [PATCH 13/26] light box for qrcode in ump --- .../ConversationVC+Interaction.swift | 6 ++ .../Components/SwiftUI/LightBox.swift | 46 ++++++++---- .../Components/SwiftUI/QRCodeView.swift | 38 ++++++---- .../Components/SwiftUI/UserProfileModel.swift | 75 +++++++++++-------- SessionUIKit/Utilities/QRCode.swift | 46 +++++++++++- 5 files changed, 147 insertions(+), 64 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 0d4c2d73b5..3654367d19 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1563,6 +1563,11 @@ extension ConversationVC: return (lookup?.sessionId, cellViewModel.authorId) }() + 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 @@ -1573,6 +1578,7 @@ extension ConversationVC: info: .init( sessionId: sessionId, blindedId: blindedId, + qrCodeImage: qrCodeImage, profileInfo: profileInfo, displayName: cellViewModel.authorName, nickname: cellViewModel.profile?.displayName( diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift index b528fa18fc..b9af52e625 100644 --- a/SessionUIKit/Components/SwiftUI/LightBox.swift +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -3,10 +3,10 @@ import SwiftUI public struct LightBox: View { - @Binding var isPresented: Bool - + @EnvironmentObject var host: HostWrapper + public var title: String? - public var itemsToShare: [Any] = [] + public var itemsToShare: [UIImage] = [] public var content: () -> Content public var body: some View { @@ -17,30 +17,46 @@ public struct LightBox: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button { - isPresented.toggle() + self.host.controller?.dismiss(animated: true) } label: { Image(systemName: "chevron.left") .foregroundColor(themeColor: .textPrimary) } } - - ToolbarItem(placement: .bottomBar) { - HStack { - Button { - share() - } label: { - Image(systemName: "square.and.arrow.up") - .foregroundColor(themeColor: .textPrimary) - } - - Spacer() + } + .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/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index 394aa0be8f..03c310b66b 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -3,9 +3,7 @@ import SwiftUI public struct QRCodeView: View { - let string: String - let hasBackground: Bool - let logo: String? + let qrCodeImage: UIImage? let themeStyle: UIUserInterfaceStyle var backgroundThemeColor: ThemeValue { switch themeStyle { @@ -27,15 +25,21 @@ public struct QRCodeView: View { 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.string = string - self.hasBackground = hasBackground - self.logo = logo + self.qrCodeImage = QRCode.generate(for: string, hasBackground: hasBackground, iconName: logo) self.themeStyle = themeStyle } @@ -45,16 +49,18 @@ public struct QRCodeView: View { RoundedRectangle(cornerRadius: Self.cornerRadius) .fill(themeColor: backgroundThemeColor) - Image(uiImage: QRCode.generate(for: string, hasBackground: hasBackground, iconName: logo)) - .resizable() - .renderingMode(.template) - .foregroundColor(themeColor: qrCodeThemeColor) - .scaledToFit() - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - .padding(.vertical, Values.smallSpacing) + 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, diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 5dc1beae8b..3c6c6cdf00 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -11,7 +11,6 @@ public struct UserProfileModel: View { @State private var isSessionIdCopied: Bool = false @State private var isShowingTooltip: Bool = false @State private var tooltipContentFrame: CGRect = CGRect.zero - @State private var isShowingLightBoxForQRCode: Bool = false private let tooltipViewId: String = "UserProfileModelToolTip" // stringlint:ignore private let coordinateSpaceName: String = "UserProfileModel" // stringlint:ignore @@ -112,11 +111,9 @@ public struct UserProfileModel: View { .padding(.horizontal, 10) } else { ZStack(alignment: .topTrailing) { - if let sessionId = info.sessionId { + if let qrCodeImage = info.qrCodeImage { QRCodeView( - string: sessionId, - hasBackground: false, - logo: "SessionWhite40", // stringlint:ignore + qrCodeImage: qrCodeImage, themeStyle: ThemeManager.currentTheme.interfaceStyle ) .accessibility( @@ -130,7 +127,7 @@ public struct UserProfileModel: View { .padding(.vertical, 5) .padding(.horizontal, 10) .onTapGesture { - isShowingLightBoxForQRCode.toggle() + showQRCodeLightBox() } Image("ic_user_round_fill") @@ -327,31 +324,6 @@ public struct UserProfileModel: View { self.isShowingTooltip = false } } - .fullScreenCover(isPresented: $isShowingLightBoxForQRCode) { - LightBox(isPresented: $isShowingTooltip) { - VStack { - Spacer() - - if let sessionId = info.sessionId { - QRCodeView( - string: sessionId, - hasBackground: false, - logo: "SessionWhite40", // stringlint:ignore - themeStyle: ThemeManager.currentTheme.interfaceStyle - ) - .aspectRatio(1, contentMode: .fit) - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - } - - Spacer() - } - .backgroundColor(themeColor: .backgroundSecondary) - } - - } } private func copySessionId(_ sessionId: String) { @@ -369,12 +341,51 @@ public struct UserProfileModel: View { } } } + + 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) + } + ) + viewController.modalPresentationStyle = .fullScreen + self.host.controller?.present(viewController, animated: true) + } + + } public extension UserProfileModel { struct Info { let sessionId: String? let blindedId: String? + let qrCodeImage: UIImage? let profileInfo: ProfilePictureView.Info let displayName: String let nickname: String? @@ -386,6 +397,7 @@ public extension UserProfileModel { public init( sessionId: String?, blindedId: String?, + qrCodeImage: UIImage?, profileInfo: ProfilePictureView.Info, displayName: String, nickname: String?, @@ -396,6 +408,7 @@ public extension UserProfileModel { ) { self.sessionId = sessionId self.blindedId = blindedId + self.qrCodeImage = qrCodeImage self.profileInfo = profileInfo self.displayName = displayName self.nickname = nickname diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index f9a8250a7a..2ed69031d4 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -2,14 +2,14 @@ import UIKit -enum QRCode { +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 - static func generate(for string: String, hasBackground: Bool, iconName: String?) -> UIImage { + 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 { @@ -76,4 +76,46 @@ enum QRCode { 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 #colorLiteral(red: 0.1058823529, green: 0.1058823529, blue: 0.1058823529, alpha: 1) + default: return .white + } + } + var tintColor: UIColor { + switch themeStyle { + case .light: return .white + default: return #colorLiteral(red: 0.1058823529, green: 0.1058823529, blue: 0.1058823529, alpha: 1) + } + } + + 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) + } + } } From 48683ff8a840a47087ec60bb2a4953b8148fd935 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 8 Aug 2025 15:20:58 +1000 Subject: [PATCH 14/26] minor fix on input accessory view --- Session/Conversations/ConversationVC+Interaction.swift | 6 +++++- SessionUIKit/Components/SwiftUI/UserProfileModel.swift | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3654367d19..396df52f19 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1573,6 +1573,7 @@ extension ConversationVC: return cellViewModel.profile?.blocksCommunityMessageRequests != true }() + self.hideInputAccessoryView() let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModel( info: .init( @@ -1599,7 +1600,10 @@ extension ConversationVC: } ), dataManager: dependencies[singleton: .imageDataManager], - sessionProState: dependencies[singleton: .sessionProState] + sessionProState: dependencies[singleton: .sessionProState], + afterClosed: { [weak self] in + self?.showInputAccessoryView() + } ) ) present(userProfileModal, animated: true, completion: nil) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 3c6c6cdf00..2d232a13e6 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -156,6 +156,7 @@ public struct UserProfileModel: View { Text(info.displayName) .font(.Headings.H6) .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) if info.isProUser { SessionProBadge_SwiftUI(size: .large) @@ -372,7 +373,8 @@ public struct UserProfileModel: View { Spacer() } .backgroundColor(themeColor: .newConversation_background) - } + }, + customizedNavigationBackground: .backgroundSecondary ) viewController.modalPresentationStyle = .fullScreen self.host.controller?.present(viewController, animated: true) From 6c8f84b540e579470dac0dfdc3153e6f0ff638ab Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 14 Aug 2025 09:28:33 +1000 Subject: [PATCH 15/26] minor fix --- Session/Conversations/ConversationVC+Interaction.swift | 1 - SessionUIKit/Components/SwiftUI/UserProfileModel.swift | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index aa126487a1..f28d83e6ad 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1599,7 +1599,6 @@ extension ConversationVC: } ), dataManager: dependencies[singleton: .imageDataManager], - sessionProState: dependencies[singleton: .sessionProState], afterClosed: { [weak self] in self?.showInputAccessoryView() } diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index 2d232a13e6..b63e3c512f 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -17,7 +17,6 @@ public struct UserProfileModel: View { private var info: Info private var dataManager: ImageDataManagerType - private var sessionProState: SessionProManagerType let dismissType: Modal.DismissType let afterClosed: (() -> Void)? @@ -35,13 +34,11 @@ public struct UserProfileModel: View { public init( info: Info, dataManager: ImageDataManagerType, - sessionProState: SessionProManagerType, dismissType: Modal.DismissType = .recursive, afterClosed: (() -> Void)? = nil ) { self.info = info self.dataManager = dataManager - self.sessionProState = sessionProState self.dismissType = dismissType self.afterClosed = afterClosed } @@ -72,8 +69,7 @@ public struct UserProfileModel: View { ProfilePictureSwiftUI( size: .modal, info: info.profileInfo, - dataManager: self.dataManager, - sessionProState: self.sessionProState + dataManager: self.dataManager ) .scaleEffect(scale, anchor: .topLeading) .onTapGesture { From 4c4b6103ce40fb6e27c0a7c948e9d44432ec848d Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 12 Sep 2025 09:38:15 +1000 Subject: [PATCH 16/26] clean up --- .../ConversationVC+Interaction.swift | 2 +- .../Components/SwiftUI/Modal+SwiftUI.swift | 17 +++++---- .../Components/SwiftUI/UserProfileModel.swift | 35 ++++--------------- SessionUIKit/Utilities/QRCode.swift | 4 +-- SessionUIKit/Utilities/String+Utilities.swift | 2 +- .../Utilities/SwiftUI+Utilities.swift | 22 ++++++++++++ 6 files changed, 44 insertions(+), 38 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index adf6d75a2c..c734c079f2 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1585,7 +1585,7 @@ extension ConversationVC: self.hideInputAccessoryView() let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModel( + modal: UserProfileModal( info: .init( sessionId: sessionId, blindedId: blindedId, diff --git a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift index c43cb6f7a9..2fa2c8ee75 100644 --- a/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/Modal+SwiftUI.swift @@ -28,7 +28,9 @@ public struct Modal_SwiftUI: View where Content: View { Spacer() VStack(spacing: 0) { - content { completion in close(completion: completion) } + content { internalAfterClosed in + close(internalAfterClosed) + } } .backgroundColor(themeColor: .alert_background) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) @@ -56,14 +58,11 @@ public struct Modal_SwiftUI: View where Content: View { } } ) - .onDisappear { - afterClosed?() - } } // MARK: - Dismiss Logic - private func close(completion: (() -> Void)? = nil) { + 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 @@ -76,7 +75,13 @@ public struct Modal_SwiftUI: View where Content: View { } } - targetViewController?.presentingViewController?.dismiss(animated: true, completion: completion) + targetViewController?.presentingViewController?.dismiss( + animated: true, + completion: { + afterClosed?() + internalAfterClosed?() + } + ) } } diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index b63e3c512f..2fa2f8d395 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -4,7 +4,7 @@ import SwiftUI import Lucide import Combine -public struct UserProfileModel: View { +public struct UserProfileModal: View { @EnvironmentObject var host: HostWrapper @State private var isProfileImageToggled: Bool = true @State private var isProfileImageExpanding: Bool = false @@ -12,8 +12,8 @@ public struct UserProfileModel: View { @State private var isShowingTooltip: Bool = false @State private var tooltipContentFrame: CGRect = CGRect.zero - private let tooltipViewId: String = "UserProfileModelToolTip" // stringlint:ignore - private let coordinateSpaceName: String = "UserProfileModel" // stringlint:ignore + private let tooltipViewId: String = "UserProfileModalToolTip" // stringlint:ignore + private let coordinateSpaceName: String = "UserProfileModal" // stringlint:ignore private var info: Info private var dataManager: ImageDataManagerType @@ -167,11 +167,12 @@ public struct UserProfileModel: View { switch (info.sessionId, info.blindedId) { case (.some(let sessionId), .none): return ("accountId".localized(), sessionId) - case (.some(let sessionId), .some(_)): + case (.some(let sessionId), .some): return ("accountId".localized(), sessionId.splitIntoLines(charactersForLines: [23, 23, 20])) case (.none, .some(let blindedId)): return ("blindedId".localized(), blindedId) - default : return ("", "") // Shouldn't happen + case (.none, .none): + return ("", "") // Shouldn't happen } }() @@ -375,11 +376,9 @@ public struct UserProfileModel: View { viewController.modalPresentationStyle = .fullScreen self.host.controller?.present(viewController, animated: true) } - - } -public extension UserProfileModel { +public extension UserProfileModal { struct Info { let sessionId: String? let blindedId: String? @@ -417,23 +416,3 @@ public extension UserProfileModel { } } } - -struct ConditionalTruncation: ViewModifier { - let shouldTruncate: Bool - - func body(content: Content) -> some View { - if shouldTruncate { - content - .lineLimit(1) - .truncationMode(.middle) - } else { - content - } - } -} - -extension View { - func shouldTruncate(_ condition: Bool) -> some View { - modifier(ConditionalTruncation(shouldTruncate: condition)) - } -} diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 2ed69031d4..a5df2c5dc8 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -85,14 +85,14 @@ public enum QRCode { ) -> UIImage { var backgroundColor: UIColor { switch themeStyle { - case .light: return #colorLiteral(red: 0.1058823529, green: 0.1058823529, blue: 0.1058823529, alpha: 1) + case .light: return .classicDark1 default: return .white } } var tintColor: UIColor { switch themeStyle { case .light: return .white - default: return #colorLiteral(red: 0.1058823529, green: 0.1058823529, blue: 0.1058823529, alpha: 1) + default: return .classicDark1 } } diff --git a/SessionUIKit/Utilities/String+Utilities.swift b/SessionUIKit/Utilities/String+Utilities.swift index 742d856a91..3181285787 100644 --- a/SessionUIKit/Utilities/String+Utilities.swift +++ b/SessionUIKit/Utilities/String+Utilities.swift @@ -33,7 +33,7 @@ public extension String { for count in charactersForLines { let end = self.index(start, offsetBy: count, limitedBy: self.endIndex) ?? self.endIndex - var line = String(self[start.. some View { + if shouldTruncate { + content + .lineLimit(1) + .truncationMode(.middle) + } else { + content + } + } +} + +extension View { + func shouldTruncate(_ condition: Bool) -> some View { + modifier(ConditionalTruncation(shouldTruncate: condition)) + } +} From c52889dd23532606324ba2873cc1eb41973e2aba Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 12 Sep 2025 09:42:59 +1000 Subject: [PATCH 17/26] further clean up --- Session.xcodeproj/project.pbxproj | 8 ++++---- .../{UserProfileModel.swift => UserProfileModal.swift} | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) rename SessionUIKit/Components/SwiftUI/{UserProfileModel.swift => UserProfileModal.swift} (98%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 3d3753de3e..f84581e414 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -203,7 +203,7 @@ 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 /* UserProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B6BAFD2E39F50E00E718BB /* UserProfileModel.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 */; }; @@ -1586,7 +1586,7 @@ 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 /* UserProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileModel.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 = ""; }; @@ -2853,7 +2853,7 @@ 94AAB1502E1F752600A6FA18 /* CyclicGradientView.swift */, 94AAB14E2E1F6CB300A6FA18 /* SessionProBadge+SwiftUI.swift */, 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */, - 94B6BAFD2E39F50E00E718BB /* UserProfileModel.swift */, + 94B6BAFD2E39F50E00E718BB /* UserProfileModal.swift */, 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */, FD8A5B1F2DC03332004C689B /* AdaptiveText.swift */, FD8A5B212DC0489B004C689B /* AdaptiveHStack.swift */, @@ -6171,7 +6171,7 @@ FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, 942256942C23F8DD00C0FDBF /* ActivityView.swift in Sources */, FD42ECD22E3071DE002D03EA /* ThemeText.swift in Sources */, - 94B6BAFE2E39F51800E718BB /* UserProfileModel.swift in Sources */, + 94B6BAFE2E39F51800E718BB /* UserProfileModal.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, 94B6BB022E3AE85C00E718BB /* QRCode.swift in Sources */, C331FFE82558FB0000070591 /* SNTextView.swift in Sources */, diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift similarity index 98% rename from SessionUIKit/Components/SwiftUI/UserProfileModel.swift rename to SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 2fa2f8d395..604d233bd9 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -325,6 +325,8 @@ public struct UserProfileModal: View { } private func copySessionId(_ sessionId: String) { + guard !isSessionIdCopied else { return } + UIPasteboard.general.string = sessionId // Ensure we are on the main thread just in case @@ -332,7 +334,8 @@ public struct UserProfileModal: View { withAnimation(.easeInOut(duration: 0.25)) { isSessionIdCopied.toggle() } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(4250)) { + // 4 seconds delay + the animation duration above + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(4) + .milliseconds(250)) { withAnimation(.easeInOut(duration: 0.25)) { isSessionIdCopied.toggle() } From a86730047a2fae64351b9ae1ec96acc3de860a74 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 24 Sep 2025 10:25:50 +1000 Subject: [PATCH 18/26] feat: tap on author label to show user profile modal --- .../Conversations/Message Cells/VisibleMessageCell.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 7fe5fb337f..5f01975064 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -993,7 +993,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { + 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)) { From 6edb57a4487694a383e0793c9e0be60873ae7e6a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 24 Sep 2025 12:08:55 +1000 Subject: [PATCH 19/26] feat: show pro cta when tapping pro badge on user profile modal for non-pro users --- .../ConversationVC+Interaction.swift | 81 +++++++++++-------- .../Conversations/ConversationViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 35 ++++---- .../Utilities/SessionProState.swift | 25 ++++++ .../Components/SwiftUI/ProCTAModal.swift | 38 +++++++++ .../AttachmentApprovalViewController.swift | 55 +++++++------ 6 files changed, 160 insertions(+), 76 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3987f2f299..7bb3745d9e 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 func showSessionProCTAIfNeeded(_ variant: ProCTAModal.Variant) -> 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: variant, - 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: } func handleCharacterLimitLabelTapped() { - guard !showSessionProCTAIfNeeded(.longerMessages) 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: 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: } func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { - guard !showSessionProCTAIfNeeded(.longerMessages) 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( @@ -1624,8 +1628,21 @@ extension ConversationVC: openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey ) }, - onProBadgeTapped: { [weak self] in - self?.showSessionProCTAIfNeeded(.generic) + 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], 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/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 9daf4ee913..efa513ea85 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -692,8 +692,15 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl label: "Upload" ), dataManager: dependencies[singleton: .imageDataManager], - onProBageTapped: { [weak self] in - self?.showSessionProCTAIfNeeded() + onProBageTapped: { [weak self, dependencies] 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 +743,14 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dependencies[cache: .libSession].isSessionPro || !dependencies[feature: .sessionProEnabled] ) else { - self?.showSessionProCTAIfNeeded() + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) return } @@ -770,21 +784,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/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index feb5e96ffe..c4ac3cabb6 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 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/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 20105dff65..9940f0d28f 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -367,6 +367,44 @@ public protocol SessionProManagerType: AnyObject { var isSessionProSubject: CurrentValueSubject { get } var isSessionProPublisher: AnyPublisher { get } func upgradeToPro(completion: ((_ result: Bool) -> Void)?) + @discardableResult func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + dismissType: Modal.DismissType, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool +} + +// MARK: - Convenience +public extension SessionProManagerType { + @discardableResult func showSessionProCTAIfNeeded( + _ variant: ProCTAModal.Variant, + beforePresented: (() -> Void)?, + afterClosed: (() -> Void)?, + presenting: ((UIViewController) -> Void)? + ) -> Bool { + showSessionProCTAIfNeeded( + variant, + dismissType: .recursive, + beforePresented: beforePresented, + afterClosed: afterClosed, + presenting: presenting + ) + } + + @discardableResult 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/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 0332840e9c..4a50f4a050 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -637,31 +637,22 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.approvalDelegate?.attachmentApprovalDidCancel(self) } - // MARK: - Session Pro CTA - - @discardableResult func showSessionProCTAIfNeeded() -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionPro) else { - return false - } - self.hideInputAccessoryView() - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: .longerMessages, - dataManager: dependencies[singleton: .imageDataManager], - afterClosed: { [weak self] in - self?.showInputAccessoryView() - self?.bottomToolView.attachmentTextToolbar.updateNumberOfCharactersLeft(self?.bottomToolView.attachmentTextToolbar.text ?? "") - } - ) - ) - present(sessionProModal, animated: true, completion: nil) - - return true - } - 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( @@ -688,7 +679,21 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { 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( From 15e07acb02a79a0ec60807376fa8fc6347dc935a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 24 Sep 2025 15:39:12 +1000 Subject: [PATCH 20/26] fix: blinded id truncation --- .../ConversationVC+Interaction.swift | 17 +++++++++++--- .../Components/SwiftUI/UserProfileModal.swift | 1 - .../Utilities/SwiftUI+Utilities.swift | 22 ------------------- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 7bb3745d9e..69ead7c464 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1587,13 +1587,24 @@ extension ConversationVC: guard let profileInfo: ProfilePictureView.Info = info else { return } let (sessionId, blindedId): (String?, String?) = { - guard (try? SessionId.Prefix(from: cellViewModel.authorId)) == .blinded15 else { + 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].read { db in - try? BlindedIdLookup.fetchOne(db, id: cellViewModel.authorId) + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: cellViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) } - return (lookup?.sessionId, cellViewModel.authorId) + return (lookup?.sessionId, cellViewModel.authorId.truncated(prefix: 10, suffix: 10)) }() let qrCodeImage: UIImage? = { diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 604d233bd9..624d3d68ea 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -200,7 +200,6 @@ public struct UserProfileModal: View { .font(isIPhone5OrSmaller ? .Display.base : .Display.large) .foregroundColor(themeColor: .textPrimary) .multilineTextAlignment(.center) - .shouldTruncate(info.sessionId == nil) .padding(.horizontal, info.blindedId == nil ? 0 : Values.largeSpacing) } diff --git a/SessionUIKit/Utilities/SwiftUI+Utilities.swift b/SessionUIKit/Utilities/SwiftUI+Utilities.swift index 6613ab5a57..8df425efcc 100644 --- a/SessionUIKit/Utilities/SwiftUI+Utilities.swift +++ b/SessionUIKit/Utilities/SwiftUI+Utilities.swift @@ -282,25 +282,3 @@ public extension View { } } } - -// MARK: Conditional Truncation - -struct ConditionalTruncation: ViewModifier { - let shouldTruncate: Bool - - func body(content: Content) -> some View { - if shouldTruncate { - content - .lineLimit(1) - .truncationMode(.middle) - } else { - content - } - } -} - -extension View { - func shouldTruncate(_ condition: Bool) -> some View { - modifier(ConditionalTruncation(shouldTruncate: condition)) - } -} From f51a40f9cf8794a19de14c8808fe5d34a60d69dc Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 24 Sep 2025 16:27:56 +1000 Subject: [PATCH 21/26] fix: nickname, display name and account id resolving for user profile modal in communities --- .../ConversationVC+Interaction.swift | 22 ++++++--- .../Components/SwiftUI/UserProfileModal.swift | 47 ++++++++++++------- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 69ead7c464..93e153886b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1594,7 +1594,7 @@ extension ConversationVC: else { return (cellViewModel.authorId, nil) } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].read { db in + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in try BlindedIdLookup.fetchOrCreate( db, blindedId: cellViewModel.authorId, @@ -1607,6 +1607,19 @@ extension ConversationVC: 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 @@ -1625,11 +1638,8 @@ extension ConversationVC: blindedId: blindedId, qrCodeImage: qrCodeImage, profileInfo: profileInfo, - displayName: cellViewModel.authorName, - nickname: cellViewModel.profile?.displayName( - for: cellViewModel.threadVariant, - ignoringNickname: true - ), + displayName: displayName, + contactDisplayName: contactDisplayName, isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: cellViewModel.profile) }), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: { [weak self] in diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 624d3d68ea..57f50ba308 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -26,7 +26,7 @@ public struct UserProfileModal: View { .localizedFormatted(baseFont: Fonts.Body.smallRegular) } else { return "tooltipAccountIdVisible" - .put(key: "name", value: info.displayName) + .put(key: "name", value: (info.displayName ?? "")) .localizedFormatted(baseFont: Fonts.Body.smallRegular) } } @@ -148,17 +148,28 @@ public struct UserProfileModal: View { } // Display name & Nickname (ProBadge) - HStack(spacing: Values.smallSpacing) { - Text(info.displayName) - .font(.Headings.H6) - .foregroundColor(themeColor: .textPrimary) - .multilineTextAlignment(.center) - - if info.isProUser { - SessionProBadge_SwiftUI(size: .large) - .onTapGesture { - info.onProBadgeTapped?() + 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) + } } } @@ -243,9 +254,9 @@ public struct UserProfileModal: View { } .padding(.bottom, 12) } else { - if !info.isMessageRequestsEnabled { + if !info.isMessageRequestsEnabled, let displayName: String = info.displayName { AttributedText("messageRequestsTurnedOff" - .put(key: "name", value: info.displayName) + .put(key: "name", value: displayName) .localizedFormatted(Fonts.Body.smallRegular) ) .font(.Body.smallRegular) @@ -386,8 +397,8 @@ public extension UserProfileModal { let blindedId: String? let qrCodeImage: UIImage? let profileInfo: ProfilePictureView.Info - let displayName: String - let nickname: String? + let displayName: String? + let contactDisplayName: String? let isProUser: Bool let isMessageRequestsEnabled: Bool let onStartThread: (() -> Void)? @@ -398,8 +409,8 @@ public extension UserProfileModal { blindedId: String?, qrCodeImage: UIImage?, profileInfo: ProfilePictureView.Info, - displayName: String, - nickname: String?, + displayName: String?, + contactDisplayName: String?, isProUser: Bool, isMessageRequestsEnabled: Bool, onStartThread: (() -> Void)?, @@ -410,7 +421,7 @@ public extension UserProfileModal { self.qrCodeImage = qrCodeImage self.profileInfo = profileInfo self.displayName = displayName - self.nickname = nickname + self.contactDisplayName = contactDisplayName self.isProUser = isProUser self.isMessageRequestsEnabled = isMessageRequestsEnabled self.onStartThread = onStartThread From b66890558e959afebc660e62fa061de1cc6fc729 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 24 Sep 2025 17:03:35 +1000 Subject: [PATCH 22/26] fix: animation for profile picture expanding --- .../Components/SwiftUI/UserProfileModal.swift | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 57f50ba308..7d64459533 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -86,20 +86,26 @@ public struct UserProfileModal: View { if info.sessionId != nil { let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) - AttributedText(Lucide.Icon.qrCode.attributedString(size: iconSize, baselineOffset: 0)) - .font(.system(size: iconSize)) - .foregroundColor(themeColor: .black) - .background( - Circle() - .foregroundColor(themeColor: .primary) - .frame(width: buttonSize, height: buttonSize) - ) - .padding(.trailing, isProfileImageExpanding ? 28 : 4) - .onTapGesture { - withAnimation { - self.isProfileImageToggled.toggle() - } + 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) From 47e5519f94573eec1d99512650e49c0db4c49d46 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 09:42:34 +1000 Subject: [PATCH 23/26] clean up --- Session.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 08d3bc43f0..cd344a81ee 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -255,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 */; }; @@ -1645,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 = ""; }; From 4af806645e06804c5a74fbcd99f75aef52952bb5 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 09:43:34 +1000 Subject: [PATCH 24/26] clean up --- .../AttachmentApprovalViewController.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index c1f9dbe2ff..e3d4039eb8 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -679,8 +679,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // MARK: - AttachmentTextToolbarDelegate extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { -<<<<<<< HEAD - func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { + @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { guard dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( .longerMessages, beforePresented: { [weak self] in @@ -696,10 +695,7 @@ extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { ) else { return } -======= - @MainActor func attachmentTextToolBarDidTapCharacterLimitLabel(_ attachmentTextToolbar: AttachmentTextToolbar) { - guard !showSessionProCTAIfNeeded() else { return } ->>>>>>> animated-profile-picture + self.hideInputAccessoryView() let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( From a2e01ac8694318cc67987c9fea8d79d4a21c6db1 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 09:46:45 +1000 Subject: [PATCH 25/26] fix an concurrency issue after merge --- Session/Settings/SettingsViewModel.swift | 36 +++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index efa513ea85..52ae1b611a 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -693,14 +693,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ), dataManager: dependencies[singleton: .imageDataManager], onProBageTapped: { [weak self, dependencies] in - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro - ), - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) + 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 @@ -743,14 +745,16 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dependencies[cache: .libSession].isSessionPro || !dependencies[feature: .sessionProEnabled] ) else { - dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( - .animatedProfileImage( - isSessionProActivated: dependencies[cache: .libSession].isSessionPro - ), - presenting: { modal in - self?.transitionToScreen(modal, transitionType: .present) - } - ) + Task { @MainActor in + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + .animatedProfileImage( + isSessionProActivated: dependencies[cache: .libSession].isSessionPro + ), + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) + } return } From e0e031f52f47bbb0464b0da7a4be092f73ac4a19 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 11:43:02 +1000 Subject: [PATCH 26/26] fix a minor ui style issue --- .../Components/SwiftUI/UserProfileModal.swift | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index 7d64459533..b54bd7a0df 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -229,15 +229,15 @@ public struct UserProfileModal: View { Text("message".localized()) .font(.Body.baseBold) .foregroundColor(themeColor: .sessionButton_text) + .framing( + maxWidth: .infinity, + height: Values.smallButtonHeight + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) } - .framing( - maxWidth: .infinity, - height: Values.smallButtonHeight - ) - .overlay( - Capsule() - .stroke(themeColor: .sessionButton_border) - ) .buttonStyle(PlainButtonStyle()) Button { @@ -246,24 +246,25 @@ public struct UserProfileModal: View { 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) - .framing( - maxWidth: .infinity, - height: Values.smallButtonHeight - ) - .overlay( - Capsule() - .stroke(themeColor: .sessionButton_border) - ) .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) + AttributedText( + "messageRequestsTurnedOff" + .put(key: "name", value: displayName) + .localizedFormatted(Fonts.Body.smallRegular) ) .font(.Body.smallRegular) .foregroundColor(themeColor: .textSecondary) @@ -276,18 +277,18 @@ public struct UserProfileModal: View { close(info.onStartThread) } label: { Text("message".localized()) - .font(.system(size: Values.mediumFontSize)) + .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) - .frame( - width: (geometry.size.width - Values.mediumSpacing) / 2, - height: Values.smallButtonHeight - ) - .overlay( - Capsule() - .stroke(themeColor: (info.isMessageRequestsEnabled ? .sessionButton_border : .disabled)) - ) .buttonStyle(PlainButtonStyle()) } .frame(