From 70a29512b76fd9cd6f04c790d4ae13f348e68eda Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:37:00 +0400 Subject: [PATCH] ios: server operators ui (#5114) * wip * refactor, fix bindings * wip * wip * fixes * wip * information map, logos * global conditions hack * restructure * restructure * texts * text * restructure * wip * restructure * rename * wip * conditions for all * comment * onboarding wip * onboarding wip * fix paddings * fix paddings * wip * fix padding * onboarding wip * nav link instead of sheet * pretty button * large titles * notifications mode button style * reenable demo operator * Revert "reenable demo operator" This reverts commit 42111eb333bd5482100567c2f9855756d364caf3. * padding * reenable demo operator * refactor (removes additional model api) * style * bold * bold * light/dark * fix button * comment * wip * remove preset * new types * api types * apis * smp and xftp servers in single view * test operator servers, refactor * save in main view * better progress * better in progress * remove shadow * update * apis * conditions view wip * load text * remove custom servers button from onboarding, open already conditions in nav link * allow to continue with simplex on onboarding * footer * existing users notice * fix to not show nothing on no action * disable notice * review later * disable notice * wip * wip * wip * wip * optional tag * fix * fix tags * fix * wip * remove coding keys * fix onboarding * rename * rework model wip * wip * wip * wip * fix * wip * wip * delete * simplify * wip * fix delete * ios: server operators ui wip * refactor * edited * save servers on dismiss/back * ios: add address card and remove address from onboarding (#5181) * ios: add address card and remove address from onboarding * allow for address creation in info when open via card * conditions interactions wip * conditions interactions wip * fix * wip * wip * wip * wip * rename * wip * fix * remove operator binding * fix set enabled * rename * cleanup * text * fix info view dark mode * update lib * ios: operators & servers validation * fix * ios: align onboarding style * ios: align onboarding style * ios: operators info (#5207) * ios: operators info * update * update texts * texts --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --------- Co-authored-by: Diogo Co-authored-by: Evgeny Poberezkin --- .../flux_logo-light.imageset/Contents.json | 21 + .../Flux_logo_blue_white.png | Bin 0 -> 33847 bytes .../flux_logo.imageset/Contents.json | 21 + .../flux_logo.imageset/Flux_logo_blue.png | Bin 0 -> 34876 bytes .../flux_logo_symbol.imageset/Contents.json | 21 + .../Flux_symbol_blue-white.png | Bin 0 -> 17248 bytes apps/ios/Shared/ContentView.swift | 29 +- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/Model/SimpleXAPI.swift | 74 ++- .../Shared/Views/ChatList/ChatListView.swift | 27 +- .../Views/ChatList/ServersSummaryView.swift | 51 +- .../Onboarding/AddressCreationCard.swift | 116 ++++ .../Onboarding/ChooseServerOperators.swift | 344 +++++++++++ .../Views/Onboarding/CreateProfile.swift | 86 ++- .../Shared/Views/Onboarding/HowItWorks.swift | 21 +- .../Views/Onboarding/OnboardingView.swift | 6 +- .../Onboarding/SetNotificationsMode.swift | 63 +- .../Shared/Views/Onboarding/SimpleXInfo.swift | 174 +++--- .../Views/Onboarding/WhatsNewView.swift | 433 +++++++------ .../NetworkAndServers/NetworkAndServers.swift | 363 ++++++++++- .../NetworkAndServers/NewServerView.swift | 156 +++++ .../NetworkAndServers/OperatorView.swift | 569 ++++++++++++++++++ .../ProtocolServerView.swift | 57 +- .../ProtocolServersView.swift | 472 +++++++-------- .../ScanProtocolServer.swift | 24 +- .../Views/UserSettings/SettingsView.swift | 27 +- .../UserSettings/UserAddressLearnMore.swift | 46 +- .../Views/UserSettings/UserAddressView.swift | 42 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 66 +- apps/ios/SimpleXChat/APITypes.swift | 443 ++++++++++++-- 30 files changed, 3014 insertions(+), 740 deletions(-) create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo.imageset/Flux_logo_blue.png create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json create mode 100644 apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png create mode 100644 apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift create mode 100644 apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift create mode 100644 apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json new file mode 100644 index 0000000000..d3a15f9a33 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flux_logo_blue_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png b/apps/ios/Shared/Assets.xcassets/flux_logo-light.imageset/Flux_logo_blue_white.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d6dda4fedd57195f0ee425a0a28a579f16b266 GIT binary patch literal 33847 zcmafb1ymGj*Y3;^5=w_iI)t<$B140eAc%A$ih?-AAl(e8bcqPkQo_(8Eig19qM(2h zGIS^%(%g6S^Lz*Y|K58RYaN#|Z|r>b-p@OsH?FImB0EC{fj~~FT~*S7KnM{K2=oRC zG58yk=pJ_PKSJj#YI-E#KOd4iArJ@$L`_LS&%@$JDtU_j-I^o+4{5g`3dIj-HE!r8 zl5XLe8WZF5a_%Ma1|~9Ezxu49E2{f}kNZ1|*r(FM?qR!Z9R=F=UryJ$yV+~shSVz9z7ayH$e7iJ8P%H3JGeGwJe7?iuP6MJ!g);iU zq8oNWa|V}=s`S>6Fbhj#aiea0mZou?-udl;RrhV;_d9OnyIg~D$shlrx(FqekZn;- z!N$B@c3JFoZ9!?irG~Mb4dm2wLJ;;GEr-xE$kdwCzf&sJ(|?9IN+x>dF#oyq&Zdl2 zrRh^%-Lp^{j^jU_u@Ist7Bu%Kv%4O~(@jKhRo*EQZ5gw>RT(eB&cZ|XWp(E6L!ZH3 zaTORP!Jb{dd~3c>QMEpD{)wjbn6VgX4CL64Qn37#T%yVeSC@-I!Uaquw%c#=B6OSX zL8BtBZynlTF!6Am9DZ^)vAOhb+nQcEN7{3-gjWNO|8TIw=n!i!7~?A5mtT~eU*+p` zg0w53?rErt>O8(O#?T2?@_vq-~G7@^F zTzO&Nd7ImQxvp625|XHUr)gB!e)y!QwP z?8w;AFRVHKmfsi60GO(Vd-Do8KP6-A+b7L>P+9s<7df?`VT1{$&I)0(!k4vn5rL}kc^~qtV+K}r+8%pwIoJPdD4repu8N!HQo`?$a=!UIf z?PQYRzmIU0LmUX|!6bdsX5M;1tr&ghe86>m+#PDVN6$_YFmq;vnZvq+aW7Qn_doZY zc--hHc$zitxWUB8N05M1R6lQvLxz2+lm3O;73LKCE*ONLk;Dhh@Lcy!B}qH3e(`Tgke0c5mhaIWTU5( zggyR%`b*QX9P;Rv%*(7Ey_pAI;&kRG=c*tl%Xr0dZ&RXOF_Kvg_l|A~H_m<(+RARz z_#b9!1}@(`J;vhu^W%+rSl~ONIV%Nqjiv`MvQe1k!9{&u(L>*{PS7c*jR7tXjG;tRhe_MOHBH`+D-hjKn=4AQf)3!nOft2b^n!DF7AYVL$szyC9g)Bc zSMurG81UwgRpCaSOI-kuPdnV>wsmb=+? z7@8V~6!@QE0u8}0e)4%qmWN8mBLn;|8B_$l{YzpVT)-F!5Difi&@%}}S^*_RMq_ZE ztH(9?kpdWwgdJr=)Dr^1B$X$So2H?6h7&LSB}NDtOK~OirmEAUP?fT?*g=EapVS$y z99t^%tUNj^$lz#DIb3q_-rR=&4KtxD3cMn6OoA@r(EfBs#Se}pohJ*<(flPTn$qXM z9^7iz-De!{kY z7M|ly{!xY*+gaN!p9Up`s}EOydLNan}dpcJG1s9U;xPq=xN zF%{{Qn!Nrj!{xW0{pEebYc6fme|?Y(CjeySjS~b;Vql*r1m|wh0j3Z_3vEj_ZhG2A zKkwg`FF0^qDqK|dK({8lhGqtyp%8cZY~*Xxt{-X~B(Nb!c5zYuX$bw{k2)#jT`}kvLq`3)vk0sk!wKz|`z-!;$FWC!aJu@*%>O(gT8W&6`;&zXL;hz) z2*p}Fe`>7!E0RLR%ToE)Gjj<~^e-21ACKFmq4HOVDB>qxEie=9nupMR`Aju)1s2Pd zF+_;l6C3V33YM7IPon(GAV>+-IhI=1KY{;e^~bLcNXh|^+i@{>Lg#UFOiaya@l?Tyb+ zBHH^G)4jOc^rovWcGE&RCIUFAi0lCvoCG=8d3{Tcaz z3}tr3yYrPAM>FBZBi7U{IjbF+V24l?wMOb+yTM@8j0ipTt^$7o91FS8MIxXINwwP2&9e3Ko!q-gQHI1aUx;W-B@O^5|jK~`jySXH^&0YA6YODY-+7Xn1|1 z__-uW`rH^3^EYH`gzDh!rEAPDlI(=lie(F`?>q6xaK1e`p;L!xAi;T%wK&c<)9H4j6}^%)ib;YVP<~q5Dl3Z=gD*y9pBq9IJDbG7dGv_ zKlRL@<+TP7IQ^#bUcxU>n|u3Xi6_W*-J;fnV*sI`f}g;GrLMpJ?fDn3V)=o1-aL3F z-y>>t-rV#mM?8%N)8L5&O_mUgQF1dIqeg-7y>#Q~6KO>)J;V)F6KBj$o|K5@+||s+ zQXjDK3PaZf%g{Eo@di?=#IzkPvkPN5sj7P=Tbrl%#xo)kk2}Hw&F~8+YX; znF`~Jh2SX5F}Rs`4@Zi}9j&ofUYnct+f5~gx4uekHtb|`dXw_TyuZKD%Kbc+{Kxwz z&np0;KJioKAzliDf?xwgZ`dze#B!iN5=AKrdzITHJpNfg*!&%K>hci7pt;mb)at{- zYTT3`FaL)B-d+0b(Qszetkw5YTV$5dT~SJB!hwGA+nfBLcI^1LqjTLXaYB3rUNnK99+;`Dhfq^N8#d;@RX^yH-}rv_Fr#OHRdahf&UJf} zeM~tn%WEZ61nT=D7w!w^uyc7>tAXac^8Pl44l%Nbm(ITqe}gD$;#Jpor77#6&E5(k zLKTYdVasW9r@0k_8h6~=hElhCTaYWaH@z8REXi_7@+S5wo^QD59?lQ?g9Go|s7JT| zvW#cPEQ8|`+RDRo(}{0x9hQA^Ky~me4eI)RALN?6#_3_^9@3}_=?TcO4o%A9X8#4Z zN6(3^nBlnY$r!<&yywsT`fiZaZUPKRk&f<5RRi0M^yVD1ZIWrL*%z~ho69;O_aOu) z2UG^nQlqSqPa~J3OMJ`NQ=T(dN=Isj1*ds4y1zFMw%;^1?Yqt9M}1>7MTnQ*+{b@) z*sms=-5H2F(dycV2HFhlI0YZ@p_F^S{n9cU_>Uv_u8z(x1pd;0%;W%&) z_dWD+@u2W=<$f)R!zCkH^I&=0M*=CU-PdPNAG~!qtMrp$EmD6oQP!E8f9%WBd#K@8 zHF+l+O&;Hz)VCr9lz1z*_+PD@Di13Huu+RHk&ckOO^{fxYXG5_n*f$Rj(DPQau-fN z=df1NDJM7-$S(JaxIi2w8|<&AxRE{dIg$T#-3B@~{7QG=3n@_Z2AOhQ!P_B{;kZyT z*wwCe70f^pUoB-^SgFu~PjT(IQly>T79)$~u%X)KY|C zdpT<-ACnVL;-ITiA-YxhP%$K@-lwDJ8uk{ZhgVyS3vTRfMXki$ zB2FXW-59mgogKf%#!}W4M<9Gr9Tbh)Z(Nqnhk?bbIwajYz+|1BO$D8)9^&SlltBrIgBBDT=wzQh;#3?E1X1Q5`#=( zJ=oqC^JR&`DQw@L8AIIY5U_x5DnDu*DTO=Naj8d;v2b=_a&rkOnYz=9eR(cqxSul< zb){-&?|*0WVb$qpS6@-J?F|2$3O5^{urN<<%61l6nr^Lb(t)|LL*i=d*j$^P%q&O7b23?C07}79ff1!c6y84( zXPb^UV5x`4?;P^$&Qdc$$XS)A1wCg%LOZt5&bnmmM^n<6=fNehL29=ikb~ zSP%>B@1$k4ahw;eiu+XjEo87dH_?IjD60B9Zdh$xW!`a*EH*7s-!bA(L` zXFElL*d>0p{T8p0gFVR~U*3jGNfI)YLQg~ypL`REqC@*Q4gDeb0{Nh*}=9oqGMnpG~x0UiL@5-E1XCK z>NCkeN~#X7lZlD(p(_5!TgQ)lNXBwk|C2H*tI20xQuW^$DSc8sX-_%By3K7LHNgL7 zYk;EBtWT?fkj{PJPM_h-_s8Gs@!>s{)sXl)e&MO(3pbFm1en-Oc2fHcd8Hd1;I!O!M=k^^^7%8gO*r$S5VvTlzvQgHpEMm?_m& zyYEBBqPFP6^&Nn)V*9_g@LdDqesf*P;c}vYc^yZLI%#gi_n>z?6iOvaZ(7@lUdd}N zxZiae6aB8eYRBq!d8IyHe%xO$k-y<)vYHm9XfRhtE;e>*NwwGwgdY=?E5@%eB6yGrPF0vT_?lXf zXP>J8VXfQs@d?rcr>3_DH}n$y1xO`WV8%pj-cAI5yI&;jhK5dGry-5ReC!{tn0+B| zv1NfajN|nyKGeg`9>+9qaL&Fs;m@T&ILSL(WS`tMc@_q8Q* zYP#KJShnLuwbnI?6hwhI|M++onc_x8@D`*c()f;8`nil=!Aj(>7QH19xn<{wIEUD7 zO~;1!FOLrd4`8XKw-dQz^i#?aaeiOy=)A`82xBRj1f;<6V&mhc=jp2_EL`Gc9FTQt zCc$H{$1ZYBcXzVjPu#j95U*uyYMZKdTkA74^KVK$Wms58ckBJ8US4@Oc!|^s`Jh-H@ zrbX>x;#it?wQlKa5yVjAOh|du_i(zmDzyo@ap^~0*1w`?ReqC8p3;tE-{;k97daWZ zFmo|sIaq2MkH?!=@X_P`arvgnsxT%PzNg2z1bK3IxhklyOn+bxxYqcYeQ((?CU3~N z&>belOh^A=V7$R*;1?kBI9D^+wIfD9zbda@hPqp&REP-SK)r z7>RsI(LCEH^Mo`&fA4yR*Uqny`njRL#eFRFL0&azOf+2777M;1NEAhtA(|bJ$Ip)8 z7(Z4lWIZ-Q)#yB2PMnRK2dcNJ1WAunlW-3UdFl8{&-K!T|JorF^^U5Bm;LLL`mTqO z!|(3J;8MlIIw|EyzANL5gadCi&9X*^jMiqO=I04eOS6>nw-kD|C!uKl)0`P(-M+S* zert0kc0MJ&Mkz*82A}&2Pm7%CX2i�>Y9E5G~yEbTaSm_@QckJ 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

PL9g z6h*=qHjR?V04VHf$ok+Anp8CL0tH>_YJrVk;9mdUMWdt})>r=@{YP!)n`8>w+I^sX zWXTV3wTqF8y)nY`C`cvCQ<Xl8rK`k9V1sx=>679_xlFhtp?u{j9Lp?OhG10$HN%L z4|wMqkb|8@*~xx`zHIc8A3Kdo4<2J+)_44^We|^1C)0rWxIDWy z(LRO9=1l#ne3hixWMejW9As28GL(h)mwS{yWGx1MkiVInsQmH0!uDaqS#9P1@N1il z!s>)jt~dPT7uC6Jh|RiHkyEXOzD@TDNa6(n^Q8#xFa^D*pgjzOm#A$~WVv+kQ)~s4JlU@sj3Fr6A$eph^{noKkB^R-Sj}Y$T5(7o`k2Pu z!VXtNuk|et)sdNE?Dg2{6E{X}=k=WgZ8}3@+Tj<;$NGGGFOHseDm6ZD?_%C|UZJ2} zO$TDHona0SYc~4f{zH<|ZnRWMlmku9#ee67s(eMS#*3|R+CBE}(Vi<*iWs}JOA7zO z^}n?MA6t@cWe!h7kwvkJ;N5%HV0k4PatLT-K#FOVfRUe?Sx8BWPfg|yorxk6DX75( zGB4XFZD3|)WUQ1anE#~I+T*Uxz`U;h!=e|wOO}N%EaW{*pIH^eP$faJ z$UDE6Mh-whU@)T-p-VyQt2uA1v-zRY=?1vV`lTk%4wzDod6^Z|jQ@JxTO~Q;s-|{G z6kgc-N_q8xykFvx)lVaSoJM!b?~y%wGkJAOSqe=NclBO=v|)WXmC;m+EQu+TUwZzU zZzLBKY%rdC-7BBHc5gaX;`JIziGU;{L<>ESQp`AKl^!uLyH>|%JQ5*&lD4$1RW?j(S6^RrT?=391JdP7mMxrR3;2shl&n zTqQw>Vjwz$xV@7ZHAeY@(dsnYKwoGPw;xpkTHvjJW-khKCJ%Cm6s)vhPz@Dhc(!a<2X1VC%~-$T#@4#Ch3=AOkp!RT1?QPQsH8*WmXwHKd#8#6Ew z?Weeedf9ss>D?quAcL!CT^Rsp z0pzZPi|C3reD2JdjgUsL2H|wXH0Ze7JM{0W5!1c_*X5huc_#PrX4`FEeRBN8 zpX>hqli+@!GiRMkJOcD9LHe760N{cv#fv3CKI*?DRwJMgT=w+G|1}lWR{}503i5@% zNO~$V_Pm$Ath5JAmQtRWtn}m5>v(7HIZ}+|j;_rEMUY3u9GEfAA`Ny%Jeb@J>BVXi z;E!yxWWfML1zzyq6S*}4aP-hAdn2X-ye>o4Dzp~NjJ$DV*Wd=VaK9CdV zc5(4Kt37sk*Z7E=6CE>X8imF8+*8U{@#M9eE+J#;jhbrTd&b}6zG~+3qJGGPWKZt- zq1^)`X;|5j(w3JTT7Z0%JY{+oe>CWPP_qz70=StM5$Q`HX-m@RHZaCN8M6w&)z?D% z8T=+fxZ>tHKTnK<-1s=bE3*5VChj*$qUBs)j@BZ;?|8VAgg#S_?LX3WDV6xi)kj% z>8?S5u4sklD%yc1xc%r+`Q-W9=!%Yrovkhu{i8Ed-EW`KLj;8`nZz0Kf#wfiXf2KOhtmf=CL(6FU?_;CHr{(1srmD9_ZraB zw;BAE-Am?*R?uCMcmlfTRa`vhTr3=v-1GI?rKlLjUja+guIMc+wQvA*QsY&&eKQpTWYD`tL3$YATWLaao zK-G-^)#N?Th#odu=C^yV_5bsjzo_!6)M7Lrri7mC&!SKKzi{TbikU*$jIiV4@xWlg zk!I^iNje^H2Bq1`o>|Fu^$I@XR}-oyoN#oNfaFLG>@k361tE*HjJEqN)yS>vH<3dl zng?kAqleaU|9YmK4r#Y2M@l>0J|VfN`1bHb#d>xyaP>y!#CwRsYT_-Ro8Zli{_*}f zyrmI4n=X_s0p5Dc4~Re!Drj&`1Z$ zR}af&<#Co~Z^F@)2Gb%9QVBM?81a>c#g$?oKcp)IW5F=>NM4gw`THL9 zZbKY5I*i89C3=?S?=S){@lYC~z4iME2fg>SczjK;XE%<$ANq$ch9RVzzI>=-yT0ec zj-7P&NnPjHjhhP8U7HG^NNc?PkJqW5a5*cne`wryDhqXXGXKC1byVmKrUI>qL9os` z836*`XE@#=25yrq8c~C*lSD}Ut&!QUtpMv2-=Dm)#eVO?@JkHOgO%-155R3n8bOz^KUoJe9lIQqm5aj&8^u{nJT-KU+O6qJQ|JaOo}<#}c8h?nbc+)A5CG z_2ZX=>e!)QNn;Vd&OBdLg@DpRgczJ7gAux?@LZQ1Ab<3~4So!wQQr+4_%0ZICp!7X zV?S__Q47e=M^R93O@bPdH!(_bl#~QA_(9kk0BCCZ|B>P zDZeMim>BpINXbShmTX^s#)rVdEBkHU6sfGu0Bg?5gC7NQJF^M6cO|M0Ma2mvkgxtG z|Ic&0a|p(hpDK7&rum7sZjGwz=#*16yL8!ynJo)}t4AKjWh*-&%lKYc&<(Y3U#Gf6yKVm_@hXv9G1s-pVQ)BI;3(~jf zC#IU}=0mbyKgY3Uks*6Fc=Gk;8tP|29Pu~pO1i{Us*csbl-h=XpTyv5z29z|hs!%U zY~0D0U*}(fC&=FO#l#M`(LwM53Cji1$t?+A4FL~sA1cCLmpv#m4IB+g z>(<`#3e({6rC@ZgA8%#v*M@CNDoQonXdg=HnSShHz2Dn5UB|Ib0=L%kI87k$nhJxrJ8h zuroDPRgIh!L-#UlDI$bSiGJ>^W?I>Ecb|Mzy`USPwno&bJ)+e8f4aKLfGD)44NC|M z(k0SJNQb1PluCD(NY~QcA|a(ncSuSj9ScY!h;)NUD2+(Rch-B~`(3Yp@Xzi!XU?37 zXP%k0T#|%s|H=@sDG{Hpl|;Q7Dm=D82UoWaGPb`=*O7t-anrjdhkgBe#c3;n6X!^| z4ppTwiwYu&ccLgAIzKd#k2&p$kDrgQ;Mgm@Fs^1w4ahs)Q6vWhuB?f39h)1ZBlC6x zM%!!U{DLozVYnX+zH?=jy54a=r=;Fn+4KY1`Z1-}Kk6co+Y--uX650F&dM;R^jHJuT2Uz+pvBy`~-J&_?Qv@&Q^hLV=IYf zqfornY5Ck1TR~d}+AXk70vN`@-D}_5`ajbz$0_Y~^OC(6FziU8u)m@JhqWF?7Ri_u z)RzP(WHccIjo+oac5t2_x<;z7y~}i1R`33a6XSp?_uz~|y5Q7$IOf+^7uBZtAs+QK0#DLIZ@D-(Zx;z@5Tv;_XsFj!a{;qLAM_Kn@eicl`d$P zcv-gz2O_5bc6C>Vx-1q2ke>dZt_O&{pg1()%Xo{YNGwH3I>v=2-ctR4g`^quru*sQ zs%NMJ`8s2!$P*jVaBW6Xx4tVMYPtGbfOL1{s|Pp@ssV4@r$K!3w;o8pJ#n0Nn*`o~ zu3ymTHy`d|1sQkSc#N0l z)@=;iYq;M%PtzEN9PhcE0Gb-?2qO(xeiy*)`FkM=#6s@l%z}1O{Ss8a7YYP{11_E5 zk}snrkCnW+qJ?|Wrxd%z0fO|@a#_RP3&^-<>ER+E&LMGWvIe^;a$>-WvMls}7-_7H zE99EG7jISa2GsvvEecS~+E#xv3K5>!RT;6G#}|aV{)g%ZdJ?DT4n=>ar1xiMc*DTl zup6QHPx`uypB_``;>Q)XYPu~=fG8U77!Y%j*`&_u`cFnt998i1H+K_co-s&!%UoBz zBdrJERx@w!EmfYhsKKTO<40Q%H1x}D(2X{Nd|7{{F#KF)`HO;xZwBv7O9BJJDF^Nu z1?lc8gFZNh*wR3e-LkRG3>)H9>(bJNn@?#ci$5|-e9CKk2Xz5U>f|%<0Dirh%KuZo z1$>}0Af?xr?qObW063M^aqkadXmnqXsV9PlgSfKKF%cU#?G4ons zTy7&kh-Sue2*LYw|8bqtO#d@|aPwLLD9samimmIXKPxH?5bfwqlyHGVrXI>r{QX`( z3Q@p}?UL;1cqjf1AHmcU2DVUt{w%gZJFfS>d_txhUP0139HH)!Mv$$9LTMOFq?T@3}o^7-qF0} zK4eTpN&sIvwf7JPFN-!nqiLy&38$4KWOSlGP78z8 zJX%fh{APDceKxg3yF-=QUYMKaJiP3VC{+|teq^A7e}&dr1QaY)3c0kV0vpaTK&ZoG zmU~1L!&yOqeU*vv58^Vuu83_B|^xo30X(Nfd6s?)YZQt?p`KoVyoh9cu|4w zidM5wl8XqSv1N++fbP|6Y*e#iZM5m7vmsgD#n&-mH-lE_xTv^2Z#*#Xtp}-Laa5)I z=K}EE0t@!Eqq1R?5z_QTm<7kzZ}OW(8bp|+zynkXQl38WdKI3&p(DegjL_9U#MAyQ z=beZjSKmFKr!GVdiUK(|W+5Hl;3Hp4cc;`WPh}Z8euwvU*L0R<&}wxaKct zr?e6$41z&sI)`(R#u+2;C@p5>(6%%G!Ot%dTrdNxD7Eb^b=bZ=%fpyZAY3y*&H2lY z`96A3>Z}GhL8{Gnl6(-S6i40$Q*>r{@uC(x?*eILk<;-i;z=d<%!Chy8s0R8(`w^UQ&+NH3Gx$ zAqJw@zs`so!ScVR#L6_M#$M1oc|r)kzAu;#ob{c4`Fk`|LTM90c9FlfO6Pjn+jk_| z4RdFiS^)RO5ZU2}OAao{=@k=;PiC{HN_!pd7doEFTdRkLSTk&Gqw{2KW317-1fcWc zH(Xnc;hHo(6aF4RJdNFXm=YmmCNT0PclG9|pUh-n^`(GN$E z&trEzNh>(MCJ?I3zg2p{$)``2AbuFQq0?w8(V ziv68%FTQvjP-vY*m$j4y#&*RkOMUB8t27^O!vx(K*}RqQ+PkZj_rQ!USLu8+qotjs zUMT)DYZ!J$vub{P8dBaTTQubgR@WQAF^@ki8s$%J+_XDz2seuFQzFkt4c` zC^PiR$=o*|mI~Gi;rmB$Z6n-~xVr2SBghaf*Ap-9e!bpdzYF#XBbgW;W(&sJjbU>Z zbb0axt+1f9aWaa$ngr&Xb6wDz|GQ?%03Uekg}493k0P;M6?Bx`qgfFVJoUG3 z|A90tX5l$?^X>Y>l=wf0?EQ|V`8}~}ba4^C2W_9ZK5vnUYPYlOPlM9d`R~%!B}0!p ziZ8qDgt*NXc zg-AY1lE7<4q`iRGEZrS2!9s*2+5Vn(_MwYH^fku`k=(9z3}`HEEPU8y*_jK z0JkLQ!F73a{^ZI_nCdLTMLJ`ID5}7pHkeu{tFiXZ6|j_7DJi3B6Nv+ za~d`(Y@7TO<1V~{^~H#hJMI{hzjUM$hcsg^Ur{iwS9?qOOkJ@kw={^I$Yf?YVAPh5 zb3T2*Mnl010Foh?xyBxV%U})`{x^fd6Z1FnxD*qJ($Pkda8iFgX}6xlrZ^bk6UBF> z9w^0LyO+UGa(A|7x?Zi4RoaM9@`FcYy_WT^Sj@29@^Yw$vO{%U*O{hQf20W;pLSqZ zI_KEKJFU**VetT>6>bH+FsHjyvZg;wrSLGmxoKc2e!uV&DxNq!;xGR2?vtq6px-hd zAPxE6%r>eygT@pFzjb#)ppP2``01~OA3;1P8>5CT)i!(Vm=L^tCq&$bC}Mi$?)Gg8 zbgWE~>J(kx9Pf%M#gq)7obG9iC{Xc!dxGJGM0@YvScU@by`ly>jQ7ETfn+Ke3Lc)D zoz=|cbc1yGh9qy-a$Yqd^H6q<1wvTZnp&KKLi>(h>^|vJ$Hc_E_l=uyebmrWoG%>p z3D1J4p=Nzaz?Jf)Z^3TshQG4R7t502gb~XP0g)f`e7_=&7!sEmd3Uo9WphewKdXw0 zHZxBeEv5|e>Z>l1=S57u2LD@g0 zuK(KPGikQKc#_tOPC2jTo5xO!yB6?Pq5n1(MZxo6r&D$~B+TPZvcJU^%qP&{*g_j>tojrs`}is&-GDQ?9#|paQxio z&a77iCP8_|zB0Hm3}0AjF#J%gHQ%F`h@~iYFS3#M$AP%w-T3-$(DJL7FTUxMnR*4& zd%If%L>%SQ@(c+mkdkOkBuer%#duKyfslW?xV-jo2^&m9sl0Ja6sr=xvO`HA zUqFf^*gL1`r*B_YmQ4I{b(kq5fy!7t4;Skj-qI1ao*{SUY*4Xru4*0_!WmNDkOIlb&*wbb#i|=A5quu-V7y-XFsk+X(2xG#cA( zXpUx>iA_Jov0VQAZU+0Z(jn4VFz!dnGko8I&D+~$uU}CA;bAED3g1I8$B8G+<}rS^ zAu@^J{I9j`4t$fKt~rD5)Gvh{omY$HKlW@&&Oh0e9H0l}G5h{HmmZvTBapECxcliroH_TOlvU9To|C#%>?q zD~_~$-}38&HmQ>1Ppy{yiGqyQe52CCdgR0YPC3n<1jzsyhWDW|nu_M?(fw_^oli(P zsa0xP^E9P#cDH6}uIFq0DH!~k%a!xn9WWiRpFtENy)KJV7Yp&y7YWu*U*v%O;xJK( zQ6%|W8*lTr@XU1yMoU$+JBs?B98RiX3JqH)=bsw!6KBa|pP=c02H<2?RH;BToV}k! ziuu&Uk}_?PMetu46K>)55ryqBge{<}A!CnY8&+BtT*kD9?nwhp+gx|5)gPfud25Oqy zELGt`vj^=>&ZjE_p$E}^vCKEb!yXZm)9WqA{@hv9n{xt1TovfT|KkFn;cP?{=eVVj zJ}fLaZXH}U$cc1#T_VNvsmhL9RgRE7DY7qsh?h}t%0^j z78@l!;qwo}TSVwdG_N+dG2#|n9+(e*snVSN_40%@tv>hB9?=IXzxsmXdI!=|p8(0o z9j1usLdgTPaL+lfC6Vi3dZ;{zJf*~nM3YEFfPk4Lkt=&oup#0-z% zNZ2Y90>ZY(k&Rb341TI_4VIJRJk533-)rJw@7dyCx4?oPUASndWkq-X67w}*&F^S% zK5lrE6?Nauu)bHQ(c|2XP1o= z)N5m;6UQ?5@-VW9S2v|joTuO#hPcn6L%;Jp%DpKsvT4$ zxjY@7=uLVheSQa@0-uBlEzvw}RlTd4dbjH#FflDPi(c=n==>5erd_OKFPo%@OJyqc zXiJe=iX+Q%t2Jr$*#_PH$9OvHxHYo7K6Y}UF-n*Rgj3PPpk4EW2dX{argK&)?lWN^_$1 zO>AD@9W$`F@k@^LIN5Z zN=6`(!ZPMx(OAQ9wr4VNjDzG|K9?w4sErx1^zPs()?OM&x9P<+4y8%C2t`orYKzDC z-(-2Sy>7z{mLCeatu1|lUD_jithec*mce9}{<2)IC+x_FgOkydwwY3U=>Yw@rE2cg z8jD*rTFScM!+M`|KN3L^^bO}8D9~|Zo80~*ddTTH^M`=ujR8pl47DKoL@O_Bx~$u8 z9BHe-yr>zC)eoTt`h9#k;=+@?{uEan0DbLNUAVH82)?=up!J}uz>J3XZf(K3x2uu` zEwW$xoR7WaF!0#(7KvZ9cq65eqU4A}#JSN1aS~=-^UynrXC{>BbcNN*7(Eyso!{i%foKy%wpf$_k& z66hCqF|`ZfyJ$oH!BPx~kHztO%mOvnX6;H=oa^^KRf0X4pu8`!T0fq)sAtbO8`N4( zo*ftNBJhh)cJh49e>2H^Y<+9vW0F_z&O)>9|)H%(_Vr%JdE z4L6FH>t`PG1}|OqWN9iUEfWh0#ARp#P{GUV%_vP}0^#h9Py~bCH6b1888s$-HoP27 zfdlsO&39^~20FV&^@*E7_mAQcGH*_vIhu+KlYD04{S+XYOlmSCO7bHkNn{1wt8;-zW0W0@rV5~L-{%d!`xA$YQyqJzhw$; z{b6P8*k>JPce(cj9CK>YOMCTc?yGYxa9Mem*E*O5X|lOEwKxbuqduK><7}{ znU&VM+@_hhmg~Y`-d9Cd$D;TZ!4SY%NsTqf*+4v+zs%W$lVgK7T_J)|O=J z-3YqT`U<{IHMCmjVo|6Q%Ev^8U**q9y7)VyiBVDqJ7#gC6CPh>gO~)rrS?6gxXvj$ zh?-d3pvt|`WNM-E?X{dr*Uk8GJeT%PP!aJK579~cuT>S9jE9b->J(W|x-){otW(4@ zArwjCQvz?8SB*e1KY%&dF$Mp2UN$@oO@R$|M1sCEj4k~#l-;;)pxD0n<*nAmDIdAs zT`R(lAft+%Zg1!`|9krR-Cos~6?W9?CY(5&4!Dk!FmE+Mxnh61CT(3(*K?Q4!xY7a zv!L5S)8qx&edwDEk4raZ#P-Yv9Tx;-dOL} zZ~_-{PH?!sp~M*z?p}s(@snHWmMVPX)p%3KzyWaTG`$_rhRkto-=d=MXbaXg^+ z;I%c;cdz?)g`?YV{iZIQ-nPQQsMe#?RPy(D**ERGxP40@m7(8{W*Ro%jtYXII4dow zUniNu<#PS6SG)E<0l?;HzccgM!tCqbfNL9+T^}1lyTaKi*s{dlE#KMYylZ%NOs<%}5xLCbVJhg;h?f4WM>~cp0!&Pfrv1=;0@9F7 zamVe>Uni{*Oh%Zp0WruE!9SIzZ@I+W4MpZ_-W^#X;!V$esXh;jq_`xb%l0;8oH(VF z$}QgH0`P%MVOHAG6L7sD4q=N+PYaV?^`VC3K4T{0DuHwJ%{V_pJ>1%VC*uJxiLrEu zE#oqEToDtV#GfqX`4p|)Y`UWqWTsUhYvW>#QP70{V1un4;Uo4JTzXalN3TUa32Uz@ zhSe#Ctyz2hw!12*gfC!5a=KR*T<1}5Zyb2sd!|0LvZNXk*$VJ?LZMM;G0>;{^-Xf% zR)?A-f5fv}>cshcii{l9XbQX!pnh9D=Q#TrcF1@rJ5$MG2`CV@f8mxNr{Vv>y(LBQ+%P+9DE$`g;fSG(~vqi4)1 zIv#y>U}jZCKronkfiyk-nTQOJ33ofW?43$CdMRLc`)awEsS)M{xiyvnB!o9mScBJV z?%b!uTp->sv8@N6ir^C~V1Q()W?=@$x)JGOan8mU&_R*wH z&qoE2q~~d?;`~>WSQn#JB`S9(N7sJzu6PHbxJ*QrS%0crDf%AZCKm1pQe=bkcYaQX zOVNQ10bxYgUGYz#1z$s{F$AM!n9NDLn6Z&Vl%N=Gc#jW1p)Z5Do~wD^-^v{2qYc0qAw%vGGYY)L~4<#%BFT zqexUIqfzonG_INE?I2lJjVzDDXor^qA{Opoq>5R`wc{i5@K&X_ zAF03Rp0G&s^JY~iZZclb?#qk-cA~7vy5-s*oKN;mG{#F%5@X3=?s*FI{}< zu^J6z@qXXaO~+z*yJgzy&6lisPY@t@E6qJ-{nDCBS$q-CanplCZUSI}-4GWs0Vtnl z<^n&O{q*+{r(cwg%OiKsNFGU=5W`+W=9-)F4V|=o?a}N6;`F}}QUod74RMzQR*qD~ zFqtROs*!{)sgaAX&{bc;sWdW^E>6{fb8Yg{h*S{FN|9pUd=;qC_<^Z?{R6Fb9iPA@ zyyDkIv%~aVnuk|{(nqHXE~{qm^t{d$6K>1NE5-}0f+>*SMeWx%rCz2I5`DfX84y*r z>qNMQ*`-f+iSLFS7=eDLkpl9HU;gIhIz>Q#LZ2QqRRRzQKOMXG4f_eiSr$fTksr92 zWbJV2405?diDR6S@T7{OkHep$KKm+{hDTwQOznQvT5hzfak*z*DV`I+`^Zgxn4CvDvuq>MnCV6ptGIDgvtJxyaQ&l+^o;e zLF61cV-$!sy)f}=gtgJHlul^<2MN->h8`QZ9$Ibl34WDAZ8mnEI3Hd#GdH%g6=2~& zU#$x~{&w%M-~4A``}XnE^!5Po$d5*1^SbEa5V=a{vm{Ou&Ttd&O1g^oRzAvon^vct z7sfFlv|_s%l|E;|A7bDB0XKA0C=j}qUd~-qge##uf9z0&q@{CwRDYq;9a~*|TK{Sx zYXc333BU+^w7H+U(FeCa5g|f!hQgg!oRmTYiJTteyM~!h9J}03%6qf;T~i`@iw0Os zSldPuBbPKLp9rO}f_*h~4m?3&2K(B7!@twQkhjxvRg0t^OzL+^uhqReqj4#vJR=>? zem7)3r0bQoMggOKY^_EXQIP3P>fViLDg4*W`f2^Sc)Tv}IFtU(M6mzT`=SBLXew zlp0sNkGnR+9epbEw;tL*($ue}JV<%h^24Mp^y!0{bKkz$81^RWFh{hQgBm012l?(} z@^l2eH|wS^x-op+*HsLT6ROBW=ii%|*Vk@1Y1*(;-I!&^v~av^dsd-2!I#13!-u{j z@-tFVXD{AYgt6|;&ym&u8X&a5J@i7R-WQY2GF+_iW(=9lRwhn~fVd~!6_9WAiqof& z15{r`1gbY?F=NpF}}U^Lv<*?mEPfEM^^AAEsG1Ya!(eorXM1QSJRWcfXJ?H zYdUU3G3b@Q>`PcIADCDqRoT>N>OfW~5klmQ@bynzAPwDuTkg}6t?|}bqrD+@f<;d5v z)05^Y)2@f2ZQbzD1wxah+b4Dl=_zP90&YScaUjgVP^&xMXW*VmCHmkP8m?=E)`*ul z-$DM-={{0qQ{YoEdxzDZr}3H69hj0Q`J#F&5!~N&aKrc{uc4F z_Kx_kF>@3#1SWkt2j1t&g2wBU?n>bk|BfoZzYLX!cok;drzzD=@2#pm*-G9)@bG{3 zZVm#kD|g1Gm|T1!FYECumR4};g=Z&ONJ+W2&c=s#hvEPlW44% z$GMoNigVTJG{+F&1S-S2bN3z5!Zow-@G;l%n`@-C*>Sn`QCAB$U&hQg^Sg;$x$qTK zQu!9|WV)Ml3>$-=%O7Pi#&1V1e^r-T9h8fe&X3v{5dIK#D|6;W+HL?Lz|Lj26C@iL z;B9vu`n~x$u_%n5xKNIPScywi>RtEL#b*06TIow4x%%4A(UcQ&k2UsxreinQ&0H4G zy~2Ep7K3Zs{pPzqa=LbusY>~Wpr3Q8tMzCMi_X1w6!R+xI_8~6Y`^%H9L9rGIxC73 zCWt)A^u@RyRUj3$PiLM`5VnMT>M$lq!T;_?x#vUqY)~WC2R?yMw|BN z(!Q={8-~c~k_kP*Kq#3cfk#cK^#=PL`;F~pX_$hi$M>1xS)#`lX{(%x)pS@C)I~CB zdLsn#du-jKm)z&2!0VBIiXHoe!MePu6E@P*pMrJ}=D}b+L`boB3m`@gi~APCejr;v zDC6xhxh(ohz4-5ZeQ2H{&l&t4%FqJ=s-4b{>0hKkEGzPHT}>9aK~q=n0Rn?P%v*5* zAtpAujPJ9bXpDkiKa}JXonix(nr{vVY_JxOF^Y7n3_XTfI(T5^g6weV;AT-~CP*G| z=N2Oulo==}OML_((+!YY7fwVzSYfN@sVtIT8&m~vUifiYNi0=FyKN9*QTbrq{Mw4% z+^*{if*TUiRp#)9y-dJby^GbDJeAA}V!Fmg)8~r_+^nqLvGmiI$F&aqLXaz?qBNvL zEFC`F*Ub;K-OSxhw@0or647w7SGHa4%Lnc|^yOcBrwSOk#FeC^oQ-{1?_O7y9(_J& z7sPgC=~kh&;3kf<3Z=jSoAY#2Pp%&>GM#8}ooo|4Anc-TZhV+!GxO4RPdDs?qmYcB zKZ}gS%2CvZ7~Ry=YmIl?LPHnNgXo85))pgyVb3EgTQ=T$;4wZsA4=yP3Sx!pn1N=L z!Vz>-+;3<%O?Ljix{Vf=K`7yjW3Nz8)fyEPQ{^<{|HnTy@E@&_6{h`1bjC3I4rzv*l->ru>VZI zTilCee>;(+t*oJlo%qJuI!|ce0k}tj^0KDfr^G}C$dLv896qaFvzYw9H7#cxg}p7+us zvrL?${JTjq5G6zd{v+k+D@Ab}Gc1bXXm~-M|CJk;<$}66A7mqg3)=2!K+M%5kh@V!0U3M>b>&CxVpWvRX;(?OJEwL)IzQ|4 zV5VA?CT~7lWFII}j+>Ha-h zn4sEnCCD}>{A~hlOj~$T7J%hQ{{zbf(5DKJ(0qwhS{dSPF6F4{m%nfz>J1Q7InI7KdaT z6XsPr%9bP)hqvNaAJEnToWLgB!1@uJLwELPI}gL19E9l|JcWdX;3?Lz5%t;CO9*nJ zF`Ed#kX`$LSxG`Ww?r`79pU-_NX|i_N(PxB&`CD~p!iacTU{fp( zAo(4(4M08>%qXp;s zmo>Np17@6_UiqA7vL@PGpDW9)*u2vv;{}R ziJ{bF0TplYvLPepX?Mjw83z)_2=E;Yyuf&`OQ$WWC)NCM6B(e~%?A-stcIvy0b!qQ3EbP=2Y z-*1s*>wB5z!cDWo;t*w9Z#NRNL{KClhC)zRzryzX5XLu`n_C@{#7XxZk|7D;cPy

W2K(0wF}%NNDHu03>~rg8xul9rc!!c@(rq!VNpnZ}9SC?-o*ZNh-dM z{6zJf`}Fyb)Nxwn;vvE>5)SQVEr=1 z!98*zMnsxsO7BUG#w_t$YKUclJaADVy34T6*iY7HQL~5P80b7XMA;Ib(H`9Du;l%D zHw*h*iA?;D&n(AEm|#(~%EU;Y-y;@b4qU^f)_w;B#k!nshNC~+&tcV2+V0{c^2di% z8X-X(FhT25<$TUs)OrmEXrl`hRUiJF($Ic?y4mFDuMPdZx4h;gQ z;G#h)#DWwB#foLTY=iy=G*Sy@t3yCq){v&Iy0XQ8oDVkKlQ9!Q#3dz><0#Y}{SJ$qBn=-w@AqZ=v$@WDcmt5I`0S7o>pR-!E8O@I zq4!PavCts*vvp?d`z@139Ce8MPVYSU_lcNIS|qHnC}J-2YZ7sbi)Rihk>tp;Fwq|e z?L%AU$AtDzr*=~g-9793GF1O{sz8W8){7?i%CfAbxs6I%vpQ}&0ZSI0i}Uyg^b{(cgFOK}nUq78l^X8B;$xjB63ATg;_HQO0Y!_D9Q(WUPYakDdV zgUg@tV-1SD4-?F;dOqlZe3q%*U=Ri&W(B`66CW9n_v~aO8eH)|!VZLFVrggub&WQJ0=qZ@6mGsUCG)u=4LcKjtuh;F{?a* z5bH0)-ps??oA|ZgiIetiKYc2E@-)a9TJ$62VRk=Xu~gk#7o%aBf9~_Q#InhP0M(-f zm)PD$6f3tr!o-Bzvn3Idz@9Bya;n5X;VEVQ5O(q}0RRO?;A58zSi`KWC8F(kuahw& zOtFc$^dzdjkfaZ5t3?eHg#25rC1e1oRHfd;bWaQSefQ)Rt+T4k0@^$qS|pyzW`?HF znch~XryPEl=lwrUB!R3A!rf_537_%i$@YM%Y?O#G6-or6YK&qg{H(?z--W77V<-Ik za)^rXfcbZi-~1w|!i|JRwOawaM_y6BEAlw9ksnq9+@bMj;(MZRv&di*J5_qWm)BJ4>hC`<8E5|D$@ujO z$Q(f>(37n7l%#^Hv6w$Iz<=#&oEX#~9H+T)AmN`cpJ*SzaWOo3%JLjDBeK`tLFq>8 z#X^*5=j_>$H^UoFE7hR~6IYp0g#Wgl;B9j~2JvOm8~n7f!#mT?tj79In8{-#td-Eu zSfGd(OZ+fy>OZH$2nMI)u!yKAYX~1K#v=MmS;szggnh^NDl$u9mj#B$`ET_Hb{9_& zsHMo`ncAO;QFSiI;z*}*_)$kDPdHRpASF$Tfmv(sgT##xs2g-+`S{N}ha!-Ku6qLM z8Ra_>%f9maib$)X2C4FzF>rJ=huvK&-@(XcM#s{0dzZpLE>Geef)dt6$+a4sRJycN zoN{~99HMYQi^_3_2U+vI*7A8~EBex~w(6rq1Bb9bo)I$;Nlb9EL6cRvp88b2azwZR zj!A)hDQp%GtLPb~M)}}wvOg!u6S0r_Ym@(Z3)`wdoKg;PR_f+%I2zJ;48IkUx6qn7USkoIK`Motw!DX+5>?6^WZ%97 zc|kx9q;81)Kex`TgV<|vODk+3{pfA@@$qKz?Z+SLOI`SCP;2Fa9(I?sF}nEJh3Zyu zlmAEEEI{;ZDof=Kx`cW4!!~Z2kZ6oRU@tI|dAnS+%J(%6AkupSZa7URAAAa049mTQ zaQ}Q+L0r)NLFd3~VfM!7;uDuCP$7BLF%%+UDRvc!AoE;rZ4OQk4t+9X!(}cb+5ec` zufPCcY_h8aIC!TfOwB;gA~B9}H~Go^*xh;%(aMLTpj(fX|MwQN+ z+2zOdn)}5kO3y~`hhF3B30r<&WIv`J6;uM8Fu)ho!}3(SZK zlv@MGv9HRqs@W)VO2S_C#X<8oZxV?qp)E*YdQ9d5Pxi){+fR$#&6|(uLI3@pzcb%T zqlemMo2hmv`GqH#xJX$9ebbc=^;s9QNZr(S%er&bnfi5pv#dJz&ise_SNJ2-amJD~~a{>suZ3CYZ&)Up+I((;$uF!mgeDd^9 zg`i;uP8F3w9sQ#a;=Ovf;$|X!qQqv1r%65w?};yj-$LE<@##In76WBOC4Lg?YoL)DMs z`srG`V@Lf)mKTYke)54Qkz{debe^{fblco6K2J>o8ld5c);b6#|BM~~ey-Z5!c{K3 z^tE^0)IxD4sJsa?~oG5t~iPvNN1jVnsT-C{C-@ zEErHy*Y&7*{evOh#7?SI^LTa!Q?kZsbnniemX?@){|25eN4dQ$Sd0YzJ&{q8E`MYa F{C_Q?zn%a9 literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json new file mode 100644 index 0000000000..16686bdf80 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Flux_symbol_blue-white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png b/apps/ios/Shared/Assets.xcassets/flux_logo_symbol.imageset/Flux_symbol_blue-white.png new file mode 100644 index 0000000000000000000000000000000000000000..0793b0ee85dfff83a4e180ebc0cc70230a452c28 GIT binary patch literal 17248 zcmY*>1yqz>*ES`gfHcww0@B^xHFSg2(5ZlgG=g+UI&=?R(hXA5!Z36R(kmg~ShHrH7;{^DC?MKYPhezsdzDMSVF|j@;vfIp3a9pB4%Bd)K$z7W2?a*sz?S~*X?66 zIBy0QQ=2cx+PTN?2aXyAw*<#OiQ9-}_#6t3zy3*!f=To1QRsbDlI89TbzuQg?udBA zJP-#L;(5S&>tjsZ^-A&1dkK1E5|oeFEdy;YWppsLgEgK>5x#;`S4tfRxS>1_kEjIX zLtU|RTH?0@^IzLV1(+=Jqq^&iJV*S>frB)Gv(5W^f$v~ppK=77RHloX1{2&bfiv}3 z6*3|9k_z1KO_Rh06FOf(P;U`0^k0hI3Z?q~HGeG<3nLsd&tbdSI>Iw+?5l(~L5+$< zvl#?ZSsRQtk-2!3Pwf^)fBVeVI8b93L5Yl1!*HJc(P1Pf@Oq?BwiOqLv)-r|bUd?j zVHLc~P7T!ny^`xHq33a!qlYv@!rMmb+A6ceXRq=c(ycNH2f%U?y*$?26|fH^|b*DhKT zkw}Rm{VrDF*6Ns4ar3+lZ27;gEBkFR9*5Calq86_u%AtHrPDbj%VLUyJW9Q=y6T9g zw9t7WtO$r915t}NmzUv*1WlR*Hi*V7yt2PfUmpeXNmSy607g}1=D`h9`HjiH%(eEuGbt95TJjnakbWG)4-or z|4ddgmekKw2Qvh@=!!UMaXrg;Mryw5X3g^E4f~nT-yxROgeIJ4fRS%k`$~6zIVcP9 zLf4+tWT70}1}0c+MAearBBCOhu`v9SlBX~+KGl3!-mZvzRtxM=nu^Gei@!zQ+@y)y z&jT4aKj7kO((=&3SwMj#?kFtC=90*T?Z!7hR)dK=^xg@F0t@#gEZFTcJ7H=JF@71z zTQ=lp>ll)M#EWUVuGM?9f!bKhv}?F{Xo}Gc ztoH=e%M)5N*4`ZunP2n3hQL4MZHtL)z77rED-FpDMZGh)sOabWN(1Fw&DPs?L&EAr zJe9$r>+5=E$F+>k!Oyk5fv_F#o^{82GA$~6Cf_^N`*x$HofiFy6_<^(#=wqg0-fVA zk;1F@QK~yqX3v}DmKki1bl?4@&m!^FW$-o&ep(`ZNEa?r8ENWcU1g&~&6_4ZfAw$( z=})hpQ@qU?i&rlL$9SqqI9X8)>AJcefBh6cA7|BPEsmz82HPVnJj^)RW~XW1`KBY z%~!3F$TiXuccr>`kpAvgvD@Rq!aof$`}J-YWu@S0<_3AQyDu-xCyG3Aa6qgp)( z7znMKD%}mhv5n0A)7OMk{Tk8QBx6xQOKG#Y)M>jTmn{lH8LYQKVpSRNCHyaDAzS~K zz)TDdzGvw8Gepe~He1fy2d&{do-&z-Yewi^NHR`mp)4065_h<(-d!%)RkqupK!JxF zV*fZi6Od1E7;owzn<}P$MC7s05LA`U zf~fC_ku-4L%2R(xem_>NmS1gtTZ+L11*v)tN~1i)EOuMoa!s9o{3PSCk2n1UN`i)J#Yt z`dWH%%6uK$^F+z7$#hiFIVl7xnrtG2#Sn9|W5BeP&kN2I{uUGngCq<^(A00}QT8PQgA7sCOpUaUzf;`B^bRx; zXxW%6zIIvtG$&0u`1PxhRM#t{5i^d*N%5jp7YoDi3V0Jf>|NufDw?^qV~x_DlmvR6 zOkmK!oouCPKUr=Q9!#Y1QW57cvXNRbAVgvj*G%N7Y^;+vOI5k=3uLGS+H_l@7>4&* z!Z$PJd;8M|Ay;rkhV>MQyNs0Y%@D2)!jv|2GuOpf-W>=|xEm5C%L(yDT&<6Zwo#N_ z)`Bp8i14~^x#A^oO7PA6`?naImc}URDy!GXGE)7mWQyM|_PVkAf84I^+GWVR7D8m~%9?a4T?OnRb2 zlTqrT1bJN~fZdVC;K&dC`mr+eyULst2ui|QQJza6GZ{p5I#}+KCOj((s_eZl|Q#`7@8N=laNOG>|Q9=SwP$1Wx z#i}F+rb&8Mz7FyAW(J(2%xB&Rpty1)P3A20f*Mff=vSuhAgivO=AtRui~|)|Mj#6_j08tD=UFhyIG9qs-DTN2Pn?SO10j z4@c}scn+=-Jom*=H)=?@;wPs`q7($>(Up4TJPI(1nvUv_M#Yxr*U%3iH#>}J(rO9-HN|R ziy4E*er2sORp0MzbdJ+$;3j*Pp1s#7E~I=j-H-$kdJ(mSYa7{NOyJ-_{WU`da+6ru zwDJA$r-G-gYjb4~r>6nk!-K~&>={%Zc)Zk!WLM-~Q3~$U@n%E&J$Qv)e?~)&v_)7q zz|OF-`ltLSTSCR6UR-KbviV@Z#b+x)Uh=aBw&9VNSEA42^b7G*aA1p4Mji7|6N?BqkcyusZ#>xM#4rjUn?GK@_WZ8WKB=9+iocB_mG`@ z%u*3(Xq3#5g>gL4K{K4-aoYyD|6Ra`kM6$n6LV9(SzHW%eJOieI-b1}>ClfW0a(v2#*bYdQ zb5zoh`Qt`^)J3(t<`tyx*$=@kP1K4Kn+xu2GohE)ioptSEayIY@urZdJoDgAhzPsa zEQx!gxt&T8DN#RU;ffC~cRM`3Pq65pMLxgS(B(QRgNOsmM;V_?%Y4I`ty4VitfVZd*1$&KuWIv{von#*Hs~~^T$y6B} zE6nm*2d~_$A6i$mK17jpkUf-rjZ?J_nP}phn!9#j-;P{h8<%Qr=%+;Ftt6f>sH}Kl zcF~#)xmSH=zqes)|B*+f(rInrZBHLhRrM8Z;21YaE>9715VtG$$!cAeKw+z6hq}P< z#nk$VtnkU<*6qg}W_l29j+orJM@$zc*V%AhE;w!PLe&+A2!g6YI{76KZ~T^iWA&Y$ zkMVwAdVIoY*k^gInE)gF_r_5R4;-fz{%YMUw8FL!$dEh(ZI5-dQ}XYJY33G-p=ZXq zTr0ycE=2ba_9u~|-33$jXn`Wr^k)5leg2%EoR2^_hjo156IEPwd5J2KBJRUBF0x&0 z`cHNE#X^g(+W4%NeOhxaN{1|rk>moRxnw|a`Jy*ychpKF>_pQPY7AbwzdBj{g$o%_ zGZ*{iOpM0i@0sjR0$zB6Kb;`%2{KUf;&NPBB)tYRQDv5kd$)QjVJx@uY@WqVdrr$y zR-RGyKBjjf8x6gIXVdjquMWIj>YLhObHR6sr<07-Qc!bW4OMRWeZ#r*HO2mjHb)n$ zXh^1hHGk=9k8DGI7~!veRG0y7sJTuQWqvz}t7z=3M(7#FLI|3()g4xcFc_aJ(1R zt*GM5<+YQ-M?{`H+o6Z_B+ z?~c68| zVKL}e4U|86+!}fw&&|m`QHWP^ogxDVh}Qe;tv6?8m*N=aks%FfPM9>9Rs<>4DdEGw z)gk1gKXEap+TYnY1n%Pt8=sTjGOOZ6hLVSoXzE~3@-^)H2Jai0OxxGHrrB?}Il0eP z8Vg+`RmtHbjd8yDKsd$_l=)?u9YX+~;jGLY z+4Vnnu1HMnqF{Dg*K#x&93e{p$A~KK)jo9R68kmK@GD=L=`cMb zc%sOIqtV=sl^&(zZN>9LwWT`gSSTq)AcDnT!#D?Xo&y;==$O=z64F?BKVk`4T42ld z>1K@q#6yF>m`rj*ksK)q!D52J=lI*qaCN6pX@u6AL{xcEnXhamjJ9lEn|aN2)9f-$ zLy_Q0Ag#a9c@IU~zU^PnU3Cck#^8cMUhLevl9FW69oCvV_As0;9)tk9s?WOXi#Lqb z2c14|S4MnCVArEr<&GqMTi5*_dLd)-UJ!}NV#5c?^xMpur(5ItX}!*8SsNrw-$QoS znD8k_82~})ieO^jB)t8b>QsO|;3LY5+-@Vq*B`t6MqXlFbXMc+2>%H2Zl{o9R+mv; zE5FN23NGi7(KyEa@zWbVT6~z)5g4g5yg;%9Ci4F|Y^FFIblIGapeLUVsV^Xh^Sbiz z60pYvm~Jw=#!Tp6G=a^d?3S@Q9u{Y-O{@#!SDib?zVryM663r5s&y#!m+RB~fivru zgQ(W78_KV{AD>^MA(u%)ZYJcnlyS%2!%Nrz5uzJ4X^J*tS*emYDoX=RkNCyFWP{)48W#O#@skc;CFR^RS0tWpv|z-gfXh?A zr>p%R|4jR1xcJy<^eAG3V7k86Ji@MWLHLK{6$=eoq2}@aT|M!~KBsRsxyDAo>Pr6* zK&p2~9Pw|&t6eFPaB6ofe4fs;4$dAZ^kA{`|AD8~++n~yga14$K0N}h=)9M4iH_Ig z4?&@I6UxxPUOLyh>~DyyS!~tM4eX*&4^E1{!7;IA3Y1+URpba0;IZ{fbp23S=9iZr zzVJ|HU^X$S)OMZS-koT_K6dEX+>zqwtaPq-Q&C=IkAaP>Gl=z*Ep=X1JrBf7^bneL*nJhkwXR(E^;hP~!`Ku@2!W3eewP21H(%mZE~8 z@@{!U+mi5WwDuMQhvD=V|C^)tI%dnat-pS~-7Bex{#AXEVUGu+!!7F5$m9h&#dqPw=GEvsso(1apE zq<5xK65}zlMY{IC^A$mvhj{$B#CDvJaH}cNv)$!F&Z6PI@)OcW>3YOF+Q{C~Os+(i zf#fBZ^XAN4JRhSSgB|f#B1exZU&vMN&ENeS265N-Dr;#2E@J~FdJ4=DKU7d8^r44t z>BBw5SwT6OHs|EkE$v=3w<&W^7V<~eP*TVP7={;O3z&ntGT{)Z;~!ww-bdtD|qUp##sLkbW*xR>}izVf15 zW<|GE*27eXHp@RBlRcnKSS*-SvT8Tgb;jrK%$HJ9O=^G~xQ*CW%f+OdToJuI?>m-E zjhNa9*>&h!zFFPy*i8`5&;AkB`g?k~HNT=SVF7@e!4Ce7xV-KO5?@GO-ilU|T-AoM>NnA;*-M1CE~VTwv?^8iUx6=-m_bmO1g2oGBDGC zd^#}}F6;^y7PH{+IW^@8K!J+-?}}*;Grz`Uu+=POGl6gJznX1!-ikhJtm9KK%uXdD zdQbO@F@?m6$VH}iiY6%hM@%~!*^}cuneX)IiPU2%EW~kcdh=q&bP>ybMuCMA7iQ5* zFuTq^bo5+3s)*c>KXdzo6-$vHUaOjr~^Mo%?-a-0Ulds;1f3bKYloz_uV4| zUNF-?q{I&++SAHLcPdekT~^QjS*@xQrrd$WOQvU#RJg>?~0J7&P*4i zwj6J~-8iVH-Zl2!lJD&)b1kR2Sqq1I;B#)mY1i-=6$b z77oTp-w@0SR5~we(8MCc`6T9v3)_^}QQm_xgaa0ZJ{UwoLai+=BSwWCj#~v6YV6xL zTgRe54oo({;f(gMfbq)TR4Rm=PGWVe73o>j(C;RsH;u2sq-pWEblBSbYpMLhH2{D^ z*FV85#?74WwzlW$7Nvi|Z!(YN^h4pC0X+X>r0$D54BzIdsp}OmoJ@fOB3;IRf~wGa zcmf`qX48@obTtcO?v-#J{uE-MdR(|&6nK8WWyeLqs-0)**#4$9yo68HJISy;R|o>P zgT)$(jznM~?Aq@BbMYQERQ$!^bRto6+k*~705h|{t%!iwuM?;+o@?yXeDhpu*SXE- zv@kSwXWF^b5?-x%e3hMP8mC&4y0c+ZTIT8ct6ut4FChxj0BUJ=hpqPlC#N1u3s=i2 zgNCuNa`0+EIQgx2BU1hO^a@8Q8)>_@<+s(*d?t&B>F-bxgh!?tc}j%(Q14+Y10d3O zR(B{H5Tk)sjDXS&`0s-tLxgmOC=$KeFA9%`+itJL?pB6{HJYo$?uIyvIepFKj@_FB z#?SB#8;wGl+5`6D}AkqYk=Tw%ywA$;%GK&%sHbt$7X^(#{y{@~^t^r#Y>e&?cVv-ru%v-m(ZUTn9cM0_N%2Q~h0tzrAWxb8Ow_kRcw z#WF!h*#H?3ZCvEiG+wAs~ukzc&U|Z;-QZ zqu$Y~;%F6-)56JFabdrTwN)qjvJWvfU)3u&__!l)x8u8hT@w83KJm*kjorMF0xSN4 zJ8PHItFyCsb-TW%`=$D|Ry#?IVjGo=;>naj{td9rLUT`%pi$;@fRTkZ`p0_V1+`$y}F)Q(PjaY}>g^^o)0;j#S?pUSb=9gt38|1C^e*zpuu?-8fO?X2kK z_bofS^OrLTkNbV=Q6BS~!6sgvOg<^Pjgi1Mu6F>}pSK>~hfvX0i;fw&sFL}_YxRi+ z-Kq_Bd9wGddBRYWS;9d@Qm*^m5WCw-A(4kjKh5=>Pu&@+==of3#e9KVE0yRbG{tQA zNet5{vwO@;Zw2H)QJ1*I=jB*67jsevWOPt_wat~~@~$YV{m)7Xxf_Cl%!DtAFSvaB z3w?bK50pj%Rw%{6?&l}}LApC0=QSx_5FNSue5k61-*KVn-}bb=zI_kKbzo;>8NRi8 zpf`|@7@-a;{}6`zh1~{AGHuNC)b#O^Qlz;U)2EE|$MK)rJf`3Y0LgxZ$v)P1xv!A- z$HX~B%r=G#5y%$0g*No~CHmxedKePu?rWS!O_r{DmoCcSw0iNn=8QijRC#_Ga|wL$ za@v1aV>;|T27+53tPnZDtPV8%3Ojx$u-u&`IJZRJ-yGGBD;A4Ywitk#t-oB%HJ6ltFA9!iu zw;BvyQxAj6@*gMr>TYm6E?3H#p+LNPT+95O<5OMp-atzid_v*Pu#6C2b`$g!@vTT@ z;hU1nA5_Q_BOsm6QJ;>=DpdO!n%-`&5Orow;s>ibB_xVmfdAOT7l zssGgiG|PR#&@{*M+~I^WLvhH8f zDu_+$OM;AAWnuIAeWc5CC%Xg=Utb(N;}&1NBoN8#)s9UF-E@B!p$>3p8~Qnw8(y_P zzI$$Gq_2p1F=nJ3NY@Z()q`^J;(?IMBkHm>jHDxk&G}1XLDP#aPkANAapODm@WZH9 zY|`1>>0a%nZx7p_Gu4YAxD6!z1k65N1M(ATMlK8Gb-nFca>$eoDUge>ZU&f zt{|+?-J!3hpphV@X_hz_ls~%!?X!P}0m!bHq^IA8ljCs4m5%NaO9(@T$6PHWxUyV* zXxs$&A*+IfH)R#tJ*bxCTX_cHQrF()4~qQn#N}3`(R4SQ2s{7mCT-KWyqUuge9;~S zS813?RW6@b0%;`%>7^WX!`>&E2e|t#v5IZV@7~1j=gVu2;%K>dJt2)JE(t6xQK=dM zx#cpAcfi~jl=U{p_r2L_=RAdTInJBMISBqZqzVuY-5DbGhn4`TlR7OM`MVLDxuv(d zYdoMTDgIoVa-s z?%jw zprGTavyGBQUZ69XIKR-o6-x@Z@he#85cl$;6)3s(aaI?N+c3J`+yH!tX*VcZY`9bj7c3<_(zuZaE3%;a6Y&;1)7V6NWNVL-+E7c>D(1uVM1Lum{P!a9)`G?9Plz(0 zQA(ZP^$g(Bn3;+7f&^YAGLBlM^W8L3kuv{_U%zGu%TSn^5+9j|4^nHCdZ)@Y2n~b* zi<>D_KCU5h7(W?|j_58Ai}_}4J}wcBa$SO2<$#0r4&Z@=Hn z5^}HJYKW98X5PJlDl&@d0VB|ez>YSw-#;IchU21gWbu;L*!aqR+;x&y*%Eba)vmZ zaD~gCU%Xx6wQ^=c1X>QcGseFN`Aw&9o_PrIxKGIpZc0NqZOEnr9NJ~3X`tS6h5<&v zra&m&;BFD=;Zb!uiJI|v0s;DAtBz*{hOGVj>Cx*Q(oV~SSZ4rC!?;Vw+H=7pCm z%bfqS+%|zOmjLTV4`?J3$wQvamnxQgz&IOJ?FokYter`w{?>)J`@(Hw+j1ht1R2>L^LhCT=ErivST4OOA-8 z!=M{rTC+(_XaeG6l=582@x4Uv5 zfbBl~KE`hNQhO_boaJ4C8Kt)$bYo`KPYbq3-T1ix>`qr~GiOP>P|}E-4jS?4-{*8# z*{F^T1d0Itoia=#j6|yFV?D)qzxm+S%n-<5_>iM+`*6-!vkwXzGBeW7>BP<}NNNdtda=39cB!URuXZOV z@D(a$T9yb%DAaH~sdYKA^RO6F`q*Qj-xeovv9y5SveJ_LHMK(LEH~0Ay&W@G+wLksT6gLsD_Mi3ufd3CqdyN5} zUubW!Koz_oC4_i=X9kz5@%(Q>>fEqD1Ztu)XZ8DL^m zv2q5uoKC4M!I!sD&xI(B)|v>80jAp(Nsg2-!>i`0w>SUmZTjc;89ifBOoYJ^9AmLe>Y)skSo|hlUvxyzjEb)$n+FWWx2$2_KScM zYg9oQ16PYR39KjATSs@nQ28zEIE{)x%YYwC&>J{gG?UuE;lPJN2=O?b5dFCK3ZIye z^iWD*y168FjIY6^Uo`iJ!XbR)9nI&H)_vO3D$*}afS@Y6E(v&)q!>M{01m}+WzyTO z7R-P!_{@846JrO;4#K1uX6pe!g3=j(!EOajy10wJfUm|R80zm1r{ zOkjJz8t!>%KyrlV{HeXp{b?AYfzr0ky;JU#8Q*`$IYYBFGGKSo0a-28ZM+69gYj|T z{QbL=qLLu?i%;*Ajn@3$_Y|AW!7G7T#{;UY zuzU?&8BiRq7F4Q}VFkYi0w!SCI%k}t*GnajQwN@i?5h7B_j#F=pwd66mfrv;CC>)! zX3^}c4d1rTN3xdx=On89&wi;C(+fgE(DS}#_>8ZPLC0=(xOy+oRQ2SA=My^u)=}!M zi|}#5+Exq{7+VRmvlDC-+c(wKVG#+cf zO^6l&J76GoL0^0_A=S5{Pe_RUOAexyslSgg9K3`WuDAdc`BLJ-m>e@qHOUJ8?ZIJeFLGhP zGi)Bw?2AobTJRI1m~ANQ`@^sM;S_}`qo}T18sXMGC^2WvXjN^!||IJInVxSSiz&e83Gb?NIX|JxpIj zwt4A9+#Jv0pi2Ry8COcX7B4+=C+uJ3n_p}wxSR(aL2H&c?( zS|4!G4+KKe_r+@`I}>z-oo)03L{zsUe@s`pAdlT_BZivJ*Ss#=&9?SYpGQ}Q{3A#DK$iG-1z+8?tg1DK*|`Js2otAEfBCFYsN#`t|HNzkv=t34zF^3?<0VA zGmK*>aPMb!0jTTcJE02bHabFlDzua*cD;_qtM+464-)cUMvt-plYit7Km&v@E4(KV zTAO80fqLVk(ed-BZA^C=aH$Y0L*5zI{Vy@6<1Tm6_!a)UD!6=XAQ5Zw0UOB<_CF!H za0_!nA`Rv84eE@9!)+Pa$;g^3%bgcLlFN5`XX?sn6>jm}Q-Vb*yIb72hUG&z1^4PK zrc{vv**EWjr@0SgFOhhfB*(BF)Q?s!o7Rh884{NPQEJR@#wGYb2RRUfFPE~(ZG5|d zG#%$dd0o!qHL~7fa8OwnoSg%DZkl?68b~QB9j|G@tWi~wwI07o+7~-w_h3UKr$dbe z~h1EU6tqk0=BeeHh-+wy6r@BdwET=k{^P*^=??L>Q5#MXD?}^wS8Cx$&w+P zswZ0l3444&KfMQh!BknrqT6s*UWmlTERdwAKLpU*`bM0m!6CZs6b0YU!1)EAgJ;Gr z3mXQuGt;DD3^Y5tGaL0+onM4cA}%G~zzbEQP?~}SHwtX63x_^xgXejK&&N=Ht41Wb zpoG30>a8j|>)Lu?5@{_US5n`=$PN?|Ir%pcVIu(k=tbjOh3gxWU0*=cv{aI-fjuez zgF_}$u!H8Cuwko4l-&o9G|fvaiFu;yymS5C7eQ|^inRmmv#a2zze(ha-HdaMjUq7yE(4fStsaj8;>ixdgz*itGl%Q$j z&c)_lHI$q4kQ>h|JwmmG>AME3*<`Jiao@*kpV8ts&wa~_g2BV2C{vQ37b>?YL1TD3 zoTE^^Ip_lWXv;MZski-()Y4923fCFvT(72)Qq9-XN`dD=ap^5O2O9u>?Ee-mxap_$`7Zas=^s3aM;-S5P z4Ix=v(zxhpc3-%LhVE*ridXO6#$>1)dvYEGWH##AGx6c!h;Ln1ZbCbuBh&iOthOQcg}{1voA6V5`aZ6NQ$v|TpwDRpY5G{$a->Ojj8^lzf*W` z)0ZWPB4kCsz8o>vv{pVAY6>pmqF}wTeEIO#JC#+{qa^hvdFx|;rk=I*0RsB@&*ay+ z=&5&?Z@H!GH!C>7Lfzk#NU%(BP}8H8(xN^G3iXJc{{TdvuJ`!5VXk|DN_3ajf<(TS zaoVo-Sl7Dsp&~8JnrVu1ZhuG*_;qJ39Q2E?dIwiJH%cvw0yMH-E1F{AYCRJhKcGPV z(dvR1(sUH6^PRZ_rq!8VyTmtFJvGhYUU;dXr&TK6N?{m&8{`Kg6PgWL4< z@?5pB^QV#$@*5wBx!*Fr^0P-Xc9L`0UtdG+UeG>pz3-1Zoj2bPkL{M>@i-Bje>GR$ zQAu`|zSo)yzF8W0D~`BnJYZLgZq#j@d?me<%i|~!O1LPv8=ST=>Q{wVyZn7%LR+~T z-zFB1<8MB!27*PX1^@c4!zNp!;xF_8oOg~FP@G?)*3p8m{7}pP`>^5r_;yWY(Zst1 zV$~{4VyzJ~`Q>T6Y~SeT3@kOwWn&Ai10Q?T*z4ui;w%(_mM_QS@uh3XC>k6M{66j; znI_p>xmWZ@*=a?J)k|JT8->zHX2!a&#idj)oY>lb#(u5*abY`7722^$We0N2G6Xw* zIS84H5U&$@YmicxptR`fy~zZZnn+45akV_It2ZWBbT|5Ak(5%cUo}!lEA;z`7nEnN zIGa7NElr;7t7XR|FE0yrTaT)AvOb6La3;rTSbv#n^3eVsN5(ez1B=*G+OV{zDU_zV zJQiAdU*c+EgL1r9{rq3D7HmBOB7{NXG?tU8g_cHCm5gri)N8fQ;g4(FdCGUWReFI! zX*Ym=+3N<&hg=2T`UtLyKtL`mRvP192i9|Bln@4J=(6#PACYnz@O*vnU(FY(d(HMX>$r}k~=U)6}lLg#%5^hbUECuAOS90w6=!6l1YdsTX6tvz+;VcCI7ce!|Z zi|#snEgtBZSJDA54I6A5^GmG1N6a+*M~z`I(FqAJ*)8^6ij!@Maj5-2IjrP?PW|NR z@5hVviD$w!7LC7+uqvhwLQebkt1iV+$ZIuL9DX!9&D-ohd=CGM$F}RT_?yyLGdLm% z&O4;*t!@4I1okc)H-L3r%lpPY(pFDjZ`{4Pb<@YP?lsa|XXnARSLBUFUp6P`_krG@ zyAbW>T%j?^fvj<6HErd6$dEU?S{qLIlF&{KiUku?Wv{5O5oeVKPNUA@K`cGpynu!CXNU%3bN52$$+G7RATTIcGp z4KHPu&hjb*O@s1-Y$sZ3Oc?4jAQ$a+T484`!F@7Q3wfOQjUTB$C|&X_@-w1s`F~R| zKufGh`^qdGO;2ugtE>Oi0M(}Guu9d2r;=LA-ckBZ1(}M3)%oLj66JQf#fZ2427ryv zy#<%=v@0<=g;Hv2-^T# zf^)qc)38CbJA6|jX~yc@pypwC3=at5ZX;_pR{0o-`VUhzdBrG2RA#C|05Wmc{kYmIfXzA z9HT}jlyK}4< zIOi|(So{ty4ID&cEW;*qyW34ljc%%Vdnl4dRHXav%FJ_cj6>C!8P6%cnu6Q1^l zItX9;Ut)2jyxY8;f~k7`pl*gnj4^e+WZk(|+BH8*6iD{Tr{~Ub<=;Qmv#>+E^&Wn# zeL_hCQxe%J0!0eUIA&%Moo@rkB^;jx7@l?$fg>?8{*1i+6oqV*{x5Jv$4Y2>Q7 z(s)6cPfRpt8hfce3{pC`gJMe0wJZK6k7^FF{OT=Ah{PY1S69z<2E{MfhiXUXW1V_= zn*6p2oUngzdtiUQVne}8 z4J3r^$l|?R0MHs1{2)^@B#Z`;+8tP-@Yjg{reH0$gPc_czCci))mImM6*v|NKO5vJ zCj)c7K?&)4iNO&}(&Pi|vUc0A2r>YDV7X(4n<5uGwV^27yhd4zK|roY0i6&wo5U1# z^d&<|yD+m%T+@1Ruf9r9tTC!j62HcwLb+0>UTyL^$hZsWlMm=Pl3k3j`w(jQRLM7t za2DAZ2vqk38e?!YX=3cO2wt1c*T1Z|ruYU#0U{p^UB)fZQgAg)L{L?4-k>GSs1MRO*_Uz-sk zjof|iY}}IrN?vt*xhCU6fE%0>P>xFmQu5&y&k`X?A?!wPgR%$QJ!xtb-g07fTnukM z$dIl~>Lp_Iu3P(7pyR88g1NOD_4qCwC|+x-qT9Z~sY*ZL^`OFFgsN2Ci7_;pM_3-r z2x%8&;MgX2H6ofV&eP01;w5YSh%9;T^`QM;PMT{xAzcmM{Yts>oVsfy>SfT2MnEY5 z>&Y^90&3MLG1A<;2i>V3!rr7c>~RovT1W@(rMh&kXdTNq&Nu3o(Lh)Fq}wmJXF6#Z zpyj+0ePmI1>fe@e>FNJE+`puy1$$%0KLv9nvyZRB{jDcnvp8b}LGaCv@BW~+6k2AL z32lz){{#p~>(9SgSelVmuTd-wfectAPBRV@7r)1DC<`&6Gnld z8pnN=IMpu@tuJs3L1%;;*9U90bN-Q#2C2Kcblt+KDLQe+s(b5~%68*ml&p%)i_51- zwFY9E;ws;(EiEwRAkIk~^h;6Ng^>nj?K1+h4uT3$zST)WOu^NJPXi@IyzCrXVCE%! zfi7TVLC8pt;;0-*Vmd8s3l`a;f(r}KK+W++(+}I9V~b#{7I2g)xx6+*L)r*J@UQn! zeGmzp#l|-3lgEXj(@d;Gb)1CZ4B}lmrg&e8f-p*GP*~&w$q+=ABz9gt$!l`@GHSmF z1J#!yihd_*8(mrmF)=@)wF!|pfpSoWn2>a3(+|o0Q@H8;0M#mu;8s-CJS7=a^u{g{ zo1-rz6{W)p>T{oUCu_||TICc0*(I3!4@J9eLi4~Y;pEQ6Y#kDKh|-~)d^x+EbR&M~ z<$U#eiA+H~7)b1)`78WROGL8ujh4W7=VRRrk(fe()YR4BHYSg`IYrDvUJ1kkCBKr$ zG)FgUr#LxV8}JqCE->Faq;$lNQ9bQymc>X-6EYxm2*{t9=&BxY%@dVc@K&XR5bkR_ z{1yhDyrixk3=Fb{TE_*w7B_~8{=#V@>^hLy27BC2nylfPZ6c$HEUYR#D(}1q_IyK$ zcAIDX2EQ~3ZSdV1PR;3WI6+Tg-$5!;9eJr+BW zbxuH0sXj7P{yr>}t(V5W&W3%h2T%=0^-c&b36qAPg>%f)utkvR(s$k2(f*Eimwoo$ zXl_*IuS)L}c!KidecJEApe|(4EBU}4@L_~7=bJ8xz*kYoUD-3S4$8d1uLV3l{Vss_ zJ7$aRNS-B3V3DG*qaoVbJPY>d8^N{H$49(2xBp=o zBeh%1zu;gWqZQ7qr3tjn=U>+%5cyXU>@rQ9(s# z%2-Jovp-VqxHB<9Gv(HL>tI4#=lNV3aEdE>h*MA7my9N1JF6rFtF;u^89gK7^m!NY z2Fk~V(e|iQs5gXWcRr?u_cjUbVw?jZ;dbEu`zw=ivoNHUXw33ao;V2-cJKeS7eAu~@{I#TDIpXXo=!WGl+ff`>tv27#?ZlH(J;sP@Vlqo`1#xQKGRXIus3Z~iH zph83$$=m}RCnrS*Tf1jc z_r59){`}K_15(NZp?t;21JPT4MoyLaX$f5o=-z$5c(ww4R>H)gQ}W1_9~mC}_)M@= z;r?6mb8KxFT0IR-B|`q3NKFko(@9oQmbIbg$CoG8tzqv^l`Vmv%s^0-Rh6lfGW-1h E0Eh^ Void> = [:] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9297aa7898..c61ad412c0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -500,18 +500,6 @@ func apiDeleteToken(token: DeviceToken) async throws { try await sendCommandOkResp(.apiDeleteToken(token: token)) } -func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers { - let userId = try currentUserId("getUserProtoServers") - let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol)) - if case let .userProtoServers(_, servers) = r { return servers } - throw r -} - -func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws { - let userId = try currentUserId("setUserProtoServers") - try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers)) -} - func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> { let userId = try currentUserId("testProtoServer") let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server)) @@ -524,6 +512,65 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r } +func getServerOperators() throws -> ServerOperatorConditions { + let r = chatSendCmdSync(.apiGetServerOperators) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("getServerOperators error: \(String(describing: r))") + throw r +} + +func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions { + let r = await chatSendCmd(.apiSetServerOperators(operators: operators)) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("setServerOperators error: \(String(describing: r))") + throw r +} + +func getUserServers() async throws -> [UserOperatorServers] { + let userId = try currentUserId("getUserServers") + let r = await chatSendCmd(.apiGetUserServers(userId: userId)) + if case let .userServers(_, userServers) = r { return userServers } + logger.error("getUserServers error: \(String(describing: r))") + throw r +} + +func setUserServers(userServers: [UserOperatorServers]) async throws { + let userId = try currentUserId("setUserServers") + let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers)) + if case .cmdOk = r { return } + logger.error("setUserServers error: \(String(describing: r))") + throw r +} + +func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { + let userId = try currentUserId("validateServers") + let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) + if case let .userServersValidation(_, serverErrors) = r { return serverErrors } + logger.error("validateServers error: \(String(describing: r))") + throw r +} + +func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) { + let r = await chatSendCmd(.apiGetUsageConditions) + if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) } + logger.error("getUsageConditions error: \(String(describing: r))") + throw r +} + +func setConditionsNotified(conditionsId: Int64) async throws { + let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId)) + if case .cmdOk = r { return } + logger.error("setConditionsNotified error: \(String(describing: r))") + throw r +} + +func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions { + let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)) + if case let .serverOperatorConditions(conditions) = r { return conditions } + logger.error("acceptConditions error: \(String(describing: r))") + throw r +} + func getChatItemTTL() throws -> ChatItemTTL { let userId = try currentUserId("getChatItemTTL") return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId))) @@ -1558,6 +1605,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() + m.conditions = try getServerOperators() if m.currentUser == nil { onboardingStageDefault.set(.step1_SimpleXInfo) privacyDeliveryReceiptsSet.set(true) @@ -1624,7 +1672,7 @@ func startChat(refreshInvitations: Bool = true) throws { withAnimation { let savedOnboardingStage = onboardingStageDefault.get() m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 - ? .step3_CreateSimpleXAddress + ? .step3_ChooseServerOperators : savedOnboardingStage if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { m.setDeliveryReceipts = true diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 7b24995f62..8e7aec581b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -36,6 +36,10 @@ struct UserPickerSheetView: View { @EnvironmentObject var chatModel: ChatModel @State private var loaded = false + @State private var currUserServers: [UserOperatorServers] = [] + @State private var userServers: [UserOperatorServers] = [] + @State private var serverErrors: [UserServersError] = [] + var body: some View { NavigationView { ZStack { @@ -56,7 +60,11 @@ struct UserPickerSheetView: View { case .useFromDesktop: ConnectDesktopView() case .settings: - SettingsView() + SettingsView( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors + ) } } Color.clear // Required for list background to be rendered during loading @@ -76,6 +84,16 @@ struct UserPickerSheetView: View { { loaded = true } ) } + .onDisappear { + if serversCanBeSaved(currUserServers, userServers, serverErrors) { + showAlert( + title: NSLocalizedString("Save servers?", comment: "alert title"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: { saveServers($currUserServers, $userServers) }, + cancelButton: true + ) + } + } } } @@ -94,6 +112,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial var body: some View { @@ -282,6 +301,12 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } + if !addressCreationCardShown { + AddressCreationCard() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat) diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index 22ea78f27b..a13a159a45 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -20,6 +20,10 @@ struct ServersSummaryView: View { @State private var timer: Timer? = nil @State private var alert: SomeAlert? + @State private var currUserServers: [UserOperatorServers] = [] + @State private var userServers: [UserOperatorServers] = [] + @State private var serverErrors: [UserServersError] = [] + @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false enum PresentedUserCategory { @@ -53,6 +57,15 @@ struct ServersSummaryView: View { } .onDisappear { stopTimer() + + if serversCanBeSaved(currUserServers, userServers, serverErrors) { + showAlert( + title: NSLocalizedString("Save servers?", comment: "alert title"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: { saveServers($currUserServers, $userServers) }, + cancelButton: true + ) + } } .alert(item: $alert) { $0.alert } } @@ -275,7 +288,10 @@ struct ServersSummaryView: View { NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) { SMPServerSummaryView( summary: srvSumm, - statsStartedAt: statsStartedAt + statsStartedAt: statsStartedAt, + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors ) .navigationBarTitle("SMP server") .navigationBarTitleDisplayMode(.large) @@ -344,7 +360,10 @@ struct ServersSummaryView: View { NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) { XFTPServerSummaryView( summary: srvSumm, - statsStartedAt: statsStartedAt + statsStartedAt: statsStartedAt, + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors ) .navigationBarTitle("XFTP server") .navigationBarTitleDisplayMode(.large) @@ -486,6 +505,10 @@ struct SMPServerSummaryView: View { @AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var body: some View { List { Section("Server address") { @@ -493,9 +516,13 @@ struct SMPServerSummaryView: View { .textSelection(.enabled) if summary.known == true { NavigationLink { - ProtocolServersView(serverProtocol: .smp) - .navigationTitle("Your SMP servers") - .modifier(ThemedBackground(grouped: true)) + NetworkAndServers( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors + ) + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) } label: { Text("Open server settings") } @@ -674,6 +701,10 @@ struct XFTPServerSummaryView: View { var summary: XFTPServerSummary var statsStartedAt: Date + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var body: some View { List { Section("Server address") { @@ -681,9 +712,13 @@ struct XFTPServerSummaryView: View { .textSelection(.enabled) if summary.known == true { NavigationLink { - ProtocolServersView(serverProtocol: .xftp) - .navigationTitle("Your XFTP servers") - .modifier(ThemedBackground(grouped: true)) + NetworkAndServers( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors + ) + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) } label: { Text("Open server settings") } diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift new file mode 100644 index 0000000000..e9a8fedaf9 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -0,0 +1,116 @@ +// +// AddressCreationCard.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 13/11/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddressCreationCard: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject private var chatModel: ChatModel + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @State private var showAddressCreationAlert = false + @State private var showAddressSheet = false + @State private var showAddressInfoSheet = false + + var body: some View { + let addressExists = chatModel.userAddress != nil + let chats = chatModel.chats.filter { chat in + !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card + } + ZStack(alignment: .topTrailing) { + HStack(alignment: .top, spacing: 16) { + let envelopeSize = dynamicSize(userFont).profileImageSize + Image(systemName: "envelope.circle.fill") + .resizable() + .frame(width: envelopeSize, height: envelopeSize) + .foregroundColor(.accentColor) + VStack(alignment: .leading) { + Text("Your SimpleX address") + .font(.title3) + Spacer() + HStack(alignment: .center) { + Text("How to use it") + VStack { + Image(systemName: "info.circle") + .foregroundColor(theme.colors.secondary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .trailing) { + Image(systemName: "multiply") + .foregroundColor(theme.colors.secondary) + .onTapGesture { + showAddressCreationAlert = true + } + Spacer() + Text("Create") + .foregroundColor(.accentColor) + .onTapGesture { + showAddressSheet = true + } + } + } + .onTapGesture { + showAddressInfoSheet = true + } + .padding() + .background(theme.appColors.sentMessage) + .cornerRadius(12) + .frame(height: dynamicSize(userFont).rowHeight) + .padding(.vertical, 12) + .alert(isPresented: $showAddressCreationAlert) { + Alert( + title: Text("SimpleX address"), + message: Text("You can create it in user picker."), + dismissButton: .default(Text("Ok")) { + withAnimation { + addressCreationCardShown = true + } + } + ) + } + .sheet(isPresented: $showAddressSheet) { + NavigationView { + UserAddressView(autoCreate: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + } + .sheet(isPresented: $showAddressInfoSheet) { + NavigationView { + UserAddressLearnMore(showCreateAddressButton: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + } + .onChange(of: addressExists) { exists in + if exists, !addressCreationCardShown { + addressCreationCardShown = true + } + } + .onChange(of: chats.count) { size in + if size >= 3, !addressCreationCardShown { + addressCreationCardShown = true + } + } + .onAppear { + if addressExists, !addressCreationCardShown { + addressCreationCardShown = true + } + } + } +} + +#Preview { + AddressCreationCard() +} diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift new file mode 100644 index 0000000000..248c1b34c4 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -0,0 +1,344 @@ +// +// ChooseServerOperators.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 31.10.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct OnboardingButtonStyle: ButtonStyle { + @EnvironmentObject var theme: AppTheme + var isDisabled: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 17, weight: .semibold)) + .padding() + .frame(maxWidth: .infinity) + .background( + isDisabled + ? ( + theme.colors.isLight + ? .gray.opacity(0.17) + : .gray.opacity(0.27) + ) + : theme.colors.primary + ) + .foregroundColor( + isDisabled + ? ( + theme.colors.isLight + ? .gray.opacity(0.4) + : .white.opacity(0.2) + ) + : .white + ) + .cornerRadius(16) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + } +} + +struct ChooseServerOperators: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + var onboarding: Bool + @State private var showInfoSheet = false + @State private var serverOperators: [ServerOperator] = [] + @State private var selectedOperatorIds = Set() + @State private var reviewConditionsNavLinkActive = false + @State private var justOpened = true + + var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } } + + var body: some View { + NavigationView { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Choose operators") + .font(.largeTitle) + .bold() + + infoText() + + Spacer() + + ForEach(serverOperators) { srvOperator in + operatorCheckView(srvOperator) + } + + Spacer() + + let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } + + VStack(spacing: 8) { + if !reviewForOperators.isEmpty { + reviewConditionsButton() + } else { + continueButton() + } + if onboarding { + Text("You can disable operators and configure your servers in Network & servers settings.") + .multilineTextAlignment(.center) + .font(.footnote) + .padding(.horizontal, 32) + } + } + .padding(.bottom) + + if !onboarding && !reviewForOperators.isEmpty { + VStack(spacing: 8) { + reviewLaterButton() + ( + Text("Conditions will be accepted for enabled operators after 30 days.") + + Text(" ") + + Text("You can configure operators in Network & servers settings.") + ) + .multilineTextAlignment(.center) + .font(.footnote) + .padding(.horizontal, 32) + } + .disabled(!canReviewLater) + .padding(.bottom) + } + } + .frame(minHeight: g.size.height) + } + .onAppear { + if justOpened { + serverOperators = ChatModel.shared.conditions.serverOperators + selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + justOpened = false + } + } + .sheet(isPresented: $showInfoSheet) { + ChooseServerOperatorsInfoView() + } + } + .frame(maxHeight: .infinity) + .padding() + } + } + + private func infoText() -> some View { + HStack(spacing: 12) { + Image(systemName: "info.circle") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundColor(theme.colors.primary) + .onTapGesture { + showInfoSheet = true + } + + Text("Select operators, whose servers you will be using.") + } + } + + @ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { + let checked = selectedOperatorIds.contains(serverOperator.operatorId) + let icon = checked ? "checkmark.circle.fill" : "circle" + let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) + HStack(spacing: 10) { + Image(serverOperator.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .frame(height: 48) + Spacer() + Image(systemName: icon) + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + .foregroundColor(iconColor) + } + .background(Color(.systemBackground)) + .padding() + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + .padding(.horizontal, 2) + .onTapGesture { + if checked { + selectedOperatorIds.remove(serverOperator.operatorId) + } else { + selectedOperatorIds.insert(serverOperator.operatorId) + } + } + } + + private func reviewConditionsButton() -> some View { + ZStack { + Button { + reviewConditionsNavLinkActive = true + } label: { + Text("Review conditions") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + + NavigationLink(isActive: $reviewConditionsNavLinkActive) { + reviewConditionsDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func continueButton() -> some View { + Button { + continueToNextStep() + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + + private func reviewLaterButton() -> some View { + Button { + continueToNextStep() + } label: { + Text("Review later") + } + .buttonStyle(.borderless) + } + + private func continueToNextStep() { + if onboarding { + withAnimation { + onboardingStageDefault.set(.step4_SetNotificationsMode) + ChatModel.shared.onboardingStage = .step4_SetNotificationsMode + } + } else { + dismiss() + } + } + + private func reviewConditionsDestinationView() -> some View { + reviewConditionsView() + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + + @ViewBuilder private func reviewConditionsView() -> some View { + let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } + let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + VStack(alignment: .leading, spacing: 20) { + if !operatorsWithConditionsAccepted.isEmpty { + Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") + Text("Same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") + } else { + Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.") + } + ConditionsTextView() + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + .frame(maxHeight: .infinity) + } + + private func acceptConditionsButton() -> some View { + Button { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let operatorIds = acceptForOperators.map { $0.operatorId } + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + } + if let enabledOperators = enabledOperators(r.serverOperators) { + let r2 = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r2 + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + + private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { + var ops = operators + if !ops.isEmpty { + for i in 0.. some View { - HStack { - Button { - hideKeyboard() - withAnimation { - m.onboardingStage = .step1_SimpleXInfo - } - } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") - } - } - - Spacer() - - Button { - createProfile(displayName, showAlert: showAlert, dismiss: dismiss) - } label: { - HStack { - Text("Create") - Image(systemName: "greaterthan") - } - } - .disabled(!canCreateProfile(displayName)) + func createProfileButton() -> some View { + Button { + createProfile(displayName, showAlert: showAlert, dismiss: dismiss) + } label: { + Text("Create profile") } + .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName))) + .disabled(!canCreateProfile(displayName)) } private func showAlert(_ alert: UserProfileAlert) { @@ -176,8 +162,8 @@ private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) { try startChat() withAnimation { - onboardingStageDefault.set(.step3_CreateSimpleXAddress) - m.onboardingStage = .step3_CreateSimpleXAddress + onboardingStageDefault.set(.step3_ChooseServerOperators) + m.onboardingStage = .step3_ChooseServerOperators } } else { onboardingStageDefault.set(.onboardingComplete) diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index c1975765d2..f11dbbe7a8 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -9,8 +9,10 @@ import SwiftUI struct HowItWorks: View { + @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool + @Binding var createProfileNavLinkActive: Bool var body: some View { VStack(alignment: .leading) { @@ -37,8 +39,8 @@ struct HowItWorks: View { Spacer() if onboarding { - OnboardingActionButton() - .padding(.bottom, 8) + createFirstProfileButton() + .padding(.bottom) } } .lineLimit(10) @@ -46,10 +48,23 @@ struct HowItWorks: View { .frame(maxHeight: .infinity, alignment: .top) .modifier(ThemedBackground()) } + + private func createFirstProfileButton() -> some View { + Button { + dismiss() + createProfileNavLinkActive = true + } label: { + Text("Create your profile") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } } struct HowItWorks_Previews: PreviewProvider { static var previews: some View { - HowItWorks(onboarding: true) + HowItWorks( + onboarding: true, + createProfileNavLinkActive: Binding.constant(false) + ) } } diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 438491b5f1..de3dce21bb 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -16,6 +16,7 @@ struct OnboardingView: View { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) case .step2_CreateProfile: CreateFirstProfile() case .step3_CreateSimpleXAddress: CreateSimpleXAddress() + case .step3_ChooseServerOperators: ChooseServerOperators(onboarding: true) case .step4_SetNotificationsMode: SetNotificationsMode() case .onboardingComplete: EmptyView() } @@ -24,8 +25,9 @@ struct OnboardingView: View { enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo - case step2_CreateProfile - case step3_CreateSimpleXAddress + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators case step4_SetNotificationsMode case onboardingComplete diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 7681a42a77..03ee9c67e0 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -15,41 +15,44 @@ struct SetNotificationsMode: View { @State private var showAlert: NotificationAlert? var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text("Push notifications") - .font(.largeTitle) - .bold() - .frame(maxWidth: .infinity) - - Text("Send notifications:") - ForEach(NotificationsMode.values) { mode in - NtfModeSelector(mode: mode, selection: $notificationMode) - } - - Spacer() - - Button { - if let token = m.deviceToken { - setNotificationsMode(token, notificationMode) - } else { - AlertManager.shared.showAlertMsg(title: "No device token!") + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Push notifications") + .font(.largeTitle) + .bold() + .frame(maxWidth: .infinity) + + Text("Send notifications:") + ForEach(NotificationsMode.values) { mode in + NtfModeSelector(mode: mode, selection: $notificationMode) } - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - } label: { - if case .off = notificationMode { - Text("Use chat") - } else { - Text("Enable notifications") + + Spacer() + + Button { + if let token = m.deviceToken { + setNotificationsMode(token, notificationMode) + } else { + AlertManager.shared.showAlertMsg(title: "No device token!") + } + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + } label: { + if case .off = notificationMode { + Text("Use chat") + } else { + Text("Enable notifications") + } } + .buttonStyle(OnboardingButtonStyle()) + .padding(.bottom) } - .font(.title) - .frame(maxWidth: .infinity) + .padding() + .frame(minHeight: g.size.height) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } + .frame(maxHeight: .infinity) } private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index ee5a618e68..2e077e9d95 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -13,81 +13,85 @@ struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showHowItWorks = false + @State private var createProfileNavLinkActive = false var onboarding: Bool var body: some View { - GeometryReader { g in - ScrollView { - VStack(alignment: .leading) { - Image(colorScheme == .light ? "logo" : "logo-light") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: g.size.width * 0.67) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, minHeight: 48, alignment: .top) + NavigationView { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Image(colorScheme == .light ? "logo" : "logo-light") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: g.size.width * 0.67) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, minHeight: 48, alignment: .top) - VStack(alignment: .leading) { - Text("The next generation of private messaging") - .font(.title2) - .padding(.bottom, 30) - .padding(.horizontal, 40) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - infoRow("privacy", "Privacy redefined", - "The 1st platform without any user identifiers – private by design.", width: 48) - infoRow("shield", "Immune to spam and abuse", - "People can connect to you only via the links you share.", width: 46) - infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized", - "Open-source protocol and code – anybody can run the servers.", width: 44) - } + VStack(alignment: .leading) { + Text("The next generation of private messaging") + .font(.title2) + .padding(.bottom, 30) + .padding(.horizontal, 40) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + infoRow("privacy", "Privacy redefined", + "The 1st platform without any user identifiers – private by design.", width: 48) + infoRow("shield", "Immune to spam and abuse", + "People can connect to you only via the links you share.", width: 46) + infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized", + "Open-source protocol and code – anybody can run the servers.", width: 44) + } - Spacer() - if onboarding { - OnboardingActionButton() Spacer() + if onboarding { + onboardingActionButton() + + Button { + m.migrationState = .pasteOrScanLink + } label: { + Label("Migrate from another device", systemImage: "tray.and.arrow.down") + .font(.subheadline) + } + .frame(maxWidth: .infinity) + } + Button { - m.migrationState = .pasteOrScanLink + showHowItWorks = true } label: { - Label("Migrate from another device", systemImage: "tray.and.arrow.down") + Label("How it works", systemImage: "info.circle") .font(.subheadline) } - .padding(.bottom, 8) .frame(maxWidth: .infinity) + .padding(.bottom) } - - Button { - showHowItWorks = true - } label: { - Label("How it works", systemImage: "info.circle") - .font(.subheadline) - } - .padding(.bottom, 8) - .frame(maxWidth: .infinity) - + .frame(minHeight: g.size.height) } - .frame(minHeight: g.size.height) - } - .sheet(isPresented: Binding( - get: { m.migrationState != nil }, - set: { _ in - m.migrationState = nil - MigrationToDeviceState.save(nil) } - )) { - NavigationView { - VStack(alignment: .leading) { - MigrateToDevice(migrationState: $m.migrationState) + .sheet(isPresented: Binding( + get: { m.migrationState != nil }, + set: { _ in + m.migrationState = nil + MigrationToDeviceState.save(nil) } + )) { + NavigationView { + VStack(alignment: .leading) { + MigrateToDevice(migrationState: $m.migrationState) + } + .navigationTitle("Migrate here") + .modifier(ThemedBackground(grouped: true)) } - .navigationTitle("Migrate here") - .modifier(ThemedBackground(grouped: true)) + } + .sheet(isPresented: $showHowItWorks) { + HowItWorks( + onboarding: onboarding, + createProfileNavLinkActive: $createProfileNavLinkActive + ) } } - .sheet(isPresented: $showHowItWorks) { - HowItWorks(onboarding: onboarding) - } + .frame(maxHeight: .infinity) + .padding() } - .frame(maxHeight: .infinity) - .padding() } private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { @@ -108,49 +112,51 @@ struct SimpleXInfo: View { .padding(.bottom, 20) .padding(.trailing, 6) } -} -struct OnboardingActionButton: View { - @EnvironmentObject var m: ChatModel - @Environment(\.colorScheme) var colorScheme - - var body: some View { + @ViewBuilder private func onboardingActionButton() -> some View { if m.currentUser == nil { - actionButton("Create your profile", onboarding: .step2_CreateProfile) + createFirstProfileButton() } else { - actionButton("Make a private connection", onboarding: .onboardingComplete) + userExistsFallbackButton() } } - private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View { - Button { - withAnimation { - onboardingStageDefault.set(onboarding) - m.onboardingStage = onboarding + private func createFirstProfileButton() -> some View { + ZStack { + Button { + createProfileNavLinkActive = true + } label: { + Text("Create your profile") } - } label: { - HStack { - Text(label).font(.title2) - Image(systemName: "greaterthan") + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $createProfileNavLinkActive) { + createProfileDestinationView() + } label: { + EmptyView() } + .frame(width: 1, height: 1) + .hidden() } - .frame(maxWidth: .infinity) - .padding(.bottom) } - private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View { + private func createProfileDestinationView() -> some View { + CreateFirstProfile() + .navigationTitle("Create your profile") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + + private func userExistsFallbackButton() -> some View { Button { withAnimation { - action() + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete } } label: { - HStack { - Text(label).font(.title2) - Image(systemName: "greaterthan") - } + Text("Make a private connection") } - .frame(maxWidth: .infinity) - .padding(.bottom) + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) } } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 2ae4aa8c2b..1d1ec5b64c 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -7,190 +7,209 @@ // import SwiftUI +import SimpleXChat private struct VersionDescription { var version: String var post: URL? - var features: [FeatureDescription] + var features: [Feature] } -private struct FeatureDescription { - var icon: String? - var title: LocalizedStringKey - var description: LocalizedStringKey? +private enum Feature: Identifiable { + case feature(Description) + case view(FeatureView) + + var id: LocalizedStringKey { + switch self { + case let .feature(d): d.title + case let .view(v): v.title + } + } +} + +private struct Description { + let icon: String? + let title: LocalizedStringKey + let description: LocalizedStringKey? var subfeatures: [(icon: String, description: LocalizedStringKey)] = [] } +private struct FeatureView { + let icon: String? + let title: LocalizedStringKey + let view: () -> any View +} + private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v4.2", post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark.shield", title: "Security assessment", description: "SimpleX Chat security was audited by Trail of Bits." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.2", title: "Group links", description: "Admins can create the links to join groups." - ), - FeatureDescription( + )), + .feature(Description( icon: "checkmark", title: "Auto-accept contact requests", description: "With optional welcome message." - ), + )), ] ), VersionDescription( version: "v4.3", post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"), features: [ - FeatureDescription( + .feature(Description( icon: "mic", title: "Voice messages", description: "Max 30 seconds, received instantly." - ), - FeatureDescription( + )), + .feature(Description( icon: "trash.slash", title: "Irreversible message deletion", description: "Your contacts can allow full message deletion." - ), - FeatureDescription( + )), + .feature(Description( icon: "externaldrive.connected.to.line.below", title: "Improved server configuration", description: "Add servers by scanning QR codes." - ), - FeatureDescription( + )), + .feature(Description( icon: "eye.slash", title: "Improved privacy and security", description: "Hide app screen in the recent apps." - ), + )), ] ), VersionDescription( version: "v4.4", post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"), features: [ - FeatureDescription( + .feature(Description( icon: "stopwatch", title: "Disappearing messages", description: "Sent messages will be deleted after set time." - ), - FeatureDescription( + )), + .feature(Description( icon: "ellipsis.circle", title: "Live messages", description: "Recipients see updates as you type them." - ), - FeatureDescription( + )), + .feature(Description( icon: "checkmark.shield", title: "Verify connection security", description: "Compare security codes with your contacts." - ), - FeatureDescription( + )), + .feature(Description( icon: "camera", title: "GIFs and stickers", description: "Send them from gallery or custom keyboards." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "French interface", description: "Thanks to the users – contribute via Weblate!" - ) + )), ] ), VersionDescription( version: "v4.5", post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"), features: [ - FeatureDescription( + .feature(Description( icon: "person.crop.rectangle.stack", title: "Multiple chat profiles", description: "Different names, avatars and transport isolation." - ), - FeatureDescription( + )), + .feature(Description( icon: "rectangle.and.pencil.and.ellipsis", title: "Message draft", description: "Preserve the last message draft, with attachments." - ), - FeatureDescription( + )), + .feature(Description( icon: "network.badge.shield.half.filled", title: "Transport isolation", description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." - ), - FeatureDescription( + )), + .feature(Description( icon: "lock.doc", title: "Private filenames", description: "To protect timezone, image/voice files use UTC." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.25", title: "Reduced battery usage", description: "More improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Italian interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ) + )), ] ), VersionDescription( version: "v4.6", post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"), features: [ - FeatureDescription( + .feature(Description( icon: "lock", title: "Hidden chat profiles", description: "Protect your chat profiles with a password!" - ), - FeatureDescription( + )), + .feature(Description( icon: "phone.arrow.up.right", title: "Audio and video calls", description: "Fully re-implemented - work in background!" - ), - FeatureDescription( + )), + .feature(Description( icon: "flag", title: "Group moderation", description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" - ), - FeatureDescription( + )), + .feature(Description( icon: "plus.message", title: "Group welcome message", description: "Set the message shown to new members!" - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Further reduced battery usage", description: "More improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Chinese and Spanish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.0", post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"), features: [ - FeatureDescription( + .feature(Description( icon: "arrow.up.doc", title: "Videos and files up to 1gb", description: "Fast and no wait until the sender is online!" - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "App passcode", description: "Set it instead of system authentication." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Polish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), // Also @@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [ version: "v5.1", post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"), features: [ - FeatureDescription( + .feature(Description( icon: "face.smiling", title: "Message reactions", description: "Finally, we have them! 🚀" - ), - FeatureDescription( + )), + .feature(Description( icon: "arrow.up.message", title: "Better messages", description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "Self-destruct passcode", description: "All data is erased when it is entered." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Japanese interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.2", post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark", title: "Message delivery receipts!", description: "The second tick we missed! ✅" - ), - FeatureDescription( + )), + .feature(Description( icon: "star", title: "Find chats faster", description: "Filter unread and favorite chats." - ), - FeatureDescription( + )), + .feature(Description( icon: "exclamationmark.arrow.triangle.2.circlepath", title: "Keep your connections", description: "Fix encryption after restoring backups." - ), - FeatureDescription( + )), + .feature(Description( icon: "stopwatch", title: "Make one message disappear", description: "Even when disabled in the conversation." - ), - FeatureDescription( + )), + .feature(Description( icon: "gift", title: "A few more things", description: "- more stable message delivery.\n- a bit better groups.\n- and more!" - ), + )), ] ), VersionDescription( version: "v5.3", post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"), features: [ - FeatureDescription( + .feature(Description( icon: "desktopcomputer", title: "New desktop app!", description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" - ), - FeatureDescription( + )), + .feature(Description( icon: "lock", title: "Encrypt stored files & media", description: "App encrypts new local files (except videos)." - ), - FeatureDescription( + )), + .feature(Description( icon: "magnifyingglass", title: "Discover and join groups", description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." - ), - FeatureDescription( + )), + .feature(Description( icon: "theatermasks", title: "Simplified incognito mode", description: "Toggle incognito when connecting." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "\(4) new interface languages", description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.4", post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"), features: [ - FeatureDescription( + .feature(Description( icon: "desktopcomputer", title: "Link mobile and desktop apps! 🔗", description: "Via secure quantum resistant protocol." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.2", title: "Better groups", description: "Faster joining and more reliable messages." - ), - FeatureDescription( + )), + .feature(Description( icon: "theatermasks", title: "Incognito groups", description: "Create a group using a random profile." - ), - FeatureDescription( + )), + .feature(Description( icon: "hand.raised", title: "Block group members", description: "To hide unwanted messages." - ), - FeatureDescription( + )), + .feature(Description( icon: "gift", title: "A few more things", description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" - ), + )), ] ), VersionDescription( version: "v5.5", post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"), features: [ - FeatureDescription( + .feature(Description( icon: "folder", title: "Private notes", description: "With encrypted files and media." - ), - FeatureDescription( + )), + .feature(Description( icon: "link", title: "Paste link to connect!", description: "Search bar accepts invitation links." - ), - FeatureDescription( + )), + .feature(Description( icon: "bubble.left.and.bubble.right", title: "Join group conversations", description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Improved message delivery", description: "With reduced battery usage." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Turkish interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.6", post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"), features: [ - FeatureDescription( + .feature(Description( icon: "key", title: "Quantum resistant encryption", description: "Enable in direct chats (BETA)!" - ), - FeatureDescription( + )), + .feature(Description( icon: "tray.and.arrow.up", title: "App data migration", description: "Migrate to another device via QR code." - ), - FeatureDescription( + )), + .feature(Description( icon: "phone", title: "Picture-in-picture calls", description: "Use the app while in the call." - ), - FeatureDescription( + )), + .feature(Description( icon: "hand.raised", title: "Safer groups", description: "Admins can block a member for all." - ), - FeatureDescription( + )), + .feature(Description( icon: "character", title: "Hungarian interface", description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" - ), + )), ] ), VersionDescription( version: "v5.7", post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: "key", title: "Quantum resistant encryption", description: "Will be enabled in direct chats!" - ), - FeatureDescription( + )), + .feature(Description( icon: "arrowshape.turn.up.forward", title: "Forward and save messages", description: "Message source remains private." - ), - FeatureDescription( + )), + .feature(Description( icon: "music.note", title: "In-call sounds", description: "When connecting audio and video calls." - ), - FeatureDescription( + )), + .feature(Description( icon: "person.crop.square", title: "Shape profile images", description: "Square, circle, or anything in between." - ), - FeatureDescription( + )), + .feature(Description( icon: "antenna.radiowaves.left.and.right", title: "Network management", description: "More reliable network connection." - ) + )), ] ), VersionDescription( version: "v5.8", post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"), features: [ - FeatureDescription( + .feature(Description( icon: "arrow.forward", title: "Private message routing 🚀", description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." - ), - FeatureDescription( + )), + .feature(Description( icon: "network.badge.shield.half.filled", title: "Safely receive files", description: "Confirm files from unknown servers." - ), - FeatureDescription( + )), + .feature(Description( icon: "battery.50", title: "Improved message delivery", description: "With reduced battery usage." - ) + )), ] ), VersionDescription( version: "v6.0", post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: nil, title: "New chat experience 🎉", description: nil, @@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [ ("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."), ("paintpalette", "Color chats with the new themes."), ] - ), - FeatureDescription( + )), + .feature(Description( icon: nil, title: "New media options", description: nil, @@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [ ("play.circle", "Play from the chat list."), ("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.") ] - ), - FeatureDescription( + )), + .feature(Description( icon: "arrow.forward", title: "Private message routing 🚀", description: "It protects your IP address and connections." - ), - FeatureDescription( + )), + .feature(Description( icon: "network", title: "Better networking", description: "Connection and servers status." - ) + )), ] ), VersionDescription( version: "v6.1", post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"), features: [ - FeatureDescription( + .feature(Description( icon: "checkmark.shield", title: "Better security ✅", description: "SimpleX protocols reviewed by Trail of Bits." - ), - FeatureDescription( + )), + .feature(Description( icon: "video", title: "Better calls", description: "Switch audio and video during the call." - ), - FeatureDescription( + )), + .feature(Description( icon: "bolt", title: "Better notifications", description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" - ), - FeatureDescription( + )), + .feature(Description( icon: nil, title: "Better user experience", description: nil, @@ -497,9 +516,25 @@ private let versionDescriptions: [VersionDescription] = [ ("arrowshape.turn.up.right", "Forward up to 20 messages at once."), ("flag", "Delete or moderate up to 200 messages.") ] - ), + )), ] ), + VersionDescription( + version: "v6.2 (beta.1)", + post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"), + features: [ + .view(FeatureView( + icon: nil, + title: "Network decentralization", + view: newOperatorsView + )), + .feature(Description( + icon: "text.quote", + title: "Improved chat navigation", + description: "- Open chat on the first unread message.\n- Jump to quoted messages." + )), + ] + ) ] private let lastVersion = versionDescriptions.last!.version @@ -514,14 +549,56 @@ func shouldShowWhatsNew() -> Bool { return v != lastVersion } +fileprivate func newOperatorsView() -> some View { + VStack(alignment: .leading) { + Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo) + .resizable() + .scaledToFit() + .frame(height: 48) + Text("The second preset operator in the app!") + .multilineTextAlignment(.leading) + .lineLimit(10) + HStack { + Button("Enable Flux") { + + } + Text("for better metadata privacy.") + } + } +} + struct WhatsNewView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @State var currentVersion = versionDescriptions.count - 1 @State var currentVersionNav = versionDescriptions.count - 1 var viaSettings = false + @State var showWhatsNew: Bool + var showOperatorsNotice: Bool var body: some View { + viewBody() + .task { + if showOperatorsNotice { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + try await setConditionsNotified(conditionsId: conditionsId) + } catch let error { + logger.error("WhatsNewView setConditionsNotified error: \(responseError(error))") + } + } + } + } + + @ViewBuilder private func viewBody() -> some View { + if showWhatsNew { + whatsNewView() + } else if showOperatorsNotice { + ChooseServerOperators(onboarding: false) + } + } + + private func whatsNewView() -> some View { VStack { TabView(selection: $currentVersion) { ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in @@ -532,9 +609,11 @@ struct WhatsNewView: View { .foregroundColor(theme.colors.secondary) .frame(maxWidth: .infinity) .padding(.vertical) - ForEach(v.features, id: \.title) { f in - featureDescription(f) - .padding(.bottom, 8) + ForEach(v.features) { f in + switch f { + case let .feature(d): featureDescription(d).padding(.bottom, 8) + case let .view(v): AnyView(v.view()).padding(.bottom, 8) + } } if let post = v.post { Link(destination: post) { @@ -546,11 +625,21 @@ struct WhatsNewView: View { } if !viaSettings { Spacer() - Button("Ok") { - dismiss() + + if showOperatorsNotice { + Button("View updated conditions") { + showWhatsNew = false + } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .center) + } else { + Button("Ok") { + dismiss() + } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .center) } - .font(.title3) - .frame(maxWidth: .infinity, alignment: .center) + Spacer() } } @@ -568,20 +657,24 @@ struct WhatsNewView: View { currentVersionNav = currentVersion } } - - private func featureDescription(_ f: FeatureDescription) -> some View { - VStack(alignment: .leading, spacing: 4) { - if let icon = f.icon { - HStack(alignment: .center, spacing: 4) { - Image(systemName: icon) - .symbolRenderingMode(.monochrome) - .foregroundColor(theme.colors.secondary) - .frame(minWidth: 30, alignment: .center) - Text(f.title).font(.title3).bold() - } - } else { - Text(f.title).font(.title3).bold() + + @ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View { + if let icon { + HStack(alignment: .center, spacing: 4) { + Image(systemName: icon) + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.colors.secondary) + .frame(minWidth: 30, alignment: .center) + Text(title).font(.title3).bold() } + } else { + Text(title).font(.title3).bold() + } + } + + private func featureDescription(_ f: Description) -> some View { + VStack(alignment: .leading, spacing: 4) { + featureHeader(f.icon, f.title) if let d = f.description { Text(d) .multilineTextAlignment(.leading) @@ -636,6 +729,6 @@ struct WhatsNewView: View { struct NewFeaturesView_Previews: PreviewProvider { static var previews: some View { - WhatsNewView() + WhatsNewView(showWhatsNew: true, showOperatorsNotice: false) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 155a3956be..2247e3d8d5 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -19,28 +19,80 @@ private enum NetworkAlert: Identifiable { } } +private enum NetworkAndServersSheet: Identifiable { + case showConditions(conditionsAction: UsageConditionsAction) + + var id: String { + switch self { + case .showConditions: return "showConditions" + } + } +} + struct NetworkAndServers: View { + @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme: ColorScheme @EnvironmentObject var theme: AppTheme + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @State private var sheetItem: NetworkAndServersSheet? = nil + @State private var justOpened = true + @State private var showSaveDialog = false var body: some View { VStack { List { + let conditionsAction = m.conditions.conditionsAction + let anyOperatorEnabled = userServers.contains(where: { $0.operator?.enabled ?? false }) Section { - NavigationLink { - ProtocolServersView(serverProtocol: .smp) - .navigationTitle("Your SMP servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Message servers") + ForEach(userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in + if let serverOperator = userOperatorServers.operator { + serverOperatorView(idx, serverOperator) + } else { + EmptyView() + } } - NavigationLink { - ProtocolServersView(serverProtocol: .xftp) - .navigationTitle("Your XFTP servers") + if let conditionsAction = conditionsAction, anyOperatorEnabled { + conditionsButton(conditionsAction) + } + } header: { + Text("Preset servers") + .foregroundColor(theme.colors.secondary) + } footer: { + switch conditionsAction { + case let .review(_, deadline, _): + if let deadline = deadline, anyOperatorEnabled { + Text("Conditions will be considered accepted on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + } + default: + EmptyView() + } + } + + Section { + if let idx = userServers.firstIndex(where: { $0.operator == nil }) { + NavigationLink { + YourServersView( + userServers: $userServers, + serverErrors: $serverErrors, + operatorIndex: idx + ) + .navigationTitle("Your servers") .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Media & file servers") + } label: { + HStack { + Text("Your servers") + + if userServers[idx] != currUserServers[idx] { + Spacer() + unsavedChangesIndicator() + } + } + } } NavigationLink { @@ -55,6 +107,17 @@ struct NetworkAndServers: View { .foregroundColor(theme.colors.secondary) } + Section { + Button("Save servers", action: { saveServers($currUserServers, $userServers) }) + .disabled(!serversCanBeSaved(currUserServers, userServers, serverErrors)) + } footer: { + if let errStr = globalServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else if !serverErrors.isEmpty { + ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) + } + } + Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { NavigationLink { RTCServers() @@ -74,11 +137,287 @@ struct NetworkAndServers: View { } } } + .task { + // this condition is needed to prevent re-setting the servers when exiting single server view + if justOpened { + do { + currUserServers = try await getUserServers() + userServers = currUserServers + serverErrors = [] + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error loading servers", comment: "alert title"), + message: responseError(error) + ) + } + } + justOpened = false + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + if serversCanBeSaved(currUserServers, userServers, serverErrors) { + showSaveDialog = true + } else { + dismiss() + } + }) + .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) { + Button("Save") { + saveServers($currUserServers, $userServers) + dismiss() + } + Button("Exit without saving") { dismiss() } + } + .sheet(item: $sheetItem) { item in + switch item { + case let .showConditions(conditionsAction): + UsageConditionsView( + conditionsAction: conditionsAction, + currUserServers: $currUserServers, + userServers: $userServers + ) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View { + NavigationLink() { + OperatorView( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors, + operatorIndex: operatorIndex, + useOperator: serverOperator.enabled + ) + .navigationBarTitle("\(serverOperator.tradeName) servers") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Image(serverOperator.logo(colorScheme)) + .resizable() + .scaledToFit() + .grayscale(serverOperator.enabled ? 0.0 : 1.0) + .frame(width: 24, height: 24) + Text(serverOperator.tradeName) + .foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary) + + if userServers[operatorIndex] != currUserServers[operatorIndex] { + Spacer() + unsavedChangesIndicator() + } + } + } + } + + private func unsavedChangesIndicator() -> some View { + Image(systemName: "pencil") + .foregroundColor(theme.colors.secondary) + .symbolRenderingMode(.monochrome) + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + } + + private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { + Button { + sheetItem = .showConditions(conditionsAction: conditionsAction) + } label: { + switch conditionsAction { + case .review: + Text("Review conditions") + case .accepted: + Text("Accepted conditions") + } + } + } +} + +struct UsageConditionsView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + var conditionsAction: UsageConditionsAction + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Conditions of use") + .font(.largeTitle) + .bold() + .padding(.top) + .padding(.top) + + switch conditionsAction { + + case let .review(operators, _, _): + Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") + ConditionsTextView() + acceptConditionsButton(operators.map { $0.operatorId }) + .padding(.bottom) + .padding(.bottom) + + case let .accepted(operators): + Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + } + .padding(.horizontal) + .frame(maxHeight: .infinity) + } + + private func acceptConditionsButton(_ operatorIds: [Int64]) -> some View { + Button { + acceptForOperators(operatorIds) + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + + func acceptForOperators(_ operatorIds: [Int64]) { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance($userServers, r.serverOperators) + dismiss() + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } +} + +func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { + let userServersToValidate = userServers.wrappedValue + Task { + do { + let errs = try await validateServers(userServers: userServersToValidate) + await MainActor.run { + serverErrors.wrappedValue = errs + } + } catch let error { + logger.error("validateServers error: \(responseError(error))") + } + } +} + +func serversCanBeSaved( + _ currUserServers: [UserOperatorServers], + _ userServers: [UserOperatorServers], + _ serverErrors: [UserServersError] +) -> Bool { + return userServers != currUserServers && serverErrors.isEmpty +} + +struct ServersErrorView: View { + @EnvironmentObject var theme: AppTheme + var errStr: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + Text(errStr) + .foregroundColor(theme.colors.secondary) + } + } +} + +func globalServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalError { + return errStr + } + } + return nil +} + +func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalSMPError { + return errStr + } + } + return nil +} + +func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? { + for err in serverErrors { + if let errStr = err.globalXFTPError { + return errStr + } + } + return nil +} + +func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { + let duplicateHostsList = serverErrors.compactMap { err in + if case let .duplicateServer(_, _, duplicateHost) = err { + return duplicateHost + } else { + return nil + } + } + return Set(duplicateHostsList) +} + +func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { + let userServersToSave = userServers.wrappedValue + Task { + do { + try await setUserServers(userServers: userServersToSave) + // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers) + do { + let updatedServers = try await getUserServers() + await MainActor.run { + currUserServers.wrappedValue = updatedServers + userServers.wrappedValue = updatedServers + } + } catch let error { + logger.error("saveServers getUserServers error: \(responseError(error))") + await MainActor.run { + currUserServers.wrappedValue = userServersToSave + } + } + } catch let error { + logger.error("saveServers setUserServers error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error saving servers", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, _ updatedOperators: [ServerOperator]) { + for i in 0.. some View { + VStack { + let serverAddress = parseServerAddress(serverToEdit.server) + let valid = serverAddress?.valid == true + List { + Section { + TextEditor(text: $serverToEdit.server) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your server address") + .foregroundColor(theme.colors.secondary) + if !valid { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + useServerSection(valid) + if valid { + Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { + MutableQRCode(uri: $serverToEdit.server) + .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) + } + } + } + } + } + + private func useServerSection(_ valid: Bool) -> some View { + Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test server") { + testing = true + serverToEdit.tested = nil + Task { + if let f = await testServerConnection(server: $serverToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!valid || testing) + Spacer() + showTestStatus(server: serverToEdit) + } + Toggle("Use for new connections", isOn: $serverToEdit.enabled) + } + } +} + +func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? { + if let serverAddress = parseServerAddress(server.server) { + let serverProtocol = serverAddress.serverProtocol + let hostnames = serverAddress.hostnames + let matchingOperator = userServers.compactMap { $0.operator }.first { op in + op.serverDomains.contains { domain in + hostnames.contains { hostname in + hostname.hasSuffix(domain) + } + } + } + return (serverProtocol, matchingOperator) + } else { + return nil + } +} + +func addServer( + _ server: UserServer, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ dismiss: DismissAction +) { + if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) { + if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) { + switch serverProtocol { + case .smp: userServers[i].wrappedValue.smpServers.append(server) + case .xftp: userServers[i].wrappedValue.xftpServers.append(server) + } + validateServers_(userServers, serverErrors) + dismiss() + if let op = matchingOperator { + showAlert( + NSLocalizedString("Operator server", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName) + ) + } + } else { // Shouldn't happen + dismiss() + showAlert(NSLocalizedString("Error adding server", comment: "alert title")) + } + } else { + dismiss() + if server.server.trimmingCharacters(in: .whitespaces) != "" { + showAlert( + NSLocalizedString("Invalid server address!", comment: "alert title"), + message: NSLocalizedString("Check server address and try again.", comment: "alert title") + ) + } + } +} + +#Preview { + NewServerView( + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]) + ) +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift new file mode 100644 index 0000000000..ef02e94e3f --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -0,0 +1,569 @@ +// +// OperatorView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.10.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct OperatorView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + @Environment(\.editMode) private var editMode + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int + @State var useOperator: Bool + @State private var useOperatorToggleReset: Bool = false + @State private var showConditionsSheet: Bool = false + @State private var selectedServer: String? = nil + @State private var testing = false + + var body: some View { + operatorView() + .opacity(testing ? 0.4 : 1) + .overlay { + if testing { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .allowsHitTesting(!testing) + } + + @ViewBuilder private func operatorView() -> some View { + let duplicateHosts = findDuplicateHosts(serverErrors) + VStack { + List { + Section { + infoViewLink() + useOperatorToggle() + } header: { + Text("Operator") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + switch (userServers[operatorIndex].operator_.conditionsAcceptance) { + case let .accepted(acceptedAt): + if let acceptedAt = acceptedAt { + Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") + .foregroundColor(theme.colors.secondary) + } + case let .required(deadline): + if userServers[operatorIndex].operator_.enabled, let deadline = deadline { + Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + } + } + } + } + + if userServers[operatorIndex].operator_.enabled { + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { + Section { + Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage) + .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in + validateServers_($userServers, $serverErrors) + } + Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy) + .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Use for messages") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } + } + } + + // Preset servers can't be deleted + if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty { + Section { + ForEach($userServers[operatorIndex].smpServers) { srv in + if srv.wrappedValue.preset { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + } header: { + Text("Message servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].smpServers) { srv in + if !srv.wrappedValue.preset && !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteSMPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Added message servers") + .foregroundColor(theme.colors.secondary) + } + } + + if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty { + Section { + Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage) + .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Use for files") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } + } + } + + // Preset servers can't be deleted + if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty { + Section { + ForEach($userServers[operatorIndex].xftpServers) { srv in + if srv.wrappedValue.preset { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + } header: { + Text("Media & file servers") + .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } + } + } + + if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty { + Section { + ForEach($userServers[operatorIndex].xftpServers) { srv in + if !srv.wrappedValue.preset && !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } + } + .onDelete { indexSet in + deleteXFTPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) + } + } header: { + Text("Added media & file servers") + .foregroundColor(theme.colors.secondary) + } + } + + Section { + TestServersButton( + smpServers: $userServers[operatorIndex].smpServers, + xftpServers: $userServers[operatorIndex].xftpServers, + testing: $testing + ) + } + } + } + } + .toolbar { + if ( + !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty || + !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty + ) { + EditButton() + } + } + .sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) { + SingleOperatorUsageConditionsView( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors, + operatorIndex: operatorIndex + ) + .modifier(ThemedBackground(grouped: true)) + } + } + + private func infoViewLink() -> some View { + NavigationLink() { + OperatorInfoView(serverOperator: userServers[operatorIndex].operator_) + .navigationBarTitle("Network operator") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Image(userServers[operatorIndex].operator_.logo(colorScheme)) + .resizable() + .scaledToFit() + .grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0) + .frame(width: 24, height: 24) + Text(userServers[operatorIndex].operator_.tradeName) + } + } + } + + private func useOperatorToggle() -> some View { + Toggle("Use servers", isOn: $useOperator) + .onChange(of: useOperator) { useOperatorToggle in + if useOperatorToggleReset { + useOperatorToggleReset = false + } else if useOperatorToggle { + switch userServers[operatorIndex].operator_.conditionsAcceptance { + case .accepted: + userServers[operatorIndex].operator_.enabled = true + validateServers_($userServers, $serverErrors) + case let .required(deadline): + if deadline == nil { + showConditionsSheet = true + } else { + userServers[operatorIndex].operator_.enabled = true + validateServers_($userServers, $serverErrors) + } + } + } else { + userServers[operatorIndex].operator_.enabled = false + validateServers_($userServers, $serverErrors) + } + } + } + + private func onUseToggleSheetDismissed() { + if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed { + useOperatorToggleReset = true + useOperator = false + } + } +} + +func conditionsTimestamp(_ date: Date) -> String { + let localDateFormatter = DateFormatter() + localDateFormatter.dateStyle = .medium + localDateFormatter.timeStyle = .none + return localDateFormatter.string(from: date) +} + +struct OperatorInfoView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + var serverOperator: ServerOperator + + var body: some View { + VStack { + List { + Section { + VStack(alignment: .leading) { + Image(serverOperator.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .frame(height: 48) + if let legalName = serverOperator.legalName { + Text(legalName) + } + } + } + Section { + VStack(alignment: .leading, spacing: 12) { + ForEach(serverOperator.info.description, id: \.self) { d in + Text(d) + } + } + } + Section { + Link("\(serverOperator.info.website)", destination: URL(string: serverOperator.info.website)!) + } + } + } + } +} + +struct ConditionsTextView: View { + @State private var conditionsData: (UsageConditions, String?, UsageConditions?)? + @State private var failedToLoad: Bool = false + + let defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + + var body: some View { + viewBody() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + do { + conditionsData = try await getUsageConditions() + } catch let error { + logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))") + failedToLoad = true + } + } + } + + // TODO Markdown & diff rendering + @ViewBuilder private func viewBody() -> some View { + if let (usageConditions, conditionsText, acceptedConditions) = conditionsData { + if let conditionsText = conditionsText { + ScrollView { + Text(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines)) + .padding() + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + } else { + let conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/\(usageConditions.conditionsCommit)/PRIVACY.md" + conditionsLinkView(conditionsLink) + } + } else if failedToLoad { + conditionsLinkView(defaultConditionsLink) + } else { + ProgressView() + .scaleEffect(2) + } + } + + private func conditionsLinkView(_ conditionsLink: String) -> some View { + VStack(alignment: .leading, spacing: 20) { + Text("Current conditions text couldn't be loaded, you can review conditions via this link:") + Link(destination: URL(string: conditionsLink)!) { + Text(conditionsLink) + .multilineTextAlignment(.leading) + } + } + } +} + +struct SingleOperatorUsageConditionsView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int + @State private var usageConditionsNavLinkActive: Bool = false + + var body: some View { + viewBody() + } + + @ViewBuilder private func viewBody() -> some View { + let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted } + if case .accepted = userServers[operatorIndex].operator_.conditionsAcceptance { + + // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + + } else if !operatorsWithConditionsAccepted.isEmpty { + + NavigationView { + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + Text("Conditions are already accepted for following operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.") + Text("Same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.") + conditionsAppliedToOtherOperatorsText() + usageConditionsNavLinkButton() + + Spacer() + + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + } + + } else { + + VStack(alignment: .leading, spacing: 20) { + Group { + viewHeader() + Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.") + conditionsAppliedToOtherOperatorsText() + ConditionsTextView() + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + } + .frame(maxHeight: .infinity) + + } + } + + private func viewHeader() -> some View { + Text("Use servers of \(userServers[operatorIndex].operator_.tradeName)") + .font(.largeTitle) + .bold() + .padding(.top) + .padding(.top) + } + + @ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View { + let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter { + $0.enabled && + !$0.conditionsAcceptance.conditionsAccepted && + $0.operatorId != userServers[operatorIndex].operator_.operatorId + } + if !otherOperatorsToApply.isEmpty { + Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.") + } + } + + @ViewBuilder private func acceptConditionsButton() -> some View { + let operatorIds = ChatModel.shared.conditions.serverOperators + .filter { + $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator + ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted + } + .map { $0.operatorId } + Button { + acceptForOperators(operatorIds, operatorIndex) + } label: { + Text("Accept conditions") + } + .buttonStyle(OnboardingButtonStyle()) + } + + func acceptForOperators(_ operatorIds: [Int64], _ operatorIndexToEnable: Int) { + Task { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + await MainActor.run { + ChatModel.shared.conditions = r + updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance($userServers, r.serverOperators) + userServers[operatorIndexToEnable].operator?.enabled = true + validateServers_($userServers, $serverErrors) + dismiss() + } + } catch let error { + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func usageConditionsNavLinkButton() -> some View { + ZStack { + Button { + usageConditionsNavLinkActive = true + } label: { + Text("View conditions") + } + + NavigationLink(isActive: $usageConditionsNavLinkActive) { + usageConditionsDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func usageConditionsDestinationView() -> some View { + VStack(spacing: 20) { + ConditionsTextView() + .padding(.top) + + acceptConditionsButton() + .padding(.bottom) + .padding(.bottom) + } + .padding(.horizontal) + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } +} + +#Preview { + OperatorView( + currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + operatorIndex: 1, + useOperator: ServerOperator.sampleData1.enabled + ) +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index da29dfac29..13d01874ed 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -12,15 +12,15 @@ import SimpleXChat struct ProtocolServerView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme - let serverProtocol: ServerProtocol - @Binding var server: ServerCfg - @State var serverToEdit: ServerCfg + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var server: UserServer + @State var serverToEdit: UserServer + var backLabel: LocalizedStringKey @State private var showTestFailure = false @State private var testing = false @State private var testFailure: ProtocolTestFailure? - var proto: String { serverProtocol.rawValue.uppercased() } - var body: some View { ZStack { if server.preset { @@ -32,9 +32,33 @@ struct ProtocolServerView: View { ProgressView().scaleEffect(2) } } - .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) { - server = serverToEdit - dismiss() + .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { + if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers), + let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) { + if serverToEditProtocol != serverProtocol { + dismiss() + showAlert( + NSLocalizedString("Error updating server", comment: "alert title"), + message: NSLocalizedString("Server protocol changed.", comment: "alert title") + ) + } else if serverToEditOperator != serverOperator { + dismiss() + showAlert( + NSLocalizedString("Error updating server", comment: "alert title"), + message: NSLocalizedString("Server operator changed.", comment: "alert title") + ) + } else { + server = serverToEdit + validateServers_($userServers, $serverErrors) + dismiss() + } + } else { + dismiss() + showAlert( + NSLocalizedString("Invalid server address!", comment: "alert title"), + message: NSLocalizedString("Check server address and try again.", comment: "alert title") + ) + } }) .alert(isPresented: $showTestFailure) { Alert( @@ -62,7 +86,7 @@ struct ProtocolServerView: View { private func customServer() -> some View { VStack { let serverAddress = parseServerAddress(serverToEdit.server) - let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol + let valid = serverAddress?.valid == true List { Section { TextEditor(text: $serverToEdit.server) @@ -112,10 +136,7 @@ struct ProtocolServerView: View { Spacer() showTestStatus(server: serverToEdit) } - let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset Toggle("Use for new connections", isOn: $serverToEdit.enabled) - .disabled(useForNewDisabled) - .foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground) } } } @@ -142,7 +163,7 @@ struct BackButton: ViewModifier { } } -@ViewBuilder func showTestStatus(server: ServerCfg) -> some View { +@ViewBuilder func showTestStatus(server: UserServer) -> some View { switch server.tested { case .some(true): Image(systemName: "checkmark") @@ -155,7 +176,7 @@ struct BackButton: ViewModifier { } } -func testServerConnection(server: Binding) async -> ProtocolTestFailure? { +func testServerConnection(server: Binding) async -> ProtocolTestFailure? { do { let r = try await testProtoServer(server: server.wrappedValue.server) switch r { @@ -178,9 +199,11 @@ func testServerConnection(server: Binding) async -> ProtocolTestFailu struct ProtocolServerView_Previews: PreviewProvider { static var previews: some View { ProtocolServerView( - serverProtocol: .smp, - server: Binding.constant(ServerCfg.sampleData.custom), - serverToEdit: ServerCfg.sampleData.custom + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + server: Binding.constant(UserServer.sampleData.custom), + serverToEdit: UserServer.sampleData.custom, + backLabel: "Your SMP servers" ) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index 0fb37d5c49..ed3c5c773c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -11,238 +11,166 @@ import SimpleXChat private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")! -struct ProtocolServersView: View { +struct YourServersView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject private var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.editMode) private var editMode - let serverProtocol: ServerProtocol - @State private var currServers: [ServerCfg] = [] - @State private var presetServers: [ServerCfg] = [] - @State private var configuredServers: [ServerCfg] = [] - @State private var otherServers: [ServerCfg] = [] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var operatorIndex: Int @State private var selectedServer: String? = nil @State private var showAddServer = false + @State private var newServerNavLinkActive = false @State private var showScanProtoServer = false - @State private var justOpened = true @State private var testing = false - @State private var alert: ServerAlert? = nil - @State private var showSaveDialog = false - - var proto: String { serverProtocol.rawValue.uppercased() } var body: some View { - ZStack { - protocolServersView() - if testing { - ProgressView().scaleEffect(2) + yourServersView() + .opacity(testing ? 0.4 : 1) + .overlay { + if testing { + ProgressView() + .scaleEffect(2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } - } + .allowsHitTesting(!testing) } - enum ServerAlert: Identifiable { - case testsFailed(failures: [String: ProtocolTestFailure]) - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") - - var id: String { - switch self { - case .testsFailed: return "testsFailed" - case let .error(title, _): return "error \(title)" - } - } - } - - private func protocolServersView() -> some View { + @ViewBuilder private func yourServersView() -> some View { + let duplicateHosts = findDuplicateHosts(serverErrors) List { - if !configuredServers.isEmpty { + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { - ForEach($configuredServers) { srv in - protocolServerView(srv) - } - .onMove { indexSet, offset in - configuredServers.move(fromOffsets: indexSet, toOffset: offset) + ForEach($userServers[operatorIndex].smpServers) { srv in + if !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .smp, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } } .onDelete { indexSet in - configuredServers.remove(atOffsets: indexSet) + deleteSMPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) } } header: { - Text("Configured \(proto) servers") + Text("Message servers") .foregroundColor(theme.colors.secondary) } footer: { - Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") - .foregroundColor(theme.colors.secondary) - .lineLimit(10) + if let errStr = globalSMPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } } } - if !otherServers.isEmpty { + if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty { Section { - ForEach($otherServers) { srv in - protocolServerView(srv) - } - .onMove { indexSet, offset in - otherServers.move(fromOffsets: indexSet, toOffset: offset) + ForEach($userServers[operatorIndex].xftpServers) { srv in + if !srv.wrappedValue.deleted { + ProtocolServerViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + duplicateHosts: duplicateHosts, + server: srv, + serverProtocol: .xftp, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { + EmptyView() + } } .onDelete { indexSet in - otherServers.remove(atOffsets: indexSet) + deleteXFTPServer($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors) } } header: { - Text("Other \(proto) servers") + Text("Media & file servers") .foregroundColor(theme.colors.secondary) + } footer: { + if let errStr = globalXFTPServersError(serverErrors) { + ServersErrorView(errStr: errStr) + } else { + Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .foregroundColor(theme.colors.secondary) + .lineLimit(10) + } } } Section { - Button("Add server") { - showAddServer = true + ZStack { + Button("Add server") { + showAddServer = true + } + + NavigationLink(isActive: $newServerNavLinkActive) { + newServerDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } footer: { + if let errStr = globalServersError(serverErrors) { + ServersErrorView(errStr: errStr) } } Section { - Button("Reset") { partitionServers(currServers) } - .disabled(Set(allServers) == Set(currServers) || testing) - Button("Test servers", action: testServers) - .disabled(testing || allServersDisabled) - Button("Save servers", action: saveServers) - .disabled(saveDisabled) + TestServersButton( + smpServers: $userServers[operatorIndex].smpServers, + xftpServers: $userServers[operatorIndex].xftpServers, + testing: $testing + ) howToButton() } } - .toolbar { EditButton() } - .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { - Button("Enter server manually") { - otherServers.append(ServerCfg.empty) - selectedServer = allServers.last?.id + .toolbar { + if ( + !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty || + !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty + ) { + EditButton() } + } + .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { + Button("Enter server manually") { newServerNavLinkActive = true } Button("Scan server QR code") { showScanProtoServer = true } - Button("Add preset servers", action: addAllPresets) - .disabled(hasAllPresets()) } .sheet(isPresented: $showScanProtoServer) { - ScanProtocolServer(servers: $otherServers) - .modifier(ThemedBackground(grouped: true)) - } - .modifier(BackButton(disabled: Binding.constant(false)) { - if saveDisabled { - dismiss() - justOpened = false - } else { - showSaveDialog = true - } - }) - .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) { - Button("Save") { - saveServers() - dismiss() - justOpened = false - } - Button("Exit without saving") { dismiss() } - } - .alert(item: $alert) { a in - switch a { - case let .testsFailed(fs): - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") - return Alert( - title: Text("Tests failed!"), - message: Text("Some servers failed the test:\n" + msg) - ) - case .error: - return Alert( - title: Text("Error") - ) - } - } - .onAppear { - // this condition is needed to prevent re-setting the servers when exiting single server view - if justOpened { - do { - let r = try getUserProtoServers(serverProtocol) - currServers = r.protoServers - presetServers = r.presetServers - partitionServers(currServers) - } catch let error { - alert = .error( - title: "Error loading \(proto) servers", - error: "Error: \(responseError(error))" - ) - } - justOpened = false - } else { - partitionServers(allServers) - } - } - } - - private func partitionServers(_ servers: [ServerCfg]) { - configuredServers = servers.filter { $0.preset || $0.enabled } - otherServers = servers.filter { !($0.preset || $0.enabled) } - } - - private var allServers: [ServerCfg] { - configuredServers + otherServers - } - - private var saveDisabled: Bool { - allServers.isEmpty || - Set(allServers) == Set(currServers) || - testing || - !allServers.allSatisfy { srv in - if let address = parseServerAddress(srv.server) { - return uniqueAddress(srv, address) - } - return false - } || - allServersDisabled - } - - private var allServersDisabled: Bool { - allServers.allSatisfy { !$0.enabled } - } - - private func protocolServerView(_ server: Binding) -> some View { - let srv = server.wrappedValue - return NavigationLink(tag: srv.id, selection: $selectedServer) { - ProtocolServerView( - serverProtocol: serverProtocol, - server: server, - serverToEdit: srv + ScanProtocolServer( + userServers: $userServers, + serverErrors: $serverErrors ) - .navigationBarTitle(srv.preset ? "Preset server" : "Your server") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - let address = parseServerAddress(srv.server) - HStack { - Group { - if let address = address { - if !address.valid || address.serverProtocol != serverProtocol { - invalidServer() - } else if !uniqueAddress(srv, address) { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } else if !srv.enabled { - Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) - } else { - showTestStatus(server: srv) - } - } else { - invalidServer() - } - } - .frame(width: 16, alignment: .center) - .padding(.trailing, 4) - - let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1) - if srv.enabled { - v - } else { - v.foregroundColor(theme.colors.secondary) - } - } } } + private func newServerDestinationView() -> some View { + NewServerView( + userServers: $userServers, + serverErrors: $serverErrors + ) + .navigationTitle("New server") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + func howToButton() -> some View { Button { DispatchQueue.main.async { @@ -255,33 +183,114 @@ struct ProtocolServersView: View { } } } +} + +struct ProtocolServerViewLink: View { + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var duplicateHosts: Set + @Binding var server: UserServer + var serverProtocol: ServerProtocol + var backLabel: LocalizedStringKey + @Binding var selectedServer: String? + + var body: some View { + let proto = serverProtocol.rawValue.uppercased() + + NavigationLink(tag: server.id, selection: $selectedServer) { + ProtocolServerView( + userServers: $userServers, + serverErrors: $serverErrors, + server: $server, + serverToEdit: server, + backLabel: backLabel + ) + .navigationBarTitle("\(proto) server") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + let address = parseServerAddress(server.server) + HStack { + Group { + if let address = address { + if !address.valid || address.serverProtocol != serverProtocol { + invalidServer() + } else if address.hostnames.contains(where: duplicateHosts.contains) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else if !server.enabled { + Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) + } else { + showTestStatus(server: server) + } + } else { + invalidServer() + } + } + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + + let v = Text(address?.hostnames.first ?? server.server).lineLimit(1) + if server.enabled { + v + } else { + v.foregroundColor(theme.colors.secondary) + } + } + } + } private func invalidServer() -> some View { Image(systemName: "exclamationmark.circle").foregroundColor(.red) } +} - private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool { - allServers.allSatisfy { srv in - address.hostnames.allSatisfy { host in - srv.id == s.id || !srv.server.contains(host) - } +func deleteSMPServer( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let server = userServers[operatorServersIndex].wrappedValue.smpServers[idx] + if server.serverId == nil { + userServers[operatorServersIndex].wrappedValue.smpServers.remove(at: idx) + } else { + var updatedServer = server + updatedServer.deleted = true + userServers[operatorServersIndex].wrappedValue.smpServers[idx] = updatedServer } } +} - private func hasAllPresets() -> Bool { - presetServers.allSatisfy { hasPreset($0) } - } - - private func addAllPresets() { - for srv in presetServers { - if !hasPreset(srv) { - configuredServers.append(srv) - } +func deleteXFTPServer( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let server = userServers[operatorServersIndex].wrappedValue.xftpServers[idx] + if server.serverId == nil { + userServers[operatorServersIndex].wrappedValue.xftpServers.remove(at: idx) + } else { + var updatedServer = server + updatedServer.deleted = true + userServers[operatorServersIndex].wrappedValue.xftpServers[idx] = updatedServer } } +} - private func hasPreset(_ srv: ServerCfg) -> Bool { - allServers.contains(where: { $0.server == srv.server }) +struct TestServersButton: View { + @Binding var smpServers: [UserServer] + @Binding var xftpServers: [UserServer] + @Binding var testing: Bool + + var body: some View { + Button("Test servers", action: testServers) + .disabled(testing || allServersDisabled) + } + + private var allServersDisabled: Bool { + smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } } private func testServers() { @@ -292,68 +301,59 @@ struct ProtocolServersView: View { await MainActor.run { testing = false if !fs.isEmpty { - alert = .testsFailed(failures: fs) + let msg = fs.map { (srv, f) in + "\(srv): \(f.localizedDescription)" + }.joined(separator: "\n") + showAlert( + NSLocalizedString("Tests failed!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) + ) } } } } private func resetTestStatus() { - for i in 0.. [String: ProtocolTestFailure] { var fs: [String: ProtocolTestFailure] = [:] - for i in 0..) { switch resp { case let .success(r): - if parseServerAddress(r.string) != nil { - servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false)) - dismiss() - } else { - showAddressError = true - } + var server: UserServer = .empty + server.server = r.string + addServer(server, $userServers, $serverErrors, dismiss) case let .failure(e): logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)") dismiss() @@ -54,6 +45,9 @@ struct ScanProtocolServer: View { struct ScanProtocolServer_Previews: PreviewProvider { static var previews: some View { - ScanProtocolServer(servers: Binding.constant([])) + ScanProtocolServer( + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]) + ) } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 12a982e76b..e73697e42a 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -50,6 +50,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" +let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -107,6 +108,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness, DEFAULT_CHAT_ITEM_TAIL: true, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, + DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -135,6 +137,7 @@ let appDefaults: [String: Any] = [ let hintDefaults = [ DEFAULT_LA_NOTICE_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN, + DEFAULT_ADDRESS_CREATION_CARD_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_MUTE_PROFILE_ALERT, @@ -263,6 +266,10 @@ struct SettingsView: View { @EnvironmentObject var theme: AppTheme @State private var showProgress: Bool = false + @Binding var currUserServers: [UserOperatorServers] + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + var body: some View { ZStack { settingsView() @@ -289,9 +296,13 @@ struct SettingsView: View { .disabled(chatModel.chatRunning != true) NavigationLink { - NetworkAndServers() - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) + NetworkAndServers( + currUserServers: $currUserServers, + userServers: $userServers, + serverErrors: $serverErrors + ) + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) } label: { settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } } @@ -356,7 +367,7 @@ struct SettingsView: View { } } NavigationLink { - WhatsNewView(viaSettings: true) + WhatsNewView(viaSettings: true, showWhatsNew: true, showOperatorsNotice: false) .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.inline) } label: { @@ -525,7 +536,11 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = User.sampleData - return SettingsView() - .environmentObject(chatModel) + return SettingsView( + currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]) + ) + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift index 15f6a1c7d7..d4bc0959c9 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressLearnMore.swift @@ -9,15 +9,47 @@ import SwiftUI struct UserAddressLearnMore: View { + @State var showCreateAddressButton = false + @State private var createAddressLinkActive = false + var body: some View { - List { - VStack(alignment: .leading, spacing: 18) { - Text("You can share your address as a link or QR code - anybody can connect to you.") - Text("You won't lose your contacts if you later delete your address.") - Text("When people request to connect, you can accept or reject it.") - Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).") + VStack { + List { + VStack(alignment: .leading, spacing: 18) { + Text("You can share your address as a link or QR code - anybody can connect to you.") + Text("You won't lose your contacts if you later delete your address.") + Text("When people request to connect, you can accept or reject it.") + Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).") + } + .listRowBackground(Color.clear) } - .listRowBackground(Color.clear) + .frame(maxHeight: .infinity) + + if showCreateAddressButton { + addressCreationButton() + .padding() + } + } + } + + private func addressCreationButton() -> some View { + ZStack { + Button { + createAddressLinkActive = true + } label: { + Text("Create SimpleX address") + } + .buttonStyle(OnboardingButtonStyle()) + + NavigationLink(isActive: $createAddressLinkActive) { + UserAddressView(autoCreate: true) + .navigationTitle("SimpleX address") + .navigationBarTitleDisplayMode(.large) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 2469dc59db..cbc3e9b79e 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -15,6 +15,7 @@ struct UserAddressView: View { @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false + @State var autoCreate = false @State private var aas = AutoAcceptState() @State private var savedAAS = AutoAcceptState() @State private var ignoreShareViaProfileChange = false @@ -67,6 +68,11 @@ struct UserAddressView: View { } } } + .onAppear { + if chatModel.userAddress == nil, autoCreate { + createAddress() + } + } } @Namespace private var bottomID @@ -212,26 +218,30 @@ struct UserAddressView: View { private func createAddressButton() -> some View { Button { - progressIndicator = true - Task { - do { - let connReqContact = try await apiCreateUserAddress() - DispatchQueue.main.async { - chatModel.userAddress = UserContactLink(connReqContact: connReqContact) - alert = .shareOnCreate - progressIndicator = false - } - } catch let error { - logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") - let a = getErrorAlert(error, "Error creating address") - alert = .error(title: a.title, error: a.message) - await MainActor.run { progressIndicator = false } - } - } + createAddress() } label: { Label("Create SimpleX address", systemImage: "qrcode") } } + + private func createAddress() { + progressIndicator = true + Task { + do { + let connReqContact = try await apiCreateUserAddress() + DispatchQueue.main.async { + chatModel.userAddress = UserContactLink(connReqContact: connReqContact) + alert = .shareOnCreate + progressIndicator = false + } + } catch let error { + logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") + let a = getErrorAlert(error, "Error creating address") + alert = .error(title: a.title, error: a.message) + await MainActor.run { progressIndicator = false } + } + } + } private func deleteAddressButton() -> some View { Button(role: .destructive) { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8a63cd3309..8dc195e17f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -144,20 +144,22 @@ 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */; }; 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; + 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; + 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; + 642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */; }; + 642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA82F2CEB3D4B005E9412 /* libffi.a */; }; + 642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8302CEB3D4B005E9412 /* libgmp.a */; }; + 642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8312CEB3D4B005E9412 /* libgmpxx.a */; }; + 642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; - 643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; }; - 643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */; }; - 643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; }; - 643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */; }; - 643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; }; + 643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; - 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; @@ -200,7 +202,9 @@ 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; + B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; + B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; @@ -436,7 +440,7 @@ 5CB634AC29E46CF70066AD6B /* LocalAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthView.swift; sourceTree = ""; }; 5CB634AE29E4BB7D0066AD6B /* SetAppPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetAppPasscodeView.swift; sourceTree = ""; }; 5CB634B029E5EFEA0066AD6B /* PasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasscodeView.swift; sourceTree = ""; }; - 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; wrapsLines = 0; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; 5CBD285529565CAE00EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; @@ -487,20 +491,22 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatMenuButton.swift; sourceTree = ""; }; 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; + 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; + 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a"; sourceTree = ""; }; + 642BA82F2CEB3D4B005E9412 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 642BA8302CEB3D4B005E9412 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 642BA8312CEB3D4B005E9412 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a"; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; - 643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = ""; }; - 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a"; sourceTree = ""; }; - 643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = ""; }; - 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; path = "Libraries/libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a"; sourceTree = ""; }; - 643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = ""; }; + 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = ""; }; 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = ""; }; 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; - 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; @@ -544,7 +550,9 @@ 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; + B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; + B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; @@ -657,14 +665,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */, - 643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */, - 643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a in Frameworks */, - 643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a in Frameworks */, + 642BA8342CEB3D4B005E9412 /* libffi.a in Frameworks */, + 642BA8352CEB3D4B005E9412 /* libgmp.a in Frameworks */, + 642BA8372CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a in Frameworks */, + 642BA8332CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a in Frameworks */, + 642BA8362CEB3D4B005E9412 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -741,6 +749,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( + 642BA82F2CEB3D4B005E9412 /* libffi.a */, + 642BA8302CEB3D4B005E9412 /* libgmp.a */, + 642BA8312CEB3D4B005E9412 /* libgmpxx.a */, + 642BA8322CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH-ghc9.6.3.a */, + 642BA82E2CEB3D4B005E9412 /* libHSsimplex-chat-6.2.0.0-1FOg7s6V4oE9PrQV6sHPkH.a */, ); path = Libraries; sourceTree = ""; @@ -812,11 +825,6 @@ 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, - 643B3B422CCBEB080083A2CF /* libffi.a */, - 643B3B442CCBEB080083A2CF /* libgmp.a */, - 643B3B402CCBEB080083A2CF /* libgmpxx.a */, - 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs-ghc9.6.3.a */, - 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.2.0.0-ICCEDdz3q5b2XylcUTCFFs.a */, 5CA059C2279559F40002BEB4 /* Shared */, 5CDCAD462818589900503DA2 /* SimpleX NSE */, CEE723A82C3BD3D70009AE93 /* SimpleX SE */, @@ -875,13 +883,15 @@ 5CB0BA8C282711BC00B3292C /* Onboarding */ = { isa = PBXGroup; children = ( + B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */, 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, - 64466DC729FC2B3B00E3D48D /* CreateSimpleXAddress.swift */, 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */, + 640743602CD360E600158442 /* ChooseServerOperators.swift */, + B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */, ); path = Onboarding; sourceTree = ""; @@ -1056,8 +1066,10 @@ isa = PBXGroup; children = ( 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, + 642BA82C2CE50495005E9412 /* NewServerView.swift */, 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, + 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */, 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, ); @@ -1383,10 +1395,12 @@ 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */, 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, + 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, + B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */, 5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */, @@ -1413,12 +1427,12 @@ 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, + B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */, 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, - 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */, @@ -1536,7 +1550,9 @@ 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */, 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */, + 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */, 184158C131FDB829D8A117EA /* VideoPlayerView.swift in Sources */, + 643B3B4E2CCFD6400083A2CF /* OperatorView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3c9b91fa0b..5470059e92 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -72,9 +72,15 @@ public enum ChatCommand { case apiGetGroupLink(groupId: Int64) case apiCreateMemberContact(groupId: Int64, groupMemberId: Int64) case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) - case apiGetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol) - case apiSetUserProtoServers(userId: Int64, serverProtocol: ServerProtocol, servers: [ServerCfg]) case apiTestProtoServer(userId: Int64, server: String) + case apiGetServerOperators + case apiSetServerOperators(operators: [ServerOperator]) + case apiGetUserServers(userId: Int64) + case apiSetUserServers(userId: Int64, userServers: [UserOperatorServers]) + case apiValidateServers(userId: Int64, userServers: [UserOperatorServers]) + case apiGetUsageConditions + case apiSetConditionsNotified(conditionsId: Int64) + case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) case apiSetChatItemTTL(userId: Int64, seconds: Int64?) case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) @@ -231,9 +237,15 @@ public enum ChatCommand { case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiCreateMemberContact(groupId, groupMemberId): return "/_create member contact #\(groupId) \(groupMemberId)" case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" - case let .apiGetUserProtoServers(userId, serverProtocol): return "/_servers \(userId) \(serverProtocol)" - case let .apiSetUserProtoServers(userId, serverProtocol, servers): return "/_servers \(userId) \(serverProtocol) \(protoServersStr(servers))" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case .apiGetServerOperators: return "/_operators" + case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" + case let .apiGetUserServers(userId): return "/_servers \(userId)" + case let .apiSetUserServers(userId, userServers): return "/_servers \(userId) \(encodeJSON(userServers))" + case let .apiValidateServers(userId, userServers): return "/_validate_servers \(userId) \(encodeJSON(userServers))" + case .apiGetUsageConditions: return "/_conditions" + case let .apiSetConditionsNotified(conditionsId): return "/_conditions_notified \(conditionsId)" + case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" @@ -386,9 +398,15 @@ public enum ChatCommand { case .apiGetGroupLink: return "apiGetGroupLink" case .apiCreateMemberContact: return "apiCreateMemberContact" case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" - case .apiGetUserProtoServers: return "apiGetUserProtoServers" - case .apiSetUserProtoServers: return "apiSetUserProtoServers" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiGetServerOperators: return "apiGetServerOperators" + case .apiSetServerOperators: return "apiSetServerOperators" + case .apiGetUserServers: return "apiGetUserServers" + case .apiSetUserServers: return "apiSetUserServers" + case .apiValidateServers: return "apiValidateServers" + case .apiGetUsageConditions: return "apiGetUsageConditions" + case .apiSetConditionsNotified: return "apiSetConditionsNotified" + case .apiAcceptConditions: return "apiAcceptConditions" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" @@ -475,10 +493,6 @@ public enum ChatCommand { func joinedIds(_ ids: [Int64]) -> String { ids.map { "\($0)" }.joined(separator: ",") } - - func protoServersStr(_ servers: [ServerCfg]) -> String { - encodeJSON(ProtoServersConfig(servers: servers)) - } func chatItemTTLStr(seconds: Int64?) -> String { if let seconds = seconds { @@ -548,8 +562,11 @@ public enum ChatResponse: Decodable, Error { case apiChats(user: UserRef, chats: [ChatData]) case apiChat(user: UserRef, chat: ChatData) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) - case userProtoServers(user: UserRef, servers: UserProtoServers) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case serverOperatorConditions(conditions: ServerOperatorConditions) + case userServers(user: UserRef, userServers: [UserOperatorServers]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) case chatItemTTL(user: UserRef, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) @@ -721,8 +738,11 @@ public enum ChatResponse: Decodable, Error { case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .chatItemInfo: return "chatItemInfo" - case .userProtoServers: return "userProtoServers" case .serverTestResult: return "serverTestResult" + case .serverOperatorConditions: return "serverOperators" + case .userServers: return "userServers" + case .userServersValidation: return "userServersValidation" + case .usageConditions: return "usageConditions" case .chatItemTTL: return "chatItemTTL" case .networkConfig: return "networkConfig" case .contactInfo: return "contactInfo" @@ -890,8 +910,11 @@ public enum ChatResponse: Decodable, Error { case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat): return withUser(u, String(describing: chat)) case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") - case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" + case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") + case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") @@ -1175,86 +1198,426 @@ public struct DBEncryptionConfig: Codable { public var newKey: String } -struct SMPServersConfig: Encodable { - var smpServers: [ServerCfg] -} - public enum ServerProtocol: String, Decodable { case smp case xftp } -public struct ProtoServersConfig: Codable { - public var servers: [ServerCfg] +public enum OperatorTag: String, Codable { + case simplex = "simplex" + case flux = "flux" + case xyz = "xyz" + case demo = "demo" } -public struct UserProtoServers: Decodable { - public var serverProtocol: ServerProtocol - public var protoServers: [ServerCfg] - public var presetServers: [ServerCfg] +public struct ServerOperatorInfo: Decodable { + public var description: [String] + public var website: String + public var logo: String + public var largeLogo: String + public var logoDarkMode: String + public var largeLogoDarkMode: String } -public struct ServerCfg: Identifiable, Equatable, Codable, Hashable { +public let operatorsInfo: Dictionary = [ + .simplex: ServerOperatorInfo( + description: ["SimpleX Chat preset servers"], + website: "https://simplex.chat", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ), + .flux: ServerOperatorInfo( + description: [ + "Flux is the largest decentralized cloud infrastructure, leveraging a global network of user-operated computational nodes.", + "Flux offers a powerful, scalable, and affordable platform designed to support individuals, businesses, and cutting-edge technologies like AI. With high uptime and worldwide distribution, Flux ensures reliable, accessible cloud computing for all." + ], + website: "https://runonflux.com", + logo: "flux_logo_symbol", + largeLogo: "flux_logo", + logoDarkMode: "flux_logo_symbol", + largeLogoDarkMode: "flux_logo-light" + ), + .xyz: ServerOperatorInfo( + description: ["XYZ servers"], + website: "XYZ website", + logo: "shield", + largeLogo: "logo", + logoDarkMode: "shield", + largeLogoDarkMode: "logo-light" + ), + .demo: ServerOperatorInfo( + description: ["Demo operator"], + website: "Demo website", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) +] + +public struct UsageConditions: Decodable { + public var conditionsId: Int64 + public var conditionsCommit: String + public var notifiedAt: Date? + public var createdAt: Date + + public static var sampleData = UsageConditions( + conditionsId: 1, + conditionsCommit: "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt: nil, + createdAt: Date.now + ) +} + +public enum UsageConditionsAction: Decodable { + case review(operators: [ServerOperator], deadline: Date?, showNotice: Bool) + case accepted(operators: [ServerOperator]) + + public var showNotice: Bool { + switch self { + case let .review(_, _, showNotice): showNotice + case .accepted: false + } + } +} + +public struct ServerOperatorConditions: Decodable { + public var serverOperators: [ServerOperator] + public var currentConditions: UsageConditions + public var conditionsAction: UsageConditionsAction? + + public static var empty = ServerOperatorConditions( + serverOperators: [], + currentConditions: UsageConditions(conditionsId: 0, conditionsCommit: "empty", notifiedAt: nil, createdAt: .now), + conditionsAction: nil + ) +} + +public enum ConditionsAcceptance: Equatable, Codable, Hashable { + case accepted(acceptedAt: Date?) + // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. + // No deadline indicates it's required to accept conditions for the operator to start using it. + case required(deadline: Date?) + + public var conditionsAccepted: Bool { + switch self { + case .accepted: true + case .required: false + } + } + + public var usageAllowed: Bool { + switch self { + case .accepted: true + case let .required(deadline): deadline != nil + } + } +} + +public struct ServerOperator: Identifiable, Equatable, Codable { + public var operatorId: Int64 + public var operatorTag: OperatorTag? + public var tradeName: String + public var legalName: String? + public var serverDomains: [String] + public var conditionsAcceptance: ConditionsAcceptance + public var enabled: Bool + public var smpRoles: ServerRoles + public var xftpRoles: ServerRoles + + public var id: Int64 { operatorId } + + public static func == (l: ServerOperator, r: ServerOperator) -> Bool { + l.operatorId == r.operatorId && l.operatorTag == r.operatorTag && l.tradeName == r.tradeName && l.legalName == r.legalName && + l.serverDomains == r.serverDomains && l.conditionsAcceptance == r.conditionsAcceptance && l.enabled == r.enabled && + l.smpRoles == r.smpRoles && l.xftpRoles == r.xftpRoles + } + + public var legalName_: String { + legalName ?? tradeName + } + + public var info: ServerOperatorInfo { + return if let operatorTag = operatorTag { + operatorsInfo[operatorTag] ?? ServerOperator.dummyOperatorInfo + } else { + ServerOperator.dummyOperatorInfo + } + } + + public static let dummyOperatorInfo = ServerOperatorInfo( + description: ["Default"], + website: "Default", + logo: "decentralized", + largeLogo: "logo", + logoDarkMode: "decentralized-light", + largeLogoDarkMode: "logo-light" + ) + + public func logo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.logo : info.logoDarkMode + } + + public func largeLogo(_ colorScheme: ColorScheme) -> String { + colorScheme == .light ? info.largeLogo : info.largeLogoDarkMode + } + + public static var sampleData1 = ServerOperator( + operatorId: 1, + operatorTag: .simplex, + tradeName: "SimpleX Chat", + legalName: "SimpleX Chat Ltd", + serverDomains: ["simplex.im"], + conditionsAcceptance: .accepted(acceptedAt: nil), + enabled: true, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + + public static var sampleData2 = ServerOperator( + operatorId: 2, + operatorTag: .xyz, + tradeName: "XYZ", + legalName: nil, + serverDomains: ["xyz.com"], + conditionsAcceptance: .required(deadline: nil), + enabled: false, + smpRoles: ServerRoles(storage: false, proxy: true), + xftpRoles: ServerRoles(storage: false, proxy: true) + ) + + public static var sampleData3 = ServerOperator( + operatorId: 3, + operatorTag: .demo, + tradeName: "Demo", + legalName: nil, + serverDomains: ["demo.com"], + conditionsAcceptance: .required(deadline: nil), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: false), + xftpRoles: ServerRoles(storage: true, proxy: false) + ) +} + +public struct ServerRoles: Equatable, Codable { + public var storage: Bool + public var proxy: Bool +} + +public struct UserOperatorServers: Identifiable, Equatable, Codable { + public var `operator`: ServerOperator? + public var smpServers: [UserServer] + public var xftpServers: [UserServer] + + public var id: String { + if let op = self.operator { + "\(op.operatorId)" + } else { + "nil operator" + } + } + + public var operator_: ServerOperator { + get { + self.operator ?? ServerOperator( + operatorId: 0, + operatorTag: nil, + tradeName: "", + legalName: "", + serverDomains: [], + conditionsAcceptance: .accepted(acceptedAt: nil), + enabled: false, + smpRoles: ServerRoles(storage: true, proxy: true), + xftpRoles: ServerRoles(storage: true, proxy: true) + ) + } + set { `operator` = newValue } + } + + public static var sampleData1 = UserOperatorServers( + operator: ServerOperator.sampleData1, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) + + public static var sampleDataNilOperator = UserOperatorServers( + operator: nil, + smpServers: [UserServer.sampleData.preset], + xftpServers: [UserServer.sampleData.xftpPreset] + ) +} + +public enum UserServersError: Decodable { + case noServers(protocol: ServerProtocol, user: UserRef?) + case storageMissing(protocol: ServerProtocol, user: UserRef?) + case proxyMissing(protocol: ServerProtocol, user: UserRef?) + case invalidServer(protocol: ServerProtocol, invalidServer: String) + case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + + public var globalError: String? { + switch self { + case let .noServers(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .storageMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + case let .proxyMissing(`protocol`, _): + switch `protocol` { + case .smp: return globalSMPError + case .xftp: return globalXFTPError + } + default: return nil + } + } + + public var globalSMPError: String? { + switch self { + case let .noServers(.smp, user): + let text = NSLocalizedString("No message servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.smp, user): + let text = NSLocalizedString("No servers to receive messages.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.smp, user): + let text = NSLocalizedString("No servers for private message routing.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + public var globalXFTPError: String? { + switch self { + case let .noServers(.xftp, user): + let text = NSLocalizedString("No media & file servers.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .storageMissing(.xftp, user): + let text = NSLocalizedString("No servers to send files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + case let .proxyMissing(.xftp, user): + let text = NSLocalizedString("No servers to receive files.", comment: "servers error") + if let user = user { + return userStr(user) + " " + text + } else { + return text + } + default: + return nil + } + } + + private func userStr(_ user: UserRef) -> String { + String.localizedStringWithFormat(NSLocalizedString("For chat profile %@:", comment: "servers error"), user.localDisplayName) + } +} + +public struct UserServer: Identifiable, Equatable, Codable, Hashable { + public var serverId: Int64? public var server: String public var preset: Bool public var tested: Bool? public var enabled: Bool + public var deleted: Bool var createdAt = Date() -// public var sendEnabled: Bool // can we potentially want to prevent sending on the servers we use to receive? -// Even if we don't see the use case, it's probably better to allow it in the model -// In any case, "trusted/known" servers are out of scope of this change - public init(server: String, preset: Bool, tested: Bool?, enabled: Bool) { + public init(serverId: Int64?, server: String, preset: Bool, tested: Bool?, enabled: Bool, deleted: Bool) { + self.serverId = serverId self.server = server self.preset = preset self.tested = tested self.enabled = enabled + self.deleted = deleted } - public static func == (l: ServerCfg, r: ServerCfg) -> Bool { - l.server == r.server && l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled + public static func == (l: UserServer, r: UserServer) -> Bool { + l.serverId == r.serverId && l.server == r.server && l.preset == r.preset && l.tested == r.tested && + l.enabled == r.enabled && l.deleted == r.deleted } public var id: String { "\(server) \(createdAt)" } - public static var empty = ServerCfg(server: "", preset: false, tested: nil, enabled: false) + public static var empty = UserServer(serverId: nil, server: "", preset: false, tested: nil, enabled: false, deleted: false) public var isEmpty: Bool { server.trimmingCharacters(in: .whitespaces) == "" } public struct SampleData { - public var preset: ServerCfg - public var custom: ServerCfg - public var untested: ServerCfg + public var preset: UserServer + public var custom: UserServer + public var untested: UserServer + public var xftpPreset: UserServer } public static var sampleData = SampleData( - preset: ServerCfg( + preset: UserServer( + serverId: 1, server: "smp://abcd@smp8.simplex.im", preset: true, tested: true, - enabled: true + enabled: true, + deleted: false ), - custom: ServerCfg( + custom: UserServer( + serverId: 2, server: "smp://abcd@smp9.simplex.im", preset: false, tested: false, - enabled: false + enabled: false, + deleted: false ), - untested: ServerCfg( + untested: UserServer( + serverId: 3, server: "smp://abcd@smp10.simplex.im", preset: false, tested: nil, - enabled: true + enabled: true, + deleted: false + ), + xftpPreset: UserServer( + serverId: 4, + server: "xftp://abcd@xftp8.simplex.im", + preset: true, + tested: true, + enabled: true, + deleted: false ) ) enum CodingKeys: CodingKey { + case serverId case server case preset case tested case enabled + case deleted } }