+0<4h`8JO0CyZIg5Mo7MV`EslU
zipCDOB@U=SzSgSp7Zd~K_lMIaokp90r{0KzyCT=V?-^F4{L5iG)l?-ylA)-?n}
zduT@ZgK~&nDDVgEUcAR~B0t4}pJF2n_`ph!TK8p%K&2wE}K(Ko;LB8B7#~Mpn`FT9CcHy$X)D0J$2em|6)RKYrX6pRA2c$OwY4=oStA2Td&|)@z#29>w^wxo4X7A@X*^f(c+nD9#8*jk?0s+V=TC%&
z(=yc8a#w-RD{y5EaVQ)KrDiI(V)PgGz*70soj$u1voi5S>{l@LV^$ycE542>er#;@@#5mq(Xuaj>S&m
z16|h#mGjE#+9sm(Ja2<`cr-LK;J
z#eEbIod%}`y@30%_{M{LP2g=B_V)I^CPDDPvaG-h^Y{+I4x5{sFE|v#Im;vn3XB)C
z`vvZZ2pobPrl+SL@((SMkbA;}UWd>-C9&OpN>UZ)9(%Ky5TVy0a0qt5*q~I!0@nn8
zuU4yfC?uf;2sSo0ew6|0{K6^NjrH~Q1?qvGH!H*dwX@o#kROousmY0+=vjbh-w@z>M$qKJ^@)%?IyZ719-9E2sZ{eo57mUPWz985$+B|gwo5#pv^udi}Z
z!baFvCLwcRu$haFZz)0|6(C>{Rz!r^1LET-w?GjSxd4F@p$yMtgt){9Vi6!<5#AXc
z9mVvFOxKys5{oak>%(IcAYc(LOE@+c3^cR&zN8q5Re(TpLNife_Zy0t*aZkggk}+E
z&gUi(0Ro6{Y-|i$_ek@C#_%}bB_3+IP6-j&Oo(45CMHTel75r_%ZemugVRk
zBAW^E3;%bm;t$1qA_-c6W)l8PP9!ZpkhlN=u9(Lkj4F$SRKaJUoUcYHka!4oGd(?h
zMXky|OhRHH&1JOZ2Z{d+6N3dVX
zdn64Fp+JR;1;VSvD&_RUQm5M4dDr5*o
z3D7}=*n)Awb1+hX4vr8x(f%ZF#R`fG9xb?0C=?b=2(|4nT7Zq9YNmZhEK|0rU^7YV
z=NH9wxoMLb0Xo1HU%!5Rg+HKkS#d=)Zm`=e@!z%h%;>hZp>nxfn5X4`n&m8nS3l%NfNofj!yM8|9ID}v
zGyK69lM*Gw#y@M7kB@LoUw5}+RyzXcfQ`_w=}3vh%5HFmIZW6?45x6yxlD2*8NX-4nW*P2P+OW)fGAFk_r
z*TctnZe3q{P^;BC;qN<3OG_OgMl&MB2vnpH))ZH50~`u
zk1FsbJXY3)R1O7P0ijrDRIJBU9MdKUbpSfJ>EHc^in=WnbtO>1Lm(9F?B?d?as9y0
zS}4|3=)Y}A|7}b4di|E0KLwk$e&`$WaurU8}?3b+qpw7notqb(U8bBxOUp@4g!
z!krC;J0qkrLMQvPm6eq_A$y>JW5m+r^YXNHk(fhAZ=X=eJE4Gu>s!m{2!*_j_X0k4
z?AS*Xu9b9zLiSg{tyb&2-hCuYm2IaZ9t+t!0f(+KggYa|B|7R_zkvp9@d$4OjG0;$
z?x(_Xkn5oQ^ocKZ96WKM8BPWG~qK4{Kv_X>II81ct+`Yuzhc>ynf>vo^Lq0&CR0pi%dW_|~d~^kFP?
zsvm1K{;ODwwGo&`6)6a-4K6DM95!Vv3XLjK4A!R9)}_#)%M9@z5<-drDfR5c#Kezl
zYir+&MOY=^SVz@9A*5JPxp>Pqm)aTuqeP@v$v+Aqn$XOgv>M&UEQ6G`4;fikJ9DGdzd7
zR7y3mu<5$42R;@;`~;@fgEBXdFD-=**S$!c*(OPRrquOtX(==Z6bK2x2_mIcUrI@#
zQSTKtYY-A~sH`TB!c*wj$q9sz7-iIl3l%U+Mua3%cxkE7H84v?gd|dUX{oRX9Jd3QHTpo*O`z^98yN^-MiP8oa9cS*|0Korv8kvoMB
z7v&T$A=yC1CU+{GD*}gG!hS1+kb%f+XyuB)RqX>rh%1bYjN~dS4J_bRt94#j^CcuN
zGoh9SB2NS^eb$i>PZ(HB1C^1&E5>;tgcQNppk<{&BQT>cAqApl)&AAJ^5DS(dHndXJbdu<
zF`j$$_>tUSyDyI(JrY7vfaZ=DvrH>BBm+K2UkLX%Cw?O*PMna@(a{~RLBzNvv9`7r
z2s{#ZT=Z-A)`Spw7KJ}f`vsXIu&>brsl1`(W)It-%?^ZR3L8QVBR!1om~GE2PfvzW1)U#E!K$QuarB
ze}9cp+%VGiNT-UxD&DunLr$JNDXtMW++KR=r9H35qxkMrol$&vAmi1$cHj5)dxOtg
z
zKXv^YINApf9^4jr)Ce-VZ*=>yD?}HpB*Tqmvt-a-DR~4OnpaKnkbcf{B-Sh-wd^JC
z?LRjjwKW6SH>#o^eO-)VBB$-)>0`}Fs0;A6*3#F_G5v7~p&Ye4Z
zUPlxUqIQ>5-L)v5#c;#g8zRRAa(8BAGqqX`n?<&Jy`E@dwOXzJ7f<)Q!UyKWZ%_1m
zeeui1fuis*NJ`ahAn%1}_4caVym`|VAt&}n_Y|Gp1rAuTK{
z7<-Y;$U7@5D|6jm=sD0oF);!2>ZlM>5x@NMi~Ria&p||8U0n_QfV6HtvW=tvdQ>_;
zbb{|=IXKyHzS|331&qOBb-?C?5JSK^7oGt8fuLNMVmZ)gGy)MXf43|f8yjLVV}}nP
z{?EI2@9uH!x(@WKdBsx}h>@rx8`R&^nuzcLPMta>mNV8%lf8E2I4gu03e2hb`T5lA
z+sUcPz}T~#*SbB~)x`42eGbYpT8eS0Q_6NA&@?TX(VTXg`uO7FqTKqQTVgTmYGggX
z`^h~5_GR+u$iEy3h83drM`9kR>B7K5+T0JM$w_5De2?qbu8Yldn^4-*$g#Dx<@EX&
z5f?MQ*cykJ4N6B*83}dwq`EHZzDWXhAcgMQ)X}XI>osu{{1AWt=I@yX%@w6C-5oWeDlqok+k3Ni3?p>(B|&Fqjv~c*S^k*
zcWP=Xa3hJmK*ZP?bP%Hca@*NmPTE~Ya<+Wr;_ezg0`H<*6h=u2qlmq5Wjt|YWMoH>
zttGwa3UHiF*_e*x18doClS$xj|M$0^U+2n%lBUr+g6y`5h1}ZJ6dDFd61X}$G`(Uq
z5a{XCrxP!@zdrKouBD|14}1Uo9{>HZI6@Ozji!_0F0`}j-+s~e+O=za2kpu5z|Z0j
z|Ne)bulF*nlG0?Cw($wKxeB(O-A{l&eng4-#9W8yp3vgCVCt!Q~WMdA=ruI10uH9j~(=fQiKvIwwV3R3j(c?dB7oFq*xqoquhp_SOtNjD+j&e
zBh^cD0w2kS2NAf*2wV_Y!+!nM*OC={9r~e71k+S#549B^B5*@UVcfcPD<}t@{I_(+
zxCG+;!pva?w1%Qj8})5lC#QUv^kM`q7<|wn%6<+gW`ZxxUoz6XLSsrKUVzD|bLY-k
zH+12{e&3A1B>-`L{`u!Ye=|C)4vrrTo=2obS_jdZdJ+T<4?A`G)Sl-d=6BzFR~8o+
zgY~`*cu+>7fXf6Twdu$f2p0lQd=Ji8J~(dtSyH~tSX00xFsm@foOU7#gBN)3!oq^F
z^=p^M2;*=K>^WH1Vtt!+oDnJYxwp@yHtdFtFP&`=a7T!jphN@_=9#3z!Rc?lW!|nL
zo^-Z#7=b+nYh5_aMNB~W+fiV!bE
zeB!soifmA`Y7ioWtLT427JR4ygNBCT2dZQt)qrS-v~%}b6!lg-ck`>8k{6g&v$L~9
z-Sq>1R#Y=a^T#mT8a)`~kLaKd%m`coNM{joCntMvW`}eXo4;ROxH@oS0xk~tK2;Ib
zjEA{_y)V%h!Ix{`3JA(Kn$9MPHzMjw9%N043q+OTjfmPXhh=d2FvuUW-oQdw$k0gB
zuDF|MO}Y5xVyd}eh#j3(Wtm}vf{O|MPWX3}8I65uqg3rKHR|mOmr*qcQ53s=?fM?02J6k!
zr%xNdMQv~k|Dnvu1%|(g|QEc~oE-nyf&YZDo
ze`#Edurw&TX6#D}4edtLNFCwcScL+k3WuGQmC8mZ8)6rb@rm(NhM0*&Cf%h=mkKNW
z_|U{5VdfkOcu6SW!WhJ;8U%>A=$6PTz`x_*!GndZiG66Jl(>Mkp7bM|Ir3ww`|A_F
z3^Ax?zr%ezg(f*D(x@VXC|uink-AD<}`451B@l`P{$n
z{&`4hV6~&i+XAlF>unlYd%?*H5HF*y9rzG|BLfR;Z)z7ViaSCAqJevqh$vn=@Sz5b
z^f@xHKx5a7S2dNsAf7{`%1(TUzL)`y|Gts*qK_cjV$_XM*FHR%1HKyn&5nJcbsfAQ
zo`ckx;w7e{8r>0OcL*4{b=;yRgqTCP{_Vt{>vl534IzY>$R2I(5wO;nyicPEF_CWC
z+#}%Iw{N$Fdqops8tb7pchL24GnA8r5MmNtm$qF6tmUM(5JF6%>(aKXfWvZQ=op6mqk
z*Bm6-Cb2IgDTNLS$0}m$r<1VJchboPIFbY>9C)&$ztN07H`;fvF<*9`1X>YF45Enw
zM&Ve^ETqw6Vln;ZL|r-Iz!&cP?;Y_0Y+k47b35rhHR=CNJ`>BqksZElH|hgr9ZPB&
z8htjxZgvb`@?ZdXAT<6#KgpmSibQ)cHKlI
z{?$MKiYEwK1Tv?q3s=2KoiVtt=GBL3-kVCmKn0Bdy|=`2AmTfB?gYKJqx(h+KOLSF
z2Za0e&97zg%SEqBN6Lp9G*j)2lj%spW5U4ia6sv3
zd3pK8wC`_7HjtNpBo2WO3$FAuxQGd)DILX0Wnyi=`Dsas{n3P!K-jo3m*s0R2{`PJ
zcEXV+#5A%-Uyw<_!51(J6H+*XL|>3uz>LC#6we^h7i4mw#CjH8EoodhF;vgdw2cNp+o;9TniJD8eM+=hpv5d
zXRoQf(A%yHfQ|C8&9a1(Z
zg{9CDOr;6g3!0vaOr0YNbD?)bSDKKg6zMwerqC36$2Zn=LD=9yJO>RMMY_(rDLjRa
z2pc@GmEh|_h^MG0^x?|N%5^D;(gY0d-MjZ!u7`=Qpk%bVy85w{NNFi_1WQZAN6IJ}
zb)$3v!!{4s09+3fFMxRQnJ>Cz)Q!>w3~)%3rW3j59tp7p{j7hG=3ufSD0K}SC#(1dcN`HFZ&id_f2)
znQ*G;eX)R{juYl#ZEfv3=ZQ(#g!8;Z9SO{XH39~hC!Uy?_%56$W|@f;2FgS#-ZLuR
zIk5<9Qs_7|w@8uLU{>|QXcMa!k-3GniV%_me1>c=t9oH&1dem;*s=HZhwzawr6w6b
z%DlO``N`_)>P4{_>jey$Qj;v8EE%2@*b&QdLckGPYQiS+9|<8nu&G3p2B!oJFbq+z
z*RL=dlQ=M{aCWtdw=E7~rHPdUh9PKhkvNf2<(hbba}hWW*T#N>wXXjBKX6gF5l#y@
zB3v7z4UdwO052kN
z98IgI_3BKM%RSm*A9Gtg!aD&+v|6q6C^=Ecb^~`A!d+QenG+B3R=^PyvYq&}&P~F5
z0Y^~Cb`S}-(P&((>NpdVpn!KlA)86qh%ry$?t}vFK`7)4DrCCW)za46XS&Aq!d1=+
z1>6U%AA{xw$#R^twE_zReB=JOEKt*4tSXGrPNT
z5Q=tQA1*N3W`_dK3D(MbcUEt+LQ)a#KZUJ==j-+Q0;BEVP{0MDDfXDA*bwnq?3~HS
zypvG0Frscyw8cRImju#my_>DoYOnd~WQWs0H-zbNX;8pJAT+=r;#vJrA9q#IVXgKm
zgv%&994O!lK)SuLv4LY$k!l~;5B#)k&UV;?yrlOxRJ2R#94}F@!-oPkgqWx?E!9M%
z*4tRs(K-^U-hR}-TZdvb3kui*jvhVQRM9}xV|r`qQxNtGdXL8t84R<)H0u}1hZqVi`00000NkvXXu0mjfd~!1z
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(