From 37b78edb91f60a9f78d727c5e78a49c9a4885f41 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 28 Oct 2024 18:18:26 +0400 Subject: [PATCH 01/34] ios: move Network and servers settings modules to folder (#5110) --- .../AdvancedNetworkSettings.swift | 0 .../NetworkAndServers.swift | 0 .../ProtocolServerView.swift | 0 .../ProtocolServersView.swift | 0 .../ScanProtocolServer.swift | 0 apps/ios/SimpleX.xcodeproj/project.pbxproj | 18 +++++++++++++----- 6 files changed, 13 insertions(+), 5 deletions(-) rename apps/ios/Shared/Views/UserSettings/{ => NetworkAndServers}/AdvancedNetworkSettings.swift (100%) rename apps/ios/Shared/Views/UserSettings/{ => NetworkAndServers}/NetworkAndServers.swift (100%) rename apps/ios/Shared/Views/UserSettings/{ => NetworkAndServers}/ProtocolServerView.swift (100%) rename apps/ios/Shared/Views/UserSettings/{ => NetworkAndServers}/ProtocolServersView.swift (100%) rename apps/ios/Shared/Views/UserSettings/{ => NetworkAndServers}/ScanProtocolServer.swift (100%) diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift diff --git a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift similarity index 100% rename from apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift rename to apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2b1160061c..7aaa439adb 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -912,10 +912,9 @@ 5CB924DF27A8678B00ACCCDD /* UserSettings */ = { isa = PBXGroup; children = ( + 643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, - 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, - 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 5CADE79929211BB900072E13 /* PreferencesView.swift */, 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, @@ -923,9 +922,6 @@ 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, - 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, - 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, - 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, 5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */, @@ -1056,6 +1052,18 @@ path = Database; sourceTree = ""; }; + 643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */ = { + isa = PBXGroup; + children = ( + 5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */, + 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */, + 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */, + 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, + 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, + ); + path = NetworkAndServers; + sourceTree = ""; + }; 6440CA01288AEC770062C672 /* Group */ = { isa = PBXGroup; children = ( From 97df069730e2d63b3eb7b644b127a7e3cc7b03ed Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 4 Nov 2024 13:28:57 +0000 Subject: [PATCH 02/34] core: add support for server operators (#4961) * core: add support for server operators * migration * update schema and queries, rfc * add usage conditions tables * core: server operators new apis draft * update * conditions * update * add get conditions api * add get conditions API * WIP * compiles * fix schema * core: ui logic in types (#5139) * update --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- cabal.project | 2 +- docs/rfcs/2024-10-27-server-operators.md | 24 ++++ package.yaml | 1 + scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 10 ++ src/Simplex/Chat.hs | 50 +++++++- src/Simplex/Chat/Controller.hs | 25 +++- .../Migrations/M20241027_server_operators.hs | 70 +++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 39 +++++++ src/Simplex/Chat/Operators.hs | 110 ++++++++++++++++++ src/Simplex/Chat/Operators/Conditions.hs | 19 +++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 77 ++++++++++-- src/Simplex/Chat/Terminal.hs | 8 +- src/Simplex/Chat/View.hs | 24 ++-- tests/ChatClient.hs | 7 +- tests/RandomServers.hs | 4 +- 17 files changed, 440 insertions(+), 36 deletions(-) create mode 100644 docs/rfcs/2024-10-27-server-operators.md create mode 100644 src/Simplex/Chat/Migrations/M20241027_server_operators.hs create mode 100644 src/Simplex/Chat/Operators.hs create mode 100644 src/Simplex/Chat/Operators/Conditions.hs diff --git a/cabal.project b/cabal.project index c9b8b11722..61ce04a569 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ffecf200d4874dfa34f6d15b269964c0115a54ca + tag: ff05a465ee15ac7ae2c14a9fb703a18564950631 source-repository-package type: git diff --git a/docs/rfcs/2024-10-27-server-operators.md b/docs/rfcs/2024-10-27-server-operators.md new file mode 100644 index 0000000000..5456d28f08 --- /dev/null +++ b/docs/rfcs/2024-10-27-server-operators.md @@ -0,0 +1,24 @@ +# Server operators + +## Problem + +All preconfigured servers operated by a single company create a risk that user connections can be analysed by aggregating transport information from these servers. + +The solution is to have more than one operator servers pre-configured in the app. + +For operators to be protected from any violations of rights of other users or third parties by the users who use servers of these operators, the users have to explicitely accept conditions of use with the operator, in the same way they accept conditions of use with SimpleX Chat Ltd by downloading the app. + +## Solution + +Allow to assign operators to servers, both with preconfigured operators and servers, and with user-defined operators. Agent added support for server roles, chat app could: +- allow assigning server roles only on the operator level. +- only on server level. +- on both, with server roles overriding operator roles (that would require a different type for server for chat app). + +For simplicity of both UX and logic it is probably better to allow assigning roles only on operators' level, and servers without set operators can be used for both roles. + +For agreements, it is sufficient to record the signatures of these agreements on users' devices, together with the copy of signed agreement (or its hash and version) in a separate table. The terms themselves could be: +- included in the app - either in code or in migration. +- referenced with a stable link to a particular commit. + +The first solution seems better, as it avoids any third party dependency, and the agreement size is relatively small (~31kb), to reduce size we can store it compressed. diff --git a/package.yaml b/package.yaml index 94dc13ad2e..2fc50a3532 100644 --- a/package.yaml +++ b/package.yaml @@ -29,6 +29,7 @@ dependencies: - email-validate == 2.3.* - exceptions == 0.10.* - filepath == 1.4.* + - file-embed == 0.0.15.* - http-types == 0.12.* - http2 >= 4.2.2 && < 4.3 - memory == 0.18.* diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8de91675e3..3e0f103641 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ffecf200d4874dfa34f6d15b269964c0115a54ca" = "0kb8hq37fc5g198wq7dswnlwjzk67q8rrzil2dii5lc6xfr47jbs"; + "https://github.com/simplex-chat/simplexmq.git"."ff05a465ee15ac7ae2c14a9fb703a18564950631" = "1gv4nwqzbqkj7y3ffkiwkr4qwv52vdzppsds5vsfqaayl14rzmgp"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 96d16f5004..c7d603457c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -150,10 +150,13 @@ library Simplex.Chat.Migrations.M20240920_user_order Simplex.Chat.Migrations.M20241008_indexes Simplex.Chat.Migrations.M20241010_contact_requests_contact_id + Simplex.Chat.Migrations.M20241027_server_operators Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.WebRTC + Simplex.Chat.Operators + Simplex.Chat.Operators.Conditions Simplex.Chat.Options Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol @@ -213,6 +216,7 @@ library , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -276,6 +280,7 @@ executable simplex-bot , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -340,6 +345,7 @@ executable simplex-bot-advanced , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -407,6 +413,7 @@ executable simplex-broadcast-bot , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -472,6 +479,7 @@ executable simplex-chat , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -543,6 +551,7 @@ executable simplex-directory-service , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 @@ -642,6 +651,7 @@ test-suite simplex-chat-test , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* + , file-embed ==0.0.15.* , filepath ==1.4.* , generic-random ==1.5.* , http-types ==0.12.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 885d4303c8..380f6c5d24 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -67,6 +67,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events +import Simplex.Chat.Operators import Simplex.Chat.Options import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol @@ -97,7 +98,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), OperatorId, ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -152,7 +153,7 @@ defaultChatConfig = { smp = _defaultSMPServers, useSMP = 4, ntf = _defaultNtfServers, - xftp = L.map (presetServerCfg True) defaultXFTPServers, + xftp = L.map (presetServerCfg True allRoles operatorSimpleXChat) defaultXFTPServers, useXFTP = L.length defaultXFTPServers, netCfg = defaultNetworkConfig }, @@ -181,7 +182,7 @@ _defaultSMPServers :: NonEmpty (ServerCfg 'PSMP) _defaultSMPServers = L.fromList $ map - (presetServerCfg True) + (presetServerCfg True allRoles operatorSimpleXChat) [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", @@ -195,12 +196,15 @@ _defaultSMPServers = "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" ] <> map - (presetServerCfg False) + (presetServerCfg False allRoles operatorSimpleXChat) [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] +operatorSimpleXChat :: Maybe OperatorId +operatorSimpleXChat = Just 1 + _defaultNtfServers :: [NtfServer] _defaultNtfServers = [ "ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,5ex3mupcazy3zlky64ab27phjhijpemsiby33qzq3pliejipbtx5xgad.onion" @@ -1484,8 +1488,11 @@ processChatCommand' vr = \case pure $ CRConnNtfMessages ntfMsgs APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do cfg@ChatConfig {defaultServers} <- asks config - servers <- withFastStore' (`getProtocolServers` user) - pure $ CRUserProtoServers user $ AUPS $ UserProtoServers p (useServers cfg p servers) (cfgServers p defaultServers) + srvs <- withFastStore' (`getProtocolServers` user) + ts <- liftIO getCurrentTime + operators <- withFastStore' $ \db -> getServerOperators db ts + let servers = AUPS $ UserProtoServers p (useServers cfg p srvs) (cfgServers p defaultServers) + pure $ CRUserProtoServers {user, servers, operators} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) @@ -1501,6 +1508,37 @@ processChatCommand' vr = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv + APIGetServerOperators -> pure $ chatCmdError Nothing "not supported" + APISetServerOperators _operators -> pure $ chatCmdError Nothing "not supported" + APIGetUserServers userId -> withUserId userId $ \user -> + pure $ chatCmdError (Just user) "not supported" + APISetUserServers userId _userServers -> withUserId userId $ \user -> + pure $ chatCmdError (Just user) "not supported" + APIValidateServers _userServers -> + -- response is CRUserServersValidation + pure $ chatCmdError Nothing "not supported" + APIGetUsageConditions -> do + -- TODO + -- get current conditions + -- get latest accepted conditions (from operators) + ts <- liftIO getCurrentTime + let usageConditions = + UsageConditions + { conditionsId = 1, + conditionsCommit = "abc", + notifiedAt = Nothing, + createdAt = ts + } + pure + CRUsageConditions + { usageConditions = usageConditions, + conditionsText = usageConditionsText, + acceptedConditions = Nothing + } + APISetConditionsNotified _conditionsId -> do + pure $ chatCmdError Nothing "not supported" + APIAcceptConditions _conditionsId _opIds -> + pure $ chatCmdError Nothing "not supported" APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b39b4d7456..bd2cee3e50 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -57,6 +57,7 @@ import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types @@ -70,7 +71,7 @@ import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, OperatorId, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) @@ -352,6 +353,14 @@ data ChatCommand | SetUserProtoServers AProtoServersConfig | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth + | APIGetServerOperators + | APISetServerOperators (NonEmpty (OperatorId, Bool)) + | APIGetUserServers UserId + | APISetUserServers UserId (NonEmpty UserServers) + | APIValidateServers (NonEmpty UserServers) -- response is CRUserServersValidation + | APIGetUsageConditions + | APISetConditionsNotified Int64 + | APIAcceptConditions Int64 (NonEmpty OperatorId) | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId @@ -577,8 +586,12 @@ data ChatResponse | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserProtoServers {user :: User, servers :: AUserProtoServers} + | CRUserProtoServers {user :: User, servers :: AUserProtoServers, operators :: [ServerOperator]} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} + | CRServerOperators {operators :: [ServerOperator], conditionsAction :: UsageConditionsAction} + | CRUserServers {userServers :: [UserServers]} + | CRUserServersValidation {serverErrors :: [UserServersError]} + | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} @@ -948,6 +961,12 @@ data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) ( deriving instance Show AProtoServersConfig +data UserServersError + = USEStorageMissing + | USEProxyMissing + | USEDuplicate {server :: AProtoServerWithAuth} + deriving (Show) + data UserProtoServers p = UserProtoServers { serverProtocol :: SProtocolType p, protoServers :: NonEmpty (ServerCfg p), @@ -1526,6 +1545,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) + $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) $(JQ.deriveJSON defaultJSON ''ContactSubStatus) diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs new file mode 100644 index 0000000000..bc9f40bddf --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -0,0 +1,70 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241027_server_operators where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241027_server_operators :: Query +m20241027_server_operators = + [sql| +CREATE TABLE server_operators ( + server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + role_storage INTEGER NOT NULL DEFAULT 1, + role_proxy INTEGER NOT NULL DEFAULT 1, + accepted_conditions_commit TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +ALTER TABLE protocol_servers ADD COLUMN server_operator_id INTEGER REFERENCES server_operators ON DELETE SET NULL; + +CREATE TABLE usage_conditions ( + usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE operator_usage_conditions ( + operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_id INTEGER REFERENCES server_operators (server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_protocol_servers_server_operator_id ON protocol_servers(server_operator_id); +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(server_operator_id); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(server_operator_id, conditions_commit); + +INSERT INTO server_operators + (server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled) + VALUES (1, 'simplex', 'SimpleX Chat', 'SimpleX Chat Ltd', 'simplex.im', 1); +INSERT INTO server_operators + (server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled) + VALUES (2, 'xyz', 'XYZ', 'XYZ Ltd', 'xyz.com', 0); + +-- UPDATE protocol_servers SET server_operator_id = 1 WHERE host LIKE "%.simplex.im" OR host LIKE "%.simplex.im,%"; +|] + +down_m20241027_server_operators :: Query +down_m20241027_server_operators = + [sql| +DROP INDEX idx_operator_usage_conditions_conditions_commit; +DROP INDEX idx_operator_usage_conditions_server_operator_id; +DROP INDEX idx_protocol_servers_server_operator_id; + +ALTER TABLE protocol_servers DROP COLUMN server_operator_id; + +DROP TABLE operator_usage_conditions; +DROP TABLE usage_conditions; +DROP TABLE server_operators; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 2619a5c4e5..07c363eda9 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -450,6 +450,7 @@ CREATE TABLE IF NOT EXISTS "protocol_servers"( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), protocol TEXT NOT NULL DEFAULT 'smp', + server_operator_id INTEGER REFERENCES server_operators ON DELETE SET NULL, UNIQUE(user_id, host, port) ); CREATE TABLE xftp_file_descriptions( @@ -589,6 +590,34 @@ CREATE TABLE note_folders( unread_chat INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE app_settings(app_settings TEXT NOT NULL); +CREATE TABLE server_operators( + server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + role_storage INTEGER NOT NULL DEFAULT 1, + role_proxy INTEGER NOT NULL DEFAULT 1, + accepted_conditions_commit TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE usage_conditions( + usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE operator_usage_conditions( + operator_usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, + server_operator_id INTEGER REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -890,3 +919,13 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes( group_member_id ); CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id); +CREATE INDEX idx_protocol_servers_server_operator_id ON protocol_servers( + server_operator_id +); +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions( + server_operator_id +); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions( + server_operator_id, + conditions_commit +); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs new file mode 100644 index 0000000000..9a2dac0b1b --- /dev/null +++ b/src/Simplex/Chat/Operators.hs @@ -0,0 +1,110 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Operators where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ +import Data.FileEmbed +import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import Language.Haskell.TH.Syntax (lift) +import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Types.Util (textParseJSON) +import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerRoles) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolType (..)) +import Simplex.Messaging.Util (safeDecodeUtf8) + +usageConditionsCommit :: Text +usageConditionsCommit = "165143a1112308c035ac00ed669b96b60599aa1c" + +usageConditionsText :: Text +usageConditionsText = + $( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md") + in [|stripFrontMatter (safeDecodeUtf8 $(lift s))|] + ) + +data OperatorTag = OTSimplex | OTXyz + deriving (Show) + +instance FromField OperatorTag where fromField = fromTextField_ textDecode + +instance ToField OperatorTag where toField = toField . textEncode + +instance FromJSON OperatorTag where + parseJSON = textParseJSON "OperatorTag" + +instance ToJSON OperatorTag where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance TextEncoding OperatorTag where + textDecode = \case + "simplex" -> Just OTSimplex + "xyz" -> Just OTXyz + _ -> Nothing + textEncode = \case + OTSimplex -> "simplex" + OTXyz -> "xyz" + +data UsageConditions = UsageConditions + { conditionsId :: Int64, + conditionsCommit :: Text, + notifiedAt :: Maybe UTCTime, + createdAt :: UTCTime + } + deriving (Show) + +data UsageConditionsAction + = UCAReview {operators :: [ServerOperator], deadline :: Maybe UTCTime, showNotice :: Bool} + | UCAAccepted {operators :: [ServerOperator]} + deriving (Show) + +-- TODO UI logic +usageConditionsAction :: UsageConditionsAction +usageConditionsAction = UCAAccepted [] + +data ConditionsAcceptance + = CAAccepted {acceptedAt :: UTCTime} + | CARequired {deadline :: Maybe UTCTime} + deriving (Show) + +data ServerOperator = ServerOperator + { operatorId :: OperatorId, + operatorTag :: Maybe OperatorTag, + tradeName :: Text, + legalName :: Maybe Text, + serverDomains :: [Text], + acceptedConditions :: ConditionsAcceptance, + enabled :: Bool, + roles :: ServerRoles + } + deriving (Show) + +data UserServers = UserServers + { operator :: ServerOperator, + smpServers :: NonEmpty (ProtoServerWithAuth 'PSMP), + xftpServers :: NonEmpty (ProtoServerWithAuth 'PXFTP) + } + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''UsageConditions) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) + +$(JQ.deriveJSON defaultJSON ''ServerOperator) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) + +$(JQ.deriveJSON defaultJSON ''UserServers) diff --git a/src/Simplex/Chat/Operators/Conditions.hs b/src/Simplex/Chat/Operators/Conditions.hs new file mode 100644 index 0000000000..55cf8b658d --- /dev/null +++ b/src/Simplex/Chat/Operators/Conditions.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Operators.Conditions where + +import Data.Char (isSpace) +import Data.Text (Text) +import qualified Data.Text as T + +stripFrontMatter :: Text -> Text +stripFrontMatter = + T.unlines + . dropWhile ("# " `T.isPrefixOf`) -- strip title + . dropWhile (T.all isSpace) + . dropWhile fm + . (\ls -> let ls' = dropWhile (not . fm) ls in if null ls' then ls else ls') + . dropWhile fm + . T.lines + where + fm = ("---" `T.isPrefixOf`) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index e2d12e78d7..e33f2336cc 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -114,6 +114,7 @@ import Simplex.Chat.Migrations.M20240827_calls_uuid import Simplex.Chat.Migrations.M20240920_user_order import Simplex.Chat.Migrations.M20241008_indexes import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id +import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -227,7 +228,8 @@ schemaMigrations = ("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid), ("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order), ("20241008_indexes", m20241008_indexes, Just down_m20241008_indexes), - ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id) + ("20241010_contact_requests_contact_id", m20241010_contact_requests_contact_id, Just down_m20241010_contact_requests_contact_id), + ("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index fb9774a54e..fe2cc737fb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -47,7 +47,9 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, + -- overwriteOperatorsAndServers, overwriteProtocolServers, + getServerOperators, createCall, deleteCalls, getCalls, @@ -76,6 +78,7 @@ import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call import Simplex.Chat.Messages +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared @@ -83,7 +86,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB @@ -521,20 +524,25 @@ getProtocolServers db User {userId} = <$> DB.query db [sql| - SELECT host, port, key_hash, basic_auth, preset, tested, enabled - FROM protocol_servers - WHERE user_id = ? AND protocol = ?; + SELECT s.host, s.port, s.key_hash, s.basic_auth, s.server_operator_id, s.preset, s.tested, s.enabled, o.role_storage, o.role_proxy + FROM protocol_servers s + LEFT JOIN server_operators o USING (server_operator_id) + WHERE s.user_id = ? AND s.protocol = ? |] (userId, decodeLatin1 $ strEncode protocol) where protocol = protocolTypeI @p - toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> ServerCfg p - toServerCfg (host, port, keyHash, auth_, preset, tested, enabled) = + toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Maybe OperatorId, Bool, Maybe Bool, Bool, Maybe Bool, Maybe Bool) -> ServerCfg p + toServerCfg (host, port, keyHash, auth_, operator, preset, tested, enabled, storage_, proxy_) = let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in ServerCfg {server, preset, tested, enabled} + roles = ServerRoles {storage = fromMaybe True storage_, proxy = fromMaybe True proxy_} + in ServerCfg {server, operator, preset, tested, enabled, roles} -overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () +-- overwriteOperatorsAndServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> Maybe [ServerOperator] -> [ServerCfg p] -> ExceptT StoreError IO [ServerCfg p] +-- overwriteOperatorsAndServers db user@User {userId} operators_ servers = do +overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = + -- liftIO $ mapM_ (updateServerOperators_ db) operators_ checkConstraint SEUniqueID . ExceptT $ do currentTs <- getCurrentTime DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol) @@ -549,9 +557,62 @@ overwriteProtocolServers db User {userId} servers = |] ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs)) pure $ Right () + -- Right <$> getProtocolServers db user where protocol = decodeLatin1 $ strEncode $ protocolTypeI @p +getServerOperators :: DB.Connection -> UTCTime -> IO [ServerOperator] +getServerOperators db ts = + map toOperator + <$> DB.query_ + db + [sql| + SELECT server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled, role_storage, role_proxy + FROM server_operators; + |] + where + -- TODO get conditions state + toOperator (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) = + let roles = ServerRoles {storage, proxy} + in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains = [domains], acceptedConditions = CAAccepted ts, enabled, roles} + +-- updateServerOperators_ :: DB.Connection -> [ServerOperator] -> IO [ServerOperator] +-- updateServerOperators_ db operators = do +-- DB.execute_ db "DELETE FROM server_operators WHERE preset = 0" +-- let (existing, new) = partition (isJust . operatorId) operators +-- existing' <- mapM (\op -> upsertExisting op $> op) existing +-- new' <- mapM insertNew new +-- pure $ existing' <> new' +-- where +-- upsertExisting ServerOperator {operatorId, name, preset, enabled, roles = ServerRoles {storage, proxy}} +-- | preset = +-- DB.execute +-- db +-- [sql| +-- UPDATE server_operators +-- SET enabled = ?, role_storage = ?, role_proxy = ? +-- WHERE server_operator_id = ? +-- |] +-- (enabled, storage, proxy, operatorId) +-- | otherwise = +-- DB.execute +-- db +-- [sql| +-- INSERT INTO server_operators (server_operator_id, name, preset, enabled, role_storage, role_proxy) +-- VALUES (?,?,?,?,?,?) +-- |] +-- (operatorId, name, preset, enabled, storage, proxy) +-- insertNew op@ServerOperator {name, preset, enabled, roles = ServerRoles {storage, proxy}} = do +-- DB.execute +-- db +-- [sql| +-- INSERT INTO server_operators (name, preset, enabled, role_storage, role_proxy) +-- VALUES (?,?,?,?,?) +-- |] +-- (name, preset, enabled, storage, proxy) +-- opId <- insertedRowId db +-- pure op {operatorId = Just opId} + createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do currentTs <- getCurrentTime diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 5cc695db04..e38a34d45f 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -13,7 +13,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB -import Simplex.Chat (defaultChatConfig) +import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) @@ -21,7 +21,7 @@ import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent.Env.SQLite (presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (allRoles, presetServerCfg) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) import System.IO (hFlush, hSetEcho, stdin, stdout) @@ -34,14 +34,14 @@ terminalChatConfig = { smp = L.fromList $ map - (presetServerCfg True) + (presetServerCfg True allRoles operatorSimpleXChat) [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ], useSMP = 3, ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], - xftp = L.map (presetServerCfg True) defaultXFTPServers, + xftp = L.map (presetServerCfg True allRoles operatorSimpleXChat) defaultXFTPServers, useXFTP = L.length defaultXFTPServers, netCfg = defaultNetworkConfig diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ade36476c7..c53e5a2749 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) -import Data.List (groupBy, intercalate, intersperse, partition, sortOn) +import Data.List (foldl', groupBy, intercalate, intersperse, partition, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -42,6 +42,7 @@ import Simplex.Chat.Help import Simplex.Chat.Markdown import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types @@ -95,8 +96,12 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChats chats -> viewChats ts tz chats CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiParsedMarkdown ft -> [viewJSON ft] - CRUserProtoServers u userServers -> ttyUser u $ viewUserServers userServers testView + CRUserProtoServers u userServers operators -> ttyUser u $ viewUserServers userServers operators testView CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure + CRServerOperators {} -> [] + CRUserServers {} -> [] + CRUserServersValidation _ -> [] + CRUsageConditions {} -> [] CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile @@ -1209,8 +1214,8 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] -viewUserServers :: AUserProtoServers -> Bool -> [StyledString] -viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) testView = +viewUserServers :: AUserProtoServers -> [ServerOperator] -> Bool -> [StyledString] +viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) operators testView = customServers <> if testView then [] @@ -1228,8 +1233,8 @@ viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, preset pName = protocolName p customServers = if null protoServers - then ("no " <> pName <> " servers saved, using presets: ") : viewServers presetServers - else viewServers protoServers + then ("no " <> pName <> " servers saved, using presets: ") : viewServers operators presetServers + else viewServers operators protoServers protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode @@ -1326,8 +1331,11 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] <> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] -viewServers :: ProtocolTypeI p => NonEmpty (ServerCfg p) -> [StyledString] -viewServers = map (plain . B.unpack . strEncode . (\ServerCfg {server} -> server)) . L.toList +viewServers :: ProtocolTypeI p => [ServerOperator] -> NonEmpty (ServerCfg p) -> [StyledString] +viewServers operators = map (plain . (\ServerCfg {server, operator} -> B.unpack (strEncode server) <> viewOperator operator)) . L.toList + where + ops :: Map (Maybe Int64) Text = foldl' (\m ServerOperator {operatorId, tradeName} -> M.insert (Just operatorId) tradeName m) M.empty operators + viewOperator = maybe "" $ \op -> " (operator " <> maybe (show op) T.unpack (M.lookup (Just op) ops) <> ")" viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 75b85d7a5f..d435af186e 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -423,11 +423,10 @@ smpServerCfg = ServerConfig { transports = [(serverPort, transport @TLS, False)], tbqSize = 1, - -- serverTbqSize = 1, - msgQueueQuota = 16, msgStoreType = AMSType SMSMemory, - maxJournalMsgCount = 1000, - maxJournalStateLines = 1000, + msgQueueQuota = 16, + maxJournalMsgCount = 24, + maxJournalStateLines = 4, queueIdBytes = 12, msgIdBytes = 6, storeLogFile = Nothing, diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index 0c6baa71bb..e0b1939c9e 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -9,7 +9,7 @@ import Control.Monad (replicateM) import qualified Data.List.NonEmpty as L import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers) import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..)) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol) import Test.Hspec @@ -18,6 +18,8 @@ randomServersTests = describe "choosig random servers" $ do it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers it "should keep all 6 XFTP servers" testRandomXFTPServers +deriving instance Eq ServerRoles + deriving instance Eq (ServerCfg p) testRandomSMPServers :: IO () From bdaec30fa084e4c18964035d432abc42a55288a7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 4 Nov 2024 21:11:03 +0400 Subject: [PATCH 03/34] core: getServerOperators, getUserServers, getUsageConditions apis wip (#5141) --- src/Simplex/Chat.hs | 31 +++++++------ src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Operators.hs | 38 ++++++++++++---- src/Simplex/Chat/Store/Profiles.hs | 73 ++++++++++++++++++++++++------ src/Simplex/Chat/Store/Shared.hs | 1 + 5 files changed, 107 insertions(+), 38 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 380f6c5d24..bd165ea5e6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1489,8 +1489,7 @@ processChatCommand' vr = \case APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do cfg@ChatConfig {defaultServers} <- asks config srvs <- withFastStore' (`getProtocolServers` user) - ts <- liftIO getCurrentTime - operators <- withFastStore' $ \db -> getServerOperators db ts + operators <- withFastStore $ \db -> getServerOperators db let servers = AUPS $ UserProtoServers p (useServers cfg p srvs) (cfgServers p defaultServers) pure $ CRUserProtoServers {user, servers, operators} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> @@ -1508,27 +1507,31 @@ processChatCommand' vr = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv - APIGetServerOperators -> pure $ chatCmdError Nothing "not supported" + APIGetServerOperators -> do + operators <- withFastStore $ \db -> getServerOperators db + let conditionsAction = usageConditionsAction operators + pure $ CRServerOperators operators conditionsAction APISetServerOperators _operators -> pure $ chatCmdError Nothing "not supported" - APIGetUserServers userId -> withUserId userId $ \user -> - pure $ chatCmdError (Just user) "not supported" + APIGetUserServers userId -> withUserId userId $ \user -> do + (operators, smpServers, xftpServers) <- withFastStore $ \db -> do + operators <- getServerOperators db + smpServers <- liftIO $ getServers db user SPSMP + xftpServers <- liftIO $ getServers db user SPXFTP + pure (operators, smpServers, xftpServers) + let userServers = groupByOperator operators smpServers xftpServers + pure $ CRUserServers user userServers + where + getServers :: (ProtocolTypeI p) => DB.Connection -> User -> SProtocolType p -> IO [ServerCfg p] + getServers db user _p = getProtocolServers db user APISetUserServers userId _userServers -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "not supported" APIValidateServers _userServers -> -- response is CRUserServersValidation pure $ chatCmdError Nothing "not supported" APIGetUsageConditions -> do + usageConditions <- withFastStore $ \db -> getCurrentUsageConditions db -- TODO - -- get current conditions -- get latest accepted conditions (from operators) - ts <- liftIO getCurrentTime - let usageConditions = - UsageConditions - { conditionsId = 1, - conditionsCommit = "abc", - notifiedAt = Nothing, - createdAt = ts - } pure CRUsageConditions { usageConditions = usageConditions, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bd2cee3e50..2cb8e0cd42 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -589,7 +589,7 @@ data ChatResponse | CRUserProtoServers {user :: User, servers :: AUserProtoServers, operators :: [ServerOperator]} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperators {operators :: [ServerOperator], conditionsAction :: UsageConditionsAction} - | CRUserServers {userServers :: [UserServers]} + | CRUserServers {user :: User, userServers :: [UserServers]} | CRUserServersValidation {serverErrors :: [UserServersError]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 9a2dac0b1b..ff110e2ada 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -12,7 +13,9 @@ import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import Data.FileEmbed import Data.Int (Int64) -import Data.List.NonEmpty (NonEmpty) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) @@ -20,10 +23,10 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types.Util (textParseJSON) -import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerRoles) +import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolType (..)) +import Simplex.Messaging.Protocol (ProtocolType (..)) import Simplex.Messaging.Util (safeDecodeUtf8) usageConditionsCommit :: Text @@ -72,8 +75,8 @@ data UsageConditionsAction deriving (Show) -- TODO UI logic -usageConditionsAction :: UsageConditionsAction -usageConditionsAction = UCAAccepted [] +usageConditionsAction :: [ServerOperator] -> UsageConditionsAction +usageConditionsAction _operators = UCAAccepted [] data ConditionsAcceptance = CAAccepted {acceptedAt :: UTCTime} @@ -93,12 +96,31 @@ data ServerOperator = ServerOperator deriving (Show) data UserServers = UserServers - { operator :: ServerOperator, - smpServers :: NonEmpty (ProtoServerWithAuth 'PSMP), - xftpServers :: NonEmpty (ProtoServerWithAuth 'PXFTP) + { operator :: Maybe ServerOperator, + smpServers :: [ServerCfg 'PSMP], + xftpServers :: [ServerCfg 'PXFTP] } deriving (Show) +groupByOperator :: [ServerOperator] -> [ServerCfg 'PSMP] -> [ServerCfg 'PXFTP] -> [UserServers] +groupByOperator srvOperators smpSrvs xftpSrvs = + map createOperatorServers (M.toList combinedMap) + where + srvOperatorId :: ServerCfg p -> Maybe Int64 + srvOperatorId ServerCfg {operator} = operator + operatorMap :: Map (Maybe Int64) (Maybe ServerOperator) + operatorMap = M.fromList [(Just (operatorId op), Just op) | op <- srvOperators] `M.union` M.singleton Nothing Nothing + initialMap :: Map (Maybe Int64) ([ServerCfg 'PSMP], [ServerCfg 'PXFTP]) + initialMap = M.fromList [(key, ([], [])) | key <- M.keys operatorMap] + smpsMap = foldr (\server acc -> M.adjust (\(smps, xftps) -> (server : smps, xftps)) (srvOperatorId server) acc) initialMap smpSrvs + combinedMap = foldr (\server acc -> M.adjust (\(smps, xftps) -> (smps, server : xftps)) (srvOperatorId server) acc) smpsMap xftpSrvs + createOperatorServers (key, (groupedSmps, groupedXftps)) = + UserServers + { operator = fromMaybe Nothing (M.lookup key operatorMap), + smpServers = groupedSmps, + xftpServers = groupedXftps + } + $(JQ.deriveJSON defaultJSON ''UsageConditions) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index fe2cc737fb..d6627505f3 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,6 +50,7 @@ module Simplex.Chat.Store.Profiles -- overwriteOperatorsAndServers, overwriteProtocolServers, getServerOperators, + getCurrentUsageConditions, createCall, deleteCalls, getCalls, @@ -73,7 +74,8 @@ import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time (addUTCTime) +import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call @@ -540,7 +542,7 @@ getProtocolServers db User {userId} = -- overwriteOperatorsAndServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> Maybe [ServerOperator] -> [ServerCfg p] -> ExceptT StoreError IO [ServerCfg p] -- overwriteOperatorsAndServers db user@User {userId} operators_ servers = do -overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () +overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () overwriteProtocolServers db User {userId} servers = -- liftIO $ mapM_ (updateServerOperators_ db) operators_ checkConstraint SEUniqueID . ExceptT $ do @@ -556,25 +558,66 @@ overwriteProtocolServers db User {userId} servers = VALUES (?,?,?,?,?,?,?,?,?,?,?) |] ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs)) - pure $ Right () -- Right <$> getProtocolServers db user + pure $ Right () where protocol = decodeLatin1 $ strEncode $ protocolTypeI @p -getServerOperators :: DB.Connection -> UTCTime -> IO [ServerOperator] -getServerOperators db ts = - map toOperator - <$> DB.query_ - db - [sql| - SELECT server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled, role_storage, role_proxy - FROM server_operators; +getServerOperators :: DB.Connection -> ExceptT StoreError IO [ServerOperator] +getServerOperators db = do + conditions <- getCurrentUsageConditions db + liftIO $ + map (toOperator conditions) + <$> DB.query_ + db + [sql| + SELECT + so.server_operator_id, so.server_operator_tag, so.trade_name, so.legal_name, + so.server_domains, so.enabled, so.role_storage, so.role_proxy, + LastOperatorConditions.conditions_commit, LastOperatorConditions.accepted_at + FROM server_operators so + LEFT JOIN ( + SELECT server_operator_id, conditions_commit, accepted_at, MAX(operator_usage_conditions_id) + FROM operator_usage_conditions + GROUP BY server_operator_id + ) LastOperatorConditions ON LastOperatorConditions.server_operator_id = so.server_operator_id |] where - -- TODO get conditions state - toOperator (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) = - let roles = ServerRoles {storage, proxy} - in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains = [domains], acceptedConditions = CAAccepted ts, enabled, roles} + toOperator :: + UsageConditions -> + ( (OperatorId, Maybe OperatorTag, Text, Maybe Text, Text, Bool, Bool, Bool) + :. (Maybe Text, Maybe UTCTime) + ) -> + ServerOperator + toOperator + UsageConditions {conditionsCommit, createdAt} + ( (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) + :. (operatorConditionsCommit_, acceptedAt_) + ) = + let roles = ServerRoles {storage, proxy} + acceptedConditions = case (operatorConditionsCommit_, acceptedAt_) of + (Nothing, _) -> CARequired Nothing + (Just operatorConditionsCommit, Just acceptedAt) + | conditionsCommit == operatorConditionsCommit -> CAAccepted acceptedAt + _ -> CARequired (Just $ conditionsDeadline createdAt) + in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains = [domains], acceptedConditions, enabled, roles} + conditionsDeadline :: UTCTime -> UTCTime + conditionsDeadline = addUTCTime (31 * nominalDay) + +getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions +getCurrentUsageConditions db = + ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ + DB.query_ + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + ORDER BY usage_conditions_id DESC LIMIT 1 + |] + +toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions +toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) = + UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} -- updateServerOperators_ :: DB.Connection -> [ServerOperator] -> IO [ServerOperator] -- updateServerOperators_ db operators = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index f9a8685ec8..083079e2ea 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -127,6 +127,7 @@ data StoreError | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} + | SEUsageConditionsNotFound deriving (Show, Exception) $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) From 3b0205b25f5a3377d0b5c6162f21d6cc82b4565a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:15:20 +0400 Subject: [PATCH 04/34] core: setServerOperators, getUsageConditions api wip (#5145) --- src/Simplex/Chat.hs | 16 ++-- src/Simplex/Chat/Controller.hs | 2 +- .../Migrations/M20241027_server_operators.hs | 10 +- src/Simplex/Chat/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Operators.hs | 15 ++- src/Simplex/Chat/Store/Profiles.hs | 91 ++++++++++++++++--- 6 files changed, 105 insertions(+), 31 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bd165ea5e6..b083134e2c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1511,7 +1511,10 @@ processChatCommand' vr = \case operators <- withFastStore $ \db -> getServerOperators db let conditionsAction = usageConditionsAction operators pure $ CRServerOperators operators conditionsAction - APISetServerOperators _operators -> pure $ chatCmdError Nothing "not supported" + APISetServerOperators operatorsEnabled -> do + operators <- withFastStore $ \db -> setServerOperators db operatorsEnabled + let conditionsAction = usageConditionsAction operators + pure $ CRServerOperators operators conditionsAction APIGetUserServers userId -> withUserId userId $ \user -> do (operators, smpServers, xftpServers) <- withFastStore $ \db -> do operators <- getServerOperators db @@ -1529,14 +1532,15 @@ processChatCommand' vr = \case -- response is CRUserServersValidation pure $ chatCmdError Nothing "not supported" APIGetUsageConditions -> do - usageConditions <- withFastStore $ \db -> getCurrentUsageConditions db - -- TODO - -- get latest accepted conditions (from operators) + (usageConditions, acceptedConditions) <- withFastStore $ \db -> do + usageConditions <- getCurrentUsageConditions db + acceptedConditions <- getLatestAcceptedConditions db + pure (usageConditions, acceptedConditions) pure CRUsageConditions - { usageConditions = usageConditions, + { usageConditions, conditionsText = usageConditionsText, - acceptedConditions = Nothing + acceptedConditions } APISetConditionsNotified _conditionsId -> do pure $ chatCmdError Nothing "not supported" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2cb8e0cd42..81e7a9980b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -354,7 +354,7 @@ data ChatCommand | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth | APIGetServerOperators - | APISetServerOperators (NonEmpty (OperatorId, Bool)) + | APISetServerOperators (NonEmpty OperatorEnabled) | APIGetUserServers UserId | APISetUserServers UserId (NonEmpty UserServers) | APIValidateServers (NonEmpty UserServers) -- response is CRUserServersValidation diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs index bc9f40bddf..fc0ca21e54 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -11,13 +11,13 @@ m20241027_server_operators = CREATE TABLE server_operators ( server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, server_operator_tag TEXT, + app_vendor INTEGER NOT NULL, trade_name TEXT NOT NULL, legal_name TEXT, server_domains TEXT, enabled INTEGER NOT NULL DEFAULT 1, role_storage INTEGER NOT NULL DEFAULT 1, role_proxy INTEGER NOT NULL DEFAULT 1, - accepted_conditions_commit TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -46,11 +46,11 @@ CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_ CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(server_operator_id, conditions_commit); INSERT INTO server_operators - (server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled) - VALUES (1, 'simplex', 'SimpleX Chat', 'SimpleX Chat Ltd', 'simplex.im', 1); + (server_operator_id, server_operator_tag, app_vendor, trade_name, legal_name, server_domains, enabled) + VALUES (1, 'simplex', 1, 'SimpleX Chat', 'SimpleX Chat Ltd', 'simplex.im', 1); INSERT INTO server_operators - (server_operator_id, server_operator_tag, trade_name, legal_name, server_domains, enabled) - VALUES (2, 'xyz', 'XYZ', 'XYZ Ltd', 'xyz.com', 0); + (server_operator_id, server_operator_tag, app_vendor, trade_name, legal_name, server_domains, enabled) + VALUES (2, 'xyz', 0, 'XYZ', 'XYZ Ltd', 'xyz.com', 0); -- UPDATE protocol_servers SET server_operator_id = 1 WHERE host LIKE "%.simplex.im" OR host LIKE "%.simplex.im,%"; |] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 07c363eda9..1541f36b60 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -593,13 +593,13 @@ CREATE TABLE app_settings(app_settings TEXT NOT NULL); CREATE TABLE server_operators( server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, server_operator_tag TEXT, + app_vendor INTEGER NOT NULL, trade_name TEXT NOT NULL, legal_name TEXT, server_domains TEXT, enabled INTEGER NOT NULL DEFAULT 1, role_storage INTEGER NOT NULL DEFAULT 1, role_proxy INTEGER NOT NULL DEFAULT 1, - accepted_conditions_commit TEXT, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index ff110e2ada..6fc5663085 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -79,7 +79,7 @@ usageConditionsAction :: [ServerOperator] -> UsageConditionsAction usageConditionsAction _operators = UCAAccepted [] data ConditionsAcceptance - = CAAccepted {acceptedAt :: UTCTime} + = CAAccepted {acceptedAt :: Maybe UTCTime} | CARequired {deadline :: Maybe UTCTime} deriving (Show) @@ -89,7 +89,14 @@ data ServerOperator = ServerOperator tradeName :: Text, legalName :: Maybe Text, serverDomains :: [Text], - acceptedConditions :: ConditionsAcceptance, + conditionsAcceptance :: ConditionsAcceptance, + enabled :: Bool, + roles :: ServerRoles + } + deriving (Show) + +data OperatorEnabled = OperatorEnabled + { operatorId :: OperatorId, enabled :: Bool, roles :: ServerRoles } @@ -106,10 +113,10 @@ groupByOperator :: [ServerOperator] -> [ServerCfg 'PSMP] -> [ServerCfg 'PXFTP] - groupByOperator srvOperators smpSrvs xftpSrvs = map createOperatorServers (M.toList combinedMap) where - srvOperatorId :: ServerCfg p -> Maybe Int64 srvOperatorId ServerCfg {operator} = operator + opId ServerOperator {operatorId} = operatorId operatorMap :: Map (Maybe Int64) (Maybe ServerOperator) - operatorMap = M.fromList [(Just (operatorId op), Just op) | op <- srvOperators] `M.union` M.singleton Nothing Nothing + operatorMap = M.fromList [(Just (opId op), Just op) | op <- srvOperators] `M.union` M.singleton Nothing Nothing initialMap :: Map (Maybe Int64) ([ServerCfg 'PSMP], [ServerCfg 'PXFTP]) initialMap = M.fromList [(key, ([], [])) | key <- M.keys operatorMap] smpsMap = foldr (\server acc -> M.adjust (\(smps, xftps) -> (server : smps, xftps)) (srvOperatorId server) acc) initialMap smpSrvs diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index d6627505f3..259d08d9ad 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,7 +50,9 @@ module Simplex.Chat.Store.Profiles -- overwriteOperatorsAndServers, overwriteProtocolServers, getServerOperators, + setServerOperators, getCurrentUsageConditions, + getLatestAcceptedConditions, createCall, deleteCalls, getCalls, @@ -72,7 +74,7 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) -import Data.Text (Text) +import Data.Text (Text, splitOn) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) @@ -565,44 +567,80 @@ overwriteProtocolServers db User {userId} servers = getServerOperators :: DB.Connection -> ExceptT StoreError IO [ServerOperator] getServerOperators db = do - conditions <- getCurrentUsageConditions db + now <- liftIO getCurrentTime + currentConditions <- getCurrentUsageConditions db + latestAcceptedConditions <- getLatestAcceptedConditions db liftIO $ - map (toOperator conditions) + map (toOperator now currentConditions latestAcceptedConditions) <$> DB.query_ db [sql| SELECT so.server_operator_id, so.server_operator_tag, so.trade_name, so.legal_name, so.server_domains, so.enabled, so.role_storage, so.role_proxy, - LastOperatorConditions.conditions_commit, LastOperatorConditions.accepted_at + AcceptedConditions.conditions_commit, AcceptedConditions.accepted_at FROM server_operators so LEFT JOIN ( SELECT server_operator_id, conditions_commit, accepted_at, MAX(operator_usage_conditions_id) FROM operator_usage_conditions GROUP BY server_operator_id - ) LastOperatorConditions ON LastOperatorConditions.server_operator_id = so.server_operator_id + ) AcceptedConditions ON AcceptedConditions.server_operator_id = so.server_operator_id |] where toOperator :: + UTCTime -> UsageConditions -> + Maybe UsageConditions -> ( (OperatorId, Maybe OperatorTag, Text, Maybe Text, Text, Bool, Bool, Bool) :. (Maybe Text, Maybe UTCTime) ) -> ServerOperator toOperator - UsageConditions {conditionsCommit, createdAt} + now + UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} + latestAcceptedConditions_ ( (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) - :. (operatorConditionsCommit_, acceptedAt_) + :. (operatorCommit_, acceptedAt_) ) = let roles = ServerRoles {storage, proxy} - acceptedConditions = case (operatorConditionsCommit_, acceptedAt_) of + serverDomains = splitOn "," domains + conditionsAcceptance = case (latestAcceptedConditions_, operatorCommit_) of + -- no conditions were ever accepted for any operator(s) + -- (shouldn't happen as there should always be record for SimpleX Chat) (Nothing, _) -> CARequired Nothing - (Just operatorConditionsCommit, Just acceptedAt) - | conditionsCommit == operatorConditionsCommit -> CAAccepted acceptedAt - _ -> CARequired (Just $ conditionsDeadline createdAt) - in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains = [domains], acceptedConditions, enabled, roles} - conditionsDeadline :: UTCTime -> UTCTime - conditionsDeadline = addUTCTime (31 * nominalDay) + -- no conditions were ever accepted for this operator + (_, Nothing) -> CARequired Nothing + (Just UsageConditions {conditionsCommit = latestAcceptedCommit}, Just operatorCommit) + | latestAcceptedCommit == currentCommit -> + if operatorCommit == latestAcceptedCommit + then -- current conditions were accepted for operator + CAAccepted acceptedAt_ + else -- current conditions were NOT accepted for operator, but were accepted for other operator(s) + CARequired Nothing + | otherwise -> + if operatorCommit == latestAcceptedCommit + then -- new conditions available, latest accepted conditions were accepted for operator + conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + else -- new conditions available, latest accepted conditions were NOT accepted for operator (were accepted for other operator(s)) + CARequired Nothing + in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains, conditionsAcceptance, enabled, roles} + conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> ConditionsAcceptance + conditionsRequiredOrDeadline createdAt notifiedAtOrNow = + if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt + then CARequired (Just $ conditionsDeadline notifiedAtOrNow) + else CARequired Nothing + where + conditionsDeadline :: UTCTime -> UTCTime + conditionsDeadline = addUTCTime (31 * nominalDay) + +setServerOperators :: DB.Connection -> NonEmpty OperatorEnabled -> ExceptT StoreError IO [ServerOperator] +setServerOperators db operatorsEnabled = do + liftIO $ forM_ operatorsEnabled $ \OperatorEnabled {operatorId, enabled, roles = ServerRoles {storage, proxy}} -> + DB.execute + db + "UPDATE server_operators SET enabled = ?, role_storage = ?, role_proxy = ? WHERE server_operator_id = ?" + (enabled, storage, proxy, operatorId) + getServerOperators db getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions getCurrentUsageConditions db = @@ -619,6 +657,31 @@ toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) = UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} +getLatestAcceptedConditions :: DB.Connection -> ExceptT StoreError IO (Maybe UsageConditions) +getLatestAcceptedConditions db = do + (latestAcceptedCommit_ :: Maybe Text) <- + liftIO $ + maybeFirstRow fromOnly $ + DB.query_ + db + [sql| + SELECT conditions_commit + FROM operator_usage_conditions + WHERE conditions_accepted = 1 + ORDER BY accepted_at DESC + LIMIT 1 + |] + forM latestAcceptedCommit_ $ \latestAcceptedCommit -> + ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ + DB.query + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ? + |] + (Only latestAcceptedCommit) + -- updateServerOperators_ :: DB.Connection -> [ServerOperator] -> IO [ServerOperator] -- updateServerOperators_ db operators = do -- DB.execute_ db "DELETE FROM server_operators WHERE preset = 0" From 2da89c2cf1d155e228979c6460c5dddb0cca73c3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:40:33 +0400 Subject: [PATCH 05/34] core: setConditionsNotified, acceptConditions, setUserServers, validateServers apis wip (#5147) --- src/Simplex/Chat.hs | 39 +++++---- src/Simplex/Chat/Controller.hs | 14 +--- src/Simplex/Chat/Operators.hs | 65 +++++++++++++-- src/Simplex/Chat/Store/Profiles.hs | 127 ++++++++++++++++++++++------- 4 files changed, 181 insertions(+), 64 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b083134e2c..69b78ba9d4 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1489,7 +1489,7 @@ processChatCommand' vr = \case APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do cfg@ChatConfig {defaultServers} <- asks config srvs <- withFastStore' (`getProtocolServers` user) - operators <- withFastStore $ \db -> getServerOperators db + (operators, _) <- withFastStore $ \db -> getServerOperators db let servers = AUPS $ UserProtoServers p (useServers cfg p srvs) (cfgServers p defaultServers) pure $ CRUserProtoServers {user, servers, operators} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> @@ -1508,44 +1508,51 @@ processChatCommand' vr = \case TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv APIGetServerOperators -> do - operators <- withFastStore $ \db -> getServerOperators db - let conditionsAction = usageConditionsAction operators + (operators, conditionsAction) <- withFastStore $ \db -> getServerOperators db pure $ CRServerOperators operators conditionsAction APISetServerOperators operatorsEnabled -> do - operators <- withFastStore $ \db -> setServerOperators db operatorsEnabled - let conditionsAction = usageConditionsAction operators + (operators, conditionsAction) <- withFastStore $ \db -> setServerOperators db operatorsEnabled pure $ CRServerOperators operators conditionsAction APIGetUserServers userId -> withUserId userId $ \user -> do (operators, smpServers, xftpServers) <- withFastStore $ \db -> do - operators <- getServerOperators db + (operators, _) <- getServerOperators db smpServers <- liftIO $ getServers db user SPSMP xftpServers <- liftIO $ getServers db user SPXFTP pure (operators, smpServers, xftpServers) let userServers = groupByOperator operators smpServers xftpServers pure $ CRUserServers user userServers where - getServers :: (ProtocolTypeI p) => DB.Connection -> User -> SProtocolType p -> IO [ServerCfg p] + getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ServerCfg p] getServers db user _p = getProtocolServers db user - APISetUserServers userId _userServers -> withUserId userId $ \user -> - pure $ chatCmdError (Just user) "not supported" - APIValidateServers _userServers -> - -- response is CRUserServersValidation - pure $ chatCmdError Nothing "not supported" + APISetUserServers userId userServers -> withUserId userId $ \user -> do + let errors = validateUserServers userServers + unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) + withFastStore $ \db -> setUserServers db user userServers + -- TODO set protocol servers for agent + ok_ + APIValidateServers userServers -> do + let errors = validateUserServers userServers + pure $ CRUserServersValidation errors APIGetUsageConditions -> do (usageConditions, acceptedConditions) <- withFastStore $ \db -> do usageConditions <- getCurrentUsageConditions db acceptedConditions <- getLatestAcceptedConditions db pure (usageConditions, acceptedConditions) + -- TODO if db commit is different from source commit, conditionsText should be nothing in response pure CRUsageConditions { usageConditions, conditionsText = usageConditionsText, acceptedConditions } - APISetConditionsNotified _conditionsId -> do - pure $ chatCmdError Nothing "not supported" - APIAcceptConditions _conditionsId _opIds -> - pure $ chatCmdError Nothing "not supported" + APISetConditionsNotified conditionsId -> do + currentTs <- liftIO getCurrentTime + withFastStore' $ \db -> setConditionsNotified db conditionsId currentTs + ok_ + APIAcceptConditions conditionsId operators -> do + currentTs <- liftIO getCurrentTime + (operators', conditionsAction) <- withFastStore $ \db -> acceptConditions db conditionsId operators currentTs + pure $ CRServerOperators operators' conditionsAction APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 81e7a9980b..cbfa0969d4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -71,7 +71,7 @@ import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, OperatorId, ServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) @@ -360,7 +360,7 @@ data ChatCommand | APIValidateServers (NonEmpty UserServers) -- response is CRUserServersValidation | APIGetUsageConditions | APISetConditionsNotified Int64 - | APIAcceptConditions Int64 (NonEmpty OperatorId) + | APIAcceptConditions Int64 (NonEmpty ServerOperator) | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId @@ -588,7 +588,7 @@ data ChatResponse | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserProtoServers {user :: User, servers :: AUserProtoServers, operators :: [ServerOperator]} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} - | CRServerOperators {operators :: [ServerOperator], conditionsAction :: UsageConditionsAction} + | CRServerOperators {operators :: [ServerOperator], conditionsAction :: Maybe UsageConditionsAction} | CRUserServers {user :: User, userServers :: [UserServers]} | CRUserServersValidation {serverErrors :: [UserServersError]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} @@ -961,12 +961,6 @@ data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) ( deriving instance Show AProtoServersConfig -data UserServersError - = USEStorageMissing - | USEProxyMissing - | USEDuplicate {server :: AProtoServerWithAuth} - deriving (Show) - data UserProtoServers p = UserProtoServers { serverProtocol :: SProtocolType p, protoServers :: NonEmpty (ServerCfg p), @@ -1545,8 +1539,6 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) - $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) $(JQ.deriveJSON defaultJSON ''ContactSubStatus) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6fc5663085..5e32807ddc 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -13,20 +13,22 @@ import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import Data.FileEmbed import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isNothing) import Data.Text (Text) -import Data.Time.Clock (UTCTime) +import Data.Time (addUTCTime) +import Data.Time.Clock (UTCTime, nominalDay) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types.Util (textParseJSON) -import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles) +import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtocolType (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, ProtocolType (..)) import Simplex.Messaging.Util (safeDecodeUtf8) usageConditionsCommit :: Text @@ -74,9 +76,30 @@ data UsageConditionsAction | UCAAccepted {operators :: [ServerOperator]} deriving (Show) --- TODO UI logic -usageConditionsAction :: [ServerOperator] -> UsageConditionsAction -usageConditionsAction _operators = UCAAccepted [] +usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction +usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do + let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators + if null enabledOperators + then Nothing + else + if all conditionsAccepted enabledOperators + then + let acceptedForOperators = filter conditionsAccepted operators + in Just $ UCAAccepted acceptedForOperators + else + let acceptForOperators = filter (not . conditionsAccepted) enabledOperators + deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + showNotice = isNothing notifiedAt + in Just $ UCAReview acceptForOperators deadline showNotice + +conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> Maybe UTCTime +conditionsRequiredOrDeadline createdAt notifiedAtOrNow = + if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt + then Just $ conditionsDeadline notifiedAtOrNow + else Nothing -- required + where + conditionsDeadline :: UTCTime -> UTCTime + conditionsDeadline = addUTCTime (31 * nominalDay) data ConditionsAcceptance = CAAccepted {acceptedAt :: Maybe UTCTime} @@ -95,6 +118,11 @@ data ServerOperator = ServerOperator } deriving (Show) +conditionsAccepted :: ServerOperator -> Bool +conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of + CAAccepted {} -> True + _ -> False + data OperatorEnabled = OperatorEnabled { operatorId :: OperatorId, enabled :: Bool, @@ -128,6 +156,27 @@ groupByOperator srvOperators smpSrvs xftpSrvs = xftpServers = groupedXftps } +data UserServersError + = USEStorageMissing + | USEProxyMissing + | USEDuplicate {server :: AProtoServerWithAuth} + deriving (Show) + +validateUserServers :: NonEmpty UserServers -> [UserServersError] +validateUserServers userServers = + let storageMissing_ = if any (canUseForRole storage) userServers then [] else [USEStorageMissing] + proxyMissing_ = if any (canUseForRole proxy) userServers then [] else [USEProxyMissing] + -- TODO duplicate errors + -- allSMPServers = + -- map (\ServerCfg {server} -> server) $ + -- concatMap (\UserServers {smpServers} -> smpServers) userServers + in storageMissing_ <> proxyMissing_ -- <> duplicateErrors + where + canUseForRole :: (ServerRoles -> Bool) -> UserServers -> Bool + canUseForRole roleSel UserServers {operator, smpServers, xftpServers} = case operator of + Just ServerOperator {roles} -> roleSel roles + Nothing -> not (null smpServers) && not (null xftpServers) + $(JQ.deriveJSON defaultJSON ''UsageConditions) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) @@ -137,3 +186,5 @@ $(JQ.deriveJSON defaultJSON ''ServerOperator) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) $(JQ.deriveJSON defaultJSON ''UserServers) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 259d08d9ad..f4f574c3d7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -53,6 +53,9 @@ module Simplex.Chat.Store.Profiles setServerOperators, getCurrentUsageConditions, getLatestAcceptedConditions, + setConditionsNotified, + acceptConditions, + setUserServers, createCall, deleteCalls, getCalls, @@ -76,8 +79,7 @@ import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Text (Text, splitOn) import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Time (addUTCTime) -import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) +import Data.Time.Clock (UTCTime (..), getCurrentTime) import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call @@ -542,6 +544,7 @@ getProtocolServers db User {userId} = roles = ServerRoles {storage = fromMaybe True storage_, proxy = fromMaybe True proxy_} in ServerCfg {server, operator, preset, tested, enabled, roles} +-- TODO remove -- overwriteOperatorsAndServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> Maybe [ServerOperator] -> [ServerCfg p] -> ExceptT StoreError IO [ServerCfg p] -- overwriteOperatorsAndServers db user@User {userId} operators_ servers = do overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () @@ -565,27 +568,29 @@ overwriteProtocolServers db User {userId} servers = where protocol = decodeLatin1 $ strEncode $ protocolTypeI @p -getServerOperators :: DB.Connection -> ExceptT StoreError IO [ServerOperator] +getServerOperators :: DB.Connection -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) getServerOperators db = do now <- liftIO getCurrentTime currentConditions <- getCurrentUsageConditions db latestAcceptedConditions <- getLatestAcceptedConditions db - liftIO $ - map (toOperator now currentConditions latestAcceptedConditions) - <$> DB.query_ - db - [sql| - SELECT - so.server_operator_id, so.server_operator_tag, so.trade_name, so.legal_name, - so.server_domains, so.enabled, so.role_storage, so.role_proxy, - AcceptedConditions.conditions_commit, AcceptedConditions.accepted_at - FROM server_operators so - LEFT JOIN ( - SELECT server_operator_id, conditions_commit, accepted_at, MAX(operator_usage_conditions_id) - FROM operator_usage_conditions - GROUP BY server_operator_id - ) AcceptedConditions ON AcceptedConditions.server_operator_id = so.server_operator_id - |] + operators <- + liftIO $ + map (toOperator now currentConditions latestAcceptedConditions) + <$> DB.query_ + db + [sql| + SELECT + so.server_operator_id, so.server_operator_tag, so.trade_name, so.legal_name, + so.server_domains, so.enabled, so.role_storage, so.role_proxy, + AcceptedConditions.conditions_commit, AcceptedConditions.accepted_at + FROM server_operators so + LEFT JOIN ( + SELECT server_operator_id, conditions_commit, accepted_at, MAX(operator_usage_conditions_id) + FROM operator_usage_conditions + GROUP BY server_operator_id + ) AcceptedConditions ON AcceptedConditions.server_operator_id = so.server_operator_id + |] + pure (operators, usageConditionsAction operators currentConditions now) where toOperator :: UTCTime -> @@ -620,20 +625,12 @@ getServerOperators db = do | otherwise -> if operatorCommit == latestAcceptedCommit then -- new conditions available, latest accepted conditions were accepted for operator - conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) else -- new conditions available, latest accepted conditions were NOT accepted for operator (were accepted for other operator(s)) CARequired Nothing in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains, conditionsAcceptance, enabled, roles} - conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> ConditionsAcceptance - conditionsRequiredOrDeadline createdAt notifiedAtOrNow = - if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt - then CARequired (Just $ conditionsDeadline notifiedAtOrNow) - else CARequired Nothing - where - conditionsDeadline :: UTCTime -> UTCTime - conditionsDeadline = addUTCTime (31 * nominalDay) -setServerOperators :: DB.Connection -> NonEmpty OperatorEnabled -> ExceptT StoreError IO [ServerOperator] +setServerOperators :: DB.Connection -> NonEmpty OperatorEnabled -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) setServerOperators db operatorsEnabled = do liftIO $ forM_ operatorsEnabled $ \OperatorEnabled {operatorId, enabled, roles = ServerRoles {storage, proxy}} -> DB.execute @@ -667,7 +664,6 @@ getLatestAcceptedConditions db = do [sql| SELECT conditions_commit FROM operator_usage_conditions - WHERE conditions_accepted = 1 ORDER BY accepted_at DESC LIMIT 1 |] @@ -682,6 +678,77 @@ getLatestAcceptedConditions db = do |] (Only latestAcceptedCommit) +setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO () +setConditionsNotified db conditionsId notifiedAt = + DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, conditionsId) + +acceptConditions :: DB.Connection -> Int64 -> NonEmpty ServerOperator -> UTCTime -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) +acceptConditions db conditionsId operators acceptedAt = do + UsageConditions {conditionsCommit} <- getUsageConditionsById_ db conditionsId + liftIO $ forM_ operators $ \ServerOperator {operatorId, operatorTag} -> + DB.execute + db + [sql| + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at) + VALUES (?,?,?,?) + |] + (operatorId, operatorTag, conditionsCommit, acceptedAt) + getServerOperators db + +getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions +getUsageConditionsById_ db conditionsId = + ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ + DB.query + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE usage_conditions_id = ? + |] + (Only conditionsId) + +setUserServers :: DB.Connection -> User -> NonEmpty UserServers -> ExceptT StoreError IO () +setUserServers db User {userId} userServers = do + currentTs <- liftIO getCurrentTime + forM_ userServers $ do + \UserServers {operator, smpServers, xftpServers} -> do + forM_ operator $ \op -> liftIO $ updateOperator currentTs op + overwriteServers currentTs operator smpServers + overwriteServers currentTs operator xftpServers + where + updateOperator :: UTCTime -> ServerOperator -> IO () + updateOperator currentTs ServerOperator {operatorId, enabled, roles = ServerRoles {storage, proxy}} = + DB.execute + db + [sql| + UPDATE server_operators + SET enabled = ?, role_storage = ?, role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + |] + (enabled, storage, proxy, operatorId, currentTs) + overwriteServers :: forall p. ProtocolTypeI p => UTCTime -> Maybe ServerOperator -> [ServerCfg p] -> ExceptT StoreError IO () + overwriteServers currentTs serverOperator servers = + checkConstraint SEUniqueID . ExceptT $ do + case serverOperator of + Nothing -> + DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND server_operator_id IS NULL AND protocol = ?" (userId, protocol) + Just ServerOperator {operatorId} -> + DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND server_operator_id = ? AND protocol = ?" (userId, operatorId, protocol) + forM_ servers $ \ServerCfg {server, operator, preset, tested, enabled} -> do + let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server + DB.execute + db + [sql| + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, operator, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, operator) :. (preset, tested, enabled, userId, currentTs, currentTs)) + pure $ Right () + where + protocol = decodeLatin1 $ strEncode $ protocolTypeI @p + -- updateServerOperators_ :: DB.Connection -> [ServerOperator] -> IO [ServerOperator] -- updateServerOperators_ db operators = do -- DB.execute_ db "DELETE FROM server_operators WHERE preset = 0" From 8396e70e7b82b111f2d2e156d10a92dea4883319 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:13:08 +0400 Subject: [PATCH 06/34] core: validate servers - find servers with duplicate hosts (#5150) --- src/Simplex/Chat/Operators.hs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 5e32807ddc..cedc3ca6d1 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -14,6 +14,7 @@ import qualified Data.Aeson.TH as JQ import Data.FileEmbed import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isNothing) @@ -28,7 +29,7 @@ import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, ProtocolType (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), SProtocolType (..)) import Simplex.Messaging.Util (safeDecodeUtf8) usageConditionsCommit :: Text @@ -159,23 +160,34 @@ groupByOperator srvOperators smpSrvs xftpSrvs = data UserServersError = USEStorageMissing | USEProxyMissing - | USEDuplicate {server :: AProtoServerWithAuth} + | USEDuplicateSMP {server :: AProtoServerWithAuth} + | USEDuplicateXFTP {server :: AProtoServerWithAuth} deriving (Show) validateUserServers :: NonEmpty UserServers -> [UserServersError] validateUserServers userServers = let storageMissing_ = if any (canUseForRole storage) userServers then [] else [USEStorageMissing] proxyMissing_ = if any (canUseForRole proxy) userServers then [] else [USEProxyMissing] - -- TODO duplicate errors - -- allSMPServers = - -- map (\ServerCfg {server} -> server) $ - -- concatMap (\UserServers {smpServers} -> smpServers) userServers - in storageMissing_ <> proxyMissing_ -- <> duplicateErrors + + allSMPServers = map (\ServerCfg {server} -> server) $ concatMap (\UserServers {smpServers} -> smpServers) userServers + duplicateSMPServers = findDuplicatesByHost allSMPServers + duplicateSMPErrors = map (USEDuplicateSMP . AProtoServerWithAuth SPSMP) duplicateSMPServers + + allXFTPServers = map (\ServerCfg {server} -> server) $ concatMap (\UserServers {xftpServers} -> xftpServers) userServers + duplicateXFTPServers = findDuplicatesByHost allXFTPServers + duplicateXFTPErrors = map (USEDuplicateXFTP . AProtoServerWithAuth SPXFTP) duplicateXFTPServers + in storageMissing_ <> proxyMissing_ <> duplicateSMPErrors <> duplicateXFTPErrors where canUseForRole :: (ServerRoles -> Bool) -> UserServers -> Bool canUseForRole roleSel UserServers {operator, smpServers, xftpServers} = case operator of Just ServerOperator {roles} -> roleSel roles Nothing -> not (null smpServers) && not (null xftpServers) + findDuplicatesByHost :: [ProtoServerWithAuth p] -> [ProtoServerWithAuth p] + findDuplicatesByHost servers = + let allHosts = concatMap (L.toList . host . protoServer) servers + hostCounts = M.fromListWith (+) [(host, 1 :: Int) | host <- allHosts] + duplicateHosts = M.keys $ M.filter (> 1) hostCounts + in filter (\srv -> any (`elem` duplicateHosts) (L.toList $ host . protoServer $ srv)) servers $(JQ.deriveJSON defaultJSON ''UsageConditions) From ef0f21a11c082c9f3b9b5bc77d2d70c6bd9ee5ce Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:45:00 +0400 Subject: [PATCH 07/34] core: operator apis commands (#5155) --- src/Simplex/Chat.hs | 8 ++++++++ src/Simplex/Chat/Operators.hs | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 69b78ba9d4..dad3a6f813 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8140,6 +8140,14 @@ chatCommandP = "/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/_operators" $> APIGetServerOperators, + "/_operators " *> (APISetServerOperators <$> jsonP), + "/_user_servers " *> (APIGetUserServers <$> A.decimal), + "/_user_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), + "/_validate_servers " *> (APIValidateServers <$> jsonP), + "/_conditions" $> APIGetUsageConditions, + "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), + "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <* A.space <*> jsonP), "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), "/ttl " *> (SetChatItemTTL <$> ciTTL), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index cedc3ca6d1..b3f92caaf9 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -195,6 +195,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) $(JQ.deriveJSON defaultJSON ''ServerOperator) +$(JQ.deriveJSON defaultJSON ''OperatorEnabled) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) $(JQ.deriveJSON defaultJSON ''UserServers) From d42cab8e227db171ac4292e2523454fb925529f2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 14 Nov 2024 17:43:34 +0000 Subject: [PATCH 08/34] core: preset operators and servers (#5142) * core: preset servers and operators (WIP) * usageConditionsToAdd * simplify * WIP * database entity IDs * preset operators and servers (compiles) * update (most tests pass) * remove imports * fix * update * make preset servers lists potentially empty in some operators, as long as the combined list is not empty * CLI API in progress, validateUserServers * make servers of disabled operators "unknown", consider only enabled servers when switching profile links * exclude disabled operators when receiving files * fix TH in ghc 8.10.7 * add type for ghc 8.10.7 * pattern match for ghc 8.10.7 * ghc 8.10.7 fix attempt * remove additional pattern, update servers * do not strip title from conditions * remove space --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- cabal.project | 2 +- package.yaml | 3 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 21 +- src/Simplex/Chat.hs | 449 +++++++++------- src/Simplex/Chat/Controller.hs | 96 ++-- .../Migrations/M20241027_server_operators.hs | 18 +- src/Simplex/Chat/Migrations/chat_schema.sql | 9 +- src/Simplex/Chat/Operators.hs | 396 ++++++++++++--- src/Simplex/Chat/Operators/Conditions.hs | 2 +- src/Simplex/Chat/Stats.hs | 3 +- src/Simplex/Chat/Store/Profiles.hs | 478 ++++++++++-------- src/Simplex/Chat/Store/Shared.hs | 1 + src/Simplex/Chat/Terminal.hs | 37 +- src/Simplex/Chat/Terminal/Main.hs | 4 +- src/Simplex/Chat/View.hs | 115 +++-- tests/ChatClient.hs | 19 +- tests/ChatTests/Direct.hs | 77 ++- tests/ChatTests/Groups.hs | 2 + tests/ChatTests/Profiles.hs | 12 +- tests/RandomServers.hs | 51 +- 21 files changed, 1148 insertions(+), 649 deletions(-) diff --git a/cabal.project b/cabal.project index 61ce04a569..74f944c37d 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ff05a465ee15ac7ae2c14a9fb703a18564950631 + tag: 93f30c8edf9243ad2291dd6427d87328e282560a source-repository-package type: git diff --git a/package.yaml b/package.yaml index 2fc50a3532..4a95d52044 100644 --- a/package.yaml +++ b/package.yaml @@ -39,6 +39,7 @@ dependencies: - optparse-applicative >= 0.15 && < 0.17 - random >= 1.1 && < 1.3 - record-hasfield == 1.0.* + - scientific ==0.3.7.* - simple-logger == 0.1.* - simplexmq >= 5.0 - socks == 0.6.* @@ -73,7 +74,7 @@ when: - bytestring == 0.10.* - process >= 1.6 && < 1.6.18 - template-haskell == 2.16.* - - text >= 1.2.3.0 && < 1.3 + - text >= 1.2.4.0 && < 1.3 library: source-dirs: src diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3e0f103641..bd2602c1b6 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ff05a465ee15ac7ae2c14a9fb703a18564950631" = "1gv4nwqzbqkj7y3ffkiwkr4qwv52vdzppsds5vsfqaayl14rzmgp"; + "https://github.com/simplex-chat/simplexmq.git"."93f30c8edf9243ad2291dd6427d87328e282560a" = "1zf0sp9dy6kz4zvyz6mdgmhydps7khcq84n30irp983w1xh7gzs7"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 47987cd697..d3ea814011 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -228,6 +228,7 @@ library , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplexmq >=5.0 , socks ==0.6.* @@ -254,7 +255,7 @@ library bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-bot main-is: Main.hs @@ -292,6 +293,7 @@ executable simplex-bot , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -319,7 +321,7 @@ executable simplex-bot bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-bot-advanced main-is: Main.hs @@ -357,6 +359,7 @@ executable simplex-bot-advanced , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -384,7 +387,7 @@ executable simplex-bot-advanced bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot main-is: Main.hs @@ -425,6 +428,7 @@ executable simplex-broadcast-bot , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -452,7 +456,7 @@ executable simplex-broadcast-bot bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -491,6 +495,7 @@ executable simplex-chat , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -519,7 +524,7 @@ executable simplex-chat bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 executable simplex-directory-service main-is: Main.hs @@ -563,6 +568,7 @@ executable simplex-directory-service , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat , simplexmq >=5.0 @@ -590,7 +596,7 @@ executable simplex-directory-service bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test type: exitcode-stdio-1.0 @@ -664,6 +670,7 @@ test-suite simplex-chat-test , optparse-applicative >=0.15 && <0.17 , random >=1.1 && <1.3 , record-hasfield ==1.0.* + , scientific ==0.3.7.* , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat @@ -692,7 +699,7 @@ test-suite simplex-chat-test bytestring ==0.10.* , process >=1.6 && <1.6.18 , template-haskell ==2.16.* - , text >=1.2.3.0 && <1.3 + , text >=1.2.4.0 && <1.3 if impl(ghc >= 9.6.2) build-depends: hspec ==2.11.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c517aa52d5..86b6a5e51b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -6,6 +6,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE RankNTypes #-} @@ -43,7 +44,7 @@ import Data.Functor (($>)) import Data.Functor.Identity import Data.Int (Int64) import Data.List (find, foldl', isSuffixOf, mapAccumL, partition, sortOn, zipWith4) -import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|)) +import Data.List.NonEmpty (NonEmpty (..), (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -54,6 +55,7 @@ import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) +import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Data.Word (Word32) @@ -98,7 +100,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, getFastNetworkConfig, ipAddressProtected, withLockMap) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), OperatorId, ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, enabledServerCfg, presetServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -138,6 +140,32 @@ import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell, openFile) import UnliftIO.STM +operatorSimpleXChat :: NewServerOperator +operatorSimpleXChat = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTSimplex, + tradeName = "SimpleX Chat", + legalName = Just "SimpleX Chat Ltd", + serverDomains = ["simplex.im"], + conditionsAcceptance = CARequired Nothing, + enabled = True, + roles = allRoles + } + +operatorFlux :: NewServerOperator +operatorFlux = + ServerOperator + { operatorId = DBNewEntity, + operatorTag = Just OTFlux, + tradeName = "Flux", + legalName = Just "InFlux Technologies Limited", + serverDomains = ["simplexonflux.com"], + conditionsAcceptance = CARequired Nothing, + enabled = False, + roles = ServerRoles {storage = False, proxy = True} + } + defaultChatConfig :: ChatConfig defaultChatConfig = ChatConfig @@ -148,13 +176,25 @@ defaultChatConfig = }, chatVRange = supportedChatVRange, confirmMigrations = MCConsole, - defaultServers = - DefaultAgentServers - { smp = _defaultSMPServers, - useSMP = 4, + presetServers = + PresetServers + { operators = + [ PresetOperator + { operator = Just operatorSimpleXChat, + smp = simplexChatSMPServers, + useSMP = 4, + xftp = map (presetServer True) $ L.toList defaultXFTPServers, + useXFTP = 3 + }, + PresetOperator + { operator = Just operatorFlux, + smp = fluxSMPServers, + useSMP = 3, + xftp = fluxXFTPServers, + useXFTP = 3 + } + ], ntf = _defaultNtfServers, - xftp = L.map (presetServerCfg True allRoles operatorSimpleXChat) defaultXFTPServers, - useXFTP = L.length defaultXFTPServers, netCfg = defaultNetworkConfig }, tbqSize = 1024, @@ -178,32 +218,52 @@ defaultChatConfig = chatHooks = defaultChatHooks } -_defaultSMPServers :: NonEmpty (ServerCfg 'PSMP) -_defaultSMPServers = - L.fromList $ - map - (presetServerCfg True allRoles operatorSimpleXChat) - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", - "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", - "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", - "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", - "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", - "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", - "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", - "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", - "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" +simplexChatSMPServers :: [NewUserServer 'PSMP] +simplexChatSMPServers = + map + (presetServer True) + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion", + "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", + "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", + "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", + "smp://N_McQS3F9TGoh4ER0QstUf55kGnNSd-wXfNPZ7HukcM=@smp19.simplex.im,i53bbtoqhlc365k6kxzwdp5w3cdt433s7bwh3y32rcbml2vztiyyz5id.onion" + ] + <> map + (presetServer False) + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] - <> map - (presetServerCfg False allRoles operatorSimpleXChat) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ] -operatorSimpleXChat :: Maybe OperatorId -operatorSimpleXChat = Just 1 +fluxSMPServers :: [NewUserServer 'PSMP] +fluxSMPServers = + map + (presetServer True) + [ "smp://xQW_ufMkGE20UrTlBl8QqceG1tbuylXhr9VOLPyRJmw=@smp1.simplexonflux.com,qb4yoanyl4p7o33yrknv4rs6qo7ugeb2tu2zo66sbebezs4cpyosarid.onion", + "smp://LDnWZVlAUInmjmdpQQoIo6FUinRXGe0q3zi5okXDE4s=@smp2.simplexonflux.com,yiqtuh3q4x7hgovkomafsod52wvfjucdljqbbipg5sdssnklgongxbqd.onion", + "smp://1jne379u7IDJSxAvXbWb_JgoE7iabcslX0LBF22Rej0=@smp3.simplexonflux.com,a5lm4k7ufei66cdck6fy63r4lmkqy3dekmmb7jkfdm5ivi6kfaojshad.onion", + "smp://xmAmqj75I9mWrUihLUlI0ZuNLXlIwFIlHRq5Pb6cHAU=@smp4.simplexonflux.com,qpcz2axyy66u26hfdd2e23uohcf3y6c36mn7dcuilcgnwjasnrvnxjqd.onion", + "smp://rWvBYyTamuRCBYb_KAn-nsejg879ndhiTg5Sq3k0xWA=@smp5.simplexonflux.com,4ao347qwiuluyd45xunmii4skjigzuuox53hpdsgbwxqafd4yrticead.onion", + "smp://PN7-uqLBToqlf1NxHEaiL35lV2vBpXq8Nj8BW11bU48=@smp6.simplexonflux.com,hury6ot3ymebbr2535mlp7gcxzrjpc6oujhtfxcfh2m4fal4xw5fq6qd.onion" + ] + +fluxXFTPServers :: [NewUserServer 'PXFTP] +fluxXFTPServers = + map + (presetServer True) + [ "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com,apl3pumq3emwqtrztykyyoomdx4dg6ysql5zek2bi3rgznz7ai3odkid.onion", + "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com,c5jjecisncnngysah3cz2mppediutfelco4asx65mi75d44njvua3xid.onion", + "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com,dc4mohiubvbnsdfqqn7xhlhpqs5u4tjzp7xpz6v6corwvzvqjtaqqiqd.onion", + "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com,4qq5pzier3i4yhpuhcrhfbl6j25udc4czoyascrj4yswhodhfwev3nyd.onion", + "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com,q7itltdn32hjmgcqwhow4tay5ijetng3ur32bolssw32fvc5jrwvozad.onion", + "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com,upvzf23ou6nrmaf3qgnhd6cn3d74tvivlmz3p7wdfwq6fhthjrjiiqid.onion" + ] _defaultNtfServers :: [NtfServer] _defaultNtfServers = @@ -240,16 +300,19 @@ newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Boo newChatController ChatDatabase {chatStore, agentStore} user - cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, deviceNameForRemote, confirmMigrations} + cfg@ChatConfig {agentConfig = aCfg, presetServers, inlineFiles, deviceNameForRemote, confirmMigrations} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable, yesToUpMigrations}, deviceName, optFilesFolder, optTempDirectory, showReactions, allowInstantFiles, autoAcceptFileSize} backgroundMode = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} confirmMigrations' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} firstTime = dbNew chatStore currentUser <- newTVarIO user + randomSMP <- randomPresetServers SPSMP presetServers' + randomXFTP <- randomPresetServers SPXFTP presetServers' + let randomServers = RandomServers {smpServers = randomSMP, xftpServers = randomXFTP} currentRemoteHost <- newTVarIO Nothing - servers <- agentServers config + servers <- withTransaction chatStore $ \db -> agentServers db config randomServers smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode agentAsync <- newTVarIO Nothing random <- liftIO C.newRandom @@ -285,6 +348,7 @@ newChatController ChatController { firstTime, currentUser, + randomServers, currentRemoteHost, smpAgent, agentAsync, @@ -322,28 +386,41 @@ newChatController contactMergeEnabled } where - configServers :: DefaultAgentServers - configServers = - let DefaultAgentServers {smp = defSmp, xftp = defXftp, netCfg} = defaultServers - smp' = maybe defSmp (L.map enabledServerCfg) (nonEmpty smpServers) - xftp' = maybe defXftp (L.map enabledServerCfg) (nonEmpty xftpServers) - in defaultServers {smp = smp', xftp = xftp', netCfg = updateNetworkConfig netCfg simpleNetCfg} - agentServers :: ChatConfig -> IO InitialAgentServers - agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do - users <- withTransaction chatStore getUsers - smp' <- getUserServers users SPSMP - xftp' <- getUserServers users SPXFTP + presetServers' :: PresetServers + presetServers' = presetServers {operators = operators', netCfg = netCfg'} + where + PresetServers {operators, netCfg} = presetServers + netCfg' = updateNetworkConfig netCfg simpleNetCfg + operators' = case (smpServers, xftpServers) of + ([], []) -> operators + (smpSrvs, []) -> L.map disableSMP operators <> [custom smpSrvs []] + ([], xftpSrvs) -> L.map disableXFTP operators <> [custom [] xftpSrvs] + (smpSrvs, xftpSrvs) -> [custom smpSrvs xftpSrvs] + disableSMP op@PresetOperator {smp} = (op :: PresetOperator) {smp = map disableSrv smp} + disableXFTP op@PresetOperator {xftp} = (op :: PresetOperator) {xftp = map disableSrv xftp} + disableSrv :: forall p. NewUserServer p -> NewUserServer p + disableSrv srv = (srv :: NewUserServer p) {enabled = False} + custom smpSrvs xftpSrvs = + PresetOperator + { operator = Nothing, + smp = map newUserServer smpSrvs, + useSMP = 0, + xftp = map newUserServer xftpSrvs, + useXFTP = 0 + } + agentServers :: DB.Connection -> ChatConfig -> RandomServers -> IO InitialAgentServers + agentServers db ChatConfig {presetServers = PresetServers {operators = presetOps, ntf, netCfg}} rs = do + users <- getUsers db + opDomains <- operatorDomains <$> getUpdateServerOperators db presetOps (null users) + smp' <- getServers SPSMP users opDomains + xftp' <- getServers SPXFTP users opDomains pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg} where - getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ServerCfg p))) - getUserServers users protocol = case users of - [] -> pure $ M.fromList [(1, cfgServers protocol defServers)] - _ -> M.fromList <$> initialServers - where - initialServers :: IO [(UserId, NonEmpty (ServerCfg p))] - initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users - userServers :: User -> IO (NonEmpty (ServerCfg p)) - userServers user' = useServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') + getServers :: forall p. (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [User] -> [(Text, ServerOperator)] -> IO (Map UserId (NonEmpty (ServerCfg p))) + getServers p users opDomains = do + let rs' = rndServers p rs + fmap M.fromList $ forM users $ \u -> + (aUserId u,) . agentServerCfgs opDomains rs' <$> getUpdateUserServers db p presetOps rs' u updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = @@ -386,33 +463,37 @@ withFileLock :: String -> Int64 -> CM a -> CM a withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} -useServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ServerCfg p) -useServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) . nonEmpty +serverCfg :: ProtoServerWithAuth p -> ServerCfg p +serverCfg server = ServerCfg {server, operator = Nothing, enabled = True, roles = allRoles} -randomServers :: forall p. UserProtocol p => SProtocolType p -> ChatConfig -> IO (NonEmpty (ServerCfg p), [ServerCfg p]) -randomServers p ChatConfig {defaultServers} = do - let srvs = cfgServers p defaultServers - (enbldSrvs, dsbldSrvs) = L.partition (\ServerCfg {enabled} -> enabled) srvs - toUse = cfgServersToUse p defaultServers - if length enbldSrvs <= toUse - then pure (srvs, []) - else do - (enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs - let dsbldSrvs' = map (\srv -> (srv :: ServerCfg p) {enabled = False}) srvsToDisable - srvs' = sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs - pure (fromMaybe srvs $ L.nonEmpty srvs', srvs') +useServers :: forall p. UserProtocol p => SProtocolType p -> RandomServers -> [UserServer p] -> NonEmpty (NewUserServer p) +useServers p rs servers = case L.nonEmpty servers of + Nothing -> rndServers p rs + Just srvs -> L.map (\srv -> (srv :: UserServer p) {serverId = DBNewEntity}) srvs + +rndServers :: UserProtocol p => SProtocolType p -> RandomServers -> NonEmpty (NewUserServer p) +rndServers p RandomServers {smpServers, xftpServers} = case p of + SPSMP -> smpServers + SPXFTP -> xftpServers + +randomPresetServers :: forall p. UserProtocol p => SProtocolType p -> PresetServers -> IO (NonEmpty (NewUserServer p)) +randomPresetServers p PresetServers {operators} = toJust . L.nonEmpty . concat =<< mapM opSrvs operators where - server' ServerCfg {server = ProtoServerWithAuth srv _} = srv - -cfgServers :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> NonEmpty (ServerCfg p) -cfgServers p DefaultAgentServers {smp, xftp} = case p of - SPSMP -> smp - SPXFTP -> xftp - -cfgServersToUse :: UserProtocol p => SProtocolType p -> DefaultAgentServers -> Int -cfgServersToUse p DefaultAgentServers {useSMP, useXFTP} = case p of - SPSMP -> useSMP - SPXFTP -> useXFTP + toJust = \case + Just a -> pure a + Nothing -> E.throwIO $ userError "no preset servers" + opSrvs :: PresetOperator -> IO [NewUserServer p] + opSrvs op = do + let srvs = operatorServers p op + toUse = operatorServersToUse p op + (enbldSrvs, dsbldSrvs) = partition (\UserServer {enabled} -> enabled) srvs + if toUse <= 0 || toUse >= length enbldSrvs + then pure srvs + else do + (enbldSrvs', srvsToDisable) <- splitAt toUse <$> shuffle enbldSrvs + let dsbldSrvs' = map (\srv -> (srv :: NewUserServer p) {enabled = False}) srvsToDisable + pure $ sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs + server' UserServer {server = ProtoServerWithAuth srv _} = srv -- enableSndFiles has no effect when mainApp is True startChatController :: Bool -> Bool -> CM' (Async ()) @@ -556,19 +637,24 @@ processChatCommand' vr = \case forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser - (smp, smpServers) <- chooseServers SPSMP - (xftp, xftpServers) <- chooseServers SPXFTP + smpServers <- chooseServers SPSMP + xftpServers <- chooseServers SPXFTP users <- withFastStore' getUsers forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} + opDomains <- operatorDomains . fst <$> withFastStore getServerOperators + rs <- asks randomServers + let smp = agentServerCfgs opDomains (rndServers SPSMP rs) smpServers + xftp = agentServerCfgs opDomains (rndServers SPXFTP rs) xftpServers auId <- withAgent (\a -> createUser a smp xftp) ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts createPresetContactCards user `catchChatError` \_ -> pure () - withFastStore $ \db -> createNoteFolder db user - storeServers user smpServers - storeServers user xftpServers + withFastStore $ \db -> do + createNoteFolder db user + liftIO $ mapM_ (insertProtocolServer db SPSMP user ts) $ useServers SPSMP rs smpServers + liftIO $ mapM_ (insertProtocolServer db SPXFTP user ts) $ useServers SPXFTP rs xftpServers atomically . writeTVar u $ Just user pure $ CRActiveUser user where @@ -577,18 +663,10 @@ processChatCommand' vr = \case withFastStore $ \db -> do createContact db user simplexStatusContactProfile createContact db user simplexTeamContactProfile - chooseServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> CM (NonEmpty (ServerCfg p), [ServerCfg p]) - chooseServers protocol = - asks currentUser >>= readTVarIO >>= \case - Nothing -> asks config >>= liftIO . randomServers protocol - Just user -> chosenServers =<< withFastStore' (`getProtocolServers` user) - where - chosenServers servers = do - cfg <- asks config - pure (useServers cfg protocol servers, servers) - storeServers user servers = - unless (null servers) . withFastStore $ - \db -> overwriteProtocolServers db user servers + chooseServers :: forall p. ProtocolTypeI p => SProtocolType p -> CM [UserServer p] + chooseServers p = do + srvs <- chatReadVar currentUser >>= mapM (\user -> withFastStore' $ \db -> getProtocolServers db p user) + pure $ fromMaybe [] srvs coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -1486,57 +1564,67 @@ processChatCommand' vr = \case msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs pure $ CRConnNtfMessages ntfMsgs - APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do - cfg@ChatConfig {defaultServers} <- asks config - srvs <- withFastStore' (`getProtocolServers` user) - (operators, _) <- withFastStore $ \db -> getServerOperators db - let servers = AUPS $ UserProtoServers p (useServers cfg p srvs) (cfgServers p defaultServers) - pure $ CRUserProtoServers {user, servers, operators} - GetUserProtoServers aProtocol -> withUser $ \User {userId} -> - processChatCommand $ APIGetUserProtoServers userId aProtocol - APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) - | null servers || any (\ServerCfg {enabled} -> enabled) servers -> withUserId userId $ \user -> withServerProtocol p $ do - withFastStore $ \db -> overwriteProtocolServers db user servers - cfg <- asks config - lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ useServers cfg p servers - ok user - | otherwise -> withUserId userId $ \user -> pure $ chatCmdError (Just user) "all servers are disabled" - SetUserProtoServers serversConfig -> withUser $ \User {userId} -> - processChatCommand $ APISetUserProtoServers userId serversConfig + GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do + srvs <- withFastStore (`getUserServers` user) + CRUserServers user <$> liftIO (groupedServers srvs p) + where + groupedServers :: UserProtocol p => ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> SProtocolType p -> IO [UserOperatorServers] + groupedServers (operators, smpServers, xftpServers) = \case + SPSMP -> groupByOperator (operators, smpServers, []) + SPXFTP -> groupByOperator (operators, [], xftpServers) + SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do + srvs' <- mapM aUserServer srvs + userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) + case L.nonEmpty userServers_ of + Nothing -> throwChatError $ CECommandError "no servers" + Just userServers -> case srvs of + [] -> throwChatError $ CECommandError "no servers" + _ -> processChatCommand $ APISetUserServers userId $ L.map (updatedSrvs p) userServers + where + -- disable preset and replace custom servers (groupByOperator always adds custom) + updatedSrvs :: UserProtocol p => SProtocolType p -> UserOperatorServers -> UpdatedUserOperatorServers + updatedSrvs p' UserOperatorServers {operator, smpServers, xftpServers} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) + where + u = uncurry $ UpdatedUserOperatorServers operator + updateSrvs :: [UserServer p] -> [AUserServer p] + updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs' (const []) operator + disableSrv srv@UserServer {preset} = + AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} + where + aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) + aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of + Just Refl -> pure $ AUS SDBNew $ newUserServer srv + Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv) APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv - APIGetServerOperators -> do - (operators, conditionsAction) <- withFastStore $ \db -> getServerOperators db - pure $ CRServerOperators operators conditionsAction - APISetServerOperators operatorsEnabled -> do - (operators, conditionsAction) <- withFastStore $ \db -> setServerOperators db operatorsEnabled - pure $ CRServerOperators operators conditionsAction - APIGetUserServers userId -> withUserId userId $ \user -> do - (operators, smpServers, xftpServers) <- withFastStore $ \db -> do - (operators, _) <- getServerOperators db - smpServers <- liftIO $ getServers db user SPSMP - xftpServers <- liftIO $ getServers db user SPXFTP - pure (operators, smpServers, xftpServers) - let userServers = groupByOperator operators smpServers xftpServers - pure $ CRUserServers user userServers - where - getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ServerCfg p] - getServers db user _p = getProtocolServers db user + APIGetServerOperators -> uncurry CRServerOperators <$> withFastStore getServerOperators + APISetServerOperators operatorsEnabled -> withFastStore $ \db -> do + liftIO $ setServerOperators db operatorsEnabled + uncurry CRServerOperators <$> getServerOperators db + APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> + CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do let errors = validateUserServers userServers unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) - withFastStore $ \db -> setUserServers db user userServers - -- TODO set protocol servers for agent + (operators, smpServers, xftpServers) <- withFastStore $ \db -> do + setUserServers db user userServers + getUserServers db user + let opDomains = operatorDomains operators + rs <- asks randomServers + lift $ withAgent' $ \a -> do + let auId = aUserId user + setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPSMP rs) smpServers + setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPXFTP rs) xftpServers ok_ - APIValidateServers userServers -> do - let errors = validateUserServers userServers - pure $ CRUserServersValidation errors + APIValidateServers userServers -> pure $ CRUserServersValidation $ validateUserServers userServers APIGetUsageConditions -> do (usageConditions, acceptedConditions) <- withFastStore $ \db -> do usageConditions <- getCurrentUsageConditions db - acceptedConditions <- getLatestAcceptedConditions db + acceptedConditions <- liftIO $ getLatestAcceptedConditions db pure (usageConditions, acceptedConditions) -- TODO if db commit is different from source commit, conditionsText should be nothing in response pure @@ -1545,14 +1633,14 @@ processChatCommand' vr = \case conditionsText = usageConditionsText, acceptedConditions } - APISetConditionsNotified conditionsId -> do + APISetConditionsNotified condId -> do currentTs <- liftIO getCurrentTime - withFastStore' $ \db -> setConditionsNotified db conditionsId currentTs + withFastStore' $ \db -> setConditionsNotified db condId currentTs ok_ - APIAcceptConditions conditionsId operators -> do + APIAcceptConditions condId opIds -> withFastStore $ \db -> do currentTs <- liftIO getCurrentTime - (operators', conditionsAction) <- withFastStore $ \db -> acceptConditions db conditionsId operators currentTs - pure $ CRServerOperators operators' conditionsAction + acceptConditions db condId opIds currentTs + uncurry CRServerOperators <$> getServerOperators db APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do @@ -1805,8 +1893,9 @@ processChatCommand' vr = \case canKeepLink (CRInvitationUri crData _) newUser = do let ConnReqUriData {crSmpQueues = q :| _} = crData SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q - cfg <- asks config - newUserServers <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPSMP <$> withFastStore' (`getProtocolServers` newUser) + newUserServers <- + map protoServer' . filter (\ServerCfg {enabled} -> enabled) + <$> getKnownAgentServers SPSMP newUser pure $ smpServer `elem` newUserServers updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do withAgent $ \a -> changeConnectionUser a (aUserId user) (aConnId' conn) (aUserId newUser) @@ -2140,7 +2229,7 @@ processChatCommand' vr = \case where changeMemberRole user gInfo members m gEvent = do let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m - assertUserGroupRole gInfo $ maximum [GRAdmin, mRole, memRole] + assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole]) withGroupLock "memberRole" groupId . procCmd $ do unless (mRole == memRole) $ do withFastStore' $ \db -> updateGroupMemberRole db user m memRole @@ -2538,14 +2627,15 @@ processChatCommand' vr = \case pure $ CRAgentSubsTotal user subsTotal hasSession GetAgentServersSummary userId -> withUserId userId $ \user -> do agentServersSummary <- lift $ withAgent' getAgentServersSummary - cfg <- asks config - (users, smpServers, xftpServers) <- - withStore' $ \db -> (,,) <$> getUsers db <*> getServers db cfg user SPSMP <*> getServers db cfg user SPXFTP - let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers - pure $ CRAgentServersSummary user presentedServersSummary + withStore' $ \db -> do + users <- getUsers db + smpServers <- getServers db user SPSMP + xftpServers <- getServers db user SPXFTP + let presentedServersSummary = toPresentedServersSummary agentServersSummary users user smpServers xftpServers _defaultNtfServers + pure $ CRAgentServersSummary user presentedServersSummary where - getServers :: (ProtocolTypeI p, UserProtocol p) => DB.Connection -> ChatConfig -> User -> SProtocolType p -> IO (NonEmpty (ProtocolServer p)) - getServers db cfg user p = L.map (\ServerCfg {server} -> protoServer server) . useServers cfg p <$> getProtocolServers db user + getServers :: ProtocolTypeI p => DB.Connection -> User -> SProtocolType p -> IO [ProtocolServer p] + getServers db user p = map (\UserServer {server} -> protoServer server) <$> getProtocolServers db p user ResetAgentServersStats -> withAgent resetAgentServersStats >> ok_ GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails @@ -3663,8 +3753,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do - cfg <- asks config - knownSrvs <- L.map (\ServerCfg {server} -> protoServer server) . useServers cfg SPXFTP <$> withStore' (`getProtocolServers` user) + knownSrvs <- map protoServer' <$> getKnownAgentServers SPXFTP user pure $ filter (`notElem` knownSrvs) srvs ipProtectedForSrvs :: [XFTPServer] -> CM Bool ipProtectedForSrvs srvs = do @@ -3678,6 +3767,17 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} toView $ CRChatItemUpdated user aci throwChatError $ CEFileNotApproved fileId unknownSrvs +getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM [ServerCfg p] +getKnownAgentServers p user = do + rs <- asks randomServers + withStore $ \db -> do + opDomains <- operatorDomains . fst <$> getServerOperators db + srvs <- liftIO $ getProtocolServers db p user + pure $ L.toList $ agentServerCfgs opDomains (rndServers p rs) srvs + +protoServer' :: ServerCfg p -> ProtocolServer p +protoServer' ServerCfg {server} = protoServer server + getNetworkConfig :: CM' NetworkConfig getNetworkConfig = withAgent' $ liftIO . getFastNetworkConfig @@ -3876,7 +3976,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (sftConns, sfts) <- getSndFileTransferConns (rftConns, rfts) <- getRcvFileTransferConns (pcConns, pcs) <- getPendingContactConns - let conns = concat [ctConns, ucConns, mConns, sftConns, rftConns, pcConns] + let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]]) pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) -- subscribe using batched commands rs <- withAgent $ \a -> agentBatchSubscribe a conns @@ -4684,7 +4784,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ctItem = AChatItem SCTDirect SMDSnd (DirectChat ct) SWITCH qd phase cStats -> do toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) - when (phase `elem` [SPStarted, SPCompleted]) $ case qd of + when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> @@ -4969,7 +5069,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (Just fileDescrText, Just msgId) -> do partSize <- asks $ xftpDescrPartSize . config let parts = splitFileDescr partSize fileDescrText - pure . toList $ L.map (XMsgFileDescr msgId) parts + pure . L.toList $ L.map (XMsgFileDescr msgId) parts _ -> pure [] let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents GroupMember {memberId} = sender @@ -5095,7 +5195,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) - when (phase `elem` [SPStarted, SPCompleted]) $ case qd of + when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> @@ -6659,15 +6759,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageWarning "x.grp.mem.con: neither member is invitee" where inviteeXGrpMemCon :: GroupMemberIntro -> CM () - inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} - | introStatus == GMIntroReConnected = updateStatus introId GMIntroConnected - | introStatus `elem` [GMIntroToConnected, GMIntroConnected] = pure () - | otherwise = updateStatus introId GMIntroToConnected + inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of + GMIntroReConnected -> updateStatus introId GMIntroConnected + GMIntroToConnected -> pure () + GMIntroConnected -> pure () + _ -> updateStatus introId GMIntroToConnected forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () - forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} - | introStatus == GMIntroToConnected = updateStatus introId GMIntroConnected - | introStatus `elem` [GMIntroReConnected, GMIntroConnected] = pure () - | otherwise = updateStatus introId GMIntroReConnected + forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of + GMIntroToConnected -> updateStatus introId GMIntroConnected + GMIntroReConnected -> pure () + GMIntroConnected -> pure () + _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> UTCTime -> CM () @@ -8132,22 +8234,18 @@ chatCommandP = "/smp test " *> (TestProtoServer . AProtoServerWithAuth SPSMP <$> strP), "/xftp test " *> (TestProtoServer . AProtoServerWithAuth SPXFTP <$> strP), "/ntf test " *> (TestProtoServer . AProtoServerWithAuth SPNTF <$> strP), - "/_servers " *> (APISetUserProtoServers <$> A.decimal <* A.space <*> srvCfgP), - "/smp " *> (SetUserProtoServers . APSC SPSMP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP), - "/smp default" $> SetUserProtoServers (APSC SPSMP $ ProtoServersConfig []), - "/xftp " *> (SetUserProtoServers . APSC SPXFTP . ProtoServersConfig . map enabledServerCfg <$> protocolServersP), - "/xftp default" $> SetUserProtoServers (APSC SPXFTP $ ProtoServersConfig []), - "/_servers " *> (APIGetUserProtoServers <$> A.decimal <* A.space <*> strP), + "/smp " *> (SetUserProtoServers (AProtocolType SPSMP) . map (AProtoServerWithAuth SPSMP) <$> protocolServersP), + "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/_operators" $> APIGetServerOperators, "/_operators " *> (APISetServerOperators <$> jsonP), - "/_user_servers " *> (APIGetUserServers <$> A.decimal), - "/_user_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), + "/_servers " *> (APIGetUserServers <$> A.decimal), + "/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), "/_validate_servers " *> (APIValidateServers <$> jsonP), "/_conditions" $> APIGetUsageConditions, "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), - "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <* A.space <*> jsonP), + "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), "/ttl " *> (SetChatItemTTL <$> ciTTL), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), @@ -8491,7 +8589,6 @@ chatCommandP = onOffP (Just <$> (AutoAccept <$> (" incognito=" *> onOffP <|> pure False) <*> optional (A.space *> msgContentP))) (pure Nothing) - srvCfgP = strP >>= \case AProtocolType p -> APSC p <$> (A.space *> jsonP) rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (jsonP <|> text1P)) text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8406e214c9..3c2b8045d7 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -35,7 +35,6 @@ import qualified Data.ByteArray as BA import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) -import Data.Constraint (Dict (..)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) @@ -71,7 +70,7 @@ import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) @@ -85,7 +84,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) @@ -133,7 +132,7 @@ data ChatConfig = ChatConfig { agentConfig :: AgentConfig, chatVRange :: VersionRangeChat, confirmMigrations :: MigrationConfirmation, - defaultServers :: DefaultAgentServers, + presetServers :: PresetServers, tbqSize :: Natural, fileChunkSize :: Integer, xftpDescrPartSize :: Int, @@ -155,6 +154,12 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } +data RandomServers = RandomServers + { smpServers :: NonEmpty (NewUserServer 'PSMP), + xftpServers :: NonEmpty (NewUserServer 'PXFTP) + } + deriving (Show) + -- The hooks can be used to extend or customize chat core in mobile or CLI clients. data ChatHooks = ChatHooks { -- preCmdHook can be used to process or modify the commands before they are processed. @@ -173,12 +178,9 @@ defaultChatHooks = eventHook = \_ -> pure } -data DefaultAgentServers = DefaultAgentServers - { smp :: NonEmpty (ServerCfg 'PSMP), - useSMP :: Int, +data PresetServers = PresetServers + { operators :: NonEmpty PresetOperator, ntf :: [NtfServer], - xftp :: NonEmpty (ServerCfg 'PXFTP), - useXFTP :: Int, netCfg :: NetworkConfig } @@ -204,6 +206,7 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), + randomServers :: RandomServers, currentRemoteHost :: TVar (Maybe RemoteHostId), firstTime :: Bool, smpAgent :: AgentClient, @@ -347,20 +350,18 @@ data ChatCommand | APIGetGroupLink GroupId | APICreateMemberContact GroupId GroupMemberId | APISendMemberContactInvitation {contactId :: ContactId, msgContent_ :: Maybe MsgContent} - | APIGetUserProtoServers UserId AProtocolType | GetUserProtoServers AProtocolType - | APISetUserProtoServers UserId AProtoServersConfig - | SetUserProtoServers AProtoServersConfig + | SetUserProtoServers AProtocolType [AProtoServerWithAuth] | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth | APIGetServerOperators - | APISetServerOperators (NonEmpty OperatorEnabled) + | APISetServerOperators (NonEmpty ServerOperator) | APIGetUserServers UserId - | APISetUserServers UserId (NonEmpty UserServers) - | APIValidateServers (NonEmpty UserServers) -- response is CRUserServersValidation + | APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers) + | APIValidateServers (NonEmpty UpdatedUserOperatorServers) -- response is CRUserServersValidation | APIGetUsageConditions | APISetConditionsNotified Int64 - | APIAcceptConditions Int64 (NonEmpty ServerOperator) + | APIAcceptConditions Int64 (NonEmpty Int64) | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId @@ -586,10 +587,9 @@ data ChatResponse | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserProtoServers {user :: User, servers :: AUserProtoServers, operators :: [ServerOperator]} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperators {operators :: [ServerOperator], conditionsAction :: Maybe UsageConditionsAction} - | CRUserServers {user :: User, userServers :: [UserServers]} + | CRUserServers {user :: User, userServers :: [UserOperatorServers]} | CRUserServersValidation {serverErrors :: [UserServersError]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} @@ -956,23 +956,23 @@ instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding -data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} - deriving (Show) +-- data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} +-- deriving (Show) -data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) +-- data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) -deriving instance Show AProtoServersConfig +-- deriving instance Show AProtoServersConfig -data UserProtoServers p = UserProtoServers - { serverProtocol :: SProtocolType p, - protoServers :: NonEmpty (ServerCfg p), - presetServers :: NonEmpty (ServerCfg p) - } - deriving (Show) +-- data UserProtoServers p = UserProtoServers +-- { serverProtocol :: SProtocolType p, +-- protoServers :: NonEmpty (ServerCfg p), +-- presetServers :: NonEmpty (ServerCfg p) +-- } +-- deriving (Show) -data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) +-- data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) -deriving instance Show AUserProtoServers +-- deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) @@ -1575,28 +1575,28 @@ $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) -instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) +-- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where +-- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) -instance ProtocolTypeI p => FromJSON (UserProtoServers p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) +-- instance ProtocolTypeI p => FromJSON (UserProtoServers p) where +-- parseJSON = $(JQ.mkParseJSON defaultJSON ''UserProtoServers) -instance ProtocolTypeI p => ToJSON (UserProtoServers p) where - toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) - toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) +-- instance ProtocolTypeI p => ToJSON (UserProtoServers p) where +-- toJSON = $(JQ.mkToJSON defaultJSON ''UserProtoServers) +-- toEncoding = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) -instance FromJSON AUserProtoServers where - parseJSON v = J.withObject "AUserProtoServers" parse v - where - parse o = do - AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" - case userProtocol p of - Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v - Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p +-- instance FromJSON AUserProtoServers where +-- parseJSON v = J.withObject "AUserProtoServers" parse v +-- where +-- parse o = do +-- AProtocolType (p :: SProtocolType p) <- o .: "serverProtocol" +-- case userProtocol p of +-- Just Dict -> AUPS <$> J.parseJSON @(UserProtoServers p) v +-- Nothing -> fail $ "AUserProtoServers: unsupported protocol " <> show p -instance ToJSON AUserProtoServers where - toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s - toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s +-- instance ToJSON AUserProtoServers where +-- toJSON (AUPS s) = $(JQ.mkToJSON defaultJSON ''UserProtoServers) s +-- toEncoding (AUPS s) = $(JQ.mkToEncoding defaultJSON ''UserProtoServers) s $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RCS") ''RemoteCtrlSessionState) diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs index fc0ca21e54..d84cc5aa73 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -11,7 +11,6 @@ m20241027_server_operators = CREATE TABLE server_operators ( server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, server_operator_tag TEXT, - app_vendor INTEGER NOT NULL, trade_name TEXT NOT NULL, legal_name TEXT, server_domains TEXT, @@ -22,8 +21,6 @@ CREATE TABLE server_operators ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -ALTER TABLE protocol_servers ADD COLUMN server_operator_id INTEGER REFERENCES server_operators ON DELETE SET NULL; - CREATE TABLE usage_conditions ( usage_conditions_id INTEGER PRIMARY KEY AUTOINCREMENT, conditions_commit TEXT NOT NULL UNIQUE, @@ -41,18 +38,8 @@ CREATE TABLE operator_usage_conditions ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -CREATE INDEX idx_protocol_servers_server_operator_id ON protocol_servers(server_operator_id); CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions(server_operator_id); -CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(server_operator_id, conditions_commit); - -INSERT INTO server_operators - (server_operator_id, server_operator_tag, app_vendor, trade_name, legal_name, server_domains, enabled) - VALUES (1, 'simplex', 1, 'SimpleX Chat', 'SimpleX Chat Ltd', 'simplex.im', 1); -INSERT INTO server_operators - (server_operator_id, server_operator_tag, app_vendor, trade_name, legal_name, server_domains, enabled) - VALUES (2, 'xyz', 0, 'XYZ', 'XYZ Ltd', 'xyz.com', 0); - --- UPDATE protocol_servers SET server_operator_id = 1 WHERE host LIKE "%.simplex.im" OR host LIKE "%.simplex.im,%"; +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions(conditions_commit, server_operator_id); |] down_m20241027_server_operators :: Query @@ -60,9 +47,6 @@ down_m20241027_server_operators = [sql| DROP INDEX idx_operator_usage_conditions_conditions_commit; DROP INDEX idx_operator_usage_conditions_server_operator_id; -DROP INDEX idx_protocol_servers_server_operator_id; - -ALTER TABLE protocol_servers DROP COLUMN server_operator_id; DROP TABLE operator_usage_conditions; DROP TABLE usage_conditions; diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 0eb7f66913..c037a60770 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -450,7 +450,6 @@ CREATE TABLE IF NOT EXISTS "protocol_servers"( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), protocol TEXT NOT NULL DEFAULT 'smp', - server_operator_id INTEGER REFERENCES server_operators ON DELETE SET NULL, UNIQUE(user_id, host, port) ); CREATE TABLE xftp_file_descriptions( @@ -593,7 +592,6 @@ CREATE TABLE app_settings(app_settings TEXT NOT NULL); CREATE TABLE server_operators( server_operator_id INTEGER PRIMARY KEY AUTOINCREMENT, server_operator_tag TEXT, - app_vendor INTEGER NOT NULL, trade_name TEXT NOT NULL, legal_name TEXT, server_domains TEXT, @@ -919,13 +917,10 @@ CREATE INDEX idx_received_probes_group_member_id on received_probes( group_member_id ); CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id); -CREATE INDEX idx_protocol_servers_server_operator_id ON protocol_servers( - server_operator_id -); CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions( server_operator_id ); CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions( - server_operator_id, - conditions_commit + conditions_commit, + server_operator_id ); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index b3f92caaf9..55de357090 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,24 +1,42 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Operators where +import Control.Applicative ((<|>)) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import Data.FileEmbed +import Data.Foldable (foldMap') +import Data.IORef import Data.Int (Int64) +import Data.List (find, foldl') import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isNothing) +import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Scientific (floatingOrInteger) +import Data.Set (Set) +import qualified Data.Set as S import Data.Text (Text) +import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Database.SQLite.Simple.FromField (FromField (..)) @@ -26,23 +44,51 @@ import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types.Util (textParseJSON) -import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles (..)) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), SProtocolType (..)) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Transport.Client (TransportHost (..)) +import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) usageConditionsCommit :: Text -usageConditionsCommit = "165143a1112308c035ac00ed669b96b60599aa1c" +usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" + +previousConditionsCommit :: Text +previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c" usageConditionsText :: Text usageConditionsText = $( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md") - in [|stripFrontMatter (safeDecodeUtf8 $(lift s))|] + in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] ) -data OperatorTag = OTSimplex | OTXyz - deriving (Show) +data DBStored = DBStored | DBNew + +data SDBStored (s :: DBStored) where + SDBStored :: SDBStored 'DBStored + SDBNew :: SDBStored 'DBNew + +deriving instance Show (SDBStored s) + +class DBStoredI s where sdbStored :: SDBStored s + +instance DBStoredI 'DBStored where sdbStored = SDBStored + +instance DBStoredI 'DBNew where sdbStored = SDBNew + +data DBEntityId' (s :: DBStored) where + DBEntityId :: Int64 -> DBEntityId' 'DBStored + DBNewEntity :: DBEntityId' 'DBNew + +deriving instance Show (DBEntityId' s) + +type DBEntityId = DBEntityId' 'DBStored + +type DBNewEntity = DBEntityId' 'DBNew + +data OperatorTag = OTSimplex | OTFlux + deriving (Eq, Ord, Show) instance FromField OperatorTag where fromField = fromTextField_ textDecode @@ -58,11 +104,17 @@ instance ToJSON OperatorTag where instance TextEncoding OperatorTag where textDecode = \case "simplex" -> Just OTSimplex - "xyz" -> Just OTXyz + "flux" -> Just OTFlux _ -> Nothing textEncode = \case OTSimplex -> "simplex" - OTXyz -> "xyz" + OTFlux -> "flux" + +-- this and other types only define instances of serialization for known DB IDs only, +-- entities without IDs cannot be serialized to JSON +instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f + +instance ToField DBEntityId where toField (DBEntityId i) = toField i data UsageConditions = UsageConditions { conditionsId :: Int64, @@ -80,18 +132,16 @@ data UsageConditionsAction usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators - if null enabledOperators - then Nothing - else - if all conditionsAccepted enabledOperators - then - let acceptedForOperators = filter conditionsAccepted operators - in Just $ UCAAccepted acceptedForOperators - else - let acceptForOperators = filter (not . conditionsAccepted) enabledOperators - deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) - showNotice = isNothing notifiedAt - in Just $ UCAReview acceptForOperators deadline showNotice + if + | null enabledOperators -> Nothing + | all conditionsAccepted enabledOperators -> + let acceptedForOperators = filter conditionsAccepted operators + in Just $ UCAAccepted acceptedForOperators + | otherwise -> + let acceptForOperators = filter (not . conditionsAccepted) enabledOperators + deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + showNotice = isNothing notifiedAt + in Just $ UCAReview acceptForOperators deadline showNotice conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> Maybe UTCTime conditionsRequiredOrDeadline createdAt notifiedAtOrNow = @@ -107,8 +157,16 @@ data ConditionsAcceptance | CARequired {deadline :: Maybe UTCTime} deriving (Show) -data ServerOperator = ServerOperator - { operatorId :: OperatorId, +type ServerOperator = ServerOperator' 'DBStored + +type NewServerOperator = ServerOperator' 'DBNew + +data AServerOperator = forall s. ASO (SDBStored s) (ServerOperator' s) + +deriving instance Show AServerOperator + +data ServerOperator' s = ServerOperator + { operatorId :: DBEntityId' s, operatorTag :: Maybe OperatorTag, tradeName :: Text, legalName :: Maybe Text, @@ -124,81 +182,257 @@ conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAccept CAAccepted {} -> True _ -> False -data OperatorEnabled = OperatorEnabled - { operatorId :: OperatorId, - enabled :: Bool, - roles :: ServerRoles - } - deriving (Show) - -data UserServers = UserServers +data UserOperatorServers = UserOperatorServers { operator :: Maybe ServerOperator, - smpServers :: [ServerCfg 'PSMP], - xftpServers :: [ServerCfg 'PXFTP] + smpServers :: [UserServer 'PSMP], + xftpServers :: [UserServer 'PXFTP] } deriving (Show) -groupByOperator :: [ServerOperator] -> [ServerCfg 'PSMP] -> [ServerCfg 'PXFTP] -> [UserServers] -groupByOperator srvOperators smpSrvs xftpSrvs = - map createOperatorServers (M.toList combinedMap) +data UpdatedUserOperatorServers = UpdatedUserOperatorServers + { operator :: Maybe ServerOperator, + smpServers :: [AUserServer 'PSMP], + xftpServers :: [AUserServer 'PXFTP] + } + deriving (Show) + +updatedServers :: UserProtocol p => UpdatedUserOperatorServers -> SProtocolType p -> [AUserServer p] +updatedServers UpdatedUserOperatorServers {smpServers, xftpServers} = \case + SPSMP -> smpServers + SPXFTP -> xftpServers + +type UserServer p = UserServer' 'DBStored p + +type NewUserServer p = UserServer' 'DBNew p + +data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p) + +deriving instance Show (AUserServer p) + +data UserServer' s p = UserServer + { serverId :: DBEntityId' s, + server :: ProtoServerWithAuth p, + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } + deriving (Show) + +data PresetOperator = PresetOperator + { operator :: Maybe NewServerOperator, + smp :: [NewUserServer 'PSMP], + useSMP :: Int, + xftp :: [NewUserServer 'PXFTP], + useXFTP :: Int + } + +operatorServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p] +operatorServers p PresetOperator {smp, xftp} = case p of + SPSMP -> smp + SPXFTP -> xftp + +operatorServersToUse :: UserProtocol p => SProtocolType p -> PresetOperator -> Int +operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of + SPSMP -> useSMP + SPXFTP -> useXFTP + +presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p +presetServer = newUserServer_ True + +newUserServer :: ProtoServerWithAuth p -> NewUserServer p +newUserServer = newUserServer_ False True + +newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p +newUserServer_ preset enabled server = + UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} + +-- This function should be used inside DB transaction to update conditions in the database +-- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit + +-- This function is used in unit tests +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case + [] + | newUser -> (Just sourceCond, sourceCond, [sourceCond]) + | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + where + prevCond = conditions 1 prevCommit + sourceCond = conditions 2 sourceCommit + conds + | hasSourceCond -> (Nothing, last conds, []) + | otherwise -> (Nothing, sourceCond, [sourceCond]) + where + hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds + sourceCond = conditions cId sourceCommit + cId = maximum (map conditionsId conds) + 1 where - srvOperatorId ServerCfg {operator} = operator - opId ServerOperator {operatorId} = operatorId - operatorMap :: Map (Maybe Int64) (Maybe ServerOperator) - operatorMap = M.fromList [(Just (opId op), Just op) | op <- srvOperators] `M.union` M.singleton Nothing Nothing - initialMap :: Map (Maybe Int64) ([ServerCfg 'PSMP], [ServerCfg 'PXFTP]) - initialMap = M.fromList [(key, ([], [])) | key <- M.keys operatorMap] - smpsMap = foldr (\server acc -> M.adjust (\(smps, xftps) -> (server : smps, xftps)) (srvOperatorId server) acc) initialMap smpSrvs - combinedMap = foldr (\server acc -> M.adjust (\(smps, xftps) -> (smps, server : xftps)) (srvOperatorId server) acc) smpsMap xftpSrvs - createOperatorServers (key, (groupedSmps, groupedXftps)) = - UserServers - { operator = fromMaybe Nothing (M.lookup key operatorMap), - smpServers = groupedSmps, - xftpServers = groupedXftps - } + conditions cId commit = UsageConditions {conditionsId = cId, conditionsCommit = commit, notifiedAt = Nothing, createdAt} + +-- This function should be used inside DB transaction to update operators. +-- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, +-- and preserves custom operators without tags for forward compatibility. +updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [AServerOperator] +updatedServerOperators presetOps storedOps = + foldr addPreset [] presetOps + <> map (ASO SDBStored) (filter (isNothing . operatorTag) storedOps) + where + -- TODO remove domains of preset operators from custom + addPreset PresetOperator {operator} = case operator of + Nothing -> id + Just presetOp -> (storedOp' :) + where + storedOp' = case find ((operatorTag presetOp ==) . operatorTag) storedOps of + Just ServerOperator {operatorId, conditionsAcceptance, enabled, roles} -> + ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, roles} + Nothing -> ASO SDBNew presetOp + +-- This function should be used inside DB transaction to update servers. +updatedUserServers :: forall p. UserProtocol p => SProtocolType p -> NonEmpty PresetOperator -> NonEmpty (NewUserServer p) -> [UserServer p] -> NonEmpty (AUserServer p) +updatedUserServers _ _ randomSrvs [] = L.map (AUS SDBNew) randomSrvs +updatedUserServers p presetOps randomSrvs srvs = + fromMaybe (L.map (AUS SDBNew) randomSrvs) (L.nonEmpty updatedSrvs) + where + updatedSrvs = map userServer presetSrvs <> map (AUS SDBStored) (filter customServer srvs) + storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) + storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs + customServer :: UserServer p -> Bool + customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) + presetSrvs :: [NewUserServer p] + presetSrvs = concatMap (operatorServers p) presetOps + presetHosts :: Set TransportHost + presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs + userServer :: NewUserServer p -> AUserServer p + userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs) + +srvHost :: UserServer' s p -> NonEmpty TransportHost +srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv + +agentServerCfgs :: [(Text, ServerOperator)] -> NonEmpty (NewUserServer p) -> [UserServer' s p] -> NonEmpty (ServerCfg p) +agentServerCfgs opDomains randomSrvs = + fromMaybe fallbackSrvs . L.nonEmpty . mapMaybe enabledOpAgentServer + where + fallbackSrvs = L.map (snd . agentServer) randomSrvs + enabledOpAgentServer srv = + let (opEnabled, srvCfg) = agentServer srv + in if opEnabled then Just srvCfg else Nothing + agentServer :: UserServer' s p -> (Bool, ServerCfg p) + agentServer srv@UserServer {server, enabled} = + case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of + Just (_, ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled, roles}) -> + (opEnabled, ServerCfg {server, enabled, operator = Just opId, roles}) + Nothing -> + (True, ServerCfg {server, enabled, operator = Nothing, roles = allRoles}) + +matchingHost :: Text -> TransportHost -> Bool +matchingHost d = \case + THDomainName h -> d `T.isSuffixOf` T.pack h + _ -> False + +operatorDomains :: [ServerOperator] -> [(Text, ServerOperator)] +operatorDomains = foldr (\op ds -> foldr (\d -> ((d, op) :)) ds (serverDomains op)) [] + +groupByOperator :: ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs) = do + ss <- mapM (\op -> (serverDomains op,) <$> newIORef (UserOperatorServers (Just op) [] [])) ops + custom <- newIORef $ UserOperatorServers Nothing [] [] + mapM_ (addServer ss custom addSMP) (reverse smpSrvs) + mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) + opSrvs <- mapM (readIORef . snd) ss + customSrvs <- readIORef custom + pure $ opSrvs <> [customSrvs] + where + addServer :: [([Text], IORef UserOperatorServers)] -> IORef UserOperatorServers -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () + addServer ss custom add srv = + let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss + in atomicModifyIORef'_ v $ add srv + addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} + addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} data UserServersError - = USEStorageMissing - | USEProxyMissing - | USEDuplicateSMP {server :: AProtoServerWithAuth} - | USEDuplicateXFTP {server :: AProtoServerWithAuth} + = USEStorageMissing {protocol :: AProtocolType} + | USEProxyMissing {protocol :: AProtocolType} + | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: AProtoServerWithAuth, duplicateHost :: TransportHost} deriving (Show) -validateUserServers :: NonEmpty UserServers -> [UserServersError] -validateUserServers userServers = - let storageMissing_ = if any (canUseForRole storage) userServers then [] else [USEStorageMissing] - proxyMissing_ = if any (canUseForRole proxy) userServers then [] else [USEProxyMissing] - - allSMPServers = map (\ServerCfg {server} -> server) $ concatMap (\UserServers {smpServers} -> smpServers) userServers - duplicateSMPServers = findDuplicatesByHost allSMPServers - duplicateSMPErrors = map (USEDuplicateSMP . AProtoServerWithAuth SPSMP) duplicateSMPServers - - allXFTPServers = map (\ServerCfg {server} -> server) $ concatMap (\UserServers {xftpServers} -> xftpServers) userServers - duplicateXFTPServers = findDuplicatesByHost allXFTPServers - duplicateXFTPErrors = map (USEDuplicateXFTP . AProtoServerWithAuth SPXFTP) duplicateXFTPServers - in storageMissing_ <> proxyMissing_ <> duplicateSMPErrors <> duplicateXFTPErrors +validateUserServers :: NonEmpty UpdatedUserOperatorServers -> [UserServersError] +validateUserServers uss = + missingRolesErr SPSMP storage USEStorageMissing + <> missingRolesErr SPSMP proxy USEProxyMissing + <> missingRolesErr SPXFTP storage USEStorageMissing + <> duplicatServerErrs SPSMP + <> duplicatServerErrs SPXFTP where - canUseForRole :: (ServerRoles -> Bool) -> UserServers -> Bool - canUseForRole roleSel UserServers {operator, smpServers, xftpServers} = case operator of - Just ServerOperator {roles} -> roleSel roles - Nothing -> not (null smpServers) && not (null xftpServers) - findDuplicatesByHost :: [ProtoServerWithAuth p] -> [ProtoServerWithAuth p] - findDuplicatesByHost servers = - let allHosts = concatMap (L.toList . host . protoServer) servers - hostCounts = M.fromListWith (+) [(host, 1 :: Int) | host <- allHosts] - duplicateHosts = M.keys $ M.filter (> 1) hostCounts - in filter (\srv -> any (`elem` duplicateHosts) (L.toList $ host . protoServer $ srv)) servers + missingRolesErr :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> (AProtocolType -> UserServersError) -> [UserServersError] + missingRolesErr p roleSel err = [err (AProtocolType p) | not hasRole] + where + hasRole = + any (\(AUS _ UserServer {deleted, enabled}) -> enabled && not deleted) $ + concatMap (`updatedServers` p) $ filter roleEnabled (L.toList uss) + roleEnabled UpdatedUserOperatorServers {operator} = + maybe True (\ServerOperator {enabled, roles} -> enabled && roleSel roles) operator + duplicatServerErrs :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServersError] + duplicatServerErrs p = mapMaybe duplicateErr_ srvs + where + srvs = + filter (\(AUS _ UserServer {deleted}) -> not deleted) $ + concatMap (`updatedServers` p) (L.toList uss) + duplicateErr_ (AUS _ srv@UserServer {server}) = + USEDuplicateServer (AProtocolType p) (AProtoServerWithAuth p server) + <$> find (`S.member` duplicateHosts) (srvHost srv) + duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts + allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs + addHost (hs, dups) h + | h `S.member` hs = (hs, S.insert h dups) + | otherwise = (S.insert h hs, dups) + +instance ToJSON (DBEntityId' s) where + toEncoding = \case + DBEntityId i -> toEncoding i + DBNewEntity -> JE.null_ + toJSON = \case + DBEntityId i -> toJSON i + DBNewEntity -> J.Null + +instance DBStoredI s => FromJSON (DBEntityId' s) where + parseJSON v = case (v, sdbStored @s) of + (J.Null, SDBNew) -> pure DBNewEntity + (J.Number n, SDBStored) -> case floatingOrInteger n of + Left (_ :: Double) -> fail "bad DBEntityId" + Right i -> pure $ DBEntityId (fromInteger i) + _ -> fail "bad DBEntityId" + omittedField = case sdbStored @s of + SDBStored -> Nothing + SDBNew -> Just DBNewEntity $(JQ.deriveJSON defaultJSON ''UsageConditions) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance) -$(JQ.deriveJSON defaultJSON ''ServerOperator) +instance ToJSON (ServerOperator' s) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerOperator') + toJSON = $(JQ.mkToJSON defaultJSON ''ServerOperator') -$(JQ.deriveJSON defaultJSON ''OperatorEnabled) +instance DBStoredI s => FromJSON (ServerOperator' s) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerOperator') $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) -$(JQ.deriveJSON defaultJSON ''UserServers) +instance ProtocolTypeI p => ToJSON (UserServer' s p) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer') + toJSON = $(JQ.mkToJSON defaultJSON ''UserServer') + +instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer') + +instance ProtocolTypeI p => FromJSON (AUserServer p) where + parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) + +$(JQ.deriveJSON defaultJSON ''UserOperatorServers) + +instance FromJSON UpdatedUserOperatorServers where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) diff --git a/src/Simplex/Chat/Operators/Conditions.hs b/src/Simplex/Chat/Operators/Conditions.hs index 55cf8b658d..a314c1901a 100644 --- a/src/Simplex/Chat/Operators/Conditions.hs +++ b/src/Simplex/Chat/Operators/Conditions.hs @@ -9,7 +9,7 @@ import qualified Data.Text as T stripFrontMatter :: Text -> Text stripFrontMatter = T.unlines - . dropWhile ("# " `T.isPrefixOf`) -- strip title + -- . dropWhile ("# " `T.isPrefixOf`) -- strip title . dropWhile (T.all isSpace) . dropWhile fm . (\ls -> let ls' = dropWhile (not . fm) ls in if null ls' then ls else ls') diff --git a/src/Simplex/Chat/Stats.hs b/src/Simplex/Chat/Stats.hs index 6dd5c79ab1..21ad25b311 100644 --- a/src/Simplex/Chat/Stats.hs +++ b/src/Simplex/Chat/Stats.hs @@ -7,7 +7,6 @@ module Simplex.Chat.Stats where import qualified Data.Aeson.TH as J import Data.List (partition) -import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) @@ -131,7 +130,7 @@ data NtfServerSummary = NtfServerSummary -- - users are passed to exclude hidden users from totalServersSummary; -- - if currentUser is hidden, it should be accounted in totalServersSummary; -- - known is set only in user level summaries based on passed userSMPSrvs and userXFTPSrvs -toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> NonEmpty SMPServer -> NonEmpty XFTPServer -> [NtfServer] -> PresentedServersSummary +toPresentedServersSummary :: AgentServersSummary -> [User] -> User -> [SMPServer] -> [XFTPServer] -> [NtfServer] -> PresentedServersSummary toPresentedServersSummary agentSummary users currentUser userSMPSrvs userXFTPSrvs userNtfSrvs = do let (userSMPSrvsSumms, allSMPSrvsSumms) = accSMPSrvsSummaries (userSMPCurr, userSMPPrev, userSMPProx) = smpSummsIntoCategories userSMPSrvsSumms diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index f4f574c3d7..39bd4bb985 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,5 +1,8 @@ +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} @@ -47,9 +50,13 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, + getUpdateUserServers, -- overwriteOperatorsAndServers, overwriteProtocolServers, + insertProtocolServer, + getUpdateServerOperators, getServerOperators, + getUserServers, setServerOperators, getCurrentUsageConditions, getLatestAcceptedConditions, @@ -77,10 +84,11 @@ import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) -import Data.Text (Text, splitOn) +import Data.Text (Text) +import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) +import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call import Simplex.Chat.Messages @@ -92,7 +100,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Env.SQLite (OperatorId, ServerCfg (..), ServerRoles (..)) +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB @@ -100,7 +108,7 @@ import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolTypeI (..), SubscriptionMode) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode, UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) @@ -524,177 +532,282 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) -getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] -getProtocolServers db User {userId} = - map toServerCfg +getUpdateUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => DB.Connection -> SProtocolType p -> NonEmpty PresetOperator -> NonEmpty (NewUserServer p) -> User -> IO [UserServer p] +getUpdateUserServers db p presetOps randomSrvs user = do + ts <- getCurrentTime + srvs <- getProtocolServers db p user + let srvs' = L.toList $ updatedUserServers p presetOps randomSrvs srvs + mapM (upsertServer ts) srvs' + where + upsertServer :: UTCTime -> AUserServer p -> IO (UserServer p) + upsertServer ts (AUS _ s@UserServer {serverId}) = case serverId of + DBNewEntity -> insertProtocolServer db p user ts s + DBEntityId _ -> updateProtocolServer db p ts s $> s + +getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] +getProtocolServers db p User {userId} = + map toUserServer <$> DB.query db [sql| - SELECT s.host, s.port, s.key_hash, s.basic_auth, s.server_operator_id, s.preset, s.tested, s.enabled, o.role_storage, o.role_proxy - FROM protocol_servers s - LEFT JOIN server_operators o USING (server_operator_id) - WHERE s.user_id = ? AND s.protocol = ? + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + FROM protocol_servers + WHERE user_id = ? AND protocol = ? |] - (userId, decodeLatin1 $ strEncode protocol) + (userId, decodeLatin1 $ strEncode p) where - protocol = protocolTypeI @p - toServerCfg :: (NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Maybe OperatorId, Bool, Maybe Bool, Bool, Maybe Bool, Maybe Bool) -> ServerCfg p - toServerCfg (host, port, keyHash, auth_, operator, preset, tested, enabled, storage_, proxy_) = - let server = ProtoServerWithAuth (ProtocolServer protocol host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - roles = ServerRoles {storage = fromMaybe True storage_, proxy = fromMaybe True proxy_} - in ServerCfg {server, operator, preset, tested, enabled, roles} + toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p + toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) = + let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + in UserServer {serverId, server, preset, tested, enabled, deleted = False} -- TODO remove -- overwriteOperatorsAndServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> Maybe [ServerOperator] -> [ServerCfg p] -> ExceptT StoreError IO [ServerCfg p] -- overwriteOperatorsAndServers db user@User {userId} operators_ servers = do -overwriteProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> [ServerCfg p] -> ExceptT StoreError IO () -overwriteProtocolServers db User {userId} servers = +overwriteProtocolServers :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> [UserServer p] -> ExceptT StoreError IO () +overwriteProtocolServers db p User {userId} servers = -- liftIO $ mapM_ (updateServerOperators_ db) operators_ checkConstraint SEUniqueID . ExceptT $ do currentTs <- getCurrentTime - DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, protocol) - forM_ servers $ \ServerCfg {server, preset, tested, enabled} -> do - let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server + DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, decodeLatin1 $ strEncode p) + forM_ servers $ \UserServer {serverId, server, preset, tested, enabled} -> do DB.execute db [sql| INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + (server_id, protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_) :. (preset, tested, enabled, userId, currentTs, currentTs)) - -- Right <$> getProtocolServers db user + (Only serverId :. serverColumns p server :. (preset, tested, enabled, userId, currentTs, currentTs)) pure $ Right () - where - protocol = decodeLatin1 $ strEncode $ protocolTypeI @p + +insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) +insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do + DB.execute + db + [sql| + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + (serverColumns p server :. (preset, tested, enabled, userId, ts, ts)) + sId <- insertedRowId db + pure (srv :: NewUserServer p) {serverId = DBEntityId sId} + +updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO () +updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} = + DB.execute + db + [sql| + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE smp_server_id = ? + |] + (serverColumns p server :. (preset, tested, enabled, ts, serverId)) + +serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) +serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = + let protocol = decodeLatin1 $ strEncode p + auth = safeDecodeUtf8 . unBasicAuth <$> auth_ + in (protocol, host, port, keyHash, auth) getServerOperators :: DB.Connection -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) getServerOperators db = do - now <- liftIO getCurrentTime - currentConditions <- getCurrentUsageConditions db - latestAcceptedConditions <- getLatestAcceptedConditions db - operators <- - liftIO $ - map (toOperator now currentConditions latestAcceptedConditions) - <$> DB.query_ - db - [sql| - SELECT - so.server_operator_id, so.server_operator_tag, so.trade_name, so.legal_name, - so.server_domains, so.enabled, so.role_storage, so.role_proxy, - AcceptedConditions.conditions_commit, AcceptedConditions.accepted_at - FROM server_operators so - LEFT JOIN ( - SELECT server_operator_id, conditions_commit, accepted_at, MAX(operator_usage_conditions_id) - FROM operator_usage_conditions - GROUP BY server_operator_id - ) AcceptedConditions ON AcceptedConditions.server_operator_id = so.server_operator_id - |] - pure (operators, usageConditionsAction operators currentConditions now) - where - toOperator :: - UTCTime -> - UsageConditions -> - Maybe UsageConditions -> - ( (OperatorId, Maybe OperatorTag, Text, Maybe Text, Text, Bool, Bool, Bool) - :. (Maybe Text, Maybe UTCTime) - ) -> - ServerOperator - toOperator - now - UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} - latestAcceptedConditions_ - ( (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) - :. (operatorCommit_, acceptedAt_) - ) = - let roles = ServerRoles {storage, proxy} - serverDomains = splitOn "," domains - conditionsAcceptance = case (latestAcceptedConditions_, operatorCommit_) of - -- no conditions were ever accepted for any operator(s) - -- (shouldn't happen as there should always be record for SimpleX Chat) - (Nothing, _) -> CARequired Nothing - -- no conditions were ever accepted for this operator - (_, Nothing) -> CARequired Nothing - (Just UsageConditions {conditionsCommit = latestAcceptedCommit}, Just operatorCommit) - | latestAcceptedCommit == currentCommit -> - if operatorCommit == latestAcceptedCommit - then -- current conditions were accepted for operator - CAAccepted acceptedAt_ - else -- current conditions were NOT accepted for operator, but were accepted for other operator(s) - CARequired Nothing - | otherwise -> - if operatorCommit == latestAcceptedCommit - then -- new conditions available, latest accepted conditions were accepted for operator - CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) - else -- new conditions available, latest accepted conditions were NOT accepted for operator (were accepted for other operator(s)) - CARequired Nothing - in ServerOperator {operatorId, operatorTag, tradeName, legalName, serverDomains, conditionsAcceptance, enabled, roles} + currentConds <- getCurrentUsageConditions db + liftIO $ do + now <- getCurrentTime + latestAcceptedConds_ <- getLatestAcceptedConditions db + let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConds latestAcceptedConds_ now + operators <- mapM getConds =<< getServerOperators_ db + pure (operators, usageConditionsAction operators currentConds now) -setServerOperators :: DB.Connection -> NonEmpty OperatorEnabled -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) -setServerOperators db operatorsEnabled = do - liftIO $ forM_ operatorsEnabled $ \OperatorEnabled {operatorId, enabled, roles = ServerRoles {storage, proxy}} -> - DB.execute - db - "UPDATE server_operators SET enabled = ?, role_storage = ?, role_proxy = ? WHERE server_operator_id = ?" - (enabled, storage, proxy, operatorId) - getServerOperators db +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers db user = + (,,) + <$> (fst <$> getServerOperators db) + <*> liftIO (getProtocolServers db SPSMP user) + <*> liftIO (getProtocolServers db SPXFTP user) + +setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO () +setServerOperators db ops = do + currentTs <- getCurrentTime + mapM_ (updateServerOperator db currentTs) ops + +updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO () +updateServerOperator db currentTs ServerOperator {operatorId, enabled, roles = ServerRoles {storage, proxy}} = + DB.execute + db + [sql| + UPDATE server_operators + SET enabled = ?, role_storage = ?, role_proxy = ?, updated_at = ? + WHERE server_operator_id = ? + |] + (enabled, storage, proxy, operatorId, currentTs) + +getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [ServerOperator] +getUpdateServerOperators db presetOps newUser = do + conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery + now <- getCurrentTime + let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + mapM_ insertConditions condsToAdd + latestAcceptedConds_ <- getLatestAcceptedConditions db + ops <- updatedServerOperators presetOps <$> getServerOperators_ db + forM ops $ \(ASO _ op) -> + case operatorId op of + DBNewEntity -> do + op' <- insertOperator op + case (operatorTag op', acceptForSimplex_) of + (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond + _ -> pure op' + DBEntityId _ -> do + updateOperator op + getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case + CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds + CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds + ca -> pure op {conditionsAcceptance = ca} + where + insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} = + DB.execute + db + [sql| + INSERT INTO usage_conditions + (usage_conditions_id, conditions_commit, notified_at, created_at) + VALUES (?,?,?,?) + |] + (conditionsId, conditionsCommit, notifiedAt, createdAt) + updateOperator :: ServerOperator -> IO () + updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, roles = ServerRoles {storage, proxy}} = + DB.execute + db + [sql| + UPDATE server_operators + SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, role_storage = ?, role_proxy = ? + WHERE server_operator_id = ? + |] + (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage, proxy, operatorId) + insertOperator :: NewServerOperator -> IO ServerOperator + insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, roles = ServerRoles {storage, proxy}} = do + DB.execute + db + [sql| + INSERT INTO server_operators + (server_operator_tag, trade_name, legal_name, server_domains, enabled, role_storage, role_proxy) + VALUES (?,?,?,?,?,?,?) + |] + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage, proxy) + opId <- insertedRowId db + pure op {operatorId = DBEntityId opId} + autoAcceptConditions op UsageConditions {conditionsCommit} = + acceptConditions_ db op conditionsCommit Nothing + $> op {conditionsAcceptance = CAAccepted Nothing} + +serverOperatorQuery :: Query +serverOperatorQuery = + [sql| + SELECT server_operator_id, server_operator_tag, trade_name, legal_name, + server_domains, enabled, role_storage, role_proxy + FROM server_operators + |] + +getServerOperators_ :: DB.Connection -> IO [ServerOperator] +getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery + +toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool, Bool, Bool) -> ServerOperator +toServerOperator (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) = + ServerOperator + { operatorId, + operatorTag, + tradeName, + legalName, + serverDomains = T.splitOn "," domains, + conditionsAcceptance = CARequired Nothing, + enabled, + roles = ServerRoles {storage, proxy} + } + +getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance +getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do + case latestAcceptedConds_ of + Nothing -> pure $ CARequired Nothing -- no conditions accepted by any operator + Just UsageConditions {conditionsCommit = latestAcceptedCommit} -> do + operatorAcceptedConds_ <- + maybeFirstRow id $ + DB.query + db + [sql| + SELECT conditions_commit, accepted_at + FROM operator_usage_conditions + WHERE server_operator_id = ? + ORDER BY operator_usage_conditions_id DESC + LIMIT 1 + |] + (Only operatorId) + pure $ case operatorAcceptedConds_ of + Just (operatorCommit, acceptedAt_) + | operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled? + | currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) + | otherwise -> CAAccepted acceptedAt_ + _ -> CARequired Nothing -- no conditions were accepted for this operator getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions getCurrentUsageConditions db = ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ - DB.query_ - db - [sql| - SELECT usage_conditions_id, conditions_commit, notified_at, created_at - FROM usage_conditions - ORDER BY usage_conditions_id DESC LIMIT 1 - |] + DB.query_ db (usageCondsQuery <> " DESC LIMIT 1") + +usageCondsQuery :: Query +usageCondsQuery = + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + ORDER BY usage_conditions_id + |] toUsageConditions :: (Int64, Text, Maybe UTCTime, UTCTime) -> UsageConditions toUsageConditions (conditionsId, conditionsCommit, notifiedAt, createdAt) = UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} -getLatestAcceptedConditions :: DB.Connection -> ExceptT StoreError IO (Maybe UsageConditions) -getLatestAcceptedConditions db = do - (latestAcceptedCommit_ :: Maybe Text) <- - liftIO $ - maybeFirstRow fromOnly $ - DB.query_ - db - [sql| +getLatestAcceptedConditions :: DB.Connection -> IO (Maybe UsageConditions) +getLatestAcceptedConditions db = + maybeFirstRow toUsageConditions $ + DB.query_ + db + [sql| + SELECT usage_conditions_id, conditions_commit, notified_at, created_at + FROM usage_conditions + WHERE conditions_commit = ( SELECT conditions_commit FROM operator_usage_conditions ORDER BY accepted_at DESC LIMIT 1 - |] - forM latestAcceptedCommit_ $ \latestAcceptedCommit -> - ExceptT . firstRow toUsageConditions SEUsageConditionsNotFound $ - DB.query - db - [sql| - SELECT usage_conditions_id, conditions_commit, notified_at, created_at - FROM usage_conditions - WHERE conditions_commit = ? - |] - (Only latestAcceptedCommit) + ) + |] setConditionsNotified :: DB.Connection -> Int64 -> UTCTime -> IO () -setConditionsNotified db conditionsId notifiedAt = - DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, conditionsId) +setConditionsNotified db condId notifiedAt = + DB.execute db "UPDATE usage_conditions SET notified_at = ? WHERE usage_conditions_id = ?" (notifiedAt, condId) -acceptConditions :: DB.Connection -> Int64 -> NonEmpty ServerOperator -> UTCTime -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) -acceptConditions db conditionsId operators acceptedAt = do - UsageConditions {conditionsCommit} <- getUsageConditionsById_ db conditionsId - liftIO $ forM_ operators $ \ServerOperator {operatorId, operatorTag} -> - DB.execute - db - [sql| - INSERT INTO operator_usage_conditions - (server_operator_id, server_operator_tag, conditions_commit, accepted_at) - VALUES (?,?,?,?) - |] - (operatorId, operatorTag, conditionsCommit, acceptedAt) - getServerOperators db +acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> ExceptT StoreError IO () +acceptConditions db condId opIds acceptedAt = do + UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId + operators <- mapM getServerOperator_ opIds + let ts = Just acceptedAt + liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts + where + getServerOperator_ opId = + ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ + DB.query db (serverOperatorQuery <> " WHERE operator_id = ?") (Only opId) + +acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () +acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = + DB.execute + db + [sql| + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at) + VALUES (?,?,?,?) + |] + (operatorId, operatorTag, conditionsCommit, acceptedAt) getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions getUsageConditionsById_ db conditionsId = @@ -708,83 +821,22 @@ getUsageConditionsById_ db conditionsId = |] (Only conditionsId) -setUserServers :: DB.Connection -> User -> NonEmpty UserServers -> ExceptT StoreError IO () -setUserServers db User {userId} userServers = do - currentTs <- liftIO getCurrentTime - forM_ userServers $ do - \UserServers {operator, smpServers, xftpServers} -> do - forM_ operator $ \op -> liftIO $ updateOperator currentTs op - overwriteServers currentTs operator smpServers - overwriteServers currentTs operator xftpServers +setUserServers :: DB.Connection -> User -> NonEmpty UpdatedUserOperatorServers -> ExceptT StoreError IO () +setUserServers db user@User {userId} userServers = checkConstraint SEUniqueID $ liftIO $ do + ts <- getCurrentTime + forM_ userServers $ \UpdatedUserOperatorServers {operator, smpServers, xftpServers} -> do + mapM_ (updateServerOperator db ts) operator + mapM_ (upsertOrDelete SPSMP ts) smpServers + mapM_ (upsertOrDelete SPXFTP ts) xftpServers where - updateOperator :: UTCTime -> ServerOperator -> IO () - updateOperator currentTs ServerOperator {operatorId, enabled, roles = ServerRoles {storage, proxy}} = - DB.execute - db - [sql| - UPDATE server_operators - SET enabled = ?, role_storage = ?, role_proxy = ?, updated_at = ? - WHERE server_operator_id = ? - |] - (enabled, storage, proxy, operatorId, currentTs) - overwriteServers :: forall p. ProtocolTypeI p => UTCTime -> Maybe ServerOperator -> [ServerCfg p] -> ExceptT StoreError IO () - overwriteServers currentTs serverOperator servers = - checkConstraint SEUniqueID . ExceptT $ do - case serverOperator of - Nothing -> - DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND server_operator_id IS NULL AND protocol = ?" (userId, protocol) - Just ServerOperator {operatorId} -> - DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND server_operator_id = ? AND protocol = ?" (userId, operatorId, protocol) - forM_ servers $ \ServerCfg {server, operator, preset, tested, enabled} -> do - let ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_ = server - DB.execute - db - [sql| - INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, operator, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - |] - ((protocol, host, port, keyHash, safeDecodeUtf8 . unBasicAuth <$> auth_, operator) :. (preset, tested, enabled, userId, currentTs, currentTs)) - pure $ Right () - where - protocol = decodeLatin1 $ strEncode $ protocolTypeI @p - --- updateServerOperators_ :: DB.Connection -> [ServerOperator] -> IO [ServerOperator] --- updateServerOperators_ db operators = do --- DB.execute_ db "DELETE FROM server_operators WHERE preset = 0" --- let (existing, new) = partition (isJust . operatorId) operators --- existing' <- mapM (\op -> upsertExisting op $> op) existing --- new' <- mapM insertNew new --- pure $ existing' <> new' --- where --- upsertExisting ServerOperator {operatorId, name, preset, enabled, roles = ServerRoles {storage, proxy}} --- | preset = --- DB.execute --- db --- [sql| --- UPDATE server_operators --- SET enabled = ?, role_storage = ?, role_proxy = ? --- WHERE server_operator_id = ? --- |] --- (enabled, storage, proxy, operatorId) --- | otherwise = --- DB.execute --- db --- [sql| --- INSERT INTO server_operators (server_operator_id, name, preset, enabled, role_storage, role_proxy) --- VALUES (?,?,?,?,?,?) --- |] --- (operatorId, name, preset, enabled, storage, proxy) --- insertNew op@ServerOperator {name, preset, enabled, roles = ServerRoles {storage, proxy}} = do --- DB.execute --- db --- [sql| --- INSERT INTO server_operators (name, preset, enabled, role_storage, role_proxy) --- VALUES (?,?,?,?,?) --- |] --- (name, preset, enabled, storage, proxy) --- opId <- insertedRowId db --- pure op {operatorId = Just opId} + upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> UTCTime -> AUserServer p -> IO () + upsertOrDelete p ts (AUS _ s@UserServer {serverId, deleted}) = case serverId of + DBNewEntity + | deleted -> pure () + | otherwise -> void $ insertProtocolServer db p user ts s + DBEntityId srvId + | deleted -> DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) + | otherwise -> updateProtocolServer db p ts s createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 083079e2ea..fcd9896917 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -127,6 +127,7 @@ data StoreError | SERemoteCtrlNotFound {remoteCtrlId :: RemoteCtrlId} | SERemoteCtrlDuplicateCA | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} + | SEOperatorNotFound {serverOperatorId :: Int64} | SEUsageConditionsNotFound deriving (Show, Exception) diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index e38a34d45f..aa6babfcbd 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Terminal where @@ -13,15 +14,15 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB -import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) +import Simplex.Chat (_defaultNtfServers, defaultChatConfig, operatorSimpleXChat) import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) +import Simplex.Chat.Operators import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent.Env.SQLite (allRoles, presetServerCfg) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) import System.IO (hFlush, hSetEcho, stdin, stdout) @@ -29,20 +30,24 @@ import System.IO (hFlush, hSetEcho, stdin, stdout) terminalChatConfig :: ChatConfig terminalChatConfig = defaultChatConfig - { defaultServers = - DefaultAgentServers - { smp = - L.fromList $ - map - (presetServerCfg True allRoles operatorSimpleXChat) - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" - ], - useSMP = 3, - ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], - xftp = L.map (presetServerCfg True allRoles operatorSimpleXChat) defaultXFTPServers, - useXFTP = L.length defaultXFTPServers, + { presetServers = + PresetServers + { operators = + [ PresetOperator + { operator = Just operatorSimpleXChat, + smp = + map + (presetServer True) + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" + ], + useSMP = 3, + xftp = map (presetServer True) $ L.toList defaultXFTPServers, + useXFTP = 3 + } + ], + ntf = _defaultNtfServers, netCfg = defaultNetworkConfig { smpProxyMode = SPMUnknown, diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index 64703a3a92..b0eb4dac88 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -10,7 +10,7 @@ import Data.Maybe (fromMaybe) import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Network.Socket -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), DefaultAgentServers (DefaultAgentServers, netCfg), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal @@ -56,7 +56,7 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r welcome :: ChatConfig -> ChatOpts -> IO () -welcome ChatConfig {defaultServers = DefaultAgentServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = +welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = mapM_ putStrLn [ versionString versionNumber, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1e6986ee03..2f289afe4b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,12 +19,13 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) -import Data.List (foldl', groupBy, intercalate, intersperse, partition, sortOn) +import Data.List (groupBy, intercalate, intersperse, partition, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) +import Data.String import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) @@ -54,7 +55,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) -import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerCfg (..)) +import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) @@ -96,10 +97,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiParsedMarkdown ft -> [viewJSON ft] - CRUserProtoServers u userServers operators -> ttyUser u $ viewUserServers userServers operators testView CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure - CRServerOperators {} -> [] - CRUserServers {} -> [] + CRServerOperators ops ca -> viewServerOperators ops ca + CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) CRUserServersValidation _ -> [] CRUsageConditions {} -> [] CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl @@ -1214,27 +1214,31 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] -viewUserServers :: AUserProtoServers -> [ServerOperator] -> Bool -> [StyledString] -viewUserServers (AUPS UserProtoServers {serverProtocol = p, protoServers, presetServers}) operators testView = - customServers - <> if testView - then [] - else - [ "", - "use " <> highlight (srvCmd <> " test ") <> " to test " <> pName <> " server connection", - "use " <> highlight (srvCmd <> " ") <> " to configure " <> pName <> " servers", - "use " <> highlight (srvCmd <> " default") <> " to remove configured " <> pName <> " servers and use presets" - ] - <> case p of - SPSMP -> ["(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"] - SPXFTP -> ["(chat option " <> highlight' "-xftp-servers" <> " has precedence over saved XFTP servers for chat session)"] +viewUserServers :: UserOperatorServers -> [StyledString] +viewUserServers (UserOperatorServers _ [] []) = [] +viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = + [plain $ maybe "Your servers" shortViewOperator operator] + <> viewServers SPSMP smpServers + <> viewServers SPXFTP xftpServers where - srvCmd = "/" <> strEncode p - pName = protocolName p - customServers = - if null protoServers - then ("no " <> pName <> " servers saved, using presets: ") : viewServers operators presetServers - else viewServers operators protoServers + viewServers :: ProtocolTypeI p => SProtocolType p -> [UserServer p] -> [StyledString] + viewServers _ [] = [] + viewServers p srvs = [" " <> protocolName p <> " servers"] <> map (plain . (" " <> ) . viewServer) srvs + where + viewServer UserServer {server, preset, tested, enabled} = safeDecodeUtf8 (strEncode server) <> serverInfo + where + serverInfo = if null serverInfo_ then "" else parens $ T.intercalate ", " serverInfo_ + serverInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] + testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested + +serversUserHelp :: [StyledString] +serversUserHelp = + [ "", + "use " <> highlight' "/smp test " <> " to test SMP server connection", + "use " <> highlight' "/smp " <> " to configure SMP servers", + "or the same commands starting from /xftp for XFTP servers", + "chat options " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") and " <> highlight' "--xftp-servers" <> " have precedence over preset servers for new user profiles" + ] protocolName :: ProtocolTypeI p => SProtocolType p -> StyledString protocolName = plain . map toUpper . T.unpack . decodeLatin1 . strEncode @@ -1255,6 +1259,53 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case where pName = protocolName p +viewServerOperators :: [ServerOperator] -> Maybe UsageConditionsAction -> [StyledString] +viewServerOperators ops ca = map (plain . viewOperator) ops <> maybe [] viewConditionsAction ca + +viewOperator :: ServerOperator' s -> Text +viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsAcceptance} = + viewOpIdTag op + <> tradeName + <> maybe "" parens legalName + <> (", domains: " <> T.intercalate ", " serverDomains) + <> (", conditions: " <> viewOpConditions conditionsAcceptance) + <> (", " <> viewOpEnabled op) + +shortViewOperator :: ServerOperator -> Text +shortViewOperator op@ServerOperator {operatorId = DBEntityId opId, tradeName} = + tshow opId <> ". " <> tradeName <> parens (viewOpEnabled op) + +viewOpIdTag :: ServerOperator' s -> Text +viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of + DBEntityId i -> tshow i <> " - " <> tag + DBNewEntity -> tag + where + tag = maybe "" textEncode operatorTag <> ". " + +viewOpConditions :: ConditionsAcceptance -> Text +viewOpConditions = \case + CAAccepted ts -> viewCond "accepted" ts + CARequired ts -> viewCond "required" ts + where + viewCond w ts = w <> maybe "" (parens . tshow) ts + +viewOpEnabled :: ServerOperator' s -> Text +viewOpEnabled ServerOperator {enabled, roles = ServerRoles {storage, proxy}} + | enabled && storage && proxy = "enabled" + | enabled && storage = "enabled storage" + | enabled && proxy = "enabled proxy" + | otherwise = "disabled" + +viewConditionsAction :: UsageConditionsAction -> [StyledString] +viewConditionsAction = \case + UCAReview {operators, deadline, showNotice} | showNotice -> case deadline of + Just ts -> [plain $ "New conditions will be accepted at " <> tshow ts <> " for " <> ops] + Nothing -> [plain $ "New conditions have to be accepted for " <> ops] + where + ops = T.intercalate ", " $ map legalName_ operators + legalName_ ServerOperator {tradeName, legalName} = fromMaybe tradeName legalName + _ -> [] + viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case Nothing -> ["old messages are not being deleted"] @@ -1331,11 +1382,11 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] <> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] -viewServers :: ProtocolTypeI p => [ServerOperator] -> NonEmpty (ServerCfg p) -> [StyledString] -viewServers operators = map (plain . (\ServerCfg {server, operator} -> B.unpack (strEncode server) <> viewOperator operator)) . L.toList - where - ops :: Map (Maybe Int64) Text = foldl' (\m ServerOperator {operatorId, tradeName} -> M.insert (Just operatorId) tradeName m) M.empty operators - viewOperator = maybe "" $ \op -> " (operator " <> maybe (show op) T.unpack (M.lookup (Just op) ops) <> ")" +-- viewServers :: ProtocolTypeI p => [ServerOperator] -> NonEmpty (ServerCfg p) -> [StyledString] +-- viewServers operators = map (plain . (\ServerCfg {server, operator} -> B.unpack (strEncode server) <> viewOperator operator)) . L.toList +-- where +-- ops :: Map (Maybe DBEntityId) Text = foldl' (\m ServerOperator {operatorId, tradeName} -> M.insert (Just operatorId) tradeName m) M.empty operators +-- viewOperator = maybe "" $ \op -> " (operator " <> maybe (show op) T.unpack (M.lookup (Just op) ops) <> ")" viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo @@ -1934,7 +1985,9 @@ viewVersionInfo logLevel CoreVersionInfo {version, simplexmqVersion, simplexmqCo then [versionString version, updateStr, "simplexmq: " <> simplexmqVersion <> parens simplexmqCommit] else [versionString version, updateStr] where - parens s = " (" <> s <> ")" + +parens :: (IsString a, Semigroup a) => a -> a +parens s = " (" <> s <> ")" viewRemoteHosts :: [RemoteHostInfo] -> [StyledString] viewRemoteHosts = \case diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d435af186e..b3d8166f9f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,9 +25,10 @@ import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), PresetServers (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Operators (PresetOperator (..), presetServer) import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -94,8 +95,8 @@ testCoreOpts = { dbFilePrefix = "./simplex_v1", dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", - smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], - xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], + smpServers = [], + xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, logLevel = CLLImportant, logConnections = False, @@ -149,6 +150,18 @@ testCfg :: ChatConfig testCfg = defaultChatConfig { agentConfig = testAgentCfg, + presetServers = + (presetServers defaultChatConfig) + { operators = + [ PresetOperator + { operator = Nothing, + smp = map (presetServer True) ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], + useSMP = 1, + xftp = map (presetServer True) ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], + useXFTP = 1 + } + ] + }, showReceipts = False, testView = True, tbqSize = 16 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 8756657e59..bd2a267c3a 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -25,7 +25,7 @@ import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call -import Simplex.Chat.Controller (ChatConfig (..), DefaultAgentServers (..)) +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) @@ -334,8 +334,8 @@ testRetryConnectingClientTimeout tmp = do { quotaExceededTimeout = 1, messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} }, - defaultServers = - let def@DefaultAgentServers {netCfg} = defaultServers testCfg + presetServers = + let def@PresetServers {netCfg} = presetServers testCfg in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = 10}} } opts' = @@ -1141,17 +1141,32 @@ testGetSetSMPServers :: HasCallStack => FilePath -> IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/_servers 1 smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + alice ##> "/_servers 1" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset)" alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") - alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" + alice <## " smp://1234-w==@smp1.example.im" alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") - alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") + -- alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" + alice <## " smp://1234-w==:password@smp1.example.im" alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") alice ##> "/smp" - alice <## "smp://2345-w==@smp2.example.im" - alice <## "smp://3456-w==@smp3.example.im:5224" - alice #$> ("/smp default", id, "ok") - alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001") + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" + alice <## " smp://2345-w==@smp2.example.im" + alice <## " smp://3456-w==@smp3.example.im:5224" testTestSMPServerConnection :: HasCallStack => FilePath -> IO () testTestSMPServerConnection = @@ -1172,17 +1187,31 @@ testGetSetXFTPServers :: HasCallStack => FilePath -> IO () testGetSetXFTPServers = testChat2 aliceProfile bobProfile $ \alice _ -> withXFTPServer $ do - alice #$> ("/_servers 1 xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") + alice ##> "/_servers 1" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset)" alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok") - alice #$> ("/xftp", id, "xftp://1234-w==@xftp1.example.im") + alice ##> "/xftp" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" + alice <## " xftp://1234-w==@xftp1.example.im" alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok") - alice #$> ("/xftp", id, "xftp://1234-w==:password@xftp1.example.im") + alice ##> "/xftp" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" + alice <## " xftp://1234-w==:password@xftp1.example.im" alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") alice ##> "/xftp" - alice <## "xftp://2345-w==@xftp2.example.im" - alice <## "xftp://3456-w==@xftp3.example.im:5224" - alice #$> ("/xftp default", id, "ok") - alice #$> ("/xftp", id, "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002") + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" + alice <## " xftp://2345-w==@xftp2.example.im" + alice <## " xftp://3456-w==@xftp3.example.im:5224" testTestXFTPServer :: HasCallStack => FilePath -> IO () testTestXFTPServer = @@ -1800,11 +1829,17 @@ testCreateUserSameServers = where checkCustomServers alice = do alice ##> "/smp" - alice <## "smp://2345-w==@smp2.example.im" - alice <## "smp://3456-w==@smp3.example.im:5224" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" + alice <## " smp://2345-w==@smp2.example.im" + alice <## " smp://3456-w==@smp3.example.im:5224" alice ##> "/xftp" - alice <## "xftp://2345-w==@xftp2.example.im" - alice <## "xftp://3456-w==@xftp3.example.im:5224" + alice <## "Your servers" + alice <## " XFTP servers" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" + alice <## " xftp://2345-w==@xftp2.example.im" + alice <## " xftp://3456-w==@xftp3.example.im:5224" testDeleteUser :: HasCallStack => FilePath -> IO () testDeleteUser = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a7de42128c..a51f42114b 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,8 +1,10 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatTests.Groups where diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 06ed9aa5bc..1d390e1236 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2,6 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module ChatTests.Profiles where @@ -1733,7 +1734,16 @@ testChangePCCUserDiffSrv tmp = do -- Create new user with different servers alice ##> "/create user alisa" showActiveUser alice "alisa" - alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003", id, "ok") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok") + alice ##> "/smp" + alice <## "Your servers" + alice <## " SMP servers" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003" alice ##> "/user alice" showActiveUser alice "alice (Alice)" -- Change connection to newly created user and use the newly created connection diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index e0b1939c9e..8b0b94dbd5 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -1,53 +1,64 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module RandomServers where import Control.Monad (replicateM) +import Data.Foldable (foldMap') +import Data.List (sortOn) +import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Simplex.Chat (cfgServers, cfgServersToUse, defaultChatConfig, randomServers) -import Simplex.Chat.Controller (ChatConfig (..)) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..)) +import Data.Monoid (Sum (..)) +import Simplex.Chat (defaultChatConfig, randomPresetServers) +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) +import Simplex.Chat.Operators (DBEntityId' (..), NewUserServer, UserServer' (..), operatorServers, operatorServersToUse) +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol) import Test.Hspec randomServersTests :: Spec randomServersTests = describe "choosig random servers" $ do - it "should choose 4 random SMP servers and keep the rest disabled" testRandomSMPServers - it "should keep all 6 XFTP servers" testRandomXFTPServers + it "should choose 4 + 3 random SMP servers and keep the rest disabled" testRandomSMPServers + it "should choose 3 + 3 random XFTP servers and keep the rest disabled" testRandomXFTPServers deriving instance Eq ServerRoles -deriving instance Eq (ServerCfg p) +deriving instance Eq (DBEntityId' s) + +deriving instance Eq (UserServer' s p) testRandomSMPServers :: IO () testRandomSMPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPSMP 4 False =<< randomServers SPSMP defaultChatConfig + checkEnabled SPSMP 7 False =<< randomPresetServers SPSMP (presetServers defaultChatConfig) (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures testRandomXFTPServers :: IO () testRandomXFTPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPXFTP 6 True =<< randomServers SPXFTP defaultChatConfig - (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` True + checkEnabled SPXFTP 6 False =<< randomPresetServers SPXFTP (presetServers defaultChatConfig) + (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures -checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> (L.NonEmpty (ServerCfg p), [ServerCfg p]) -> IO [ServerCfg p] -checkEnabled p n allUsed (srvs, _) = do - let def = defaultServers defaultChatConfig - cfgSrvs = L.sortWith server' $ cfgServers p def - toUse = cfgServersToUse p def - srvs == cfgSrvs `shouldBe` allUsed - L.map enable srvs `shouldBe` L.map enable cfgSrvs - let enbldSrvs = L.filter (\ServerCfg {enabled} -> enabled) srvs +checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (NewUserServer p) -> IO [NewUserServer p] +checkEnabled p n allUsed srvs = do + let srvs' = sortOn server' $ L.toList srvs + PresetServers {operators = presetOps} = presetServers defaultChatConfig + presetSrvs = sortOn server' $ concatMap (operatorServers p) presetOps + Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps + srvs' == presetSrvs `shouldBe` allUsed + map enable srvs' `shouldBe` map enable presetSrvs + let enbldSrvs = filter (\UserServer {enabled} -> enabled) srvs' toUse `shouldBe` n length enbldSrvs `shouldBe` n pure enbldSrvs where - server' ServerCfg {server = ProtoServerWithAuth srv _} = srv - enable :: forall p. ServerCfg p -> ServerCfg p - enable srv = (srv :: ServerCfg p) {enabled = False} + server' UserServer {server = ProtoServerWithAuth srv _} = srv + enable :: forall p. NewUserServer p -> NewUserServer p + enable srv = (srv :: NewUserServer p) {enabled = False} From 1fbf21d3953bea03ff05d827fe46dca05845bc90 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 15 Nov 2024 07:15:04 +0000 Subject: [PATCH 09/34] core: validate servers of all user profiles (#5180) * core: validate servers of all user profiles * validate all servers * fix parsing, test --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 12 ++- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Operators.hs | 130 ++++++++++++++++++++++++--------- src/Simplex/Chat/View.hs | 2 +- tests/OperatorTests.hs | 92 +++++++++++++++++++++++ tests/RandomServers.hs | 4 +- tests/Test.hs | 2 + 8 files changed, 207 insertions(+), 40 deletions(-) create mode 100644 tests/OperatorTests.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d3ea814011..8d1a298af4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -618,6 +618,7 @@ test-suite simplex-chat-test MarkdownTests MessageBatching MobileTests + OperatorTests ProtocolTests RandomServers RemoteTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 86b6a5e51b..05f99656bb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1608,7 +1608,7 @@ processChatCommand' vr = \case APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do - let errors = validateUserServers userServers + errors <- validateAllUsersServers userId $ L.toList userServers unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) (operators, smpServers, xftpServers) <- withFastStore $ \db -> do setUserServers db user userServers @@ -1620,7 +1620,8 @@ processChatCommand' vr = \case setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPSMP rs) smpServers setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPXFTP rs) xftpServers ok_ - APIValidateServers userServers -> pure $ CRUserServersValidation $ validateUserServers userServers + APIValidateServers userId userServers -> withUserId userId $ \user -> + CRUserServersValidation user <$> validateAllUsersServers userId userServers APIGetUsageConditions -> do (usageConditions, acceptedConditions) <- withFastStore $ \db -> do usageConditions <- getCurrentUsageConditions db @@ -2926,6 +2927,11 @@ processChatCommand' vr = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p + validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError] + validateAllUsersServers currUserId userServers = withFastStore $ \db -> do + users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db) + others <- mapM (\user -> liftIO . fmap (user,) . groupByOperator =<< getUserServers db user) users' + pure $ validateUserServers userServers others forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case @@ -8242,7 +8248,7 @@ chatCommandP = "/_operators " *> (APISetServerOperators <$> jsonP), "/_servers " *> (APIGetUserServers <$> A.decimal), "/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), - "/_validate_servers " *> (APIValidateServers <$> jsonP), + "/_validate_servers " *> (APIValidateServers <$> A.decimal <* A.space <*> jsonP), "/_conditions" $> APIGetUsageConditions, "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3c2b8045d7..27acf8990b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -358,7 +358,7 @@ data ChatCommand | APISetServerOperators (NonEmpty ServerOperator) | APIGetUserServers UserId | APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers) - | APIValidateServers (NonEmpty UpdatedUserOperatorServers) -- response is CRUserServersValidation + | APIValidateServers UserId [ValidatedUserOperatorServers] -- response is CRUserServersValidation | APIGetUsageConditions | APISetConditionsNotified Int64 | APIAcceptConditions Int64 (NonEmpty Int64) @@ -590,7 +590,7 @@ data ChatResponse | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperators {operators :: [ServerOperator], conditionsAction :: Maybe UsageConditionsAction} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} - | CRUserServersValidation {serverErrors :: [UserServersError]} + | CRUserServersValidation {user :: User, serverErrors :: [UserServersError]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 55de357090..6bf1a75da4 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE KindSignatures #-} @@ -13,6 +14,7 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilyDependencies #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Operators where @@ -22,10 +24,12 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ +import Data.Either (partitionEithers) import Data.FileEmbed import Data.Foldable (foldMap') import Data.IORef import Data.Int (Int64) +import Data.Kind import Data.List (find, foldl') import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L @@ -43,11 +47,12 @@ import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) @@ -196,10 +201,56 @@ data UpdatedUserOperatorServers = UpdatedUserOperatorServers } deriving (Show) -updatedServers :: UserProtocol p => UpdatedUserOperatorServers -> SProtocolType p -> [AUserServer p] -updatedServers UpdatedUserOperatorServers {smpServers, xftpServers} = \case - SPSMP -> smpServers - SPXFTP -> xftpServers +data ValidatedUserOperatorServers = ValidatedUserOperatorServers + { operator :: Maybe ServerOperator, + smpServers :: [AValidatedServer 'PSMP], + xftpServers :: [AValidatedServer 'PXFTP] + } + deriving (Show) + +data AValidatedServer p = forall s. AVS (SDBStored s) (ValidatedServer s p) + +deriving instance Show (AValidatedServer p) + +type ValidatedServer s p = UserServer_ s ValidatedProtoServer p + +data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoServerWithAuth p)} + deriving (Show) + +class UserServersClass u where + type AServer u = (s :: ProtocolType -> Type) | s -> u + operator' :: u -> Maybe ServerOperator + partitionValid :: [AServer u p] -> ([Text], [AUserServer p]) + servers' :: UserProtocol p => u -> SProtocolType p -> [AServer u p] + +instance UserServersClass UserOperatorServers where + type AServer UserOperatorServers = UserServer_ 'DBStored ProtoServerWithAuth + operator' UserOperatorServers {operator} = operator + partitionValid ss = ([], map (AUS SDBStored) ss) + servers' UserOperatorServers {smpServers, xftpServers} = \case + SPSMP -> smpServers + SPXFTP -> xftpServers + +instance UserServersClass UpdatedUserOperatorServers where + type AServer UpdatedUserOperatorServers = AUserServer + operator' UpdatedUserOperatorServers {operator} = operator + partitionValid = ([],) + servers' UpdatedUserOperatorServers {smpServers, xftpServers} = \case + SPSMP -> smpServers + SPXFTP -> xftpServers + +instance UserServersClass ValidatedUserOperatorServers where + type AServer ValidatedUserOperatorServers = AValidatedServer + operator' ValidatedUserOperatorServers {operator} = operator + partitionValid = partitionEithers . map serverOrErr + where + serverOrErr :: AValidatedServer p -> Either Text (AUserServer p) + serverOrErr (AVS s srv@UserServer {server = server'}) = (\server -> AUS s srv {server}) <$> unVPS server' + servers' ValidatedUserOperatorServers {smpServers, xftpServers} = \case + SPSMP -> smpServers + SPXFTP -> xftpServers + +type UserServer' s p = UserServer_ s ProtoServerWithAuth p type UserServer p = UserServer' 'DBStored p @@ -209,9 +260,9 @@ data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p) deriving instance Show (AUserServer p) -data UserServer' s p = UserServer +data UserServer_ s (srv :: ProtocolType -> Type) (p :: ProtocolType) = UserServer { serverId :: DBEntityId' s, - server :: ProtoServerWithAuth p, + server :: srv p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool, @@ -352,35 +403,36 @@ groupByOperator (ops, smpSrvs, xftpSrvs) = do addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} data UserServersError - = USEStorageMissing {protocol :: AProtocolType} - | USEProxyMissing {protocol :: AProtocolType} - | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: AProtoServerWithAuth, duplicateHost :: TransportHost} + = USENoServers {protocol :: AProtocolType, user :: Maybe User} + | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} + | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} + | USEInvalidServer {protocol :: AProtocolType, invalidServer :: Text} + | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} deriving (Show) -validateUserServers :: NonEmpty UpdatedUserOperatorServers -> [UserServersError] -validateUserServers uss = - missingRolesErr SPSMP storage USEStorageMissing - <> missingRolesErr SPSMP proxy USEProxyMissing - <> missingRolesErr SPXFTP storage USEStorageMissing - <> duplicatServerErrs SPSMP - <> duplicatServerErrs SPXFTP +validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> [UserServersError] +validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others where - missingRolesErr :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> (AProtocolType -> UserServersError) -> [UserServersError] - missingRolesErr p roleSel err = [err (AProtocolType p) | not hasRole] + currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr + otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss + noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] + noServersErrs p user uss + | noServers opEnabled = [USENoServers p' user] + | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] where - hasRole = - any (\(AUS _ UserServer {deleted, enabled}) -> enabled && not deleted) $ - concatMap (`updatedServers` p) $ filter roleEnabled (L.toList uss) - roleEnabled UpdatedUserOperatorServers {operator} = - maybe True (\ServerOperator {enabled, roles} -> enabled && roleSel roles) operator - duplicatServerErrs :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServersError] - duplicatServerErrs p = mapMaybe duplicateErr_ srvs + p' = AProtocolType p + noServers cond = not $ any srvEnabled $ snd $ partitionValid $ concatMap (`servers'` p) $ filter cond uss + opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' + hasRole roleSel = maybe True (\ServerOperator {enabled, roles} -> enabled && roleSel roles) . operator' + srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted + serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] + serverErrs p uss = map (USEInvalidServer p') invalidSrvs <> mapMaybe duplicateErr_ srvs where - srvs = - filter (\(AUS _ UserServer {deleted}) -> not deleted) $ - concatMap (`updatedServers` p) (L.toList uss) + p' = AProtocolType p + (invalidSrvs, userSrvs) = partitionValid $ concatMap (`servers'` p) uss + srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) userSrvs duplicateErr_ (AUS _ srv@UserServer {server}) = - USEDuplicateServer (AProtocolType p) (AProtoServerWithAuth p server) + USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs @@ -421,18 +473,30 @@ instance DBStoredI s => FromJSON (ServerOperator' s) where $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) instance ProtocolTypeI p => ToJSON (UserServer' s p) where - toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer') - toJSON = $(JQ.mkToJSON defaultJSON ''UserServer') + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer_) + toJSON = $(JQ.mkToJSON defaultJSON ''UserServer_) instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer') + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer_) instance ProtocolTypeI p => FromJSON (AUserServer p) where parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) +instance ProtocolTypeI p => FromJSON (ValidatedProtoServer p) where + parseJSON v = ValidatedProtoServer <$> ((Right <$> parseJSON v) <|> (Left <$> parseJSON v)) + +instance (DBStoredI s, ProtocolTypeI p) => FromJSON (ValidatedServer s p) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer_) + +instance ProtocolTypeI p => FromJSON (AValidatedServer p) where + parseJSON v = (AVS SDBStored <$> parseJSON v) <|> (AVS SDBNew <$> parseJSON v) + $(JQ.deriveJSON defaultJSON ''UserOperatorServers) instance FromJSON UpdatedUserOperatorServers where parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) +instance FromJSON ValidatedUserOperatorServers where + parseJSON = $(JQ.mkParseJSON defaultJSON ''ValidatedUserOperatorServers) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2f289afe4b..317fd58a8e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -100,7 +100,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerOperators ops ca -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) - CRUserServersValidation _ -> [] + CRUserServersValidation {} -> [] CRUsageConditions {} -> [] CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs new file mode 100644 index 0000000000..1b867a3e1d --- /dev/null +++ b/tests/OperatorTests.hs @@ -0,0 +1,92 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module OperatorTests (operatorTests) where + +import qualified Data.List.NonEmpty as L +import Simplex.Chat +import Simplex.Chat.Operators +import Simplex.Chat.Types +import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) +import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Protocol +import Test.Hspec + +operatorTests :: Spec +operatorTests = describe "managing server operators" $ do + validateServers + +validateServers :: Spec +validateServers = describe "validate user servers" $ do + it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` [] + it "should fail without servers" $ do + validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing] + validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing] + validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing] + it "should fail without servers with storage role" $ do + validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing, USEStorageMissing aXFTP Nothing] + it "should fail with duplicate host" $ do + validateUserServers [invalidDuplicate] [] `shouldBe` + [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", + USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" + ] + it "should fail with invalid host" $ do + validateUserServers [invalidHost] [] `shouldBe` [USENoServers aXFTP Nothing, USEInvalidServer aSMP "smp:abcd@smp8.simplex.im"] + where + aSMP = AProtocolType SPSMP + aXFTP = AProtocolType SPXFTP + +deriving instance Eq User + +deriving instance Eq UserServersError + +valid :: UpdatedUserOperatorServers +valid = + UpdatedUserOperatorServers + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, + smpServers = map (AUS SDBNew) simplexChatSMPServers, + xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers + } + +invalidNoServers :: UpdatedUserOperatorServers +invalidNoServers = (valid :: UpdatedUserOperatorServers) {smpServers = []} + +invalidDisabled :: UpdatedUserOperatorServers +invalidDisabled = + (valid :: UpdatedUserOperatorServers) + { smpServers = map (AUS SDBNew . (\srv -> (srv :: NewUserServer 'PSMP) {enabled = False})) simplexChatSMPServers + } + +invalidDisabledOp :: UpdatedUserOperatorServers +invalidDisabledOp = + (valid :: UpdatedUserOperatorServers) + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, enabled = False} + } + +invalidNoStorage :: UpdatedUserOperatorServers +invalidNoStorage = + (valid :: UpdatedUserOperatorServers) + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, roles = allRoles {storage = False}} + } + +invalidDuplicate :: UpdatedUserOperatorServers +invalidDuplicate = + (valid :: UpdatedUserOperatorServers) + { smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"] + } + +invalidHost :: ValidatedUserOperatorServers +invalidHost = + ValidatedUserOperatorServers + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, + smpServers = [validatedServer (Left "smp:abcd@smp8.simplex.im"), validatedServer (Right "smp://abcd@smp8.simplex.im")], + xftpServers = [] + } + where + validatedServer srv = + AVS SDBNew (presetServer @'PSMP True "smp://abcd@smp8.simplex.im") {server = ValidatedProtoServer srv} diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index 8b0b94dbd5..048a2b5e5a 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -1,8 +1,10 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeSynonymInstances #-} {-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -16,7 +18,7 @@ import qualified Data.List.NonEmpty as L import Data.Monoid (Sum (..)) import Simplex.Chat (defaultChatConfig, randomPresetServers) import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) -import Simplex.Chat.Operators (DBEntityId' (..), NewUserServer, UserServer' (..), operatorServers, operatorServersToUse) +import Simplex.Chat.Operators import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), SProtocolType (..), UserProtocol) import Test.Hspec diff --git a/tests/Test.hs b/tests/Test.hs index 3d59b840dd..079c583a6e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -10,6 +10,7 @@ import MarkdownTests import MessageBatching import MobileTests import ProtocolTests +import OperatorTests import RandomServers import RemoteTests import SchemaDump @@ -31,6 +32,7 @@ main = do around tmpBracket $ describe "WebRTC encryption" webRTCTests describe "Valid names" validNameTests describe "Message batching" batchingTests + describe "Operators" operatorTests describe "Random servers" randomServersTests around testBracket $ do describe "Mobile API Tests" mobileTests From ff8e29c0eb202792058d5ed391c152d3558ec07c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:20:32 +0400 Subject: [PATCH 10/34] core: fix accept conditions query (#5187) --- src/Simplex/Chat/Store/Profiles.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 39bd4bb985..87b5d2fd64 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -796,7 +796,7 @@ acceptConditions db condId opIds acceptedAt = do where getServerOperator_ opId = ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ - DB.query db (serverOperatorQuery <> " WHERE operator_id = ?") (Only opId) + DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = From feb687d3b8bae376691f01807305d76504cfbe73 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 15 Nov 2024 12:08:15 +0000 Subject: [PATCH 11/34] core: different roles for different protocols (#5185) * core: different roles for different protocols * include current conditions in responses * fix * fix test * fix --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- src/Simplex/Chat.hs | 28 ++++++----- src/Simplex/Chat/Controller.hs | 2 +- .../Migrations/M20241027_server_operators.hs | 6 ++- src/Simplex/Chat/Migrations/chat_schema.sql | 6 ++- src/Simplex/Chat/Operators.hs | 31 ++++++++---- src/Simplex/Chat/Store/Profiles.hs | 44 +++++++++-------- src/Simplex/Chat/View.hs | 48 ++++++++++++------- tests/OperatorTests.hs | 4 +- 8 files changed, 104 insertions(+), 65 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 05f99656bb..95bb405bae 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -150,7 +150,8 @@ operatorSimpleXChat = serverDomains = ["simplex.im"], conditionsAcceptance = CARequired Nothing, enabled = True, - roles = allRoles + smpRoles = allRoles, + xftpRoles = allRoles } operatorFlux :: NewServerOperator @@ -163,7 +164,8 @@ operatorFlux = serverDomains = ["simplexonflux.com"], conditionsAcceptance = CARequired Nothing, enabled = False, - roles = ServerRoles {storage = False, proxy = True} + smpRoles = ServerRoles {storage = False, proxy = True}, + xftpRoles = allRoles } defaultChatConfig :: ChatConfig @@ -420,7 +422,7 @@ newChatController getServers p users opDomains = do let rs' = rndServers p rs fmap M.fromList $ forM users $ \u -> - (aUserId u,) . agentServerCfgs opDomains rs' <$> getUpdateUserServers db p presetOps rs' u + (aUserId u,) . agentServerCfgs p opDomains rs' <$> getUpdateUserServers db p presetOps rs' u updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = @@ -643,10 +645,10 @@ processChatCommand' vr = \case forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} - opDomains <- operatorDomains . fst <$> withFastStore getServerOperators + opDomains <- operatorDomains . serverOperators <$> withFastStore getServerOperators rs <- asks randomServers - let smp = agentServerCfgs opDomains (rndServers SPSMP rs) smpServers - xftp = agentServerCfgs opDomains (rndServers SPXFTP rs) xftpServers + let smp = agentServerCfgs SPSMP opDomains (rndServers SPSMP rs) smpServers + xftp = agentServerCfgs SPXFTP opDomains (rndServers SPXFTP rs) xftpServers auId <- withAgent (\a -> createUser a smp xftp) ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts @@ -1601,10 +1603,10 @@ processChatCommand' vr = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv - APIGetServerOperators -> uncurry CRServerOperators <$> withFastStore getServerOperators + APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators APISetServerOperators operatorsEnabled -> withFastStore $ \db -> do liftIO $ setServerOperators db operatorsEnabled - uncurry CRServerOperators <$> getServerOperators db + CRServerOperatorConditions <$> getServerOperators db APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do @@ -1617,8 +1619,8 @@ processChatCommand' vr = \case rs <- asks randomServers lift $ withAgent' $ \a -> do let auId = aUserId user - setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPSMP rs) smpServers - setProtocolServers a auId $ agentServerCfgs opDomains (rndServers SPXFTP rs) xftpServers + setProtocolServers a auId $ agentServerCfgs SPSMP opDomains (rndServers SPSMP rs) smpServers + setProtocolServers a auId $ agentServerCfgs SPXFTP opDomains (rndServers SPXFTP rs) xftpServers ok_ APIValidateServers userId userServers -> withUserId userId $ \user -> CRUserServersValidation user <$> validateAllUsersServers userId userServers @@ -1641,7 +1643,7 @@ processChatCommand' vr = \case APIAcceptConditions condId opIds -> withFastStore $ \db -> do currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs - uncurry CRServerOperators <$> getServerOperators db + CRServerOperatorConditions <$> getServerOperators db APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do @@ -3777,9 +3779,9 @@ getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> getKnownAgentServers p user = do rs <- asks randomServers withStore $ \db -> do - opDomains <- operatorDomains . fst <$> getServerOperators db + opDomains <- operatorDomains . serverOperators <$> getServerOperators db srvs <- liftIO $ getProtocolServers db p user - pure $ L.toList $ agentServerCfgs opDomains (rndServers p rs) srvs + pure $ L.toList $ agentServerCfgs p opDomains (rndServers p rs) srvs protoServer' :: ServerCfg p -> ProtocolServer p protoServer' ServerCfg {server} = protoServer server diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 27acf8990b..7fb811255f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -588,7 +588,7 @@ data ChatResponse | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} - | CRServerOperators {operators :: [ServerOperator], conditionsAction :: Maybe UsageConditionsAction} + | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} | CRUserServersValidation {user :: User, serverErrors :: [UserServersError]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs index d84cc5aa73..c4b40c4706 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -15,8 +15,10 @@ CREATE TABLE server_operators ( legal_name TEXT, server_domains TEXT, enabled INTEGER NOT NULL DEFAULT 1, - role_storage INTEGER NOT NULL DEFAULT 1, - role_proxy INTEGER NOT NULL DEFAULT 1, + smp_role_storage INTEGER NOT NULL DEFAULT 1, + smp_role_proxy INTEGER NOT NULL DEFAULT 1, + xftp_role_storage INTEGER NOT NULL DEFAULT 1, + xftp_role_proxy INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index c037a60770..0dc68034e7 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -596,8 +596,10 @@ CREATE TABLE server_operators( legal_name TEXT, server_domains TEXT, enabled INTEGER NOT NULL DEFAULT 1, - role_storage INTEGER NOT NULL DEFAULT 1, - role_proxy INTEGER NOT NULL DEFAULT 1, + smp_role_storage INTEGER NOT NULL DEFAULT 1, + smp_role_proxy INTEGER NOT NULL DEFAULT 1, + xftp_role_storage INTEGER NOT NULL DEFAULT 1, + xftp_role_proxy INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6bf1a75da4..c3d9a8823b 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -134,6 +134,13 @@ data UsageConditionsAction | UCAAccepted {operators :: [ServerOperator]} deriving (Show) +data ServerOperatorConditions = ServerOperatorConditions + { serverOperators :: [ServerOperator], + currentConditions :: UsageConditions, + conditionsAction :: Maybe UsageConditionsAction + } + deriving (Show) + usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators @@ -178,10 +185,16 @@ data ServerOperator' s = ServerOperator serverDomains :: [Text], conditionsAcceptance :: ConditionsAcceptance, enabled :: Bool, - roles :: ServerRoles + smpRoles :: ServerRoles, + xftpRoles :: ServerRoles } deriving (Show) +operatorRoles :: UserProtocol p => SProtocolType p -> ServerOperator -> ServerRoles +operatorRoles p op = case p of + SPSMP -> smpRoles op + SPXFTP -> xftpRoles op + conditionsAccepted :: ServerOperator -> Bool conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of CAAccepted {} -> True @@ -336,8 +349,8 @@ updatedServerOperators presetOps storedOps = Just presetOp -> (storedOp' :) where storedOp' = case find ((operatorTag presetOp ==) . operatorTag) storedOps of - Just ServerOperator {operatorId, conditionsAcceptance, enabled, roles} -> - ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, roles} + Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> + ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} Nothing -> ASO SDBNew presetOp -- This function should be used inside DB transaction to update servers. @@ -361,8 +374,8 @@ updatedUserServers p presetOps randomSrvs srvs = srvHost :: UserServer' s p -> NonEmpty TransportHost srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv -agentServerCfgs :: [(Text, ServerOperator)] -> NonEmpty (NewUserServer p) -> [UserServer' s p] -> NonEmpty (ServerCfg p) -agentServerCfgs opDomains randomSrvs = +agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> NonEmpty (NewUserServer p) -> [UserServer' s p] -> NonEmpty (ServerCfg p) +agentServerCfgs p opDomains randomSrvs = fromMaybe fallbackSrvs . L.nonEmpty . mapMaybe enabledOpAgentServer where fallbackSrvs = L.map (snd . agentServer) randomSrvs @@ -372,8 +385,8 @@ agentServerCfgs opDomains randomSrvs = agentServer :: UserServer' s p -> (Bool, ServerCfg p) agentServer srv@UserServer {server, enabled} = case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of - Just (_, ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled, roles}) -> - (opEnabled, ServerCfg {server, enabled, operator = Just opId, roles}) + Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) -> + (opEnabled, ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op}) Nothing -> (True, ServerCfg {server, enabled, operator = Nothing, roles = allRoles}) @@ -423,7 +436,7 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others p' = AProtocolType p noServers cond = not $ any srvEnabled $ snd $ partitionValid $ concatMap (`servers'` p) $ filter cond uss opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' - hasRole roleSel = maybe True (\ServerOperator {enabled, roles} -> enabled && roleSel roles) . operator' + hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] serverErrs p uss = map (USEInvalidServer p') invalidSrvs <> mapMaybe duplicateErr_ srvs @@ -472,6 +485,8 @@ instance DBStoredI s => FromJSON (ServerOperator' s) where $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) +$(JQ.deriveJSON defaultJSON ''ServerOperatorConditions) + instance ProtocolTypeI p => ToJSON (UserServer' s p) where toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer_) toJSON = $(JQ.mkToJSON defaultJSON ''UserServer_) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 87b5d2fd64..daf9a78fca 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -612,20 +612,21 @@ serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) auth = safeDecodeUtf8 . unBasicAuth <$> auth_ in (protocol, host, port, keyHash, auth) -getServerOperators :: DB.Connection -> ExceptT StoreError IO ([ServerOperator], Maybe UsageConditionsAction) +getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions getServerOperators db = do - currentConds <- getCurrentUsageConditions db + currentConditions <- getCurrentUsageConditions db liftIO $ do now <- getCurrentTime latestAcceptedConds_ <- getLatestAcceptedConditions db - let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConds latestAcceptedConds_ now - operators <- mapM getConds =<< getServerOperators_ db - pure (operators, usageConditionsAction operators currentConds now) + let getConds op = (\ca -> op {conditionsAcceptance = ca}) <$> getOperatorConditions_ db op currentConditions latestAcceptedConds_ now + ops <- mapM getConds =<< getServerOperators_ db + let conditionsAction = usageConditionsAction ops currentConditions now + pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) getUserServers db user = (,,) - <$> (fst <$> getServerOperators db) + <$> (serverOperators <$> getServerOperators db) <*> liftIO (getProtocolServers db SPSMP user) <*> liftIO (getProtocolServers db SPXFTP user) @@ -635,15 +636,15 @@ setServerOperators db ops = do mapM_ (updateServerOperator db currentTs) ops updateServerOperator :: DB.Connection -> UTCTime -> ServerOperator -> IO () -updateServerOperator db currentTs ServerOperator {operatorId, enabled, roles = ServerRoles {storage, proxy}} = +updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, xftpRoles} = DB.execute db [sql| UPDATE server_operators - SET enabled = ?, role_storage = ?, role_proxy = ?, updated_at = ? + SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? WHERE server_operator_id = ? |] - (enabled, storage, proxy, operatorId, currentTs) + (enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId) getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [ServerOperator] getUpdateServerOperators db presetOps newUser = do @@ -677,25 +678,25 @@ getUpdateServerOperators db presetOps newUser = do |] (conditionsId, conditionsCommit, notifiedAt, createdAt) updateOperator :: ServerOperator -> IO () - updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, roles = ServerRoles {storage, proxy}} = + updateOperator ServerOperator {operatorId, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = DB.execute db [sql| UPDATE server_operators - SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, role_storage = ?, role_proxy = ? + SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ? WHERE server_operator_id = ? |] - (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage, proxy, operatorId) + (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId) insertOperator :: NewServerOperator -> IO ServerOperator - insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, roles = ServerRoles {storage, proxy}} = do + insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do DB.execute db [sql| INSERT INTO server_operators - (server_operator_tag, trade_name, legal_name, server_domains, enabled, role_storage, role_proxy) - VALUES (?,?,?,?,?,?,?) + (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) + VALUES (?,?,?,?,?,?,?,?,?) |] - (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage, proxy) + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles) opId <- insertedRowId db pure op {operatorId = DBEntityId opId} autoAcceptConditions op UsageConditions {conditionsCommit} = @@ -706,15 +707,15 @@ serverOperatorQuery :: Query serverOperatorQuery = [sql| SELECT server_operator_id, server_operator_tag, trade_name, legal_name, - server_domains, enabled, role_storage, role_proxy + server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy FROM server_operators |] getServerOperators_ :: DB.Connection -> IO [ServerOperator] getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery -toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool, Bool, Bool) -> ServerOperator -toServerOperator (operatorId, operatorTag, tradeName, legalName, domains, enabled, storage, proxy) = +toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator +toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') = ServerOperator { operatorId, operatorTag, @@ -723,8 +724,11 @@ toServerOperator (operatorId, operatorTag, tradeName, legalName, domains, enable serverDomains = T.splitOn "," domains, conditionsAcceptance = CARequired Nothing, enabled, - roles = ServerRoles {storage, proxy} + smpRoles = serverRoles smpRoles', + xftpRoles = serverRoles xftpRoles' } + where + serverRoles (storage, proxy) = ServerRoles {storage, proxy} getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 317fd58a8e..e4c0fd5606 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -65,7 +65,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..)) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -98,7 +98,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRApiParsedMarkdown ft -> [viewJSON ft] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure - CRServerOperators ops ca -> viewServerOperators ops ca + CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) CRUserServersValidation {} -> [] CRUsageConditions {} -> [] @@ -1221,15 +1221,27 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = <> viewServers SPSMP smpServers <> viewServers SPXFTP xftpServers where - viewServers :: ProtocolTypeI p => SProtocolType p -> [UserServer p] -> [StyledString] + viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString] viewServers _ [] = [] - viewServers p srvs = [" " <> protocolName p <> " servers"] <> map (plain . (" " <> ) . viewServer) srvs + viewServers p srvs + | maybe True (\ServerOperator {enabled} -> enabled) operator = + [" " <> protocolName p <> " servers" <> maybe "" ((" " <>) . viewRoles) operator] + <> map (plain . (" " <> ) . viewServer) srvs + | otherwise = [] where viewServer UserServer {server, preset, tested, enabled} = safeDecodeUtf8 (strEncode server) <> serverInfo where serverInfo = if null serverInfo_ then "" else parens $ T.intercalate ", " serverInfo_ serverInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested + viewRoles op@ServerOperator {enabled} + | not enabled = "disabled" + | storage rs && proxy rs = "enabled" + | storage rs = "enabled storage" + | proxy rs = "enabled proxy" + | otherwise = "disabled (servers known)" + where + rs = operatorRoles p op serversUserHelp :: [StyledString] serversUserHelp = @@ -1272,8 +1284,8 @@ viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsA <> (", " <> viewOpEnabled op) shortViewOperator :: ServerOperator -> Text -shortViewOperator op@ServerOperator {operatorId = DBEntityId opId, tradeName} = - tshow opId <> ". " <> tradeName <> parens (viewOpEnabled op) +shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabled} = + tshow opId <> ". " <> tradeName <> parens (if enabled then "enabled" else "disabled") viewOpIdTag :: ServerOperator' s -> Text viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of @@ -1290,11 +1302,19 @@ viewOpConditions = \case viewCond w ts = w <> maybe "" (parens . tshow) ts viewOpEnabled :: ServerOperator' s -> Text -viewOpEnabled ServerOperator {enabled, roles = ServerRoles {storage, proxy}} - | enabled && storage && proxy = "enabled" - | enabled && storage = "enabled storage" - | enabled && proxy = "enabled proxy" - | otherwise = "disabled" +viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles} + | not enabled = "disabled" + | no smpRoles && no xftpRoles = "disabled (servers known)" + | both smpRoles && both xftpRoles = "enabled" + | otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP" <> viewRoles xftpRoles + where + no rs = not $ storage rs || proxy rs + both rs = storage rs && proxy rs + viewRoles rs + | both rs = "enabled" + | storage rs = "enabled storage" + | proxy rs = "enabled proxy" + | otherwise = "disabled (servers known)" viewConditionsAction :: UsageConditionsAction -> [StyledString] viewConditionsAction = \case @@ -1382,12 +1402,6 @@ viewConnectionStats ConnectionStats {rcvQueuesInfo, sndQueuesInfo} = ["receiving messages via: " <> viewRcvQueuesInfo rcvQueuesInfo | not $ null rcvQueuesInfo] <> ["sending messages via: " <> viewSndQueuesInfo sndQueuesInfo | not $ null sndQueuesInfo] --- viewServers :: ProtocolTypeI p => [ServerOperator] -> NonEmpty (ServerCfg p) -> [StyledString] --- viewServers operators = map (plain . (\ServerCfg {server, operator} -> B.unpack (strEncode server) <> viewOperator operator)) . L.toList --- where --- ops :: Map (Maybe DBEntityId) Text = foldl' (\m ServerOperator {operatorId, tradeName} -> M.insert (Just operatorId) tradeName m) M.empty operators --- viewOperator = maybe "" $ \op -> " (operator " <> maybe (show op) T.unpack (M.lookup (Just op) ops) <> ")" - viewRcvQueuesInfo :: [RcvQueueInfo] -> StyledString viewRcvQueuesInfo = plain . intercalate ", " . map showQueueInfo where diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 1b867a3e1d..4966bfbb97 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -29,7 +29,7 @@ validateServers = describe "validate user servers" $ do validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing] validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing] it "should fail without servers with storage role" $ do - validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing, USEStorageMissing aXFTP Nothing] + validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing] it "should fail with duplicate host" $ do validateUserServers [invalidDuplicate] [] `shouldBe` [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", @@ -71,7 +71,7 @@ invalidDisabledOp = invalidNoStorage :: UpdatedUserOperatorServers invalidNoStorage = (valid :: UpdatedUserOperatorServers) - { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, roles = allRoles {storage = False}} + { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, smpRoles = allRoles {storage = False}} } invalidDuplicate :: UpdatedUserOperatorServers From b605ebfd2adf3b426d0abc1a4fe00bd54740b48b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 15 Nov 2024 12:14:53 +0000 Subject: [PATCH 12/34] core: remove comments --- src/Simplex/Chat/Controller.hs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7fb811255f..c085dcf470 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -956,24 +956,6 @@ instance ToJSON AgentQueueId where toJSON = strToJSON toEncoding = strToJEncoding --- data ProtoServersConfig p = ProtoServersConfig {servers :: [ServerCfg p]} --- deriving (Show) - --- data AProtoServersConfig = forall p. ProtocolTypeI p => APSC (SProtocolType p) (ProtoServersConfig p) - --- deriving instance Show AProtoServersConfig - --- data UserProtoServers p = UserProtoServers --- { serverProtocol :: SProtocolType p, --- protoServers :: NonEmpty (ServerCfg p), --- presetServers :: NonEmpty (ServerCfg p) --- } --- deriving (Show) - --- data AUserProtoServers = forall p. (ProtocolTypeI p, UserProtocol p) => AUPS (UserProtoServers p) - --- deriving instance Show AUserProtoServers - data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) From 619985730ec5027ee4109eb44a80d03bc48a28d0 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 18 Nov 2024 18:44:28 +0000 Subject: [PATCH 13/34] core: use random servers for each operator (#5192) * core: use random servers for each operator (WIP, compiles with undefined stub) * compiles * fix some, break some * tests pass * cleanup * delays in tests * enable random servers test * remove new preset servers in down migration * fix migration * test --- src/Simplex/Chat.hs | 216 ++++++++++-------- src/Simplex/Chat/Controller.hs | 12 +- .../Migrations/M20241027_server_operators.hs | 2 + src/Simplex/Chat/Operators.hs | 137 ++++++----- src/Simplex/Chat/Store/Profiles.hs | 76 ++---- tests/ChatClient.hs | 19 +- tests/ChatTests/Direct.hs | 17 +- tests/ChatTests/Groups.hs | 5 - tests/ChatTests/Profiles.hs | 4 +- tests/OperatorTests.hs | 59 ++++- tests/RandomServers.hs | 20 +- 11 files changed, 319 insertions(+), 248 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 95bb405bae..819832a1ed 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3,6 +3,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} @@ -178,6 +179,8 @@ defaultChatConfig = }, chatVRange = supportedChatVRange, confirmMigrations = MCConsole, + -- this property should NOT use operator = Nothing + -- non-operator servers can be passed via options presetServers = PresetServers { operators = @@ -310,11 +313,15 @@ newChatController config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable, confirmMigrations = confirmMigrations'} firstTime = dbNew chatStore currentUser <- newTVarIO user - randomSMP <- randomPresetServers SPSMP presetServers' - randomXFTP <- randomPresetServers SPXFTP presetServers' - let randomServers = RandomServers {smpServers = randomSMP, xftpServers = randomXFTP} + randomPresetServers <- chooseRandomServers presetServers' + let rndSrvs = L.toList randomPresetServers + operatorWithId (i, op) = (\o -> o {operatorId = DBEntityId i}) <$> pOperator op + opDomains = operatorDomains $ mapMaybe operatorWithId $ zip [1..] rndSrvs + agentSMP <- randomServerCfgs "agent SMP servers" SPSMP opDomains rndSrvs + agentXFTP <- randomServerCfgs "agent XFTP servers" SPXFTP opDomains rndSrvs + let randomAgentServers = RandomAgentServers {smpServers = agentSMP, xftpServers = agentXFTP} currentRemoteHost <- newTVarIO Nothing - servers <- withTransaction chatStore $ \db -> agentServers db config randomServers + servers <- withTransaction chatStore $ \db -> agentServers db config randomPresetServers randomAgentServers smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode agentAsync <- newTVarIO Nothing random <- liftIO C.newRandom @@ -350,7 +357,8 @@ newChatController ChatController { firstTime, currentUser, - randomServers, + randomPresetServers, + randomAgentServers, currentRemoteHost, smpAgent, agentAsync, @@ -410,19 +418,26 @@ newChatController xftp = map newUserServer xftpSrvs, useXFTP = 0 } - agentServers :: DB.Connection -> ChatConfig -> RandomServers -> IO InitialAgentServers - agentServers db ChatConfig {presetServers = PresetServers {operators = presetOps, ntf, netCfg}} rs = do + randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p)) + randomServerCfgs name p opDomains rndSrvs = + toJustOrError name $ L.nonEmpty $ agentServerCfgs p opDomains $ concatMap (pServers p) rndSrvs + agentServers :: DB.Connection -> ChatConfig -> NonEmpty PresetOperator -> RandomAgentServers -> IO InitialAgentServers + agentServers db ChatConfig {presetServers = PresetServers {ntf, netCfg}} presetOps as = do users <- getUsers db - opDomains <- operatorDomains <$> getUpdateServerOperators db presetOps (null users) - smp' <- getServers SPSMP users opDomains - xftp' <- getServers SPXFTP users opDomains - pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg} + ops <- getUpdateServerOperators db presetOps (null users) + let opDomains = operatorDomains $ mapMaybe snd ops + (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users + pure InitialAgentServers {smp = M.fromList smp', xftp = M.fromList xftp', ntf, netCfg} where - getServers :: forall p. (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [User] -> [(Text, ServerOperator)] -> IO (Map UserId (NonEmpty (ServerCfg p))) - getServers p users opDomains = do - let rs' = rndServers p rs - fmap M.fromList $ forM users $ \u -> - (aUserId u,) . agentServerCfgs p opDomains rs' <$> getUpdateUserServers db p presetOps rs' u + getServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [(Text, ServerOperator)] -> User -> IO ((UserId, NonEmpty (ServerCfg 'PSMP)), (UserId, NonEmpty (ServerCfg 'PXFTP))) + getServers ops opDomains user' = do + smpSrvs <- getProtocolServers db SPSMP user' + xftpSrvs <- getProtocolServers db SPXFTP user' + uss <- groupByOperator' (ops, smpSrvs, xftpSrvs) + ts <- getCurrentTime + uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss + let auId = aUserId user' + pure $ bimap (auId,) (auId,) $ useServers as opDomains uss' updateNetworkConfig :: NetworkConfig -> SimpleNetCfg -> NetworkConfig updateNetworkConfig cfg SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} = @@ -465,28 +480,31 @@ withFileLock :: String -> Int64 -> CM a -> CM a withFileLock name = withEntityLock name . CLFile {-# INLINE withFileLock #-} -serverCfg :: ProtoServerWithAuth p -> ServerCfg p -serverCfg server = ServerCfg {server, operator = Nothing, enabled = True, roles = allRoles} +useServers :: Foldable f => RandomAgentServers -> [(Text, ServerOperator)] -> f UserOperatorServers -> (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP)) +useServers as opDomains uss = + let smp' = useServerCfgs SPSMP as opDomains $ concatMap (servers' SPSMP) uss + xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss + in (smp', xftp') -useServers :: forall p. UserProtocol p => SProtocolType p -> RandomServers -> [UserServer p] -> NonEmpty (NewUserServer p) -useServers p rs servers = case L.nonEmpty servers of - Nothing -> rndServers p rs - Just srvs -> L.map (\srv -> (srv :: UserServer p) {serverId = DBNewEntity}) srvs - -rndServers :: UserProtocol p => SProtocolType p -> RandomServers -> NonEmpty (NewUserServer p) -rndServers p RandomServers {smpServers, xftpServers} = case p of - SPSMP -> smpServers - SPXFTP -> xftpServers - -randomPresetServers :: forall p. UserProtocol p => SProtocolType p -> PresetServers -> IO (NonEmpty (NewUserServer p)) -randomPresetServers p PresetServers {operators} = toJust . L.nonEmpty . concat =<< mapM opSrvs operators +useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p) +useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains = + fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains where - toJust = \case - Just a -> pure a - Nothing -> E.throwIO $ userError "no preset servers" - opSrvs :: PresetOperator -> IO [NewUserServer p] - opSrvs op = do - let srvs = operatorServers p op + rndAgentServers :: SProtocolType p -> NonEmpty (ServerCfg p) + rndAgentServers = \case + SPSMP -> smpServers + SPXFTP -> xftpServers + +chooseRandomServers :: PresetServers -> IO (NonEmpty PresetOperator) +chooseRandomServers PresetServers {operators} = + forM operators $ \op -> do + smp' <- opSrvs SPSMP op + xftp' <- opSrvs SPXFTP op + pure (op :: PresetOperator) {smp = smp', xftp = xftp'} + where + opSrvs :: forall p. UserProtocol p => SProtocolType p -> PresetOperator -> IO [NewUserServer p] + opSrvs p op = do + let srvs = pServers p op toUse = operatorServersToUse p op (enbldSrvs, dsbldSrvs) = partition (\UserServer {enabled} -> enabled) srvs if toUse <= 0 || toUse >= length enbldSrvs @@ -497,6 +515,13 @@ randomPresetServers p PresetServers {operators} = toJust . L.nonEmpty . concat = pure $ sortOn server' $ enbldSrvs' <> dsbldSrvs' <> dsbldSrvs server' UserServer {server = ProtoServerWithAuth srv _} = srv +toJustOrError :: String -> Maybe a -> IO a +toJustOrError name = \case + Just a -> pure a + Nothing -> do + putStrLn $ name <> ": expected Just, exiting" + E.throwIO $ userError name + -- enableSndFiles has no effect when mainApp is True startChatController :: Bool -> Bool -> CM' (Async ()) startChatController mainApp enableSndFiles = do @@ -525,7 +550,7 @@ startChatController mainApp enableSndFiles = do startXFTP startWorkers = do tmp <- readTVarIO =<< asks tempDirectory runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case - Left e -> liftIO $ print $ "Error starting XFTP workers: " <> show e + Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync @@ -639,36 +664,43 @@ processChatCommand' vr = \case forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser - smpServers <- chooseServers SPSMP - xftpServers <- chooseServers SPXFTP users <- withFastStore' getUsers forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} - opDomains <- operatorDomains . serverOperators <$> withFastStore getServerOperators - rs <- asks randomServers - let smp = agentServerCfgs SPSMP opDomains (rndServers SPSMP rs) smpServers - xftp = agentServerCfgs SPXFTP opDomains (rndServers SPXFTP rs) xftpServers - auId <- withAgent (\a -> createUser a smp xftp) + (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u + auId <- withAgent $ \a -> createUser a smp' xftp' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure - user <- withFastStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts - createPresetContactCards user `catchChatError` \_ -> pure () - withFastStore $ \db -> do + user <- withFastStore $ \db -> do + user <- createUserRecordAt db (AgentUserId auId) p True ts + mapM_ (setUserServers db user ts) uss + createPresetContactCards db user `catchStoreError` \_ -> pure () createNoteFolder db user - liftIO $ mapM_ (insertProtocolServer db SPSMP user ts) $ useServers SPSMP rs smpServers - liftIO $ mapM_ (insertProtocolServer db SPXFTP user ts) $ useServers SPXFTP rs xftpServers + pure user atomically . writeTVar u $ Just user pure $ CRActiveUser user where - createPresetContactCards :: User -> CM () - createPresetContactCards user = - withFastStore $ \db -> do - createContact db user simplexStatusContactProfile - createContact db user simplexTeamContactProfile - chooseServers :: forall p. ProtocolTypeI p => SProtocolType p -> CM [UserServer p] - chooseServers p = do - srvs <- chatReadVar currentUser >>= mapM (\user -> withFastStore' $ \db -> getProtocolServers db p user) - pure $ fromMaybe [] srvs + createPresetContactCards :: DB.Connection -> User -> ExceptT StoreError IO () + createPresetContactCards db user = do + createContact db user simplexStatusContactProfile + createContact db user simplexTeamContactProfile + chooseServers :: Maybe User -> CM ([UpdatedUserOperatorServers], (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + chooseServers user_ = do + as <- asks randomAgentServers + mapM (withFastStore . flip getUserServers >=> liftIO . groupByOperator) user_ >>= \case + Just uss -> do + let opDomains = operatorDomains $ mapMaybe operator' uss + uss' = map copyServers uss + pure $ (uss',) $ useServers as opDomains uss + Nothing -> do + ps <- asks randomPresetServers + uss <- presetUserServers <$> withFastStore' (\db -> getUpdateServerOperators db ps True) + let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as + pure (uss, (smp', xftp')) + copyServers :: UserOperatorServers -> UpdatedUserOperatorServers + copyServers UserOperatorServers {operator, smpServers, xftpServers} = + let new srv = AUS SDBNew srv {serverId = DBNewEntity} + in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers} coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -1568,32 +1600,16 @@ processChatCommand' vr = \case pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) - CRUserServers user <$> liftIO (groupedServers srvs p) - where - groupedServers :: UserProtocol p => ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> SProtocolType p -> IO [UserOperatorServers] - groupedServers (operators, smpServers, xftpServers) = \case - SPSMP -> groupByOperator (operators, smpServers, []) - SPXFTP -> groupByOperator (operators, [], xftpServers) + liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs) SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do - srvs' <- mapM aUserServer srvs userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) case L.nonEmpty userServers_ of Nothing -> throwChatError $ CECommandError "no servers" Just userServers -> case srvs of [] -> throwChatError $ CECommandError "no servers" - _ -> processChatCommand $ APISetUserServers userId $ L.map (updatedSrvs p) userServers - where - -- disable preset and replace custom servers (groupByOperator always adds custom) - updatedSrvs :: UserProtocol p => SProtocolType p -> UserOperatorServers -> UpdatedUserOperatorServers - updatedSrvs p' UserOperatorServers {operator, smpServers, xftpServers} = case p' of - SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) - SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) - where - u = uncurry $ UpdatedUserOperatorServers operator - updateSrvs :: [UserServer p] -> [AUserServer p] - updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs' (const []) operator - disableSrv srv@UserServer {preset} = - AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} + _ -> do + srvs' <- mapM aUserServer srvs + processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers where aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of @@ -1607,20 +1623,21 @@ processChatCommand' vr = \case APISetServerOperators operatorsEnabled -> withFastStore $ \db -> do liftIO $ setServerOperators db operatorsEnabled CRServerOperatorConditions <$> getServerOperators db - APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> + APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do errors <- validateAllUsersServers userId $ L.toList userServers unless (null errors) $ throwChatError (CECommandError $ "user servers validation error(s): " <> show errors) - (operators, smpServers, xftpServers) <- withFastStore $ \db -> do - setUserServers db user userServers - getUserServers db user - let opDomains = operatorDomains operators - rs <- asks randomServers + uss <- withFastStore $ \db -> do + ts <- liftIO getCurrentTime + mapM (setUserServers db user ts) userServers + as <- asks randomAgentServers lift $ withAgent' $ \a -> do let auId = aUserId user - setProtocolServers a auId $ agentServerCfgs SPSMP opDomains (rndServers SPSMP rs) smpServers - setProtocolServers a auId $ agentServerCfgs SPXFTP opDomains (rndServers SPXFTP rs) xftpServers + opDomains = operatorDomains $ mapMaybe operator' $ L.toList uss + (smp', xftp') = useServers as opDomains uss + setProtocolServers a auId smp' + setProtocolServers a auId xftp' ok_ APIValidateServers userId userServers -> withUserId userId $ \user -> CRUserServersValidation user <$> validateAllUsersServers userId userServers @@ -1897,7 +1914,7 @@ processChatCommand' vr = \case let ConnReqUriData {crSmpQueues = q :| _} = crData SMPQueueUri {queueAddress = SMPQueueAddress {smpServer}} = q newUserServers <- - map protoServer' . filter (\ServerCfg {enabled} -> enabled) + map protoServer' . L.filter (\ServerCfg {enabled} -> enabled) <$> getKnownAgentServers SPSMP newUser pure $ smpServer `elem` newUserServers updateConnRecord user@User {userId} conn@PendingContactConnection {customUserProfileId} newUser = do @@ -3375,6 +3392,23 @@ processChatCommand' vr = \case msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (`getConnectionQueueInfo` acId) +protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +protocolServers p (operators, smpServers, xftpServers) = case p of + SPSMP -> (operators, smpServers, []) + SPXFTP -> (operators, [], xftpServers) + +-- disable preset and replace custom servers (groupByOperator always adds custom) +updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers +updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) + where + u = uncurry $ UpdatedUserOperatorServers operator + updateSrvs :: [UserServer p] -> [AUserServer p] + updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator + disableSrv srv@UserServer {preset} = + AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} + type ComposeMessageReq = (ComposedMessage, Maybe CIForwardedFrom) contactCITimed :: Contact -> CM (Maybe CITimed) @@ -3761,7 +3795,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do - knownSrvs <- map protoServer' <$> getKnownAgentServers SPXFTP user + knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user pure $ filter (`notElem` knownSrvs) srvs ipProtectedForSrvs :: [XFTPServer] -> CM Bool ipProtectedForSrvs srvs = do @@ -3775,13 +3809,13 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} toView $ CRChatItemUpdated user aci throwChatError $ CEFileNotApproved fileId unknownSrvs -getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM [ServerCfg p] +getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p)) getKnownAgentServers p user = do - rs <- asks randomServers + as <- asks randomAgentServers withStore $ \db -> do opDomains <- operatorDomains . serverOperators <$> getServerOperators db srvs <- liftIO $ getProtocolServers db p user - pure $ L.toList $ agentServerCfgs p opDomains (rndServers p rs) srvs + pure $ useServerCfgs p as opDomains srvs protoServer' :: ServerCfg p -> ProtocolServer p protoServer' ServerCfg {server} = protoServer server diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c085dcf470..b6229e07ba 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -70,7 +70,7 @@ import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) @@ -154,9 +154,9 @@ data ChatConfig = ChatConfig chatHooks :: ChatHooks } -data RandomServers = RandomServers - { smpServers :: NonEmpty (NewUserServer 'PSMP), - xftpServers :: NonEmpty (NewUserServer 'PXFTP) +data RandomAgentServers = RandomAgentServers + { smpServers :: NonEmpty (ServerCfg 'PSMP), + xftpServers :: NonEmpty (ServerCfg 'PXFTP) } deriving (Show) @@ -183,6 +183,7 @@ data PresetServers = PresetServers ntf :: [NtfServer], netCfg :: NetworkConfig } + deriving (Show) data InlineFilesConfig = InlineFilesConfig { offerChunks :: Integer, @@ -206,7 +207,8 @@ data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLite data ChatController = ChatController { currentUser :: TVar (Maybe User), - randomServers :: RandomServers, + randomPresetServers :: NonEmpty PresetOperator, + randomAgentServers :: RandomAgentServers, currentRemoteHost :: TVar (Maybe RemoteHostId), firstTime :: Bool, smpAgent :: AgentClient, diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs index c4b40c4706..1316e3c006 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Migrations/M20241027_server_operators.hs @@ -53,4 +53,6 @@ DROP INDEX idx_operator_usage_conditions_server_operator_id; DROP TABLE operator_usage_conditions; DROP TABLE usage_conditions; DROP TABLE server_operators; + +DELETE FROM protocol_servers WHERE host LIKE "%.simplexonflux.com,%"; |] diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index c3d9a8823b..f7a07682f9 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -27,6 +27,7 @@ import qualified Data.Aeson.TH as JQ import Data.Either (partitionEithers) import Data.FileEmbed import Data.Foldable (foldMap') +import Data.Functor.Identity import Data.IORef import Data.Int (Int64) import Data.Kind @@ -234,13 +235,13 @@ class UserServersClass u where type AServer u = (s :: ProtocolType -> Type) | s -> u operator' :: u -> Maybe ServerOperator partitionValid :: [AServer u p] -> ([Text], [AUserServer p]) - servers' :: UserProtocol p => u -> SProtocolType p -> [AServer u p] + servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p] instance UserServersClass UserOperatorServers where type AServer UserOperatorServers = UserServer_ 'DBStored ProtoServerWithAuth operator' UserOperatorServers {operator} = operator partitionValid ss = ([], map (AUS SDBStored) ss) - servers' UserOperatorServers {smpServers, xftpServers} = \case + servers' p UserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers @@ -248,7 +249,7 @@ instance UserServersClass UpdatedUserOperatorServers where type AServer UpdatedUserOperatorServers = AUserServer operator' UpdatedUserOperatorServers {operator} = operator partitionValid = ([],) - servers' UpdatedUserOperatorServers {smpServers, xftpServers} = \case + servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers @@ -259,7 +260,7 @@ instance UserServersClass ValidatedUserOperatorServers where where serverOrErr :: AValidatedServer p -> Either Text (AUserServer p) serverOrErr (AVS s srv@UserServer {server = server'}) = (\server -> AUS s srv {server}) <$> unVPS server' - servers' ValidatedUserOperatorServers {smpServers, xftpServers} = \case + servers' p ValidatedUserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers @@ -290,9 +291,13 @@ data PresetOperator = PresetOperator xftp :: [NewUserServer 'PXFTP], useXFTP :: Int } + deriving (Show) -operatorServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p] -operatorServers p PresetOperator {smp, xftp} = case p of +pOperator :: PresetOperator -> Maybe NewServerOperator +pOperator PresetOperator {operator} = operator + +pServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p] +pServers p PresetOperator {smp, xftp} = case p of SPSMP -> smp SPXFTP -> xftp @@ -335,83 +340,113 @@ usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case where conditions cId commit = UsageConditions {conditionsId = cId, conditionsCommit = commit, notifiedAt = Nothing, createdAt} +presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers] +presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_ + where + mkUS op PresetOperator {smp, xftp} = + UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) + -- This function should be used inside DB transaction to update operators. -- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, -- and preserves custom operators without tags for forward compatibility. -updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [AServerOperator] +updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [(Maybe PresetOperator, Maybe AServerOperator)] updatedServerOperators presetOps storedOps = foldr addPreset [] presetOps - <> map (ASO SDBStored) (filter (isNothing . operatorTag) storedOps) + <> map (\op -> (Nothing, Just $ ASO SDBStored op)) (filter (isNothing . operatorTag) storedOps) where -- TODO remove domains of preset operators from custom - addPreset PresetOperator {operator} = case operator of - Nothing -> id - Just presetOp -> (storedOp' :) - where - storedOp' = case find ((operatorTag presetOp ==) . operatorTag) storedOps of - Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> - ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} - Nothing -> ASO SDBNew presetOp + addPreset op = ((Just op, storedOp' <$> pOperator op) :) + where + storedOp' presetOp = case find ((operatorTag presetOp ==) . operatorTag) storedOps of + Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> + ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} + Nothing -> ASO SDBNew presetOp -- This function should be used inside DB transaction to update servers. -updatedUserServers :: forall p. UserProtocol p => SProtocolType p -> NonEmpty PresetOperator -> NonEmpty (NewUserServer p) -> [UserServer p] -> NonEmpty (AUserServer p) -updatedUserServers _ _ randomSrvs [] = L.map (AUS SDBNew) randomSrvs -updatedUserServers p presetOps randomSrvs srvs = - fromMaybe (L.map (AUS SDBNew) randomSrvs) (L.nonEmpty updatedSrvs) +updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers +updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) = + UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'} where - updatedSrvs = map userServer presetSrvs <> map (AUS SDBStored) (filter customServer srvs) - storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) - storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs - customServer :: UserServer p -> Bool - customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) - presetSrvs :: [NewUserServer p] - presetSrvs = concatMap (operatorServers p) presetOps - presetHosts :: Set TransportHost - presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs - userServer :: NewUserServer p -> AUserServer p - userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs) + stored = map (AUS SDBStored) + (smp', xftp') = case presetOp_ of + Nothing -> (stored smpServers, stored xftpServers) + Just presetOp -> (updated SPSMP smpServers, updated SPXFTP xftpServers) + where + updated :: forall p. UserProtocol p => SProtocolType p -> [UserServer p] -> [AUserServer p] + updated p srvs = map userServer presetSrvs <> stored (filter customServer srvs) + where + storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) + storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs + customServer :: UserServer p -> Bool + customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) + presetSrvs :: [NewUserServer p] + presetSrvs = pServers p presetOp + presetHosts :: Set TransportHost + presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs + userServer :: NewUserServer p -> AUserServer p + userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs) srvHost :: UserServer' s p -> NonEmpty TransportHost srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv -agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> NonEmpty (NewUserServer p) -> [UserServer' s p] -> NonEmpty (ServerCfg p) -agentServerCfgs p opDomains randomSrvs = - fromMaybe fallbackSrvs . L.nonEmpty . mapMaybe enabledOpAgentServer +agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] -> [UserServer' s p] -> [ServerCfg p] +agentServerCfgs p opDomains = mapMaybe agentServer where - fallbackSrvs = L.map (snd . agentServer) randomSrvs - enabledOpAgentServer srv = - let (opEnabled, srvCfg) = agentServer srv - in if opEnabled then Just srvCfg else Nothing - agentServer :: UserServer' s p -> (Bool, ServerCfg p) + agentServer :: UserServer' s p -> Maybe (ServerCfg p) agentServer srv@UserServer {server, enabled} = case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of - Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) -> - (opEnabled, ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op}) + Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) + | opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op} + | otherwise -> Nothing Nothing -> - (True, ServerCfg {server, enabled, operator = Nothing, roles = allRoles}) + Just ServerCfg {server, enabled, operator = Nothing, roles = allRoles} matchingHost :: Text -> TransportHost -> Bool matchingHost d = \case THDomainName h -> d `T.isSuffixOf` T.pack h _ -> False -operatorDomains :: [ServerOperator] -> [(Text, ServerOperator)] +operatorDomains :: [ServerOperator' s] -> [(Text, ServerOperator' s)] operatorDomains = foldr (\op ds -> foldr (\d -> ((d, op) :)) ds (serverDomains op)) [] -groupByOperator :: ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] -groupByOperator (ops, smpSrvs, xftpSrvs) = do - ss <- mapM (\op -> (serverDomains op,) <$> newIORef (UserOperatorServers (Just op) [] [])) ops - custom <- newIORef $ UserOperatorServers Nothing [] [] +class Box b where + box :: a -> b a + unbox :: b a -> a + +instance Box Identity where + box = Identity + unbox = runIdentity + +instance Box ((,) (Maybe a)) where + box = (Nothing,) + unbox = snd + +groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs) + +-- For the initial app start this function relies on tuple being Functor/Box +-- to preserve the information about operator being DBNew or DBStored +groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)] +groupByOperator' = groupByOperator_ +{-# INLINE groupByOperator' #-} + +groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers] +groupByOperator_ (ops, smpSrvs, xftpSrvs) = do + let ops' = mapMaybe sequence ops + customOp_ = find (isNothing . unbox) ops + ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops' + custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_ mapM_ (addServer ss custom addSMP) (reverse smpSrvs) mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) opSrvs <- mapM (readIORef . snd) ss customSrvs <- readIORef custom pure $ opSrvs <> [customSrvs] where - addServer :: [([Text], IORef UserOperatorServers)] -> IORef UserOperatorServers -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () + mkUS op = UserOperatorServers op [] [] + addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss - in atomicModifyIORef'_ v $ add srv + in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} @@ -434,7 +469,7 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] where p' = AProtocolType p - noServers cond = not $ any srvEnabled $ snd $ partitionValid $ concatMap (`servers'` p) $ filter cond uss + noServers cond = not $ any srvEnabled $ snd $ partitionValid $ concatMap (servers' p) $ filter cond uss opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted @@ -442,7 +477,7 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others serverErrs p uss = map (USEInvalidServer p') invalidSrvs <> mapMaybe duplicateErr_ srvs where p' = AProtocolType p - (invalidSrvs, userSrvs) = partitionValid $ concatMap (`servers'` p) uss + (invalidSrvs, userSrvs) = partitionValid $ concatMap (servers' p) uss srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) userSrvs duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index daf9a78fca..ec657fd6f7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -50,9 +50,6 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaAddress, updateUserAddressAutoAccept, getProtocolServers, - getUpdateUserServers, - -- overwriteOperatorsAndServers, - overwriteProtocolServers, insertProtocolServer, getUpdateServerOperators, getServerOperators, @@ -63,6 +60,7 @@ module Simplex.Chat.Store.Profiles setConditionsNotified, acceptConditions, setUserServers, + setUserServers', createCall, deleteCalls, getCalls, @@ -83,7 +81,7 @@ import Data.Functor (($>)) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe) +import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -108,7 +106,7 @@ import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON) -import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode, UserProtocol) +import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) @@ -532,18 +530,6 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) -getUpdateUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => DB.Connection -> SProtocolType p -> NonEmpty PresetOperator -> NonEmpty (NewUserServer p) -> User -> IO [UserServer p] -getUpdateUserServers db p presetOps randomSrvs user = do - ts <- getCurrentTime - srvs <- getProtocolServers db p user - let srvs' = L.toList $ updatedUserServers p presetOps randomSrvs srvs - mapM (upsertServer ts) srvs' - where - upsertServer :: UTCTime -> AUserServer p -> IO (UserServer p) - upsertServer ts (AUS _ s@UserServer {serverId}) = case serverId of - DBNewEntity -> insertProtocolServer db p user ts s - DBEntityId _ -> updateProtocolServer db p ts s $> s - getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] getProtocolServers db p User {userId} = map toUserServer @@ -561,26 +547,6 @@ getProtocolServers db p User {userId} = let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) in UserServer {serverId, server, preset, tested, enabled, deleted = False} --- TODO remove --- overwriteOperatorsAndServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> Maybe [ServerOperator] -> [ServerCfg p] -> ExceptT StoreError IO [ServerCfg p] --- overwriteOperatorsAndServers db user@User {userId} operators_ servers = do -overwriteProtocolServers :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> [UserServer p] -> ExceptT StoreError IO () -overwriteProtocolServers db p User {userId} servers = - -- liftIO $ mapM_ (updateServerOperators_ db) operators_ - checkConstraint SEUniqueID . ExceptT $ do - currentTs <- getCurrentTime - DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND protocol = ? " (userId, decodeLatin1 $ strEncode p) - forM_ servers $ \UserServer {serverId, server, preset, tested, enabled} -> do - DB.execute - db - [sql| - INSERT INTO protocol_servers - (server_id, protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - |] - (Only serverId :. serverColumns p server :. (preset, tested, enabled, userId, currentTs, currentTs)) - pure $ Right () - insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do DB.execute @@ -623,10 +589,10 @@ getServerOperators db = do let conditionsAction = usageConditionsAction ops currentConditions now pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} -getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) getUserServers db user = (,,) - <$> (serverOperators <$> getServerOperators db) + <$> (map Just . serverOperators <$> getServerOperators db) <*> liftIO (getProtocolServers db SPSMP user) <*> liftIO (getProtocolServers db SPXFTP user) @@ -646,7 +612,7 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, |] (enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId) -getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [ServerOperator] +getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)] getUpdateServerOperators db presetOps newUser = do conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery now <- getCurrentTime @@ -654,7 +620,7 @@ getUpdateServerOperators db presetOps newUser = do mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db - forM ops $ \(ASO _ op) -> + forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe case operatorId op of DBNewEntity -> do op' <- insertOperator op @@ -825,22 +791,24 @@ getUsageConditionsById_ db conditionsId = |] (Only conditionsId) -setUserServers :: DB.Connection -> User -> NonEmpty UpdatedUserOperatorServers -> ExceptT StoreError IO () -setUserServers db user@User {userId} userServers = checkConstraint SEUniqueID $ liftIO $ do - ts <- getCurrentTime - forM_ userServers $ \UpdatedUserOperatorServers {operator, smpServers, xftpServers} -> do - mapM_ (updateServerOperator db ts) operator - mapM_ (upsertOrDelete SPSMP ts) smpServers - mapM_ (upsertOrDelete SPXFTP ts) xftpServers +setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> ExceptT StoreError IO UserOperatorServers +setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts + +setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers +setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do + mapM_ (updateServerOperator db ts) operator + smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers + xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers + pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'} where - upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> UTCTime -> AUserServer p -> IO () - upsertOrDelete p ts (AUS _ s@UserServer {serverId, deleted}) = case serverId of + upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) + upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of DBNewEntity - | deleted -> pure () - | otherwise -> void $ insertProtocolServer db p user ts s + | deleted -> pure Nothing + | otherwise -> Just <$> insertProtocolServer db p user ts s DBEntityId srvId - | deleted -> DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) - | otherwise -> updateProtocolServer db p ts s + | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) + | otherwise -> Just s <$ updateProtocolServer db p ts s createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c2cc44d164..7bf7804472 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,10 +25,9 @@ import Data.Maybe (isNothing) import qualified Data.Text as T import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), PresetServers (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Options -import Simplex.Chat.Operators (PresetOperator (..), presetServer) import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -95,8 +94,8 @@ testCoreOpts = { dbFilePrefix = "./simplex_v1", dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", - smpServers = [], - xftpServers = [], + smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], + xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], simpleNetCfg = defaultSimpleNetCfg, logLevel = CLLImportant, logConnections = False, @@ -150,18 +149,6 @@ testCfg :: ChatConfig testCfg = defaultChatConfig { agentConfig = testAgentCfg, - presetServers = - (presetServers defaultChatConfig) - { operators = - [ PresetOperator - { operator = Nothing, - smp = map (presetServer True) ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], - useSMP = 1, - xftp = map (presetServer True) ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], - useXFTP = 1 - } - ] - }, showReceipts = False, testView = True, tbqSize = 16 diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index bd2a267c3a..6bbf72171e 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -240,6 +240,7 @@ testRetryConnecting tmp = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile te bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do alice <## "server connected localhost ()" + threadDelay 250000 bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" bob ##> ("/_connect 1 " <> inv) @@ -1144,27 +1145,24 @@ testGetSetSMPServers = alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset)" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" alice <## " smp://1234-w==@smp1.example.im" alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") -- alice #$> ("/smp", id, "smp://1234-w==:password@smp1.example.im") alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" alice <## " smp://1234-w==:password@smp1.example.im" alice #$> ("/smp smp://2345-w==@smp2.example.im smp://3456-w==@smp3.example.im:5224", id, "ok") alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" @@ -1190,26 +1188,23 @@ testGetSetXFTPServers = alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset)" + alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice #$> ("/xftp xftp://1234-w==@xftp1.example.im", id, "ok") alice ##> "/xftp" alice <## "Your servers" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" alice <## " xftp://1234-w==@xftp1.example.im" alice #$> ("/xftp xftp://1234-w==:password@xftp1.example.im", id, "ok") alice ##> "/xftp" alice <## "Your servers" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" alice <## " xftp://1234-w==:password@xftp1.example.im" alice #$> ("/xftp xftp://2345-w==@xftp2.example.im xftp://3456-w==@xftp3.example.im:5224", id, "ok") alice ##> "/xftp" alice <## "Your servers" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" @@ -1831,13 +1826,11 @@ testCreateUserSameServers = alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" alice <## " smp://2345-w==@smp2.example.im" alice <## " smp://3456-w==@smp3.example.im:5224" alice ##> "/xftp" alice <## "Your servers" alice <## " XFTP servers" - alice <## " xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002 (preset, disabled)" alice <## " xftp://2345-w==@xftp2.example.im" alice <## " xftp://3456-w==@xftp3.example.im:5224" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a51f42114b..bdd3b53829 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1988,7 +1988,6 @@ testGroupAsync tmp = do (bob <## "#team: you joined the group") alice #> "#team hello bob" bob <# "#team alice> hello bob" - print (1 :: Integer) withTestChat tmp "alice" $ \alice -> do withNewTestChat tmp "cath" cathProfile $ \cath -> do alice <## "1 contacts connected (use /cs for the list)" @@ -2008,7 +2007,6 @@ testGroupAsync tmp = do ] alice #> "#team hello cath" cath <# "#team alice> hello cath" - print (2 :: Integer) withTestChat tmp "bob" $ \bob -> do withTestChat tmp "cath" $ \cath -> do concurrentlyN_ @@ -2024,7 +2022,6 @@ testGroupAsync tmp = do cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 - print (3 :: Integer) withTestChat tmp "bob" $ \bob -> do withNewTestChat tmp "dan" danProfile $ \dan -> do bob <## "2 contacts connected (use /cs for the list)" @@ -2044,7 +2041,6 @@ testGroupAsync tmp = do ] threadDelay 1000000 threadDelay 1000000 - print (4 :: Integer) withTestChat tmp "alice" $ \alice -> do withTestChat tmp "cath" $ \cath -> do withTestChat tmp "dan" $ \dan -> do @@ -2066,7 +2062,6 @@ testGroupAsync tmp = do dan <## "#team: member cath (Catherine) is connected" ] threadDelay 1000000 - print (5 :: Integer) withTestChat tmp "alice" $ \alice -> do withTestChat tmp "bob" $ \bob -> do withTestChat tmp "cath" $ \cath -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 1d390e1236..3ff8808541 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -273,6 +273,7 @@ testRetryAcceptingViaContactLink tmp = testChatCfgOpts2 cfg' opts' aliceProfile bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do alice <## "server connected localhost ()" + threadDelay 250000 bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" bob ##> ("/_connect 1 " <> cLink) @@ -1737,12 +1738,11 @@ testChangePCCUserDiffSrv tmp = do alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset)" + alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice #$> ("/smp smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003", id, "ok") alice ##> "/smp" alice <## "Your servers" alice <## " SMP servers" - alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001 (preset, disabled)" alice <## " smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@127.0.0.1:7003" alice ##> "/user alice" showActiveUser alice "alice (Alice)" diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 4966bfbb97..03cea56133 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -1,6 +1,12 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -8,8 +14,10 @@ module OperatorTests (operatorTests) where +import Data.Bifunctor (second) import qualified Data.List.NonEmpty as L import Simplex.Chat +import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) @@ -19,10 +27,11 @@ import Test.Hspec operatorTests :: Spec operatorTests = describe "managing server operators" $ do - validateServers + validateServersTest + updatedServersTest -validateServers :: Spec -validateServers = describe "validate user servers" $ do +validateServersTest :: Spec +validateServersTest = describe "validate user servers" $ do it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` [] it "should fail without servers" $ do validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing] @@ -41,6 +50,50 @@ validateServers = describe "validate user servers" $ do aSMP = AProtocolType SPSMP aXFTP = AProtocolType SPXFTP +updatedServersTest :: Spec +updatedServersTest = describe "validate user servers" $ do + it "adding preset operators on first start" $ do + let ops' :: [(Maybe PresetOperator, Maybe AServerOperator)] = + updatedServerOperators operators [] + length ops' `shouldBe` 2 + all addedPreset ops' `shouldBe` True + let ops'' :: [(Maybe PresetOperator, Maybe ServerOperator)] = + saveOps ops' -- mock getUpdateServerOperators + uss <- groupByOperator' (ops'', [], []) -- no stored servers + length uss `shouldBe` 3 + [op1, op2, op3] <- pure $ map updatedUserServers uss + [p1, p2] <- pure operators -- presets + sameServers p1 op1 + sameServers p2 op2 + null (servers' SPSMP op3) `shouldBe` True + null (servers' SPXFTP op3) `shouldBe` True + it "adding preset operators and assiging servers to operator for existing users" $ do + let ops' = updatedServerOperators operators [] + ops'' = saveOps ops' + uss <- + groupByOperator' + ( ops'', + saveSrvs $ take 3 simplexChatSMPServers <> [newUserServer "smp://abcd@smp.example.im"], + saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers + ) + [op1, op2, op3] <- pure $ map updatedUserServers uss + [p1, p2] <- pure operators -- presets + sameServers p1 op1 + sameServers p2 op2 + map srvHost' (servers' SPSMP op3) `shouldBe` [["smp.example.im"]] + null (servers' SPXFTP op3) `shouldBe` True + where + addedPreset = \case + (Just PresetOperator {operator = Just op}, Just (ASO SDBNew op')) -> operatorTag op == operatorTag op' + _ -> False + saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1..] + saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1..] + sameServers preset op = do + map srvHost (pServers SPSMP preset) `shouldBe` map srvHost' (servers' SPSMP op) + map srvHost (pServers SPXFTP preset) `shouldBe` map srvHost' (servers' SPXFTP op) + srvHost' (AUS _ s) = srvHost s + PresetServers {operators} = presetServers defaultChatConfig + deriving instance Eq User deriving instance Eq UserServersError diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index 048a2b5e5a..d0d74724d0 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -14,9 +14,8 @@ import Control.Monad (replicateM) import Data.Foldable (foldMap') import Data.List (sortOn) import Data.List.NonEmpty (NonEmpty) -import qualified Data.List.NonEmpty as L import Data.Monoid (Sum (..)) -import Simplex.Chat (defaultChatConfig, randomPresetServers) +import Simplex.Chat (defaultChatConfig, chooseRandomServers) import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Operators import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) @@ -38,22 +37,25 @@ testRandomSMPServers :: IO () testRandomSMPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPSMP 7 False =<< randomPresetServers SPSMP (presetServers defaultChatConfig) + checkEnabled SPSMP 7 False =<< chooseRandomServers (presetServers defaultChatConfig) (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures testRandomXFTPServers :: IO () testRandomXFTPServers = do [srvs1, srvs2, srvs3] <- replicateM 3 $ - checkEnabled SPXFTP 6 False =<< randomPresetServers SPXFTP (presetServers defaultChatConfig) + checkEnabled SPXFTP 6 False =<< chooseRandomServers (presetServers defaultChatConfig) (srvs1 == srvs2 && srvs2 == srvs3) `shouldBe` False -- && to avoid rare failures -checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (NewUserServer p) -> IO [NewUserServer p] -checkEnabled p n allUsed srvs = do - let srvs' = sortOn server' $ L.toList srvs - PresetServers {operators = presetOps} = presetServers defaultChatConfig - presetSrvs = sortOn server' $ concatMap (operatorServers p) presetOps +checkEnabled :: UserProtocol p => SProtocolType p -> Int -> Bool -> NonEmpty (PresetOperator) -> IO [NewUserServer p] +checkEnabled p n allUsed presetOps' = do + let PresetServers {operators = presetOps} = presetServers defaultChatConfig + presetSrvs = sortOn server' $ concatMap (pServers p) presetOps + srvs' = sortOn server' $ concatMap (pServers p) presetOps' Sum toUse = foldMap' (Sum . operatorServersToUse p) presetOps + Sum toUse' = foldMap' (Sum . operatorServersToUse p) presetOps' + length presetOps `shouldBe` length presetOps' + toUse `shouldBe` toUse' srvs' == presetSrvs `shouldBe` allUsed map enable srvs' `shouldBe` map enable presetSrvs let enbldSrvs = filter (\UserServer {enabled} -> enabled) srvs' From fcae5e992582780dcf053b1353d2c615f5e15a1d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 19 Nov 2024 00:22:35 +0400 Subject: [PATCH 14/34] core: fix validation of operator servers for non current users (#5205) * core: fix validation of operator servers for non current users * style * refactor --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat.hs | 13 +++++++++++-- src/Simplex/Chat/Operators.hs | 2 ++ tests/RandomServers.hs | 2 -- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 819832a1ed..11cd8e33ad 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1611,7 +1611,7 @@ processChatCommand' vr = \case srvs' <- mapM aUserServer srvs processChatCommand $ APISetUserServers userId $ L.map (updatedServers p srvs') userServers where - aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) + aUserServer :: AProtoServerWithAuth -> CM (AUserServer p) aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of Just Refl -> pure $ AUS SDBNew $ newUserServer srv Nothing -> throwChatError $ CECommandError $ "incorrect server protocol: " <> B.unpack (strEncode srv) @@ -2949,8 +2949,17 @@ processChatCommand' vr = \case validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError] validateAllUsersServers currUserId userServers = withFastStore $ \db -> do users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db) - others <- mapM (\user -> liftIO . fmap (user,) . groupByOperator =<< getUserServers db user) users' + others <- mapM (getUserOperatorServers db) users' pure $ validateUserServers userServers others + where + getUserOperatorServers :: DB.Connection -> User -> ExceptT StoreError IO (User, [UserOperatorServers]) + getUserOperatorServers db user = do + uss <- liftIO . groupByOperator =<< getUserServers db user + pure (user, map updatedUserServers uss) + updatedUserServers uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers + updatedOp op = fromMaybe op $ find matchingOp $ mapMaybe operator' userServers + where + matchingOp op' = operatorId op' == operatorId op forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index f7a07682f9..1f9b79b56b 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -89,6 +89,8 @@ data DBEntityId' (s :: DBStored) where deriving instance Show (DBEntityId' s) +deriving instance Eq (DBEntityId' s) + type DBEntityId = DBEntityId' 'DBStored type DBNewEntity = DBEntityId' 'DBNew diff --git a/tests/RandomServers.hs b/tests/RandomServers.hs index d0d74724d0..9b83be26c4 100644 --- a/tests/RandomServers.hs +++ b/tests/RandomServers.hs @@ -29,8 +29,6 @@ randomServersTests = describe "choosig random servers" $ do deriving instance Eq ServerRoles -deriving instance Eq (DBEntityId' s) - deriving instance Eq (UserServer' s p) testRandomSMPServers :: IO () 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 15/34] 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 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 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 } } From 4b9c618ae36b7751217d59cbfb65352c7289c8d1 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 19 Nov 2024 14:10:33 +0000 Subject: [PATCH 16/34] core: remove a separate type to validate servers with invalid addresses (they are prevented by the UI) (#5211) --- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Operators.hs | 67 +++++++--------------------------- tests/OperatorTests.hs | 13 ------- 3 files changed, 15 insertions(+), 67 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b6229e07ba..e44ea2ac18 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -360,7 +360,7 @@ data ChatCommand | APISetServerOperators (NonEmpty ServerOperator) | APIGetUserServers UserId | APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers) - | APIValidateServers UserId [ValidatedUserOperatorServers] -- response is CRUserServersValidation + | APIValidateServers UserId [UpdatedUserOperatorServers] -- response is CRUserServersValidation | APIGetUsageConditions | APISetConditionsNotified Int64 | APIAcceptConditions Int64 (NonEmpty Int64) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 1f9b79b56b..ebe1da8176 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -24,7 +24,6 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ -import Data.Either (partitionEithers) import Data.FileEmbed import Data.Foldable (foldMap') import Data.Functor.Identity @@ -217,32 +216,19 @@ data UpdatedUserOperatorServers = UpdatedUserOperatorServers } deriving (Show) -data ValidatedUserOperatorServers = ValidatedUserOperatorServers - { operator :: Maybe ServerOperator, - smpServers :: [AValidatedServer 'PSMP], - xftpServers :: [AValidatedServer 'PXFTP] - } - deriving (Show) - -data AValidatedServer p = forall s. AVS (SDBStored s) (ValidatedServer s p) - -deriving instance Show (AValidatedServer p) - -type ValidatedServer s p = UserServer_ s ValidatedProtoServer p - data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoServerWithAuth p)} deriving (Show) class UserServersClass u where type AServer u = (s :: ProtocolType -> Type) | s -> u operator' :: u -> Maybe ServerOperator - partitionValid :: [AServer u p] -> ([Text], [AUserServer p]) + aUserServer' :: AServer u p -> AUserServer p servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p] instance UserServersClass UserOperatorServers where - type AServer UserOperatorServers = UserServer_ 'DBStored ProtoServerWithAuth + type AServer UserOperatorServers = UserServer' 'DBStored operator' UserOperatorServers {operator} = operator - partitionValid ss = ([], map (AUS SDBStored) ss) + aUserServer' = AUS SDBStored servers' p UserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers @@ -250,24 +236,11 @@ instance UserServersClass UserOperatorServers where instance UserServersClass UpdatedUserOperatorServers where type AServer UpdatedUserOperatorServers = AUserServer operator' UpdatedUserOperatorServers {operator} = operator - partitionValid = ([],) + aUserServer' = id servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers -instance UserServersClass ValidatedUserOperatorServers where - type AServer ValidatedUserOperatorServers = AValidatedServer - operator' ValidatedUserOperatorServers {operator} = operator - partitionValid = partitionEithers . map serverOrErr - where - serverOrErr :: AValidatedServer p -> Either Text (AUserServer p) - serverOrErr (AVS s srv@UserServer {server = server'}) = (\server -> AUS s srv {server}) <$> unVPS server' - servers' p ValidatedUserOperatorServers {smpServers, xftpServers} = case p of - SPSMP -> smpServers - SPXFTP -> xftpServers - -type UserServer' s p = UserServer_ s ProtoServerWithAuth p - type UserServer p = UserServer' 'DBStored p type NewUserServer p = UserServer' 'DBNew p @@ -276,9 +249,9 @@ data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p) deriving instance Show (AUserServer p) -data UserServer_ s (srv :: ProtocolType -> Type) (p :: ProtocolType) = UserServer +data UserServer' s (p :: ProtocolType) = UserServer { serverId :: DBEntityId' s, - server :: srv p, + server :: ProtoServerWithAuth p, preset :: Bool, tested :: Maybe Bool, enabled :: Bool, @@ -456,7 +429,6 @@ data UserServersError = USENoServers {protocol :: AProtocolType, user :: Maybe User} | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} - | USEInvalidServer {protocol :: AProtocolType, invalidServer :: Text} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} deriving (Show) @@ -471,16 +443,15 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] where p' = AProtocolType p - noServers cond = not $ any srvEnabled $ snd $ partitionValid $ concatMap (servers' p) $ filter cond uss + noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] - serverErrs p uss = map (USEInvalidServer p') invalidSrvs <> mapMaybe duplicateErr_ srvs + serverErrs p uss = mapMaybe duplicateErr_ srvs where p' = AProtocolType p - (invalidSrvs, userSrvs) = partitionValid $ concatMap (servers' p) uss - srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) userSrvs + srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) @@ -489,6 +460,8 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others addHost (hs, dups) h | h `S.member` hs = (hs, S.insert h dups) | otherwise = (S.insert h hs, dups) + userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] + userServers p = map aUserServer' . concatMap (servers' p) instance ToJSON (DBEntityId' s) where toEncoding = \case @@ -525,30 +498,18 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) $(JQ.deriveJSON defaultJSON ''ServerOperatorConditions) instance ProtocolTypeI p => ToJSON (UserServer' s p) where - toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer_) - toJSON = $(JQ.mkToJSON defaultJSON ''UserServer_) + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer') + toJSON = $(JQ.mkToJSON defaultJSON ''UserServer') instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer_) + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer') instance ProtocolTypeI p => FromJSON (AUserServer p) where parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) -instance ProtocolTypeI p => FromJSON (ValidatedProtoServer p) where - parseJSON v = ValidatedProtoServer <$> ((Right <$> parseJSON v) <|> (Left <$> parseJSON v)) - -instance (DBStoredI s, ProtocolTypeI p) => FromJSON (ValidatedServer s p) where - parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer_) - -instance ProtocolTypeI p => FromJSON (AValidatedServer p) where - parseJSON v = (AVS SDBStored <$> parseJSON v) <|> (AVS SDBNew <$> parseJSON v) - $(JQ.deriveJSON defaultJSON ''UserOperatorServers) instance FromJSON UpdatedUserOperatorServers where parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) -instance FromJSON ValidatedUserOperatorServers where - parseJSON = $(JQ.mkParseJSON defaultJSON ''ValidatedUserOperatorServers) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 03cea56133..0a00d7b83c 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -44,8 +44,6 @@ validateServersTest = describe "validate user servers" $ do [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" ] - it "should fail with invalid host" $ do - validateUserServers [invalidHost] [] `shouldBe` [USENoServers aXFTP Nothing, USEInvalidServer aSMP "smp:abcd@smp8.simplex.im"] where aSMP = AProtocolType SPSMP aXFTP = AProtocolType SPXFTP @@ -132,14 +130,3 @@ invalidDuplicate = (valid :: UpdatedUserOperatorServers) { smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"] } - -invalidHost :: ValidatedUserOperatorServers -invalidHost = - ValidatedUserOperatorServers - { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, - smpServers = [validatedServer (Left "smp:abcd@smp8.simplex.im"), validatedServer (Right "smp://abcd@smp8.simplex.im")], - xftpServers = [] - } - where - validatedServer srv = - AVS SDBNew (presetServer @'PSMP True "smp://abcd@smp8.simplex.im") {server = ValidatedProtoServer srv} From 181f72fa1f735904232bf26b21a7d222f6acdfab Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 19 Nov 2024 15:26:41 +0000 Subject: [PATCH 17/34] ios: texts about operators (#5213) * ios: texts about operators * remove comment * button for conditions --- .../Onboarding/ChooseServerOperators.swift | 24 ++++++++++++------- .../Views/Onboarding/CreateProfile.swift | 6 +++-- .../Shared/Views/Onboarding/SimpleXInfo.swift | 1 - .../NetworkAndServers/OperatorView.swift | 13 ++++------ apps/ios/SimpleXChat/APITypes.swift | 5 +++- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 248c1b34c4..4b886ad9be 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -70,6 +70,11 @@ struct ChooseServerOperators: View { ForEach(serverOperators) { srvOperator in operatorCheckView(srvOperator) } + Text("You can configure servers via settings.") + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) +// .padding(.horizontal, 32) Spacer() @@ -83,10 +88,12 @@ struct ChooseServerOperators: View { continueButton() } if onboarding { - Text("You can disable operators and configure your servers in Network & servers settings.") - .multilineTextAlignment(.center) - .font(.footnote) - .padding(.horizontal, 32) + Button("Conditions of use") { + // TODO open accepted conditions + } + .font(.callout) + .foregroundColor(reviewForOperators.isEmpty ? .accentColor : .clear) + .padding(.top) } } .padding(.bottom) @@ -136,7 +143,7 @@ struct ChooseServerOperators: View { showInfoSheet = true } - Text("Select operators, whose servers you will be using.") + Text("Select network operators to use.") } } @@ -320,14 +327,15 @@ struct ChooseServerOperators: View { struct ChooseServerOperatorsInfoView: View { var body: some View { VStack(alignment: .leading) { - Text("Why choose multiple operators") + Text("Network operators") .font(.largeTitle) + .bold() .padding(.vertical) ScrollView { VStack(alignment: .leading) { Group { - Text("Selecting multiple operators improves protection of your communication graph.") - Text("TODO Better explanation") + Text("When more than one network operator is enabled, the app will use the servers of different operators for each conversation.") + Text("For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing.") } .padding(.bottom) } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 9d1f9f4709..b9f569e96d 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -90,8 +90,10 @@ struct CreateFirstProfile: View { var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Your profile, contacts and delivered messages are stored on your device.") + .font(.callout) .foregroundColor(theme.colors.secondary) Text("The profile is only shared with your contacts.") + .font(.callout) .foregroundColor(theme.colors.secondary) HStack { @@ -114,8 +116,8 @@ struct CreateFirstProfile: View { .padding(.horizontal) .padding(.vertical, 10) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(uiColor: .tertiarySystemFill)) ) } .padding(.top) diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 2e077e9d95..ea3627871e 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -144,7 +144,6 @@ struct SimpleXInfo: View { CreateFirstProfile() .navigationTitle("Create your profile") .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) } private func userExistsFallbackButton() -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index ef02e94e3f..63586e2121 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -257,14 +257,11 @@ struct OperatorView: View { .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) - } + Image(userServers[operatorIndex].operator_.largeLogo(colorScheme)) + .resizable() + .scaledToFit() + .grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0) + .frame(height: 40) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5470059e92..016a8213c3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1221,7 +1221,10 @@ public struct ServerOperatorInfo: Decodable { public let operatorsInfo: Dictionary = [ .simplex: ServerOperatorInfo( - description: ["SimpleX Chat preset servers"], + description: [ + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ], website: "https://simplex.chat", logo: "decentralized", largeLogo: "logo", From 58c92ed004b01937ef6c233a6243c1db57669320 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:48:51 +0400 Subject: [PATCH 18/34] ios: rework existing users notice, condition views (#5214) --- apps/ios/Shared/ContentView.swift | 26 +++-- .../Onboarding/ChooseServerOperators.swift | 68 ++++++++++++-- .../Shared/Views/Onboarding/HowItWorks.swift | 1 + .../Views/Onboarding/WhatsNewView.swift | 94 +++++++++++-------- .../NetworkAndServers/NetworkAndServers.swift | 35 ++++--- .../Views/UserSettings/SettingsView.swift | 2 +- 6 files changed, 159 insertions(+), 67 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 62de4bc1c6..ac699d4a2c 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -10,11 +10,13 @@ import Intents import SimpleXChat private enum NoticesSheet: Identifiable { - case notices(showWhatsNew: Bool, showOperatorsNotice: Bool) + case whatsNew(updatedConditions: Bool) + case updatedConditions var id: String { switch self { - case .notices: return "notices" + case .whatsNew: return "whatsNew" + case .updatedConditions: return "updatedConditions" } } } @@ -274,10 +276,12 @@ struct ContentView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if !noticesShown { let showWhatsNew = shouldShowWhatsNew() - let showOperatorsNotice = chatModel.conditions.conditionsAction?.showNotice ?? false - noticesShown = showWhatsNew || showOperatorsNotice - if noticesShown { - noticesSheetItem = .notices(showWhatsNew: showWhatsNew, showOperatorsNotice: showOperatorsNotice) + let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false + noticesShown = showWhatsNew || showUpdatedConditions + if showWhatsNew { + noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions) + } else if showUpdatedConditions { + noticesSheetItem = .updatedConditions } } } @@ -288,8 +292,14 @@ struct ContentView: View { .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .sheet(item: $noticesSheetItem) { item in switch item { - case let .notices(showWhatsNew, showOperatorsNotice): - WhatsNewView(showWhatsNew: showWhatsNew, showOperatorsNotice: showOperatorsNotice) + case let .whatsNew(updatedConditions): + WhatsNewView(updatedConditions: updatedConditions) + case .updatedConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) } } if chatModel.setDeliveryReceipts { diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 4b886ad9be..09e0060c22 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -41,15 +41,27 @@ struct OnboardingButtonStyle: ButtonStyle { } } +private enum ChooseServerOperatorsSheet: Identifiable { + case showInfo + case showConditions + + var id: String { + switch self { + case .showInfo: return "showInfo" + case .showConditions: return "showConditions" + } + } +} + 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 sheetItem: ChooseServerOperatorsSheet? = nil @State private var justOpened = true var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } } @@ -74,25 +86,34 @@ struct ChooseServerOperators: View { .font(.footnote) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) -// .padding(.horizontal, 32) + .padding(.horizontal, 32) Spacer() let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } + let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) VStack(spacing: 8) { if !reviewForOperators.isEmpty { reviewConditionsButton() + } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { + setOperatorsButton() } else { continueButton() } if onboarding { - Button("Conditions of use") { - // TODO open accepted conditions + Group { + if reviewForOperators.isEmpty { + Button("Conditions of use") { + sheetItem = .showConditions + } + } else { + Text("Conditions of use") + .foregroundColor(.clear) + } } .font(.callout) - .foregroundColor(reviewForOperators.isEmpty ? .accentColor : .clear) .padding(.top) } } @@ -123,8 +144,17 @@ struct ChooseServerOperators: View { justOpened = false } } - .sheet(isPresented: $showInfoSheet) { - ChooseServerOperatorsInfoView() + .sheet(item: $sheetItem) { item in + switch item { + case .showInfo: + ChooseServerOperatorsInfoView() + case .showConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) + } } } .frame(maxHeight: .infinity) @@ -140,7 +170,7 @@ struct ChooseServerOperators: View { .frame(width: 20, height: 20) .foregroundColor(theme.colors.primary) .onTapGesture { - showInfoSheet = true + sheetItem = .showInfo } Text("Select network operators to use.") @@ -200,6 +230,28 @@ struct ChooseServerOperators: View { } } + private func setOperatorsButton() -> some View { + Button { + Task { + if let enabledOperators = enabledOperators(serverOperators) { + let r = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } + } + } + } label: { + Text("Update") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) + } + private func continueButton() -> some View { Button { continueToNextStep() diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index f11dbbe7a8..9a0ee4ddeb 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -18,6 +18,7 @@ struct HowItWorks: View { VStack(alignment: .leading) { Text("How SimpleX works") .font(.largeTitle) + .bold() .padding(.vertical) ScrollView { VStack(alignment: .leading) { diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 1d1ec5b64c..c078fb23b1 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -526,7 +526,7 @@ private let versionDescriptions: [VersionDescription] = [ .view(FeatureView( icon: nil, title: "Network decentralization", - view: newOperatorsView + view: { NewOperatorsView() } )), .feature(Description( icon: "text.quote", @@ -549,20 +549,37 @@ 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") { - +fileprivate struct NewOperatorsView: View { + @State private var showOperatorsSheet = false + + var body: 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") { + showOperatorsSheet = true + } + Text("for better metadata privacy.") } - Text("for better metadata privacy.") + } + .sheet(isPresented: $showOperatorsSheet) { + ChooseServerOperators(onboarding: false) + } + } +} + +private enum WhatsNewViewSheet: Identifiable { + case showConditions + + var id: String { + switch self { + case .showConditions: return "showConditions" } } } @@ -573,13 +590,13 @@ struct WhatsNewView: View { @State var currentVersion = versionDescriptions.count - 1 @State var currentVersionNav = versionDescriptions.count - 1 var viaSettings = false - @State var showWhatsNew: Bool - var showOperatorsNotice: Bool + var updatedConditions: Bool + @State private var sheetItem: WhatsNewViewSheet? = nil var body: some View { - viewBody() + whatsNewView() .task { - if showOperatorsNotice { + if updatedConditions { do { let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId try await setConditionsNotified(conditionsId: conditionsId) @@ -588,14 +605,16 @@ struct WhatsNewView: View { } } } - } - - @ViewBuilder private func viewBody() -> some View { - if showWhatsNew { - whatsNewView() - } else if showOperatorsNotice { - ChooseServerOperators(onboarding: false) - } + .sheet(item: $sheetItem) { item in + switch item { + case .showConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) + } + } } private func whatsNewView() -> some View { @@ -623,22 +642,19 @@ struct WhatsNewView: View { } } } + if updatedConditions { + Button("View updated conditions") { + sheetItem = .showConditions + } + } if !viaSettings { Spacer() - 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) + Button("Ok") { + dismiss() } + .font(.title3) + .frame(maxWidth: .infinity, alignment: .center) Spacer() } @@ -729,6 +745,6 @@ struct WhatsNewView: View { struct NewFeaturesView_Previews: PreviewProvider { static var previews: some View { - WhatsNewView(showWhatsNew: true, showOperatorsNotice: false) + WhatsNewView(updatedConditions: false) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 2247e3d8d5..c668ad3858 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -20,7 +20,7 @@ private enum NetworkAlert: Identifiable { } private enum NetworkAndServersSheet: Identifiable { - case showConditions(conditionsAction: UsageConditionsAction) + case showConditions var id: String { switch self { @@ -65,7 +65,7 @@ struct NetworkAndServers: View { switch conditionsAction { case let .review(_, deadline, _): if let deadline = deadline, anyOperatorEnabled { - Text("Conditions will be considered accepted on: \(conditionsTimestamp(deadline)).") + Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).") .foregroundColor(theme.colors.secondary) } default: @@ -171,9 +171,8 @@ struct NetworkAndServers: View { } .sheet(item: $sheetItem) { item in switch item { - case let .showConditions(conditionsAction): + case .showConditions: UsageConditionsView( - conditionsAction: conditionsAction, currUserServers: $currUserServers, userServers: $userServers ) @@ -221,7 +220,7 @@ struct NetworkAndServers: View { private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { Button { - sheetItem = .showConditions(conditionsAction: conditionsAction) + sheetItem = .showConditions } label: { switch conditionsAction { case .review: @@ -236,7 +235,6 @@ struct NetworkAndServers: View { struct UsageConditionsView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme - var conditionsAction: UsageConditionsAction @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @@ -248,14 +246,29 @@ struct UsageConditionsView: View { .padding(.top) .padding(.top) - switch conditionsAction { + switch ChatModel.shared.conditions.conditionsAction { - case let .review(operators, _, _): + case .none: + ConditionsTextView() + .padding(.bottom) + .padding(.bottom) + + case let .review(operators, deadline, _): 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) + VStack(spacing: 8) { + acceptConditionsButton(operators.map { $0.operatorId }) + if let deadline = deadline { + Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).") + .foregroundColor(theme.colors.secondary) + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 32) + } + } + .padding(.bottom) + .padding(.bottom) case let .accepted(operators): Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index e73697e42a..f2a1a56d01 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -367,7 +367,7 @@ struct SettingsView: View { } } NavigationLink { - WhatsNewView(viaSettings: true, showWhatsNew: true, showOperatorsNotice: false) + WhatsNewView(viaSettings: true, updatedConditions: false) .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.inline) } label: { From 4e37efdc4a68ce817f00015d66a2cc1f99007fcf Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 20 Nov 2024 07:23:25 +0000 Subject: [PATCH 19/34] core: update agent servers (#5215) --- src/Simplex/Chat.hs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 11cd8e33ad..0daf9fa394 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1620,9 +1620,26 @@ processChatCommand' vr = \case TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators - APISetServerOperators operatorsEnabled -> withFastStore $ \db -> do - liftIO $ setServerOperators db operatorsEnabled - CRServerOperatorConditions <$> getServerOperators db + APISetServerOperators operators -> do + as <- asks randomAgentServers + (opsConds, srvs) <- withFastStore $ \db -> do + liftIO $ setServerOperators db operators + opsConds <- getServerOperators db + let ops = serverOperators opsConds + ops' = map Just ops <> [Nothing] + opDomains = operatorDomains ops + liftIO $ fmap (opsConds,) . mapM (getServers db as ops' opDomains) =<< getUsers db + lift $ withAgent' $ \a -> forM_ srvs $ \(auId, (smp', xftp')) -> do + setProtocolServers a auId smp' + setProtocolServers a auId xftp' + pure $ CRServerOperatorConditions opsConds + where + getServers :: DB.Connection -> RandomAgentServers -> [Maybe ServerOperator] -> [(Text, ServerOperator)] -> User -> IO (UserId, (NonEmpty (ServerCfg 'PSMP), NonEmpty (ServerCfg 'PXFTP))) + getServers db as ops opDomains user = do + smpSrvs <- getProtocolServers db SPSMP user + xftpSrvs <- getProtocolServers db SPXFTP user + uss <- groupByOperator (ops, smpSrvs, xftpSrvs) + pure $ (aUserId user,) $ useServers as opDomains uss APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do @@ -2955,8 +2972,8 @@ processChatCommand' vr = \case getUserOperatorServers :: DB.Connection -> User -> ExceptT StoreError IO (User, [UserOperatorServers]) getUserOperatorServers db user = do uss <- liftIO . groupByOperator =<< getUserServers db user - pure (user, map updatedUserServers uss) - updatedUserServers uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers + pure (user, map updatedUserSrvs uss) + updatedUserSrvs uss = uss {operator = updatedOp <$> operator' uss} :: UserOperatorServers updatedOp op = fromMaybe op $ find matchingOp $ mapMaybe operator' userServers where matchingOp op' = operatorId op' == operatorId op From e5534c0402e606ad9aa1c9e39566690fcaf4d9e1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:28:36 +0400 Subject: [PATCH 20/34] ios: improve onboarding animations (#5216) --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 +- .../Onboarding/ChooseServerOperators.swift | 299 ++++++++++-------- .../Views/Onboarding/CreateProfile.swift | 147 ++++++--- .../Views/Onboarding/OnboardingView.swift | 31 +- .../Onboarding/SetNotificationsMode.swift | 7 +- .../Shared/Views/Onboarding/SimpleXInfo.swift | 141 ++++----- .../Views/Onboarding/WhatsNewView.swift | 5 +- 7 files changed, 359 insertions(+), 289 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c61ad412c0..13b11568d8 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1650,7 +1650,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } -func startChat(refreshInvitations: Bool = true) throws { +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared try setNetworkConfig(getNetCfg()) @@ -1669,13 +1669,15 @@ func startChat(refreshInvitations: Bool = true) throws { if let token = m.deviceToken { registerToken(token: token) } - withAnimation { - let savedOnboardingStage = onboardingStageDefault.get() - m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 - ? .step3_ChooseServerOperators - : savedOnboardingStage - if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { - m.setDeliveryReceipts = true + if !onboarding { + withAnimation { + let savedOnboardingStage = onboardingStageDefault.get() + m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1 + ? .step3_ChooseServerOperators + : savedOnboardingStage + if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() { + m.setDeliveryReceipts = true + } } } } diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 09e0060c22..45c7a94bae 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -62,104 +62,105 @@ struct ChooseServerOperators: View { @State private var selectedOperatorIds = Set() @State private var reviewConditionsNavLinkActive = false @State private var sheetItem: ChooseServerOperatorsSheet? = nil + @State private var notificationsModeNavLinkActive = 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) { + GeometryReader { g in + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !onboarding { Text("Choose operators") .font(.largeTitle) .bold() + } - infoText() - - Spacer() - - ForEach(serverOperators) { srvOperator in - operatorCheckView(srvOperator) + infoText() + + Spacer() + + ForEach(serverOperators) { srvOperator in + operatorCheckView(srvOperator) + } + Text("You can configure servers via settings.") + .font(.footnote) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 32) + + Spacer() + + let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } + let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } + let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) + + VStack(spacing: 8) { + if !reviewForOperators.isEmpty { + reviewConditionsButton() + } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { + setOperatorsButton() + } else { + continueButton() } - Text("You can configure servers via settings.") - .font(.footnote) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 32) - - Spacer() - - let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed } - let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId }) - - VStack(spacing: 8) { - if !reviewForOperators.isEmpty { - reviewConditionsButton() - } else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty { - setOperatorsButton() - } else { - continueButton() - } - if onboarding { - Group { - if reviewForOperators.isEmpty { - Button("Conditions of use") { - sheetItem = .showConditions - } - } else { - Text("Conditions of use") - .foregroundColor(.clear) + if onboarding { + Group { + if reviewForOperators.isEmpty { + Button("Conditions of use") { + sheetItem = .showConditions } + } else { + Text("Conditions of use") + .foregroundColor(.clear) } - .font(.callout) - .padding(.top) } + .font(.callout) + .padding(.top) } + } + .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) - - 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(item: $sheetItem) { item in - switch item { - case .showInfo: - ChooseServerOperatorsInfoView() - case .showConditions: - UsageConditionsView( - currUserServers: Binding.constant([]), - userServers: Binding.constant([]) - ) - .modifier(ThemedBackground(grouped: true)) - } + .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(item: $sheetItem) { item in + switch item { + case .showInfo: + ChooseServerOperatorsInfoView() + case .showConditions: + UsageConditionsView( + currUserServers: Binding.constant([]), + userServers: Binding.constant([]) + ) + .modifier(ThemedBackground(grouped: true)) } } - .frame(maxHeight: .infinity) - .padding() } + .frame(maxHeight: .infinity) + .padding() } private func infoText() -> some View { @@ -193,7 +194,7 @@ struct ChooseServerOperators: View { .frame(width: 26, height: 26) .foregroundColor(iconColor) } - .background(Color(.systemBackground)) + .background(theme.colors.background) .padding() .clipShape(RoundedRectangle(cornerRadius: 18)) .overlay( @@ -231,57 +232,83 @@ struct ChooseServerOperators: View { } private func setOperatorsButton() -> some View { - Button { - Task { - if let enabledOperators = enabledOperators(serverOperators) { - let r = try await setServerOperators(operators: enabledOperators) - await MainActor.run { - ChatModel.shared.conditions = r - continueToNextStep() - } - } else { - await MainActor.run { - continueToNextStep() + notificationsModeNavLinkButton { + Button { + Task { + if let enabledOperators = enabledOperators(serverOperators) { + let r = try await setServerOperators(operators: enabledOperators) + await MainActor.run { + ChatModel.shared.conditions = r + continueToNextStep() + } + } else { + await MainActor.run { + continueToNextStep() + } } } + } label: { + Text("Update") } - } label: { - Text("Update") + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) } private func continueButton() -> some View { - Button { - continueToNextStep() - } label: { - Text("Continue") + notificationsModeNavLinkButton { + Button { + continueToNextStep() + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) + .disabled(selectedOperatorIds.isEmpty) } - .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) - .disabled(selectedOperatorIds.isEmpty) } private func reviewLaterButton() -> some View { - Button { - continueToNextStep() - } label: { - Text("Review later") + notificationsModeNavLinkButton { + Button { + continueToNextStep() + } label: { + Text("Review later") + } + .buttonStyle(.borderless) } - .buttonStyle(.borderless) } private func continueToNextStep() { if onboarding { - withAnimation { - onboardingStageDefault.set(.step4_SetNotificationsMode) - ChatModel.shared.onboardingStage = .step4_SetNotificationsMode - } + onboardingStageDefault.set(.step4_SetNotificationsMode) + notificationsModeNavLinkActive = true } else { dismiss() } } + func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { + ZStack { + button() + + NavigationLink(isActive: $notificationsModeNavLinkActive) { + notificationsModeDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func notificationsModeDestinationView() -> some View { + SetNotificationsMode() + .navigationTitle("Push notifications") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground(grouped: false)) + } + private func reviewConditionsDestinationView() -> some View { reviewConditionsView() .navigationTitle("Conditions of use") @@ -309,40 +336,42 @@ struct ChooseServerOperators: View { } 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) + notificationsModeNavLinkButton { + 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 = r2 - continueToNextStep() + ChatModel.shared.conditions = r } - } else { + 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 { - continueToNextStep() + showAlert( + NSLocalizedString("Error accepting conditions", comment: "alert title"), + message: responseError(error) + ) } } - } catch let error { - await MainActor.run { - showAlert( - NSLocalizedString("Error accepting conditions", comment: "alert title"), - message: responseError(error) - ) - } } + } label: { + Text("Accept conditions") } - } label: { - Text("Accept conditions") + .buttonStyle(OnboardingButtonStyle()) } - .buttonStyle(OnboardingButtonStyle()) } private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index b9f569e96d..30e7d4dc83 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -38,7 +38,7 @@ struct CreateProfile: View { TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) Button { - createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss) + createProfile() } label: { Label("Create profile", systemImage: "checkmark") } @@ -78,6 +78,35 @@ struct CreateProfile: View { } } } + + private func createProfile() { + hideKeyboard() + let profile = Profile( + displayName: displayName.trimmingCharacters(in: .whitespaces), + fullName: "" + ) + let m = ChatModel.shared + do { + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + // .isEmpty check is redundant here, but it makes it clearer what is going on + if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) { + try startChat() + withAnimation { + onboardingStageDefault.set(.step3_ChooseServerOperators) + m.onboardingStage = .step3_ChooseServerOperators + } + } else { + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + dismiss() + m.users = try listUsers() + try getUserChatData() + } + } catch let error { + showCreateProfileAlert(showAlert: { alert = $0 }, error) + } + } } struct CreateFirstProfile: View { @@ -86,6 +115,7 @@ struct CreateFirstProfile: View { @Environment(\.dismiss) var dismiss @State private var displayName: String = "" @FocusState private var focusDisplayName + @State private var nextStepNavLinkActive = false var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -136,69 +166,84 @@ struct CreateFirstProfile: View { } func createProfileButton() -> some View { - Button { - createProfile(displayName, showAlert: showAlert, dismiss: dismiss) - } label: { - Text("Create profile") + ZStack { + Button { + createProfile() + } label: { + Text("Create profile") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName))) + .disabled(!canCreateProfile(displayName)) + + NavigationLink(isActive: $nextStepNavLinkActive) { + nextStepDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } - .buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName))) - .disabled(!canCreateProfile(displayName)) } private func showAlert(_ alert: UserProfileAlert) { AlertManager.shared.showAlert(userProfileAlert(alert, $displayName)) } + + private func nextStepDestinationView() -> some View { + ChooseServerOperators(onboarding: true) + .navigationTitle("Choose operators") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground(grouped: false)) + } + + private func createProfile() { + hideKeyboard() + let profile = Profile( + displayName: displayName.trimmingCharacters(in: .whitespaces), + fullName: "" + ) + let m = ChatModel.shared + do { + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat(onboarding: true) + onboardingStageDefault.set(.step3_ChooseServerOperators) + nextStepNavLinkActive = true + } catch let error { + showCreateProfileAlert(showAlert: showAlert, error) + } + } } -private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) { - hideKeyboard() - let profile = Profile( - displayName: displayName.trimmingCharacters(in: .whitespaces), - fullName: "" - ) +private func showCreateProfileAlert( + showAlert: (UserProfileAlert) -> Void, + _ error: Error +) { let m = ChatModel.shared - do { - AppChatState.shared.set(.active) - m.currentUser = try apiCreateActiveUser(profile) - // .isEmpty check is redundant here, but it makes it clearer what is going on - if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) { - try startChat() - withAnimation { - onboardingStageDefault.set(.step3_ChooseServerOperators) - m.onboardingStage = .step3_ChooseServerOperators - } + switch error as? ChatResponse { + case .chatCmdError(_, .errorStore(.duplicateName)), + .chatCmdError(_, .error(.userExists)): + if m.currentUser == nil { + AlertManager.shared.showAlert(duplicateUserAlert) } else { - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - dismiss() - m.users = try listUsers() - try getUserChatData() + showAlert(.duplicateUserError) } - } catch let error { - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): - if m.currentUser == nil { - AlertManager.shared.showAlert(duplicateUserAlert) - } else { - showAlert(.duplicateUserError) - } - case .chatCmdError(_, .error(.invalidDisplayName)): - if m.currentUser == nil { - AlertManager.shared.showAlert(invalidDisplayNameAlert) - } else { - showAlert(.invalidDisplayNameError) - } - default: - let err: LocalizedStringKey = "Error: \(responseError(error))" - if m.currentUser == nil { - AlertManager.shared.showAlert(creatUserErrorAlert(err)) - } else { - showAlert(.createUserError(error: err)) - } + case .chatCmdError(_, .error(.invalidDisplayName)): + if m.currentUser == nil { + AlertManager.shared.showAlert(invalidDisplayNameAlert) + } else { + showAlert(.invalidDisplayNameError) + } + default: + let err: LocalizedStringKey = "Error: \(responseError(error))" + if m.currentUser == nil { + AlertManager.shared.showAlert(creatUserErrorAlert(err)) + } else { + showAlert(.createUserError(error: err)) } - logger.error("Failed to create user or start chat: \(responseError(error))") } + logger.error("Failed to create user or start chat: \(responseError(error))") } private func canCreateProfile(_ displayName: String) -> Bool { diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index de3dce21bb..172db25315 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -12,13 +12,30 @@ struct OnboardingView: View { var onboarding: OnboardingStage var body: some View { - switch onboarding { - 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() + NavigationView { + switch onboarding { + case .step1_SimpleXInfo: + SimpleXInfo(onboarding: true) + .modifier(ThemedBackground(grouped: false)) + case .step2_CreateProfile: // deprecated + CreateFirstProfile() + .modifier(ThemedBackground(grouped: false)) + case .step3_CreateSimpleXAddress: // deprecated + CreateSimpleXAddress() + case .step3_ChooseServerOperators: + ChooseServerOperators(onboarding: true) + .navigationTitle("Choose operators") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground(grouped: false)) + case .step4_SetNotificationsMode: + SetNotificationsMode() + .navigationTitle("Push notifications") + .navigationBarTitleDisplayMode(.large) + .navigationBarBackButtonHidden(true) + .modifier(ThemedBackground(grouped: false)) + case .onboardingComplete: EmptyView() + } } } } diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 03ee9c67e0..91a755459a 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -17,12 +17,7 @@ struct SetNotificationsMode: View { var body: some View { GeometryReader { g in ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text("Push notifications") - .font(.largeTitle) - .bold() - .frame(maxWidth: .infinity) - + VStack(alignment: .leading, spacing: 20) { Text("Send notifications:") ForEach(NotificationsMode.values) { mode in NtfModeSelector(mode: mode, selection: $notificationMode) diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index ea3627871e..2229f47a49 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -17,81 +17,79 @@ struct SimpleXInfo: View { var onboarding: Bool var body: some View { - 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) + 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) - } - - Spacer() - - if onboarding { - onboardingActionButton() - - Button { - m.migrationState = .pasteOrScanLink - } label: { - Label("Migrate from another device", systemImage: "tray.and.arrow.down") - .font(.subheadline) - } + 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 { + createFirstProfileButton() Button { - showHowItWorks = true + m.migrationState = .pasteOrScanLink } label: { - Label("How it works", systemImage: "info.circle") + Label("Migrate from another device", systemImage: "tray.and.arrow.down") .font(.subheadline) } .frame(maxWidth: .infinity) - .padding(.bottom) } - .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) - } - .navigationTitle("Migrate here") - .modifier(ThemedBackground(grouped: true)) + + Button { + showHowItWorks = true + } label: { + Label("How it works", systemImage: "info.circle") + .font(.subheadline) } + .frame(maxWidth: .infinity) + .padding(.bottom) } - .sheet(isPresented: $showHowItWorks) { - HowItWorks( - onboarding: onboarding, - createProfileNavLinkActive: $createProfileNavLinkActive - ) + .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) + } + .navigationTitle("Migrate here") + .modifier(ThemedBackground(grouped: true)) } } - .frame(maxHeight: .infinity) - .padding() + .sheet(isPresented: $showHowItWorks) { + HowItWorks( + onboarding: onboarding, + createProfileNavLinkActive: $createProfileNavLinkActive + ) + } } + .frame(maxHeight: .infinity) + .padding() } private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { @@ -113,14 +111,6 @@ struct SimpleXInfo: View { .padding(.trailing, 6) } - @ViewBuilder private func onboardingActionButton() -> some View { - if m.currentUser == nil { - createFirstProfileButton() - } else { - userExistsFallbackButton() - } - } - private func createFirstProfileButton() -> some View { ZStack { Button { @@ -144,18 +134,7 @@ struct SimpleXInfo: View { CreateFirstProfile() .navigationTitle("Create your profile") .navigationBarTitleDisplayMode(.large) - } - - private func userExistsFallbackButton() -> some View { - Button { - withAnimation { - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - } - } label: { - Text("Make a private connection") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .modifier(ThemedBackground(grouped: false)) } } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index c078fb23b1..82497a7922 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -569,7 +569,10 @@ fileprivate struct NewOperatorsView: View { } } .sheet(isPresented: $showOperatorsSheet) { - ChooseServerOperators(onboarding: false) + NavigationView { + ChooseServerOperators(onboarding: false) + .modifier(ThemedBackground(grouped: false)) + } } } } From 313acefb193e6256d44a6f0b735b292f0d8efe44 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:18:24 +0400 Subject: [PATCH 21/34] ios: remove crashing accept button (#5217) --- apps/ios/Shared/ContentView.swift | 1 + .../Onboarding/ChooseServerOperators.swift | 2 +- .../Views/Onboarding/CreateProfile.swift | 2 +- .../Views/Onboarding/OnboardingView.swift | 8 ++++---- .../Shared/Views/Onboarding/SimpleXInfo.swift | 2 +- .../Shared/Views/Onboarding/WhatsNewView.swift | 2 +- .../NetworkAndServers/OperatorView.swift | 18 ++++++------------ 7 files changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index ac699d4a2c..c5a7a6f20b 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -294,6 +294,7 @@ struct ContentView: View { switch item { case let .whatsNew(updatedConditions): WhatsNewView(updatedConditions: updatedConditions) + .modifier(ThemedBackground()) case .updatedConditions: UsageConditionsView( currUserServers: Binding.constant([]), diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 45c7a94bae..471d27ea50 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -306,7 +306,7 @@ struct ChooseServerOperators: View { .navigationTitle("Push notifications") .navigationBarTitleDisplayMode(.large) .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) } private func reviewConditionsDestinationView() -> some View { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 30e7d4dc83..c6760319b1 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -194,7 +194,7 @@ struct CreateFirstProfile: View { .navigationTitle("Choose operators") .navigationBarTitleDisplayMode(.large) .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) } private func createProfile() { diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 172db25315..d004e0306f 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -16,10 +16,10 @@ struct OnboardingView: View { switch onboarding { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) case .step2_CreateProfile: // deprecated CreateFirstProfile() - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) case .step3_CreateSimpleXAddress: // deprecated CreateSimpleXAddress() case .step3_ChooseServerOperators: @@ -27,13 +27,13 @@ struct OnboardingView: View { .navigationTitle("Choose operators") .navigationBarTitleDisplayMode(.large) .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) case .step4_SetNotificationsMode: SetNotificationsMode() .navigationTitle("Push notifications") .navigationBarTitleDisplayMode(.large) .navigationBarBackButtonHidden(true) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) case .onboardingComplete: EmptyView() } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 2229f47a49..2d90fb2fb2 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -134,7 +134,7 @@ struct SimpleXInfo: View { CreateFirstProfile() .navigationTitle("Create your profile") .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) } } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 82497a7922..4208c4a068 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -571,7 +571,7 @@ fileprivate struct NewOperatorsView: View { .sheet(isPresented: $showOperatorsSheet) { NavigationView { ChooseServerOperators(onboarding: false) - .modifier(ThemedBackground(grouped: false)) + .modifier(ThemedBackground()) } } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 63586e2121..6cebfdcde6 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -540,18 +540,12 @@ struct SingleOperatorUsageConditionsView: View { } 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)) + ConditionsTextView() + .padding() + .padding(.bottom) + .navigationTitle("Conditions of use") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) } } From 29b54ec5b296926c56882f3db5c2f12774efbfc8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:58:13 +0400 Subject: [PATCH 22/34] ios: rework saving settings (#5219) * ios: rework saving settings * fix * shorter names --------- Co-authored-by: Evgeny Poberezkin --- .../Shared/Views/ChatList/ChatListView.swift | 31 ++++++---- .../Views/ChatList/ServersSummaryView.swift | 57 +------------------ .../NetworkAndServers/NetworkAndServers.swift | 52 ++++++++--------- .../Views/UserSettings/SettingsView.swift | 22 ++----- 4 files changed, 50 insertions(+), 112 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 8e7aec581b..6da17fb312 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -31,14 +31,22 @@ enum UserPickerSheet: Identifiable { } } +class SaveableSettings: ObservableObject { + @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) +} + +struct ServerSettings { + public var currUserServers: [UserOperatorServers] + public var userServers: [UserOperatorServers] + public var serverErrors: [UserServersError] +} + struct UserPickerSheetView: View { let sheet: UserPickerSheet @EnvironmentObject var chatModel: ChatModel - @State private var loaded = false + @StateObject private var ss = SaveableSettings() - @State private var currUserServers: [UserOperatorServers] = [] - @State private var userServers: [UserOperatorServers] = [] - @State private var serverErrors: [UserServersError] = [] + @State private var loaded = false var body: some View { NavigationView { @@ -60,11 +68,7 @@ struct UserPickerSheetView: View { case .useFromDesktop: ConnectDesktopView() case .settings: - SettingsView( - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors - ) + SettingsView() } } Color.clear // Required for list background to be rendered during loading @@ -85,15 +89,20 @@ struct UserPickerSheetView: View { ) } .onDisappear { - if serversCanBeSaved(currUserServers, userServers, serverErrors) { + if serversCanBeSaved( + ss.servers.currUserServers, + ss.servers.userServers, + ss.servers.serverErrors + ) { showAlert( title: NSLocalizedString("Save servers?", comment: "alert title"), buttonTitle: NSLocalizedString("Save", comment: "alert button"), - buttonAction: { saveServers($currUserServers, $userServers) }, + buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) }, cancelButton: true ) } } + .environmentObject(ss) } } diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index a13a159a45..b87b84ebc0 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -20,10 +20,6 @@ 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 { @@ -57,15 +53,6 @@ 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 } } @@ -288,10 +275,7 @@ struct ServersSummaryView: View { NavigationLink(tag: srvSumm.id, selection: $selectedSMPServer) { SMPServerSummaryView( summary: srvSumm, - statsStartedAt: statsStartedAt, - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors + statsStartedAt: statsStartedAt ) .navigationBarTitle("SMP server") .navigationBarTitleDisplayMode(.large) @@ -360,10 +344,7 @@ struct ServersSummaryView: View { NavigationLink(tag: srvSumm.id, selection: $selectedXFTPServer) { XFTPServerSummaryView( summary: srvSumm, - statsStartedAt: statsStartedAt, - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors + statsStartedAt: statsStartedAt ) .navigationBarTitle("XFTP server") .navigationBarTitleDisplayMode(.large) @@ -505,28 +486,11 @@ 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") { Text(summary.smpServer) .textSelection(.enabled) - if summary.known == true { - NavigationLink { - NetworkAndServers( - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors - ) - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Open server settings") - } - } } if let stats = summary.stats { @@ -701,28 +665,11 @@ 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") { Text(summary.xftpServer) .textSelection(.enabled) - if summary.known == true { - NavigationLink { - NetworkAndServers( - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors - ) - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) - } label: { - Text("Open server settings") - } - } } if let stats = summary.stats { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index c668ad3858..8b07c9a519 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -34,9 +34,7 @@ struct NetworkAndServers: View { @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] + @EnvironmentObject var ss: SaveableSettings @State private var sheetItem: NetworkAndServersSheet? = nil @State private var justOpened = true @State private var showSaveDialog = false @@ -45,9 +43,9 @@ struct NetworkAndServers: View { VStack { List { let conditionsAction = m.conditions.conditionsAction - let anyOperatorEnabled = userServers.contains(where: { $0.operator?.enabled ?? false }) + let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false }) Section { - ForEach(userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in + ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in if let serverOperator = userOperatorServers.operator { serverOperatorView(idx, serverOperator) } else { @@ -74,11 +72,11 @@ struct NetworkAndServers: View { } Section { - if let idx = userServers.firstIndex(where: { $0.operator == nil }) { + if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) { NavigationLink { YourServersView( - userServers: $userServers, - serverErrors: $serverErrors, + userServers: $ss.servers.userServers, + serverErrors: $ss.servers.serverErrors, operatorIndex: idx ) .navigationTitle("Your servers") @@ -87,7 +85,7 @@ struct NetworkAndServers: View { HStack { Text("Your servers") - if userServers[idx] != currUserServers[idx] { + if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] { Spacer() unsavedChangesIndicator() } @@ -108,12 +106,12 @@ struct NetworkAndServers: View { } Section { - Button("Save servers", action: { saveServers($currUserServers, $userServers) }) - .disabled(!serversCanBeSaved(currUserServers, userServers, serverErrors)) + Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) }) + .disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors)) } footer: { - if let errStr = globalServersError(serverErrors) { + if let errStr = globalServersError(ss.servers.serverErrors) { ServersErrorView(errStr: errStr) - } else if !serverErrors.isEmpty { + } else if !ss.servers.serverErrors.isEmpty { ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) } } @@ -141,9 +139,9 @@ struct NetworkAndServers: View { // 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 = [] + ss.servers.currUserServers = try await getUserServers() + ss.servers.userServers = ss.servers.currUserServers + ss.servers.serverErrors = [] } catch let error { await MainActor.run { showAlert( @@ -156,7 +154,7 @@ struct NetworkAndServers: View { } } .modifier(BackButton(disabled: Binding.constant(false)) { - if serversCanBeSaved(currUserServers, userServers, serverErrors) { + if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) { showSaveDialog = true } else { dismiss() @@ -164,7 +162,7 @@ struct NetworkAndServers: View { }) .confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) { Button("Save") { - saveServers($currUserServers, $userServers) + saveServers($ss.servers.currUserServers, $ss.servers.userServers) dismiss() } Button("Exit without saving") { dismiss() } @@ -173,8 +171,8 @@ struct NetworkAndServers: View { switch item { case .showConditions: UsageConditionsView( - currUserServers: $currUserServers, - userServers: $userServers + currUserServers: $ss.servers.currUserServers, + userServers: $ss.servers.userServers ) .modifier(ThemedBackground(grouped: true)) } @@ -184,9 +182,9 @@ struct NetworkAndServers: View { private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View { NavigationLink() { OperatorView( - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors, + currUserServers: $ss.servers.currUserServers, + userServers: $ss.servers.userServers, + serverErrors: $ss.servers.serverErrors, operatorIndex: operatorIndex, useOperator: serverOperator.enabled ) @@ -203,7 +201,7 @@ struct NetworkAndServers: View { Text(serverOperator.tradeName) .foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary) - if userServers[operatorIndex] != currUserServers[operatorIndex] { + if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] { Spacer() unsavedChangesIndicator() } @@ -427,10 +425,6 @@ func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, struct NetworkServersView_Previews: PreviewProvider { static var previews: some View { - NetworkAndServers( - currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) - ) + NetworkAndServers() } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f2a1a56d01..95bf327f1b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -266,10 +266,6 @@ 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() @@ -296,13 +292,9 @@ struct SettingsView: View { .disabled(chatModel.chatRunning != true) NavigationLink { - NetworkAndServers( - currUserServers: $currUserServers, - userServers: $userServers, - serverErrors: $serverErrors - ) - .navigationTitle("Network & servers") - .modifier(ThemedBackground(grouped: true)) + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) } label: { settingsRow("externaldrive.connected.to.line.below", color: theme.colors.secondary) { Text("Network & servers") } } @@ -536,11 +528,7 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.currentUser = User.sampleData - return SettingsView( - currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) - ) - .environmentObject(chatModel) + return SettingsView() + .environmentObject(chatModel) } } From f3cef7ce12ef76c4e2d9dd7329593ce6d5c86815 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:23:51 +0400 Subject: [PATCH 23/34] ios: remove unused type --- apps/ios/SimpleXChat/APITypes.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 016a8213c3..8014600d47 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1456,7 +1456,6 @@ 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? { From 61d7df89069ad9b4725d4b0f47c546fddc163b77 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 21 Nov 2024 16:54:35 +0000 Subject: [PATCH 24/34] ui: always use private routing by default --- apps/ios/SimpleXChat/APITypes.swift | 2 +- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- src/Simplex/Chat/Options.hs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 8014600d47..51aa9108a1 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1722,7 +1722,7 @@ public struct NetCfg: Codable, Equatable { public var hostMode: HostMode = .publicHost public var requiredHostMode = true public var sessionMode = TransportSessionMode.user - public var smpProxyMode: SMPProxyMode = .unknown + public var smpProxyMode: SMPProxyMode = .always public var smpProxyFallback: SMPProxyFallback = .allowProtected public var smpWebPort = false public var tcpConnectTimeout: Int // microseconds diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7f7f8a6e58..580a663945 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3683,7 +3683,7 @@ data class NetCfg( val hostMode: HostMode = HostMode.OnionViaSocks, val requiredHostMode: Boolean = false, val sessionMode: TransportSessionMode = TransportSessionMode.default, - val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown, + val smpProxyMode: SMPProxyMode = SMPProxyMode.Always, val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected, val smpWebPort: Boolean = false, val tcpConnectTimeout: Long, // microseconds diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 16ffe6e28f..f398831194 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -236,7 +236,7 @@ coreChatOptsP appDir defaultDbFileName = do ) yesToUpMigrations <- switch - ( long "--yes-migrate" + ( long "yes-migrate" <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) From 78b3b12ec1cd02e440c954add8d320f2f6b954ad Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 21 Nov 2024 17:02:55 +0000 Subject: [PATCH 25/34] ios: button to open conditions and changes (#5225) --- .../Onboarding/ChooseServerOperators.swift | 3 ++ .../NetworkAndServers/NetworkAndServers.swift | 12 ++++--- .../NetworkAndServers/OperatorView.swift | 35 ++++++++++++++++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 471d27ea50..19d67bc62c 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -9,6 +9,8 @@ import SwiftUI import SimpleXChat +let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")! + struct OnboardingButtonStyle: ButtonStyle { @EnvironmentObject var theme: AppTheme var isDisabled: Bool = false @@ -313,6 +315,7 @@ struct ChooseServerOperators: View { reviewConditionsView() .navigationTitle("Conditions of use") .navigationBarTitleDisplayMode(.large) + .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } .modifier(ThemedBackground(grouped: true)) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 8b07c9a519..9b03b79353 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -238,11 +238,13 @@ struct UsageConditionsView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - Text("Conditions of use") - .font(.largeTitle) - .bold() - .padding(.top) - .padding(.top) + HStack { + Text("Conditions of use").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } + .padding(.top) + .padding(.top) switch ChatModel.shared.conditions.conditionsAction { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 6cebfdcde6..83152a001f 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -464,11 +464,13 @@ struct SingleOperatorUsageConditionsView: View { } private func viewHeader() -> some View { - Text("Use servers of \(userServers[operatorIndex].operator_.tradeName)") - .font(.largeTitle) - .bold() - .padding(.top) - .padding(.top) + HStack { + Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold() + Spacer() + conditionsLinkButton() + } + .padding(.top) + .padding(.top) } @ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View { @@ -545,10 +547,33 @@ struct SingleOperatorUsageConditionsView: View { .padding(.bottom) .navigationTitle("Conditions of use") .navigationBarTitleDisplayMode(.large) + .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } .modifier(ThemedBackground(grouped: true)) } } +func conditionsLinkButton() -> some View { + let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit + let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL + return Menu { + Link(destination: mdUrl) { + Label("Open conditions", systemImage: "doc") + } + if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") { + Link(destination: commitUrl) { + Label("Open changes", systemImage: "ellipsis") + } + } + } label: { + Image(systemName: "arrow.up.right.circle") + .resizable() + .scaledToFit() + .frame(width: 20) + .padding(2) + .contentShape(Circle()) + } +} + #Preview { OperatorView( currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), From bab63d8f27118c3f3d59b979305c6ee5732e0065 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:23:33 +0400 Subject: [PATCH 26/34] ios: fix repeatedly showing updated conditions --- apps/ios/Shared/ContentView.swift | 13 +++++++++++++ apps/ios/Shared/Views/Onboarding/WhatsNewView.swift | 10 ---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index c5a7a6f20b..652258415e 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -295,12 +295,16 @@ struct ContentView: View { case let .whatsNew(updatedConditions): WhatsNewView(updatedConditions: updatedConditions) .modifier(ThemedBackground()) + .if(updatedConditions) { v in + v.task { await setConditionsNotified_() } + } case .updatedConditions: UsageConditionsView( currUserServers: Binding.constant([]), userServers: Binding.constant([]) ) .modifier(ThemedBackground(grouped: true)) + .task { await setConditionsNotified_() } } } if chatModel.setDeliveryReceipts { @@ -313,6 +317,15 @@ struct ContentView: View { .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) } + private func setConditionsNotified_() async { + do { + let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId + try await setConditionsNotified(conditionsId: conditionsId) + } catch let error { + logger.error("setConditionsNotified error: \(responseError(error))") + } + } + private func processUserActivity(_ activity: NSUserActivity) { let intent = activity.interaction?.intent if let intent = intent as? INStartCallIntent { diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 4208c4a068..c1c2cb8383 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -598,16 +598,6 @@ struct WhatsNewView: View { var body: some View { whatsNewView() - .task { - if updatedConditions { - do { - let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId - try await setConditionsNotified(conditionsId: conditionsId) - } catch let error { - logger.error("WhatsNewView setConditionsNotified error: \(responseError(error))") - } - } - } .sheet(item: $sheetItem) { item in switch item { case .showConditions: From 49d1b26bba44bf417b56bf6a0345bac98e1827ce Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 22 Nov 2024 10:38:00 +0000 Subject: [PATCH 27/34] core: tests for operators api, CLI command to update operators (#5226) --- src/Simplex/Chat.hs | 25 ++++++++++++++++++ src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Operators.hs | 8 ++++++ src/Simplex/Chat/View.hs | 21 ++++++++++----- tests/ChatClient.hs | 10 +++++++ tests/ChatTests/Direct.hs | 48 ++++++++++++++++++++++++++++------ 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0daf9fa394..5906da57de 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1640,6 +1640,16 @@ processChatCommand' vr = \case xftpSrvs <- getProtocolServers db SPXFTP user uss <- groupByOperator (ops, smpSrvs, xftpSrvs) pure $ (aUserId user,) $ useServers as opDomains uss + SetServerOperators operatorsRoles -> do + ops <- serverOperators <$> withFastStore getServerOperators + ops' <- mapM (updateOp ops) operatorsRoles + processChatCommand $ APISetServerOperators ops' + where + updateOp :: [ServerOperator] -> ServerOperatorRoles -> CM ServerOperator + updateOp ops r = + case find (\ServerOperator {operatorId = DBEntityId opId} -> operatorId' r == opId) ops of + Just op -> pure op {enabled = enabled' r, smpRoles = smpRoles' r, xftpRoles = xftpRoles' r} + Nothing -> throwError $ ChatErrorStore $ SEOperatorNotFound $ operatorId' r APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do @@ -8308,6 +8318,7 @@ chatCommandP = "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/_operators" $> APIGetServerOperators, "/_operators " *> (APISetServerOperators <$> jsonP), + "/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','), "/_servers " *> (APIGetUserServers <$> A.decimal), "/_servers " *> (APISetUserServers <$> A.decimal <* A.space <*> jsonP), "/_validate_servers " *> (APIValidateServers <$> A.decimal <* A.space <*> jsonP), @@ -8637,6 +8648,20 @@ chatCommandP = optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP) <|> ("yes" $> TMEEnableKeepTTL) <|> ("no" $> TMEDisableKeepTTL) + operatorRolesP = do + operatorId' <- A.decimal + enabled' <- A.char ':' *> onOffP + smpRoles' <- (":smp=" *> srvRolesP) <|> pure allRoles + xftpRoles' <- (":xftp=" *> srvRolesP) <|> pure allRoles + pure ServerOperatorRoles {operatorId', enabled', smpRoles', xftpRoles'} + srvRolesP = srvRoles <$?> A.takeTill (\c -> c == ':' || c == ',') + where + srvRoles = \case + "off" -> Right $ ServerRoles False False + "proxy" -> Right ServerRoles {storage = False, proxy = True} + "storage" -> Right ServerRoles {storage = True, proxy = False} + "on" -> Right allRoles + _ -> Left "bad ServerRoles" netCfgP = do socksProxy <- "socks=" *> ("off" $> Nothing <|> "on" $> Just defaultSocksProxyWithAuth <|> Just <$> strP) socksMode <- " socks-mode=" *> strP <|> pure SMAlways diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e44ea2ac18..23aa632478 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -358,6 +358,7 @@ data ChatCommand | TestProtoServer AProtoServerWithAuth | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) + | SetServerOperators (NonEmpty ServerOperatorRoles) | APIGetUserServers UserId | APISetUserServers UserId (NonEmpty UpdatedUserOperatorServers) | APIValidateServers UserId [UpdatedUserOperatorServers] -- response is CRUserServersValidation diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index ebe1da8176..e14e95211a 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -192,6 +192,14 @@ data ServerOperator' s = ServerOperator } deriving (Show) +data ServerOperatorRoles = ServerOperatorRoles + { operatorId' :: Int64, + enabled' :: Bool, + smpRoles' :: ServerRoles, + xftpRoles' :: ServerRoles + } + deriving (Show) + operatorRoles :: UserProtocol p => SProtocolType p -> ServerOperator -> ServerRoles operatorRoles p op = case p of SPSMP -> smpRoles op diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e4c0fd5606..f9ec3f936c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -101,7 +101,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) CRUserServersValidation {} -> [] - CRUsageConditions {} -> [] + CRUsageConditions current _ accepted_ -> viewUsageConditions current accepted_ CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile @@ -1280,8 +1280,8 @@ viewOperator op@ServerOperator {tradeName, legalName, serverDomains, conditionsA <> tradeName <> maybe "" parens legalName <> (", domains: " <> T.intercalate ", " serverDomains) + <> (", servers: " <> viewOpEnabled op) <> (", conditions: " <> viewOpConditions conditionsAcceptance) - <> (", " <> viewOpEnabled op) shortViewOperator :: ServerOperator -> Text shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabled} = @@ -1289,10 +1289,10 @@ shortViewOperator ServerOperator {operatorId = DBEntityId opId, tradeName, enabl viewOpIdTag :: ServerOperator' s -> Text viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of - DBEntityId i -> tshow i <> " - " <> tag + DBEntityId i -> tshow i <> tag DBNewEntity -> tag where - tag = maybe "" textEncode operatorTag <> ". " + tag = maybe "" (parens . textEncode) operatorTag <> ". " viewOpConditions :: ConditionsAcceptance -> Text viewOpConditions = \case @@ -1306,7 +1306,7 @@ viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles} | not enabled = "disabled" | no smpRoles && no xftpRoles = "disabled (servers known)" | both smpRoles && both xftpRoles = "enabled" - | otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP" <> viewRoles xftpRoles + | otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles where no rs = not $ storage rs || proxy rs both rs = storage rs && proxy rs @@ -1319,13 +1319,20 @@ viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles} viewConditionsAction :: UsageConditionsAction -> [StyledString] viewConditionsAction = \case UCAReview {operators, deadline, showNotice} | showNotice -> case deadline of - Just ts -> [plain $ "New conditions will be accepted at " <> tshow ts <> " for " <> ops] - Nothing -> [plain $ "New conditions have to be accepted for " <> ops] + Just ts -> [plain $ "The new conditions will be accepted for " <> ops <> " at " <> tshow ts] + Nothing -> [plain $ "The new conditions have to be accepted for " <> ops] where ops = T.intercalate ", " $ map legalName_ operators legalName_ ServerOperator {tradeName, legalName} = fromMaybe tradeName legalName _ -> [] +viewUsageConditions :: UsageConditions -> Maybe UsageConditions -> [StyledString] +viewUsageConditions current accepted_ = + [plain $ "Current conditions: " <> viewConds current <> maybe "" (\ac -> ", accepted conditions: " <> viewConds ac) accepted_] + where + viewConds UsageConditions {conditionsId, conditionsCommit, notifiedAt} = + tshow conditionsId <> maybe "" (const " (notified)") notifiedAt <> ". " <> conditionsCommit + viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case Nothing -> ["old messages are not being deleted"] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 7bf7804472..8b7e8fcd32 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -376,6 +376,16 @@ userName :: TestCC -> IO [Char] userName (TestCC ChatController {currentUser} _ _ _ _ _) = maybe "no current user" (\User {localDisplayName} -> T.unpack localDisplayName) <$> readTVarIO currentUser +testChat :: HasCallStack => Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChat = testChatCfgOpts testCfg testOpts + +testChatCfgOpts :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> (HasCallStack => TestCC -> IO ()) -> FilePath -> IO () +testChatCfgOpts cfg opts p test = testChatN cfg opts [p] test_ + where + test_ :: HasCallStack => [TestCC] -> IO () + test_ [tc] = test tc + test_ _ = error "expected 1 chat client" + testChat2 :: HasCallStack => Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> IO ()) -> FilePath -> IO () testChat2 = testChatCfgOpts2 testCfg testOpts diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 6bbf72171e..d305055d94 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -85,6 +85,8 @@ chatDirectTests = do describe "XFTP servers" $ do it "get and set XFTP servers" testGetSetXFTPServers it "test XFTP server connection" testTestXFTPServer + describe "operators and usage conditions" $ do + it "get and enable operators, accept conditions" testOperators describe "async connection handshake" $ do describe "connect when initiating client goes offline" $ do it "curr" $ testAsyncInitiatingOffline testCfg testCfg @@ -1140,8 +1142,8 @@ testSendMultiManyBatches = testGetSetSMPServers :: HasCallStack => FilePath -> IO () testGetSetSMPServers = - testChat2 aliceProfile bobProfile $ - \alice _ -> do + testChat aliceProfile $ + \alice -> do alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" @@ -1168,8 +1170,8 @@ testGetSetSMPServers = testTestSMPServerConnection :: HasCallStack => FilePath -> IO () testTestSMPServerConnection = - testChat2 aliceProfile bobProfile $ - \alice _ -> do + testChat aliceProfile $ + \alice -> do alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001" alice <## "SMP server test passed" -- to test with password: @@ -1183,8 +1185,8 @@ testTestSMPServerConnection = testGetSetXFTPServers :: HasCallStack => FilePath -> IO () testGetSetXFTPServers = - testChat2 aliceProfile bobProfile $ - \alice _ -> withXFTPServer $ do + testChat aliceProfile $ + \alice -> withXFTPServer $ do alice ##> "/_servers 1" alice <## "Your servers" alice <## " SMP servers" @@ -1210,8 +1212,8 @@ testGetSetXFTPServers = testTestXFTPServer :: HasCallStack => FilePath -> IO () testTestXFTPServer = - testChat2 aliceProfile bobProfile $ - \alice _ -> withXFTPServer $ do + testChat aliceProfile $ + \alice -> withXFTPServer $ do alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7002" alice <## "XFTP server test passed" -- to test with password: @@ -1223,6 +1225,36 @@ testTestXFTPServer = alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" +testOperators :: HasCallStack => FilePath -> IO () +testOperators = + testChatCfgOpts testCfg opts' aliceProfile $ + \alice -> do + -- initial load + alice ##> "/_conditions" + alice <##. "Current conditions: 2." + alice ##> "/_operators" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" + alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " + -- set conditions notified + alice ##> "/_conditions_notified 2" + alice <## "ok" + alice ##> "/_operators" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" + alice ##> "/_conditions" + alice <##. "Current conditions: 2 (notified)." + -- accept conditions + alice ##> "/_accept_conditions 2 1,2" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" + alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: accepted (" + -- update operators + alice ##> "/operators 2:on:smp=proxy" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: accepted (" + alice <##. "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: SMP enabled proxy, XFTP enabled, conditions: accepted (" + where + opts' = testOpts {coreOptions = testCoreOpts {smpServers = [], xftpServers = []}} + testAsyncInitiatingOffline :: HasCallStack => ChatConfig -> ChatConfig -> FilePath -> IO () testAsyncInitiatingOffline aliceCfg bobCfg tmp = do inv <- withNewTestChatCfg tmp aliceCfg "alice" aliceProfile $ \alice -> do From 396fa7f988bb12460dfe3f9217b8f0886d601029 Mon Sep 17 00:00:00 2001 From: Diogo Date: Fri, 22 Nov 2024 14:42:07 +0000 Subject: [PATCH 28/34] desktop, android: server operators (#5212) * api and types * whats new view * new package and movements * move network and servers to new package * network and servers view * wip * api update * build * conditions modal in settings * network and servers fns * save server fixes * more servers * move protocol servers view * message servers with validation * added message servers * use for files * fix error by server type * list xftp servers * android: add server view (#5221) * android add server wip * test servers button * fix save of custom servers * remove unused code * edit and view servers * fix * allow to enable untested * show all test errors in the end * android: custom servers view (#5224) * cleanup * validation footers * operator enabled validation * var -> val * reuse onboarding button * AppBarTitle without alpha * remove non scrollable title * change in AppBarTitle * changes in AppBar * bold strings + bordered text view * ChooseServerOperators * fix * new server view wip * fix * scan * rename * fix roles toggle texts * UsageConditionsView * aligned texts * more texts * replace hard coded logos with object ref * use snapshot state to recalculate errors * align views; fix accept * remove extra snapshots * fix ts * fix whatsnew * stage * animation on onboarding * refactor and fix * remember * fix start chat alert * show notice in chat list * refactor * fix validation * open conditions * whats new view updates * icon for navigation improvements * remove debug * simplify * fix * handle click when have unsaved changes * fix * Revert "fix" This reverts commit d49c3736415a9fe08464237e041c2d2b6fc665d3. * Revert "handle click when have unsaved changes" This reverts commit 39ca03f9c086b87b5b6571f93443ba16f2870d24. * fixed close of modals in whats new view * grouping * android: conditions view paddings (#5228) * revert padding * refresh operators on save * fixed modals in different views for desktop * ios: fix enabling operator model update * fix modals --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../NetworkAndServers/NetworkAndServers.swift | 2 + .../ScanProtocolServer.android.kt | 6 +- .../kotlin/chat/simplex/common/App.kt | 9 +- .../chat/simplex/common/model/ChatModel.kt | 9 + .../chat/simplex/common/model/SimpleXAPI.kt | 557 ++++++++++++-- .../chat/simplex/common/platform/Core.kt | 12 +- .../chat/simplex/common/views/WelcomeView.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chatlist/ChatListView.kt | 26 +- .../views/chatlist/ServersSummaryView.kt | 19 +- .../common/views/helpers/AppBarTitle.kt | 19 +- .../common/views/helpers/CollapsingAppBar.kt | 1 + .../common/views/helpers/DefaultTopAppBar.kt | 3 +- .../common/views/migration/MigrateToDevice.kt | 1 + .../views/onboarding/ChooseServerOperators.kt | 354 +++++++++ .../common/views/onboarding/HowItWorks.kt | 4 +- .../common/views/onboarding/OnboardingView.kt | 1 + .../views/onboarding/SetNotificationsMode.kt | 8 +- .../onboarding/SetupDatabasePassphrase.kt | 3 +- .../common/views/onboarding/SimpleXInfo.kt | 5 +- .../common/views/onboarding/WhatsNewView.kt | 261 ++++--- .../common/views/remote/ConnectMobileView.kt | 1 + .../views/usersettings/ProtocolServersView.kt | 383 ---------- .../common/views/usersettings/SettingsView.kt | 7 +- .../AdvancedNetworkSettings.kt | 3 +- .../NetworkAndServers.kt | 540 ++++++++++++-- .../networkAndServers/NewServerView.kt | 144 ++++ .../networkAndServers/OperatorView.kt | 701 ++++++++++++++++++ .../ProtocolServerView.kt | 169 +++-- .../networkAndServers/ProtocolServersView.kt | 407 ++++++++++ .../ScanProtocolServer.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 84 +++ .../resources/MR/images/flux_logo@4x.png | Bin 0 -> 34876 bytes .../MR/images/flux_logo_light@4x.png | Bin 0 -> 33847 bytes .../MR/images/flux_logo_symbol@4x.png | Bin 0 -> 17248 bytes .../resources/MR/images/ic_outbound.svg | 1 + .../ScanProtocolServer.desktop.kt | 9 - .../ScanProtocolServer.desktop.kt | 9 + 38 files changed, 3032 insertions(+), 746 deletions(-) rename apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ScanProtocolServer.android.kt (69%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/AdvancedNetworkSettings.kt (99%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/NetworkAndServers.kt (52%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ProtocolServerView.kt (51%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/{ => networkAndServers}/ScanProtocolServer.kt (62%) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 9b03b79353..8b6421b502 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -395,7 +395,9 @@ func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServer // 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() + let updatedOperators = try await getServerOperators() await MainActor.run { + ChatModel.shared.conditions = updatedOperators currUserServers.wrappedValue = updatedServers userServers.wrappedValue = updatedServers } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt similarity index 69% rename from apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt index af5a27be11..8b5def7451 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.android.kt @@ -1,13 +1,13 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import android.Manifest import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.model.ServerCfg +import chat.simplex.common.model.UserServer import com.google.accompanist.permissions.rememberPermissionState @Composable -actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { +actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) { val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) LaunchedEffect(Unit) { cameraPermissionState.launchPermissionRequest() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 7af1d574ad..b1ce003812 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.draw.* import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp @@ -42,7 +41,6 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlin.math.absoluteValue @Composable fun AppScreen() { @@ -194,6 +192,13 @@ fun MainScreen() { OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} OnboardingStage.LinkAMobile -> LinkAMobile() OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) + OnboardingStage.Step3_ChooseServerOperators -> { + val modalData = remember { ModalData() } + modalData.ChooseServerOperators(true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } // Ensure backwards compatibility with old onboarding stage for address creation, otherwise notification setup would be skipped OnboardingStage.Step3_CreateSimpleXAddress -> SetNotificationsMode(chatModel) OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ef777f151f..e501ed5a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -145,6 +145,8 @@ object ChatModel { val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) + val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) + val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null @@ -2567,6 +2569,13 @@ fun localTimestamp(t: Instant): String { return ts.toJavaLocalDateTime().format(dateFormatter) } +fun localDate(t: Instant): String { + val tz = TimeZone.currentSystemDefault() + val ts: LocalDateTime = t.toLocalDateTime(tz) + val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + return ts.toJavaLocalDateTime().format(dateFormatter) +} + @Serializable sealed class CIStatus { @Serializable @SerialName("sndNew") class SndNew: CIStatus() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 580a663945..0cab7ce8e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -26,10 +26,12 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings +import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -963,36 +965,6 @@ object ChatController { return null } - suspend fun getUserProtoServers(rh: Long?, serverProtocol: ServerProtocol): UserProtocolServers? { - val userId = kotlin.runCatching { currentUserId("getUserProtoServers") }.getOrElse { return null } - val r = sendCmd(rh, CC.APIGetUserProtoServers(userId, serverProtocol)) - return if (r is CR.UserProtoServers) { if (rh == null) r.servers else r.servers.copy(protoServers = r.servers.protoServers.map { it.copy(remoteHostId = rh) }) } - else { - Log.e(TAG, "getUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_loading_smp_servers else MR.strings.error_loading_xftp_servers), - "${r.responseType}: ${r.details}" - ) - null - } - } - - suspend fun setUserProtoServers(rh: Long?, serverProtocol: ServerProtocol, servers: List): Boolean { - val userId = kotlin.runCatching { currentUserId("setUserProtoServers") }.getOrElse { return false } - val r = sendCmd(rh, CC.APISetUserProtoServers(userId, serverProtocol, servers)) - return when (r) { - is CR.CmdOk -> true - else -> { - Log.e(TAG, "setUserProtoServers bad response: ${r.responseType} ${r.details}") - AlertManager.shared.showAlertMsg( - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.error_saving_smp_servers else MR.strings.error_saving_xftp_servers), - generalGetString(if (serverProtocol == ServerProtocol.SMP) MR.strings.ensure_smp_server_address_are_correct_format_and_unique else MR.strings.ensure_xftp_server_address_are_correct_format_and_unique) - ) - false - } - } - } - suspend fun testProtoServer(rh: Long?, server: String): ProtocolTestFailure? { val userId = currentUserId("testProtoServer") val r = sendCmd(rh, CC.APITestProtoServer(userId, server)) @@ -1005,6 +977,106 @@ object ChatController { } } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiGetServerOperators()) + + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + Log.e(TAG, "getServerOperators bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setServerOperators(rh: Long?, operators: List): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiSetServerOperators(operators)) + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + Log.e(TAG, "setServerOperators bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun getUserServers(rh: Long?): List? { + val userId = currentUserId("getUserServers") + val r = sendCmd(rh, CC.ApiGetUserServers(userId)) + return when (r) { + is CR.UserServers -> r.userServers + else -> { + Log.e(TAG, "getUserServers bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setUserServers(rh: Long?, userServers: List): Boolean { + val userId = currentUserId("setUserServers") + val r = sendCmd(rh, CC.ApiSetUserServers(userId, userServers)) + return when (r) { + is CR.CmdOk -> true + else -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.failed_to_save_servers), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "setUserServers bad response: ${r.responseType} ${r.details}") + false + } + } + } + + suspend fun validateServers(rh: Long?, userServers: List): List? { + val userId = currentUserId("validateServers") + val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) + return when (r) { + is CR.UserServersValidation -> r.serverErrors + else -> { + Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun getUsageConditions(rh: Long?): Triple? { + val r = sendCmd(rh, CC.ApiGetUsageConditions()) + return when (r) { + is CR.UsageConditions -> Triple(r.usageConditions, r.conditionsText, r.acceptedConditions) + else -> { + Log.e(TAG, "getUsageConditions bad response: ${r.responseType} ${r.details}") + null + } + } + } + + suspend fun setConditionsNotified(rh: Long?, conditionsId: Long): Boolean { + val r = sendCmd(rh, CC.ApiSetConditionsNotified(conditionsId)) + return when (r) { + is CR.CmdOk -> true + else -> { + Log.e(TAG, "setConditionsNotified bad response: ${r.responseType} ${r.details}") + false + } + } + } + + suspend fun acceptConditions(rh: Long?, conditionsId: Long, operatorIds: List): ServerOperatorConditionsDetail? { + val r = sendCmd(rh, CC.ApiAcceptConditions(conditionsId, operatorIds)) + return when (r) { + is CR.ServerOperatorConditions -> r.conditions + else -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_accepting_operator_conditions), + "${r.responseType}: ${r.details}" + ) + Log.e(TAG, "acceptConditions bad response: ${r.responseType} ${r.details}") + null + } + } + } + suspend fun getChatItemTTL(rh: Long?): ChatItemTTL { val userId = currentUserId("getChatItemTTL") val r = sendCmd(rh, CC.APIGetChatItemTTL(userId)) @@ -3037,9 +3109,15 @@ sealed class CC { class APIGetGroupLink(val groupId: Long): CC() class APICreateMemberContact(val groupId: Long, val groupMemberId: Long): CC() class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() - class APIGetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol): CC() - class APISetUserProtoServers(val userId: Long, val serverProtocol: ServerProtocol, val servers: List): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class ApiGetServerOperators(): CC() + class ApiSetServerOperators(val operators: List): CC() + class ApiGetUserServers(val userId: Long): CC() + class ApiSetUserServers(val userId: Long, val userServers: List): CC() + class ApiValidateServers(val userId: Long, val userServers: List): CC() + class ApiGetUsageConditions(): CC() + class ApiSetConditionsNotified(val conditionsId: Long): CC() + class ApiAcceptConditions(val conditionsId: Long, val operatorIds: List): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -3197,9 +3275,15 @@ sealed class CC { is APIGetGroupLink -> "/_get link #$groupId" is APICreateMemberContact -> "/_create member contact #$groupId $groupMemberId" is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" - is APIGetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()}" - is APISetUserProtoServers -> "/_servers $userId ${serverProtocol.name.lowercase()} ${protoServersStr(servers)}" is APITestProtoServer -> "/_server test $userId $server" + is ApiGetServerOperators -> "/_operators" + is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" + is ApiGetUserServers -> "/_servers $userId" + is ApiSetUserServers -> "/_servers $userId ${json.encodeToString(userServers)}" + is ApiValidateServers -> "/_validate_servers $userId ${json.encodeToString(userServers)}" + is ApiGetUsageConditions -> "/_conditions" + is ApiSetConditionsNotified -> "/_conditions_notified ${conditionsId}" + is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" @@ -3342,9 +3426,15 @@ sealed class CC { is APIGetGroupLink -> "apiGetGroupLink" is APICreateMemberContact -> "apiCreateMemberContact" is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" - is APIGetUserProtoServers -> "apiGetUserProtoServers" - is APISetUserProtoServers -> "apiSetUserProtoServers" is APITestProtoServer -> "testProtoServer" + is ApiGetServerOperators -> "apiGetServerOperators" + is ApiSetServerOperators -> "apiSetServerOperators" + is ApiGetUserServers -> "apiGetUserServers" + is ApiSetUserServers -> "apiSetUserServers" + is ApiValidateServers -> "apiValidateServers" + is ApiGetUsageConditions -> "apiGetUsageConditions" + is ApiSetConditionsNotified -> "apiSetConditionsNotified" + is ApiAcceptConditions -> "apiAcceptConditions" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" @@ -3459,8 +3549,6 @@ sealed class CC { companion object { fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" - - fun protoServersStr(servers: List) = json.encodeToString(ProtoServersConfig(servers)) } } @@ -3510,24 +3598,350 @@ enum class ServerProtocol { } @Serializable -data class ProtoServersConfig( - val servers: List +enum class OperatorTag { + @SerialName("simplex") SimpleX, + @SerialName("flux") Flux, + @SerialName("xyz") XYZ, + @SerialName("demo") Demo +} + +data class ServerOperatorInfo( + val description: List, + val website: String, + val logo: ImageResource, + val largeLogo: ImageResource, + val logoDarkMode: ImageResource, + val largeLogoDarkMode: ImageResource +) +val operatorsInfo: Map = mapOf( + OperatorTag.SimpleX to ServerOperatorInfo( + description = listOf( + "SimpleX Chat is the first communication network that has no user profile IDs of any kind, not even random numbers or keys that identify the users.", + "SimpleX Chat Ltd develops the communication software for SimpleX network." + ), + website = "https://simplex.chat", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ), + OperatorTag.Flux to ServerOperatorInfo( + description = listOf( + "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 = MR.images.flux_logo_symbol, + largeLogo = MR.images.flux_logo, + logoDarkMode = MR.images.flux_logo_symbol, + largeLogoDarkMode = MR.images.flux_logo_light + ), + OperatorTag.XYZ to ServerOperatorInfo( + description = listOf("XYZ servers"), + website = "XYZ website", + logo = MR.images.shield, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.shield, + largeLogoDarkMode = MR.images.logo_light + ), + OperatorTag.Demo to ServerOperatorInfo( + description = listOf("Demo operator"), + website = "Demo website", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ) ) @Serializable -data class UserProtocolServers( - val serverProtocol: ServerProtocol, - val protoServers: List, - val presetServers: List, +data class UsageConditionsDetail( + val conditionsId: Long, + val conditionsCommit: String, + val notifiedAt: Instant?, + val createdAt: Instant +) { + companion object { + val sampleData = UsageConditionsDetail( + conditionsId = 1, + conditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c", + notifiedAt = null, + createdAt = Clock.System.now() + ) + } +} + +@Serializable +sealed class UsageConditionsAction { + @Serializable @SerialName("review") data class Review(val operators: List, val deadline: Instant?, val showNotice: Boolean) : UsageConditionsAction() + @Serializable @SerialName("accepted") data class Accepted(val operators: List) : UsageConditionsAction() + + val shouldShowNotice: Boolean + get() = when (this) { + is Review -> showNotice + else -> false + } +} + +@Serializable +data class ServerOperatorConditionsDetail( + val serverOperators: List, + val currentConditions: UsageConditionsDetail, + val conditionsAction: UsageConditionsAction? +) { + companion object { + val empty = ServerOperatorConditionsDetail( + serverOperators = emptyList(), + currentConditions = UsageConditionsDetail(conditionsId = 0, conditionsCommit = "empty", notifiedAt = null, createdAt = Clock.System.now()), + conditionsAction = null + ) + } +} + +@Serializable() +sealed class ConditionsAcceptance { + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() + + val conditionsAccepted: Boolean + get() = when (this) { + is Accepted -> true + is Required -> false + } + + val usageAllowed: Boolean + get() = when (this) { + is Accepted -> true + is Required -> this.deadline != null + } +} + +@Serializable +data class ServerOperator( + val operatorId: Long, + val operatorTag: OperatorTag?, + val tradeName: String, + val legalName: String?, + val serverDomains: List, + val conditionsAcceptance: ConditionsAcceptance, + val enabled: Boolean, + val smpRoles: ServerRoles, + val xftpRoles: ServerRoles, +) { + companion object { + val dummyOperatorInfo = ServerOperatorInfo( + description = listOf("Default"), + website = "Default", + logo = MR.images.decentralized, + largeLogo = MR.images.logo, + logoDarkMode = MR.images.decentralized_light, + largeLogoDarkMode = MR.images.logo_light + ) + + val sampleData1 = ServerOperator( + operatorId = 1, + operatorTag = OperatorTag.SimpleX, + tradeName = "SimpleX Chat", + legalName = "SimpleX Chat Ltd", + serverDomains = listOf("simplex.im"), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + enabled = true, + smpRoles = ServerRoles(storage = true, proxy = true), + xftpRoles = ServerRoles(storage = true, proxy = true) + ) + + val sampleData2 = ServerOperator( + operatorId = 2, + operatorTag = OperatorTag.XYZ, + tradeName = "XYZ", + legalName = null, + serverDomains = listOf("xyz.com"), + conditionsAcceptance = ConditionsAcceptance.Required(deadline = null), + enabled = false, + smpRoles = ServerRoles(storage = false, proxy = true), + xftpRoles = ServerRoles(storage = false, proxy = true) + ) + + val sampleData3 = ServerOperator( + operatorId = 3, + operatorTag = OperatorTag.Demo, + tradeName = "Demo", + legalName = null, + serverDomains = listOf("demo.com"), + conditionsAcceptance = ConditionsAcceptance.Required(deadline = null), + enabled = false, + smpRoles = ServerRoles(storage = true, proxy = false), + xftpRoles = ServerRoles(storage = true, proxy = false) + ) + } + + val id: Long + get() = operatorId + + override fun equals(other: Any?): Boolean { + if (other !is ServerOperator) return false + return other.operatorId == this.operatorId && + other.operatorTag == this.operatorTag && + other.tradeName == this.tradeName && + other.legalName == this.legalName && + other.serverDomains == this.serverDomains && + other.conditionsAcceptance == this.conditionsAcceptance && + other.enabled == this.enabled && + other.smpRoles == this.smpRoles && + other.xftpRoles == this.xftpRoles + } + + override fun hashCode(): Int { + var result = operatorId.hashCode() + result = 31 * result + (operatorTag?.hashCode() ?: 0) + result = 31 * result + tradeName.hashCode() + result = 31 * result + (legalName?.hashCode() ?: 0) + result = 31 * result + serverDomains.hashCode() + result = 31 * result + conditionsAcceptance.hashCode() + result = 31 * result + enabled.hashCode() + result = 31 * result + smpRoles.hashCode() + result = 31 * result + xftpRoles.hashCode() + return result + } + + val legalName_: String + get() = legalName ?: tradeName + + val info: ServerOperatorInfo get() { + return if (this.operatorTag != null) { + operatorsInfo[this.operatorTag] ?: dummyOperatorInfo + } else { + dummyOperatorInfo + } + } + + val logo: ImageResource + @Composable + get() { + return if (isInDarkTheme()) info.logoDarkMode else info.logo + } + + val largeLogo: ImageResource + @Composable + get() { + return if (isInDarkTheme()) info.largeLogoDarkMode else info.largeLogo + } +} + +@Serializable +data class ServerRoles( + val storage: Boolean, + val proxy: Boolean ) @Serializable -data class ServerCfg( +data class UserOperatorServers( + val operator: ServerOperator?, + val smpServers: List, + val xftpServers: List +) { + val id: String + get() = operator?.operatorId?.toString() ?: "nil operator" + + val operator_: ServerOperator + get() = operator ?: ServerOperator( + operatorId = 0, + operatorTag = null, + tradeName = "", + legalName = null, + serverDomains = emptyList(), + conditionsAcceptance = ConditionsAcceptance.Accepted(null), + enabled = false, + smpRoles = ServerRoles(storage = true, proxy = true), + xftpRoles = ServerRoles(storage = true, proxy = true) + ) + + companion object { + val sampleData1 = UserOperatorServers( + operator = ServerOperator.sampleData1, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + + val sampleDataNilOperator = UserOperatorServers( + operator = null, + smpServers = listOf(UserServer.sampleData.preset), + xftpServers = listOf(UserServer.sampleData.xftpPreset) + ) + } +} + +@Serializable +sealed class UserServersError { + @Serializable @SerialName("noServers") data class NoServers(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() + @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + + val globalError: String? + get() = when (this.protocol_) { + ServerProtocol.SMP -> globalSMPError + ServerProtocol.XFTP -> globalXFTPError + } + + private val protocol_: ServerProtocol + get() = when (this) { + is NoServers -> this.protocol + is StorageMissing -> this.protocol + is ProxyMissing -> this.protocol + is DuplicateServer -> this.protocol + } + + val globalSMPError: String? + get() = if (this.protocol_ == ServerProtocol.SMP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured)}" } + ?: generalGetString(MR.strings.no_message_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_receiving)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_receiving) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + val globalXFTPError: String? + get() = if (this.protocol_ == ServerProtocol.XFTP) { + when (this) { + is NoServers -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured)}" } + ?: generalGetString(MR.strings.no_media_servers_configured) + + is StorageMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_sending)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_sending) + + is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_media_servers_configured_for_private_routing)}" } + ?: generalGetString(MR.strings.no_media_servers_configured_for_private_routing) + + else -> null + } + } else { + null + } + + private fun userStr(user: UserRef): String { + return String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + } +} + +@Serializable +data class UserServer( val remoteHostId: Long?, + val serverId: Long?, val server: String, val preset: Boolean, val tested: Boolean? = null, - val enabled: Boolean + val enabled: Boolean, + val deleted: Boolean ) { @Transient private val createdAt: Date = Date() @@ -3541,35 +3955,51 @@ data class ServerCfg( get() = server.isBlank() companion object { - val empty = ServerCfg(remoteHostId = null, server = "", preset = false, tested = null, enabled = false) + val empty = UserServer(remoteHostId = null, serverId = null, server = "", preset = false, tested = null, enabled = false, deleted = false) class SampleData( - val preset: ServerCfg, - val custom: ServerCfg, - val untested: ServerCfg + val preset: UserServer, + val custom: UserServer, + val untested: UserServer, + val xftpPreset: UserServer ) val sampleData = SampleData( - preset = ServerCfg( + preset = UserServer( remoteHostId = null, + serverId = 1, server = "smp://abcd@smp8.simplex.im", preset = true, tested = true, - enabled = true + enabled = true, + deleted = false ), - custom = ServerCfg( + custom = UserServer( remoteHostId = null, + serverId = 2, server = "smp://abcd@smp9.simplex.im", preset = false, tested = false, - enabled = false + enabled = false, + deleted = false ), - untested = ServerCfg( + untested = UserServer( remoteHostId = null, + serverId = 3, server = "smp://abcd@smp10.simplex.im", preset = false, tested = null, - enabled = true + enabled = true, + deleted = false + ), + xftpPreset = UserServer( + remoteHostId = null, + serverId = 4, + server = "xftp://abcd@xftp8.simplex.im", + preset = true, + tested = true, + enabled = true, + deleted = false ) ) } @@ -4928,8 +5358,11 @@ sealed class CR { @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() - @Serializable @SerialName("userProtoServers") class UserProtoServers(val user: UserRef, val servers: UserProtocolServers): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() + @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() @@ -5108,8 +5541,11 @@ sealed class CR { is ApiChats -> "apiChats" is ApiChat -> "apiChat" is ApiChatItemInfo -> "chatItemInfo" - is UserProtoServers -> "userProtoServers" is ServerTestResult -> "serverTestResult" + is ServerOperatorConditions -> "serverOperatorConditions" + is UserServers -> "userServers" + is UserServersValidation -> "userServersValidation" + is UsageConditions -> "usageConditions" is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" @@ -5278,8 +5714,11 @@ sealed class CR { is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") - is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" + is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") + is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") + is UsageConditions -> "usageConditions: ${json.encodeToString(usageConditions)}\nnacceptedConditions: ${json.encodeToString(acceptedConditions)}" is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 79132b5eb1..08ca72c6bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -118,6 +118,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) chatModel.currentUser.value = user + chatModel.conditions.value = chatController.getServerOperators(null) ?: ServerOperatorConditionsDetail.empty if (user == null) { chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = null @@ -137,13 +138,12 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } else if (startChat().await()) { val savedOnboardingStage = appPreferences.onboardingStage.get() - val next = if (appPlatform.isAndroid) { - OnboardingStage.Step4_SetNotificationsMode - } else { - OnboardingStage.OnboardingComplete - } val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - next + if (appPlatform.isAndroid) { + OnboardingStage.Step4_SetNotificationsMode + } else { + OnboardingStage.OnboardingComplete + } } else { savedOnboardingStage } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 17658d23e8..15d38c5490 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -165,7 +165,7 @@ fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { if (!chatModel.connectedToRemote()) { chatModel.localUserCreated.value = true } - controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) controller.startChat(user) controller.switchUIRemoteHost(null) close() @@ -204,7 +204,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () onboardingStage.set(if (appPlatform.isDesktop && chatModel.controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) { OnboardingStage.Step2_5_SetupDatabasePassphrase } else { - OnboardingStage.Step4_SetNotificationsMode + OnboardingStage.Step3_ChooseServerOperators }) } else { // the next two lines are only needed for failure case when because of the database error the app gets stuck on on-boarding screen, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 8dd3e42440..e9e590ee95 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1249,7 +1249,7 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = if (merged is MergedItem.Grouped) shouldShowAvatar(item, listItem.nextItem) else true + val showAvatar = shouldShowAvatar(item, listItem.nextItem) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } val itemSeparation: ItemSeparation val prevItemSeparationLargeGap: Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 9661a305cc..20bb65ec7d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -25,19 +25,21 @@ import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.WhatsNewView -import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton +import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -115,10 +117,26 @@ fun ToggleChatListCard() { @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } + val rhId = chatModel.remoteHostId() + LaunchedEffect(Unit) { - if (shouldShowWhatsNew(chatModel)) { + val showWhatsNew = shouldShowWhatsNew(chatModel) + val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false + if (showWhatsNew) { delay(1000L) - ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } + ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } + } else if (showUpdatedConditions) { + ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + LaunchedEffect(Unit) { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + try { + setConditionsNotified(rh = rhId, conditionsId = conditionsId) + } catch (e: Exception) { + Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}") + } + } + UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 4e3ee2340c..acbc72ff48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -48,7 +48,6 @@ import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.ProtocolServersView import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -540,15 +539,8 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, ) ) } - if (summary.known == true) { - SectionItemView(click = { - ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) } - }) { - Text(generalGetString(MR.strings.open_server_settings_button)) - } - if (summary.stats != null || summary.sessions != null) { - SectionDividerSpaced() - } + if (summary.stats != null || summary.sessions != null) { + SectionDividerSpaced() } if (summary.stats != null) { @@ -579,12 +571,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r ) ) } - if (summary.known == true) { - SectionItemView(click = { - ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) } - }) { - Text(generalGetString(MR.strings.open_server_settings_button)) - } + if (summary.stats != null || summary.subs != null || summary.sessions != null) { SectionDividerSpaced() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index 195ec020e5..afb557cc78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -17,11 +17,21 @@ import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue @Composable -fun AppBarTitle(title: String, hostDevice: Pair? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) { +fun AppBarTitle( + title: String, + hostDevice: Pair? = null, + withPadding: Boolean = true, + bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp, + enableAlphaChanges: Boolean = true +) { val handler = LocalAppBarHandler.current - val connection = handler?.connection + val connection = if (enableAlphaChanges) handler?.connection else null LaunchedEffect(title) { - handler?.title?.value = title + if (enableAlphaChanges) { + handler?.title?.value = title + } else { + handler?.connection?.scrollTrackingEnabled = false + } } val theme = CurrentColors.collectAsState() val titleColor = MaterialTheme.appColors.title @@ -54,7 +64,8 @@ fun AppBarTitle(title: String, hostDevice: Pair? = null, withPad } private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) = - if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f + if (connection?.scrollTrackingEnabled == false) 1f + else if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt index 50942169b3..ad6611b9d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CollapsingAppBar.kt @@ -84,6 +84,7 @@ class AppBarHandler( } class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection { + var scrollTrackingEnabled = true var appBarOffset: Float by mutableFloatStateOf(0f) override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index cf0c5f7e96..4bf20d2128 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -258,7 +258,8 @@ private fun AppBarCenterAligned( } private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) = - if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f + if (!connection.scrollTrackingEnabled) 0f + else if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha) val AppBarHeight = 56.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 90f8593c4a..28ec77de70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt new file mode 100644 index 0000000000..8b383e0146 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -0,0 +1,354 @@ +package chat.simplex.common.views.onboarding + +import SectionBottomSpacer +import SectionTextFooter +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ServerOperator +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ModalData.ChooseServerOperators( + onboarding: Boolean, + close: (() -> Unit) = { ModalManager.fullscreen.closeModals() }, + modalManager: ModalManager = ModalManager.fullscreen +) { + LaunchedEffect(Unit) { + prepareChatBeforeFinishingOnboarding() + } + + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { + ModalView({}, showClose = false, endButtons = { + IconButton({ modalManager.showModal { ChooseServerOperatorsInfoView() } }) { + Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + } + }) { + val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } + val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } + val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } + + ColumnWithScrollBar( + Modifier + .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), + maxIntrinsicSize = true + ) { + Box(Modifier.align(Alignment.CenterHorizontally)) { + AppBarTitle(stringResource(MR.strings.onboarding_choose_server_operators)) + } + Column(( + if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier) + .padding(horizontal = DEFAULT_PADDING) + ) { + Text(stringResource(MR.strings.onboarding_select_network_operators_to_use)) + Spacer(Modifier.height(DEFAULT_PADDING)) + } + Spacer(Modifier.weight(1f)) + Column(( + if (appPlatform.isDesktop) Modifier.width(600.dp).align(Alignment.CenterHorizontally) else Modifier) + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING) + ) { + serverOperators.value.forEachIndexed { index, srvOperator -> + OperatorCheckView(srvOperator, selectedOperatorIds) + if (index != serverOperators.value.lastIndex) { + Spacer(Modifier.height(DEFAULT_PADDING)) + } + } + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + + SectionTextFooter(annotatedStringResource(MR.strings.onboarding_network_operators_configure_via_settings), textAlign = TextAlign.Center) + } + Spacer(Modifier.weight(1f)) + + val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } + val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed } + val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() + + Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + val enabled = selectedOperatorIds.value.isNotEmpty() + when { + reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager) + selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close) + else -> ContinueButton(enabled, onboarding, close) + } + if (onboarding && reviewForOperators.isEmpty()) { + TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) { + modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsView( + currUserServers = remember { mutableStateOf(emptyList()) }, + userServers = remember { mutableStateOf(emptyList()) }, + close = close, + rhId = null + ) + } + } + } else if (onboarding || reviewForOperators.isEmpty()) { + // Reserve space + TextButtonBelowOnboardingButton("", null) + } + if (!onboarding && reviewForOperators.isNotEmpty()) { + ReviewLaterButton(canReviewLater, close) + SectionTextFooter( + annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) + + AnnotatedString(" ") + + annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure), + textAlign = TextAlign.Center + ) + SectionBottomSpacer() + } + } + } + } + } +} + +@Composable +private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorIds: MutableState>) { + val checked = selectedOperatorIds.value.contains(serverOperator.operatorId) + TextButton({ + if (checked) { + selectedOperatorIds.value -= serverOperator.operatorId + } else { + selectedOperatorIds.value += serverOperator.operatorId + } + }, + border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(18.dp) + ) { + Row(Modifier.padding(DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically) { + Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp)) + Spacer(Modifier.width(DEFAULT_PADDING_HALF).weight(1f)) + CircleCheckbox(checked) + } + } +} + +@Composable +private fun CircleCheckbox(checked: Boolean) { + if (checked) { + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_circle_filled), + null, + Modifier.size(26.dp), + tint = MaterialTheme.colors.primary + ) + Icon( + painterResource(MR.images.ic_check_filled), + null, + Modifier.size(20.dp), tint = MaterialTheme.colors.background + ) + } + } else { + Icon( + painterResource(MR.images.ic_circle), + null, + Modifier.size(26.dp), + tint = MaterialTheme.colors.secondary.copy(alpha = 0.5f) + ) + } +} + +@Composable +private fun ReviewConditionsButton( + enabled: Boolean, + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + modalManager: ModalManager +) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.operator_review_conditions, + onboarding = null, + enabled = enabled, + onclick = { + modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close) + } + } + ) +} + +@Composable +private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State>, selectedOperatorIds: State>, close: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.onboarding_network_operators_update, + onboarding = null, + enabled = enabled, + onclick = { + withBGApi { + val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value) + if (enabledOperators != null) { + val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) + if (r != null) { + chatModel.conditions.value = r + } + continueToNextStep(onboarding, close) + } + } + } + ) +} + +@Composable +private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.onboarding_network_operators_continue, + onboarding = null, + enabled = enabled, + onclick = { + continueToNextStep(onboarding, close) + } + ) +} + +@Composable +private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) { + TextButtonBelowOnboardingButton( + stringResource(MR.strings.onboarding_network_operators_review_later), + onClick = if (!enabled) null else {{ continueToNextStep(false, close) }} + ) +} + +@Composable +private fun ReviewConditionsView( + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + close: () -> Unit +) { + // remembering both since we don't want to reload the view after the user accepts conditions + val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } + val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } } + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false) + if (operatorsWithConditionsAccepted.isNotEmpty()) { + ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ }) + ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ }) + } else { + ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(chatModel.remoteHostId()) + } + Column(Modifier.padding(top = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { + AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close) + // Reserve space + TextButtonBelowOnboardingButton("", null) + } + } +} + +@Composable +private fun AcceptConditionsButton( + onboarding: Boolean, + selectedOperators: State>, + selectedOperatorIds: State>, + close: () -> Unit +) { + fun continueOnAccept() { + if (appPlatform.isDesktop || !onboarding) { + if (onboarding) { close() } + continueToNextStep(onboarding, close) + } else { + continueToSetNotificationsAfterAccept() + } + } + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier, + labelId = MR.strings.accept_conditions, + onboarding = null, + onclick = { + withBGApi { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } + val operatorIds = acceptForOperators.map { it.operatorId } + val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds) + if (r != null) { + chatModel.conditions.value = r + val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value) + if (enabledOperators != null) { + val r2 = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators) + if (r2 != null) { + chatModel.conditions.value = r2 + continueOnAccept() + } + } else { + continueOnAccept() + } + } + } + } + ) +} + +private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) { + if (onboarding) { + appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete) + } else { + close() + } +} + +private fun continueToSetNotificationsAfterAccept() { + appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + ModalManager.fullscreen.showModalCloseable(showClose = false) { SetNotificationsMode(chatModel) } +} + +private fun enabledOperators(operators: List, selectedOperatorIds: Set): List? { + val ops = ArrayList(operators) + if (ops.isNotEmpty()) { + for (i in ops.indices) { + val op = ops[i] + ops[i] = op.copy(enabled = selectedOperatorIds.contains(op.operatorId)) + } + val haveSMPStorage = ops.any { it.enabled && it.smpRoles.storage } + val haveSMPProxy = ops.any { it.enabled && it.smpRoles.proxy } + val haveXFTPStorage = ops.any { it.enabled && it.xftpRoles.storage } + val haveXFTPProxy = ops.any { it.enabled && it.xftpRoles.proxy } + val firstEnabledIndex = ops.indexOfFirst { it.enabled } + if (haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy) { + return ops + } else if (firstEnabledIndex != -1) { + var op = ops[firstEnabledIndex] + if (!haveSMPStorage) op = op.copy(smpRoles = op.smpRoles.copy(storage = true)) + if (!haveSMPProxy) op = op.copy(smpRoles = op.smpRoles.copy(proxy = true)) + if (!haveXFTPStorage) op = op.copy(xftpRoles = op.xftpRoles.copy(storage = true)) + if (!haveXFTPProxy) op = op.copy(xftpRoles = op.xftpRoles.copy(proxy = true)) + ops[firstEnabledIndex] = op + return ops + } else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled + return null + } + } else { + return null + } +} + +@Composable +private fun ChooseServerOperatorsInfoView() { + ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.onboarding_network_operators), withPadding = false) + ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_different_operators)) + ReadableText(stringResource(MR.strings.onboarding_network_operators_app_will_use_for_routing)) + SectionBottomSpacer() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 98e8ec971d..34b6209ffe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -48,8 +48,8 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? } @Composable -fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current) { - Text(annotatedStringResource(stringResId), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style) +fun ReadableText(stringResId: StringResource, textAlign: TextAlign = TextAlign.Start, padding: PaddingValues = PaddingValues(bottom = 12.dp), style: TextStyle = LocalTextStyle.current, args: Any? = null) { + Text(annotatedStringResource(stringResId, args), modifier = Modifier.padding(padding), textAlign = textAlign, lineHeight = 22.sp, style = style) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index d4c63248e5..510df13c3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -5,6 +5,7 @@ enum class OnboardingStage { Step2_CreateProfile, LinkAMobile, Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, OnboardingComplete diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index d6d5753b6c..49c91813dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -16,8 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.NotificationsMode -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.changeNotificationsMode @@ -26,7 +25,7 @@ import chat.simplex.res.MR @Composable fun SetNotificationsMode(m: ChatModel) { LaunchedEffect(Unit) { - prepareChatBeforeNotificationsSetup(m) + prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { @@ -57,6 +56,7 @@ fun SetNotificationsMode(m: ChatModel) { onboarding = OnboardingStage.OnboardingComplete, onclick = { changeNotificationsMode(currentMode.value, m) + ModalManager.fullscreen.closeModals() } ) // Reserve space @@ -99,7 +99,7 @@ fun SelectableCard(currentValue: State, newValue: T, title: String, descr Spacer(Modifier.height(14.dp)) } -private fun prepareChatBeforeNotificationsSetup(chatModel: ChatModel) { +fun prepareChatBeforeFinishingOnboarding() { // No visible users but may have hidden. In this case chat should be started anyway because it's stopped on this stage with hidden users if (chatModel.users.any { u -> !u.user.hidden }) return withBGApi { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 4ad2675e83..f20cb38dad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -17,7 +17,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* @@ -36,7 +35,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { val confirmNewKey = rememberSaveable { mutableStateOf("") } fun nextStep() { if (appPlatform.isAndroid || chatModel.currentUser.value != null) { - m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step4_SetNotificationsMode) + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_ChooseServerOperators) } else { m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index e43404cb07..b133ae27d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -164,14 +164,15 @@ fun OnboardingActionButton( @Composable fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?) { val state = getKeyboardState() + val enabled = onClick != null val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING) val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else DEFAULT_PADDING * 2) if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) { - TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = onClick != null) { + TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) { Text( text, Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF, bottom = 5.dp), - color = MaterialTheme.colors.primary, + color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index bdbef3b654..6cf945bcba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -17,17 +16,32 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.setConditionsNotified +import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @Composable -fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { +fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) { val currentVersion = remember { mutableStateOf(versionDescriptions.lastIndex) } + val rhId = chatModel.remoteHostId() + + if (updatedConditions) { + LaunchedEffect(Unit) { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + try { + setConditionsNotified(rh = rhId, conditionsId = conditionsId) + } catch (e: Exception) { + Log.d(TAG, "WhatsNewView setConditionsNotified error: ${e.message}") + } + } + } @Composable fun featureDescription(icon: ImageResource?, titleId: StringResource, descrId: StringResource?, link: String?, subfeatures: List>) { @@ -124,9 +138,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ) { AppBarTitle(String.format(generalGetString(MR.strings.new_in_version), v.version), withPadding = false, bottomPadding = DEFAULT_PADDING) + val modalManager = if (viaSettings) ModalManager.start else ModalManager.center + v.features.forEach { feature -> - if (feature.show) { - featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures) + when (feature) { + is VersionFeature.FeatureDescription -> { + if (feature.show) { + featureDescription(feature.icon, feature.titleId, feature.descrId, feature.link, feature.subfeatures) + } + } + is VersionFeature.FeatureView -> { + feature.view(modalManager) + } } } @@ -134,6 +157,18 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ReadMoreButton(v.post) } + if (updatedConditions) { + Text( + stringResource(MR.strings.view_updated_conditions), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { + modalManager.showModalCloseable { + close -> UsageConditionsView(userServers = mutableStateOf(emptyList()), currUserServers = mutableStateOf(emptyList()), close = close, rhId = rhId) + } + } + ) + } + if (!viaSettings) { Spacer(Modifier.fillMaxHeight().weight(1f)) Box( @@ -141,7 +176,9 @@ fun WhatsNewView(viaSettings: Boolean = false, close: () -> Unit) { ) { Text( generalGetString(MR.strings.ok), - modifier = Modifier.clickable(onClick = close), + modifier = Modifier.clickable(onClick = { + close() + }), style = MaterialTheme.typography.h3, color = MaterialTheme.colors.primary ) @@ -166,18 +203,26 @@ fun ReadMoreButton(url: String) { } } -private data class FeatureDescription( - val icon: ImageResource?, - val titleId: StringResource, - val descrId: StringResource?, - var subfeatures: List> = listOf(), - val link: String? = null, - val show: Boolean = true -) +private sealed class VersionFeature { + class FeatureDescription( + val icon: ImageResource?, + val titleId: StringResource, + val descrId: StringResource?, + var subfeatures: List> = listOf(), + val link: String? = null, + val show: Boolean = true + ): VersionFeature() + + class FeatureView( + val icon: ImageResource?, + val titleId: StringResource, + val view: @Composable (modalManager: ModalManager) -> Unit + ): VersionFeature() +} private data class VersionDescription( val version: String, - val features: List, + val features: List, val post: String? = null, ) @@ -186,18 +231,18 @@ private val versionDescriptions: List = listOf( version = "v4.2", post = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v4_2_security_assessment, descrId = MR.strings.v4_2_security_assessment_desc, link = "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_group, titleId = MR.strings.v4_2_group_links, descrId = MR.strings.v4_2_group_links_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_check, titleId = MR.strings.v4_2_auto_accept_contact_requests, descrId = MR.strings.v4_2_auto_accept_contact_requests_desc @@ -208,22 +253,22 @@ private val versionDescriptions: List = listOf( version = "v4.3", post = "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_mic, titleId = MR.strings.v4_3_voice_messages, descrId = MR.strings.v4_3_voice_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_delete_forever, titleId = MR.strings.v4_3_irreversible_message_deletion, descrId = MR.strings.v4_3_irreversible_message_deletion_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v4_3_improved_server_configuration, descrId = MR.strings.v4_3_improved_server_configuration_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_visibility_off, titleId = MR.strings.v4_3_improved_privacy_and_security, descrId = MR.strings.v4_3_improved_privacy_and_security_desc @@ -234,22 +279,22 @@ private val versionDescriptions: List = listOf( version = "v4.4", post = "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_timer, titleId = MR.strings.v4_4_disappearing_messages, descrId = MR.strings.v4_4_disappearing_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_pending, titleId = MR.strings.v4_4_live_messages, descrId = MR.strings.v4_4_live_messages_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v4_4_verify_connection_security, descrId = MR.strings.v4_4_verify_connection_security_desc ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_4_french_interface, descrId = MR.strings.v4_4_french_interface_descr @@ -260,33 +305,33 @@ private val versionDescriptions: List = listOf( version = "v4.5", post = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_manage_accounts, titleId = MR.strings.v4_5_multiple_chat_profiles, descrId = MR.strings.v4_5_multiple_chat_profiles_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_edit_note, titleId = MR.strings.v4_5_message_draft, descrId = MR.strings.v4_5_message_draft_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_safety_divider, titleId = MR.strings.v4_5_transport_isolation, descrId = MR.strings.v4_5_transport_isolation_descr, link = "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_task, titleId = MR.strings.v4_5_private_filenames, descrId = MR.strings.v4_5_private_filenames_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_2_bar, titleId = MR.strings.v4_5_reduced_battery_usage, descrId = MR.strings.v4_5_reduced_battery_usage_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_5_italian_interface, descrId = MR.strings.v4_5_italian_interface_descr, @@ -297,32 +342,32 @@ private val versionDescriptions: List = listOf( version = "v4.6", post = "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v4_6_hidden_chat_profiles, descrId = MR.strings.v4_6_hidden_chat_profiles_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_flag, titleId = MR.strings.v4_6_group_moderation, descrId = MR.strings.v4_6_group_moderation_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_maps_ugc, titleId = MR.strings.v4_6_group_welcome_message, descrId = MR.strings.v4_6_group_welcome_message_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_call, titleId = MR.strings.v4_6_audio_video_calls, descrId = MR.strings.v4_6_audio_video_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v4_6_reduced_battery_usage, descrId = MR.strings.v4_6_reduced_battery_usage_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v4_6_chinese_spanish_interface, descrId = MR.strings.v4_6_chinese_spanish_interface_descr, @@ -333,17 +378,17 @@ private val versionDescriptions: List = listOf( version = "v5.0", post = "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_upload_file, titleId = MR.strings.v5_0_large_files_support, descrId = MR.strings.v5_0_large_files_support_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_0_app_passcode, descrId = MR.strings.v5_0_app_passcode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_0_polish_interface, descrId = MR.strings.v5_0_polish_interface_descr, @@ -354,27 +399,27 @@ private val versionDescriptions: List = listOf( version = "v5.1", post = "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_add_reaction, titleId = MR.strings.v5_1_message_reactions, descrId = MR.strings.v5_1_message_reactions_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_chat, titleId = MR.strings.v5_1_better_messages, descrId = MR.strings.v5_1_better_messages_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_light_mode, titleId = MR.strings.v5_1_custom_themes, descrId = MR.strings.v5_1_custom_themes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_1_self_destruct_passcode, descrId = MR.strings.v5_1_self_destruct_passcode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_1_japanese_portuguese_interface, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -385,27 +430,27 @@ private val versionDescriptions: List = listOf( version = "v5.2", post = "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_check, titleId = MR.strings.v5_2_message_delivery_receipts, descrId = MR.strings.v5_2_message_delivery_receipts_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_star, titleId = MR.strings.v5_2_favourites_filter, descrId = MR.strings.v5_2_favourites_filter_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_sync_problem, titleId = MR.strings.v5_2_fix_encryption, descrId = MR.strings.v5_2_fix_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_timer, titleId = MR.strings.v5_2_disappear_one_message, descrId = MR.strings.v5_2_disappear_one_message_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_redeem, titleId = MR.strings.v5_2_more_things, descrId = MR.strings.v5_2_more_things_descr @@ -416,29 +461,29 @@ private val versionDescriptions: List = listOf( version = "v5.3", post = "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_desktop, titleId = MR.strings.v5_3_new_desktop_app, descrId = MR.strings.v5_3_new_desktop_app_descr, link = "https://simplex.chat/downloads/" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_lock, titleId = MR.strings.v5_3_encrypt_local_files, descrId = MR.strings.v5_3_encrypt_local_files_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_search, titleId = MR.strings.v5_3_discover_join_groups, descrId = MR.strings.v5_3_discover_join_groups_descr, link = "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( + VersionFeature.FeatureDescription( icon = MR.images.ic_theater_comedy, titleId = MR.strings.v5_3_simpler_incognito_mode, descrId = MR.strings.v5_3_simpler_incognito_mode_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_3_new_interface_languages, descrId = MR.strings.v5_3_new_interface_languages_descr, @@ -449,27 +494,27 @@ private val versionDescriptions: List = listOf( version = "v5.4", post = "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_desktop, titleId = MR.strings.v5_4_link_mobile_desktop, descrId = MR.strings.v5_4_link_mobile_desktop_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_group, titleId = MR.strings.v5_4_better_groups, descrId = MR.strings.v5_4_better_groups_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_theater_comedy, titleId = MR.strings.v5_4_incognito_groups, descrId = MR.strings.v5_4_incognito_groups_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_back_hand, titleId = MR.strings.v5_4_block_group_members, descrId = MR.strings.v5_4_block_group_members_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_redeem, titleId = MR.strings.v5_2_more_things, descrId = MR.strings.v5_4_more_things_descr @@ -480,28 +525,28 @@ private val versionDescriptions: List = listOf( version = "v5.5", post = "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_folder_pen, titleId = MR.strings.v5_5_private_notes, descrId = MR.strings.v5_5_private_notes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_link, titleId = MR.strings.v5_5_simpler_connect_ui, descrId = MR.strings.v5_5_simpler_connect_ui_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_forum, titleId = MR.strings.v5_5_join_group_conversation, descrId = MR.strings.v5_5_join_group_conversation_descr, link = "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( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v5_5_message_delivery, descrId = MR.strings.v5_5_message_delivery_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_5_new_interface_languages, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -512,22 +557,22 @@ private val versionDescriptions: List = listOf( version = "v5.6", post = "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_vpn_key_filled, titleId = MR.strings.v5_6_quantum_resistant_encryption, descrId = MR.strings.v5_6_quantum_resistant_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_ios_share, titleId = MR.strings.v5_6_app_data_migration, descrId = MR.strings.v5_6_app_data_migration_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_call, titleId = MR.strings.v5_6_picture_in_picture_calls, descrId = MR.strings.v5_6_picture_in_picture_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_back_hand, titleId = MR.strings.v5_6_safer_groups, descrId = MR.strings.v5_6_safer_groups_descr @@ -538,32 +583,32 @@ private val versionDescriptions: List = listOf( version = "v5.7", post = "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_vpn_key_filled, titleId = MR.strings.v5_6_quantum_resistant_encryption, descrId = MR.strings.v5_7_quantum_resistant_encryption_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_forward, titleId = MR.strings.v5_7_forward, descrId = MR.strings.v5_7_forward_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_music_note, titleId = MR.strings.v5_7_call_sounds, descrId = MR.strings.v5_7_call_sounds_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_account_box, titleId = MR.strings.v5_7_shape_profile_images, descrId = MR.strings.v5_7_shape_profile_images_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v5_7_network, descrId = MR.strings.v5_7_network_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_7_new_interface_languages, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate, @@ -574,27 +619,27 @@ private val versionDescriptions: List = listOf( version = "v5.8", post = "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_settings_ethernet, titleId = MR.strings.v5_8_private_routing, descrId = MR.strings.v5_8_private_routing_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_palette, titleId = MR.strings.v5_8_chat_themes, descrId = MR.strings.v5_8_chat_themes_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_security, titleId = MR.strings.v5_8_safe_files, descrId = MR.strings.v5_8_safe_files_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_battery_3_bar, titleId = MR.strings.v5_8_message_delivery, descrId = MR.strings.v5_8_message_delivery_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_translate, titleId = MR.strings.v5_8_persian_ui, descrId = MR.strings.whats_new_thanks_to_users_contribute_weblate @@ -605,7 +650,7 @@ private val versionDescriptions: List = listOf( version = "v6.0", post = "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_0_new_chat_experience, descrId = null, @@ -616,7 +661,7 @@ private val versionDescriptions: List = listOf( MR.images.ic_match_case to MR.strings.v6_0_increase_font_size ) ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_0_new_media_options, descrId = null, @@ -625,23 +670,23 @@ private val versionDescriptions: List = listOf( MR.images.ic_blur_on to MR.strings.v6_0_privacy_blur, ) ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_toast, titleId = MR.strings.v6_0_reachable_chat_toolbar, descrId = MR.strings.v6_0_reachable_chat_toolbar_descr, show = appPlatform.isAndroid ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_settings_ethernet, titleId = MR.strings.v5_8_private_routing, descrId = MR.strings.v6_0_private_routing_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_wifi_tethering, titleId = MR.strings.v6_0_connection_servers_status, descrId = MR.strings.v6_0_connection_servers_status_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_upgrade, titleId = MR.strings.v6_0_upgrade_app, descrId = MR.strings.v6_0_upgrade_app_descr, @@ -653,18 +698,18 @@ private val versionDescriptions: List = listOf( version = "v6.1", post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html", features = listOf( - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_verified_user, titleId = MR.strings.v6_1_better_security, descrId = MR.strings.v6_1_better_security_descr, link = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html" ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = MR.images.ic_videocam, titleId = MR.strings.v6_1_better_calls, descrId = MR.strings.v6_1_better_calls_descr ), - FeatureDescription( + VersionFeature.FeatureDescription( icon = null, titleId = MR.strings.v6_1_better_user_experience, descrId = null, @@ -678,6 +723,39 @@ private val versionDescriptions: List = listOf( ), ), ), + VersionDescription( + version = "v6.2 (beta.1)", + post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html", + features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v6_2_network_decentralization, + view = { modalManager -> + Column { + val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo + Image(painterResource(src), null, modifier = Modifier.height(48.dp)) + Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp)) + Row { + Text( + stringResource(MR.strings.v6_2_network_decentralization_enable_flux), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { + modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) } + } + ) + Text(" ") + Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason)) + } + } + } + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_chat, + titleId = MR.strings.v6_2_improved_chat_navigation, + descrId = MR.strings.v6_2_improved_chat_navigation_descr + ), + ), + ) ) private val lastVersion = versionDescriptions.last().version @@ -700,7 +778,8 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { @Composable fun PreviewWhatsNewView() { SimpleXTheme { - WhatsNewView( + val data = remember { ModalData() } + data.WhatsNewView( viaSettings = true, close = {} ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index e727b94781..1d01ab11ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -35,6 +35,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.validPort import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt deleted file mode 100644 index f5e3cda2c7..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ /dev/null @@ -1,383 +0,0 @@ -package chat.simplex.common.views.usersettings - -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionTextFooter -import SectionView -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR - -@Composable -fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { - var presetServers by remember(rhId) { mutableStateOf(emptyList()) } - var servers by remember { stateGetOrPut("servers") { emptyList() } } - var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } } - val currServers = remember(rhId) { mutableStateOf(servers) } - val testing = rememberSaveable(rhId) { mutableStateOf(false) } - val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } } - val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } } - val saveDisabled = remember(servers) { - derivedStateOf { - servers.isEmpty() || - servers == currServers.value || - testing.value || - servers.none { srv -> - val address = parseServerAddress(srv.server) - address != null && uniqueAddress(srv, address, servers) - } || - allServersDisabled.value - } - } - - KeyChangeEffect(rhId) { - servers = emptyList() - serversAlreadyLoaded = false - } - - LaunchedEffect(rhId) { - withApi { - val res = m.controller.getUserProtoServers(rhId, serverProtocol) - if (res != null) { - currServers.value = res.protoServers - presetServers = res.presetServers - if (servers.isEmpty() && !serversAlreadyLoaded) { - servers = currServers.value - serversAlreadyLoaded = true - } - } - } - } - val testServersJob = CancellableOnGoneJob() - fun showServer(server: ServerCfg) { - ModalManager.start.showModalCloseable(true) { close -> - var old by remember { mutableStateOf(server) } - val index = servers.indexOf(old) - ProtocolServerView( - m, - old, - serverProtocol, - onUpdate = { updated -> - val newServers = ArrayList(servers) - newServers.removeAt(index) - newServers.add(index, updated) - old = updated - servers = newServers - }, - onDelete = { - val newServers = ArrayList(servers) - newServers.removeAt(index) - servers = newServers - close() - }) - } - } - ModalView( - close = { - if (saveDisabled.value) close() - else showUnsavedChangesAlert({ saveServers(rhId, serverProtocol, currServers, servers, m, close) }, close) - }, - ) { - ProtocolServersLayout( - serverProtocol, - testing = testing.value, - servers = servers, - serversUnchanged = serversUnchanged.value, - saveDisabled = saveDisabled.value, - allServersDisabled = allServersDisabled.value, - m.currentUser.value, - addServer = { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.smp_servers_add), - buttons = { - Column { - SectionItemView({ - AlertManager.shared.hideAlert() - servers = servers + ServerCfg.empty - // No saving until something will be changed on the next screen to prevent blank servers on the list - showServer(servers.last()) - }) { - Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - if (appPlatform.isAndroid) { - SectionItemView({ - AlertManager.shared.hideAlert() - ModalManager.start.showModalCloseable { close -> - ScanProtocolServer(rhId) { - close() - servers = servers + it - } - } - } - ) { - Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - val hasAllPresets = hasAllPresets(presetServers, servers, m) - if (!hasAllPresets) { - SectionItemView({ - AlertManager.shared.hideAlert() - servers = (servers + addAllPresets(rhId, presetServers, servers, m)).sortedByDescending { it.preset } - }) { - Text(stringResource(MR.strings.smp_servers_preset_add), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - } - } - ) - }, - testServers = { - testServersJob.value = withLongRunningApi { - testServers(testing, servers, m) { - servers = it - } - } - }, - resetServers = { - servers = currServers.value - }, - saveSMPServers = { - saveServers(rhId, serverProtocol, currServers, servers, m) - }, - showServer = ::showServer, - ) - - if (testing.value) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) - } - } - } -} - -@Composable -private fun ProtocolServersLayout( - serverProtocol: ServerProtocol, - testing: Boolean, - servers: List, - serversUnchanged: Boolean, - saveDisabled: Boolean, - allServersDisabled: Boolean, - currentUser: User?, - addServer: () -> Unit, - testServers: () -> Unit, - resetServers: () -> Unit, - saveSMPServers: () -> Unit, - showServer: (ServerCfg) -> Unit, -) { - ColumnWithScrollBar { - AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.your_SMP_servers else MR.strings.your_XFTP_servers)) - - val configuredServers = servers.filter { it.preset || it.enabled } - val otherServers = servers.filter { !(it.preset || it.enabled) } - - if (configuredServers.isNotEmpty()) { - SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_configured else MR.strings.xftp_servers_configured).uppercase()) { - for (srv in configuredServers) { - SectionItemView({ showServer(srv) }, disabled = testing) { - ProtocolServerView(serverProtocol, srv, servers, testing) - } - } - } - SectionTextFooter( - remember(currentUser?.displayName) { - buildAnnotatedString { - append(generalGetString(MR.strings.smp_servers_per_user) + " ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(currentUser?.displayName ?: "") - } - append(".") - } - } - ) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - } - - if (otherServers.isNotEmpty()) { - SectionView(stringResource(if (serverProtocol == ServerProtocol.SMP) MR.strings.smp_servers_other else MR.strings.xftp_servers_other).uppercase()) { - for (srv in otherServers.filter { !(it.preset || it.enabled) }) { - SectionItemView({ showServer(srv) }, disabled = testing) { - ProtocolServerView(serverProtocol, srv, servers, testing) - } - } - } - } - - SectionView { - SettingsActionItem( - painterResource(MR.images.ic_add), - stringResource(MR.strings.smp_servers_add), - addServer, - disabled = testing, - textColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, - iconColor = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary - ) - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) - } - - SectionView { - SectionItemView(resetServers, disabled = serversUnchanged) { - Text(stringResource(MR.strings.reset_verb), color = if (!serversUnchanged) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - val testServersDisabled = testing || allServersDisabled - SectionItemView(testServers, disabled = testServersDisabled) { - Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!testServersDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - SectionItemView(saveSMPServers, disabled = saveDisabled) { - Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - } - } - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - HowToButton() - } - SectionBottomSpacer() - } -} - -@Composable -private fun ProtocolServerView(serverProtocol: ServerProtocol, srv: ServerCfg, servers: List, disabled: Boolean) { - val address = parseServerAddress(srv.server) - when { - address == null || !address.valid || address.serverProtocol != serverProtocol || !uniqueAddress(srv, address, servers) -> InvalidServer() - !srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) - else -> ShowTestStatus(srv) - } - Spacer(Modifier.padding(horizontal = 4.dp)) - val text = address?.hostnames?.firstOrNull() ?: srv.server - if (srv.enabled) { - Text(text, color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground, maxLines = 1) - } else { - Text(text, maxLines = 1, color = MaterialTheme.colors.secondary) - } -} - -@Composable -private fun HowToButton() { - val uriHandler = LocalUriHandler.current - SettingsActionItem( - painterResource(MR.images.ic_open_in_new), - stringResource(MR.strings.how_to_use_your_servers), - { uriHandler.openUriCatching("https://simplex.chat/docs/server.html") }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary - ) -} - -@Composable -fun InvalidServer() { - Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error) -} - -private fun uniqueAddress(s: ServerCfg, address: ServerAddress, servers: List): Boolean = servers.all { srv -> - address.hostnames.all { host -> - srv.id == s.id || !srv.server.contains(host) - } -} - -private fun hasAllPresets(presetServers: List, servers: List, m: ChatModel): Boolean = - presetServers.all { hasPreset(it, servers) } ?: true - -private fun addAllPresets(rhId: Long?, presetServers: List, servers: List, m: ChatModel): List { - val toAdd = ArrayList() - for (srv in presetServers) { - if (!hasPreset(srv, servers)) { - toAdd.add(srv) - } - } - return toAdd -} - -private fun hasPreset(srv: ServerCfg, servers: List): Boolean = - servers.any { it.server == srv.server } - -private suspend fun testServers(testing: MutableState, servers: List, m: ChatModel, onUpdated: (List) -> Unit) { - val resetStatus = resetTestStatus(servers) - onUpdated(resetStatus) - testing.value = true - val fs = runServersTest(resetStatus, m) { onUpdated(it) } - testing.value = false - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.smp_servers_test_failed), - text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg - ) - } -} - -private fun resetTestStatus(servers: List): List { - val copy = ArrayList(servers) - for ((index, server) in servers.withIndex()) { - if (server.enabled) { - copy.removeAt(index) - copy.add(index, server.copy(tested = null)) - } - } - return copy -} - -private suspend fun runServersTest(servers: List, m: ChatModel, onUpdated: (List) -> Unit): Map { - val fs: MutableMap = mutableMapOf() - val updatedServers = ArrayList(servers) - for ((index, server) in servers.withIndex()) { - if (server.enabled) { - interruptIfCancelled() - val (updatedServer, f) = testServerConnection(server, m) - updatedServers.removeAt(index) - updatedServers.add(index, updatedServer) - // toList() is important. Otherwise, Compose will not redraw the screen after first update - onUpdated(updatedServers.toList()) - if (f != null) { - fs[serverHostname(updatedServer.server)] = f - } - } - } - return fs -} - -private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: MutableState>, servers: List, m: ChatModel, afterSave: () -> Unit = {}) { - withBGApi { - if (m.controller.setUserProtoServers(rhId, protocol, servers)) { - currServers.value = servers - } - afterSave() - } -} - -private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { - AlertManager.shared.showAlertDialogStacked( - title = generalGetString(MR.strings.smp_save_servers_question), - confirmText = generalGetString(MR.strings.save_verb), - dismissText = generalGetString(MR.strings.exit_without_saving), - onConfirm = save, - onDismiss = revert, - ) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 78c5e3b212..f3d22e0cdf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -25,14 +25,13 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.res.MR -import kotlinx.coroutines.* @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { @@ -102,7 +101,7 @@ fun SettingsLayout( SectionView(stringResource(MR.strings.settings_section_title_settings)) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) @@ -118,7 +117,7 @@ fun SettingsLayout( SectionView(stringResource(MR.strings.settings_section_title_help)) { SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) if (!chatModel.desktopNoUserNoRemote) { SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt similarity index 99% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 5757b5d1f4..838cac0172 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -1,4 +1,4 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer import SectionDividerSpaced @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.SettingsPreferenceItem import chat.simplex.res.MR import java.text.DecimalFormat diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt similarity index 52% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 2c4870b121..ef5b82a5d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -1,6 +1,7 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer +import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemWithValue @@ -20,119 +21,245 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.getServerOperators +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.model.ChatController.setUserServers import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import kotlinx.coroutines.launch @Composable -fun NetworkAndServersView() { +fun ModalData.NetworkAndServersView(close: () -> Unit) { val currentRemoteHost by remember { chatModel.currentRemoteHost } // It's not a state, just a one-time value. Shouldn't be used in any state-related situations val netCfg = remember { chatModel.controller.getNetCfg() } val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } + val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } + val userServers = remember { stateGetOrPut("userServers") { emptyList() } } + val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val scope = rememberCoroutineScope() val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } - NetworkAndServersLayout( - currentRemoteHost = currentRemoteHost, - networkUseSocksProxy = networkUseSocksProxy, - onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, - toggleSocksProxy = { enable -> - val def = NetCfg.defaults - val proxyDef = NetCfg.proxyDefaults - if (enable) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.network_enable_socks), - text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value), - confirmText = generalGetString(MR.strings.confirm_verb), - onConfirm = { - withBGApi { - var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get()) - if (conf.tcpConnectTimeout == def.tcpConnectTimeout) { - conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout) - } - if (conf.tcpTimeout == def.tcpTimeout) { - conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout) - } - if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) { - conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb) - } - if (conf.rcvConcurrency == def.rcvConcurrency) { - conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency) - } - chatModel.controller.apiSetNetworkConfig(conf) - chatModel.controller.setNetCfg(conf) - networkUseSocksProxy.value = true - } - } - ) + ModalView( + close = { + if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { + close() } else { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.network_disable_socks), - text = generalGetString(MR.strings.network_disable_socks_info), - confirmText = generalGetString(MR.strings.confirm_verb), - onConfirm = { - withBGApi { - var conf = controller.getNetCfg().copy(socksProxy = null) - if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) { - conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout) - } - if (conf.tcpTimeout == proxyDef.tcpTimeout) { - conf = conf.copy(tcpTimeout = def.tcpTimeout) - } - if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) { - conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb) - } - if (conf.rcvConcurrency == proxyDef.rcvConcurrency) { - conf = conf.copy(rcvConcurrency = def.rcvConcurrency) - } - chatModel.controller.apiSetNetworkConfig(conf) - chatModel.controller.setNetCfg(conf) - networkUseSocksProxy.value = false - } - } + showUnsavedChangesAlert( + { scope.launch { saveServers(currentRemoteHost?.remoteHostId, currUserServers, userServers) }}, + close ) } } - ) + ) { + NetworkAndServersLayout( + currentRemoteHost = currentRemoteHost, + networkUseSocksProxy = networkUseSocksProxy, + onionHosts = remember { mutableStateOf(netCfg.onionHosts) }, + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + toggleSocksProxy = { enable -> + val def = NetCfg.defaults + val proxyDef = NetCfg.proxyDefaults + if (enable) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.network_enable_socks), + text = generalGetString(MR.strings.network_enable_socks_info).format(proxyPort.value), + confirmText = generalGetString(MR.strings.confirm_verb), + onConfirm = { + withBGApi { + var conf = controller.getNetCfg().withProxy(controller.appPrefs.networkProxy.get()) + if (conf.tcpConnectTimeout == def.tcpConnectTimeout) { + conf = conf.copy(tcpConnectTimeout = proxyDef.tcpConnectTimeout) + } + if (conf.tcpTimeout == def.tcpTimeout) { + conf = conf.copy(tcpTimeout = proxyDef.tcpTimeout) + } + if (conf.tcpTimeoutPerKb == def.tcpTimeoutPerKb) { + conf = conf.copy(tcpTimeoutPerKb = proxyDef.tcpTimeoutPerKb) + } + if (conf.rcvConcurrency == def.rcvConcurrency) { + conf = conf.copy(rcvConcurrency = proxyDef.rcvConcurrency) + } + chatModel.controller.apiSetNetworkConfig(conf) + chatModel.controller.setNetCfg(conf) + networkUseSocksProxy.value = true + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.network_disable_socks), + text = generalGetString(MR.strings.network_disable_socks_info), + confirmText = generalGetString(MR.strings.confirm_verb), + onConfirm = { + withBGApi { + var conf = controller.getNetCfg().copy(socksProxy = null) + if (conf.tcpConnectTimeout == proxyDef.tcpConnectTimeout) { + conf = conf.copy(tcpConnectTimeout = def.tcpConnectTimeout) + } + if (conf.tcpTimeout == proxyDef.tcpTimeout) { + conf = conf.copy(tcpTimeout = def.tcpTimeout) + } + if (conf.tcpTimeoutPerKb == proxyDef.tcpTimeoutPerKb) { + conf = conf.copy(tcpTimeoutPerKb = def.tcpTimeoutPerKb) + } + if (conf.rcvConcurrency == proxyDef.rcvConcurrency) { + conf = conf.copy(rcvConcurrency = def.rcvConcurrency) + } + chatModel.controller.apiSetNetworkConfig(conf) + chatModel.controller.setNetCfg(conf) + networkUseSocksProxy.value = false + } + } + ) + } + } + ) + } } @Composable fun NetworkAndServersLayout( currentRemoteHost: RemoteHostInfo?, networkUseSocksProxy: MutableState, onionHosts: MutableState, + currUserServers: MutableState>, + serverErrors: MutableState>, + userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { val m = chatModel + val conditionsAction = remember { m.conditions.value.conditionsAction } + val anyOperatorEnabled = remember { derivedStateOf { userServers.value.any { it.operator?.enabled == true } } } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + if (currUserServers.value.isNotEmpty() || userServers.value.isNotEmpty()) { + return@LaunchedEffect + } + try { + val servers = getUserServers(rh = currentRemoteHost?.remoteHostId) + if (servers != null) { + currUserServers.value = servers + userServers.value = servers + } + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } + } + + @Composable + fun ConditionsButton(conditionsAction: UsageConditionsAction, rhId: Long?) { + SectionItemView( + click = { ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> UsageConditionsView(currUserServers, userServers, close, rhId) } }, + ) { + Text( + stringResource(if (conditionsAction is UsageConditionsAction.Review) MR.strings.operator_review_conditions else MR.strings.operator_conditions_accepted), + color = MaterialTheme.colors.primary + ) + } + } + ColumnWithScrollBar { - val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } - val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) }} + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } + val showCustomModal = { it: @Composable (close: () -> Unit) -> Unit -> ModalManager.start.showCustomModal { close -> it(close) } } AppBarTitle(stringResource(MR.strings.network_and_servers)) + // TODO: Review this and socks. if (!chatModel.desktopNoUserNoRemote) { - SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.message_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } }) + SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { + userServers.value.forEachIndexed { index, srv -> + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + } + } + if (conditionsAction != null && anyOperatorEnabled.value) { + ConditionsButton(conditionsAction, rhId = currentRemoteHost?.remoteHostId) + } + val footerText = if (conditionsAction is UsageConditionsAction.Review && conditionsAction.deadline != null && anyOperatorEnabled.value) { + String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(conditionsAction.deadline)) + } else null - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.media_and_file_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } }) + if (footerText != null) { + SectionTextFooter(footerText) + } + SectionDividerSpaced() + } - if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) - SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) }}) - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) - if (networkUseSocksProxy.value) { - SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) - SectionDividerSpaced(maxTopPadding = true) - } else { - SectionDividerSpaced() + SectionView(generalGetString(MR.strings.settings_section_title_messages)) { + val nullOperatorIndex = userServers.value.indexOfFirst { it.operator == null } + + if (nullOperatorIndex != -1) { + SectionItemView({ + ModalManager.start.showModal { + YourServersView( + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = nullOperatorIndex, + rhId = currentRemoteHost?.remoteHostId + ) + } + }) { + Icon( + painterResource(MR.images.ic_dns), + stringResource(MR.strings.your_servers), + tint = MaterialTheme.colors.secondary + ) + TextIconSpaced() + Text(stringResource(MR.strings.your_servers), color = MaterialTheme.colors.onBackground) + + if (currUserServers.value.getOrNull(nullOperatorIndex) != userServers.value.getOrNull(nullOperatorIndex)) { + Spacer(Modifier.weight(1f)) + UnsavedChangesIndicator() } } } + + if (currentRemoteHost == null) { + UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) + SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }) + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) + if (networkUseSocksProxy.value) { + SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + SectionDividerSpaced(maxTopPadding = true) + } else { + SectionDividerSpaced(maxBottomPadding = false) + } + } } + val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) + + SectionItemView( + { scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } }, + disabled = saveDisabled, + ) { + Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } else if (serverErrors.value.isNotEmpty()) { + SectionCustomFooter { + ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) + } + } + + SectionDividerSpaced() SectionView(generalGetString(MR.strings.settings_section_title_calls)) { SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) @@ -504,6 +631,165 @@ fun showWrongProxyConfigAlert() { ) } +@Composable() +private fun ServerOperatorRow( + index: Int, + operator: ServerOperator, + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long? +) { + SectionItemView( + { + ModalManager.start.showModalCloseable { close -> + OperatorView( + currUserServers, + userServers, + serverErrors, + index, + rhId + ) + } + } + ) { + Image( + painterResource(operator.logo), + operator.tradeName, + modifier = Modifier.size(24.dp), + colorFilter = if (operator.enabled) null else ColorFilter.colorMatrix(ColorMatrix().apply { + setToSaturation(0f) + }) + ) + TextIconSpaced() + Text(operator.tradeName, color = if (operator.enabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + + if (currUserServers.value.getOrNull(index) != userServers.value.getOrNull(index)) { + Spacer(Modifier.weight(1f)) + UnsavedChangesIndicator() + } + } +} + +@Composable +private fun UnsavedChangesIndicator() { + Icon( + painterResource(MR.images.ic_edit_filled), + stringResource(MR.strings.icon_descr_edited), + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(16.dp) + ) +} + +@Composable +fun UsageConditionsView( + currUserServers: MutableState>, + userServers: MutableState>, + close: () -> Unit, + rhId: Long? +) { + suspend fun acceptForOperators(rhId: Long?, operatorIds: List, close: () -> Unit) { + try { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return + chatModel.conditions.value = r + updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance(userServers, r.serverOperators) + close() + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } + } + + @Composable + fun AcceptConditionsButton(operatorIds: List, close: () -> Unit, bottomPadding: Dp = DEFAULT_PADDING * 2) { + val scope = rememberCoroutineScope() + Column(Modifier.fillMaxWidth().padding(bottom = bottomPadding), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + labelId = MR.strings.accept_conditions, + onboarding = null, + enabled = operatorIds.isNotEmpty(), + onclick = { + scope.launch { + acceptForOperators(rhId, operatorIds, close) + } + } + ) + } + } + + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false, withPadding = false) + when (val conditionsAction = chatModel.conditions.value.conditionsAction) { + is UsageConditionsAction.Review -> { + if (conditionsAction.operators.isNotEmpty()) { + ReadableText(MR.strings.operators_conditions_will_be_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + AcceptConditionsButton(conditionsAction.operators.map { it.operatorId }, close, if (conditionsAction.deadline != null) DEFAULT_PADDING_HALF else DEFAULT_PADDING * 2) + if (conditionsAction.deadline != null) { + SectionTextFooter( + text = AnnotatedString(String.format(generalGetString(MR.strings.operator_conditions_accepted_for_enabled_operators_on), localDate(conditionsAction.deadline))), + textAlign = TextAlign.Center + ) + Spacer(Modifier.fillMaxWidth().height(DEFAULT_PADDING)) + } + } + + is UsageConditionsAction.Accepted -> { + if (conditionsAction.operators.isNotEmpty()) { + ReadableText(MR.strings.operators_conditions_accepted_for, args = conditionsAction.operators.joinToString(", ") { it.legalName_ }) + } + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + + else -> { + Column(modifier = Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + } + } +} + +@Composable +fun ServersErrorFooter(errStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = stringResource(MR.strings.server_error), + tint = Color.Red, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + errStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.smp_save_servers_question), + confirmText = generalGetString(MR.strings.save_verb), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} + fun showUpdateNetworkSettingsDialog( title: String, startsWith: String = "", @@ -521,6 +807,107 @@ fun showUpdateNetworkSettingsDialog( ) } +fun updateOperatorsConditionsAcceptance(usvs: MutableState>, updatedOperators: List) { + val modified = ArrayList(usvs.value) + for (i in modified.indices) { + val updatedOperator = updatedOperators.firstOrNull { it.operatorId == modified[i].operator?.operatorId } ?: continue + modified[i] = modified[i].copy(operator = modified[i].operator?.copy(conditionsAcceptance = updatedOperator.conditionsAcceptance)) + } + usvs.value = modified +} + +suspend fun validateServers_( + rhId: Long?, + userServersToValidate: List, + serverErrors: MutableState> +) { + try { + val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + serverErrors.value = errors + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } +} + +fun serversCanBeSaved( + currUserServers: List, + userServers: List, + serverErrors: List +): Boolean { + return userServers != currUserServers && serverErrors.isEmpty() +} + +fun globalServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalError != null) { + return err.globalError + } + } + return null +} + +fun globalSMPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalSMPError != null) { + return err.globalSMPError + } + } + return null +} + +fun globalXFTPServersError(serverErrors: List): String? { + for (err in serverErrors) { + if (err.globalXFTPError != null) { + return err.globalXFTPError + } + } + return null +} + +fun findDuplicateHosts(serverErrors: List): Set { + val duplicateHostsList = serverErrors.mapNotNull { err -> + if (err is UserServersError.DuplicateServer) { + err.duplicateHost + } else { + null + } + } + return duplicateHostsList.toSet() +} + +private suspend fun saveServers( + rhId: Long?, + currUserServers: MutableState>, + userServers: MutableState> +) { + val userServersToSave = userServers.value + try { + val set = setUserServers(rhId, userServersToSave) + + if (set) { + // Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers) + val updatedServers = getUserServers(rhId) + // Get updated operators to update model + val updatedOperators = getServerOperators(rhId) + + if (updatedOperators != null) { + chatModel.conditions.value = updatedOperators + } + + if (updatedServers != null ) { + currUserServers.value = updatedServers + userServers.value = updatedServers + } else { + currUserServers.value = userServersToSave + } + } else { + currUserServers.value = userServersToSave + } + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } +} + @Preview @Composable fun PreviewNetworkAndServersLayout() { @@ -530,6 +917,9 @@ fun PreviewNetworkAndServersLayout() { networkUseSocksProxy = remember { mutableStateOf(true) }, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, toggleSocksProxy = {}, + currUserServers = remember { mutableStateOf(emptyList()) }, + userServers = remember { mutableStateOf(emptyList()) }, + serverErrors = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt new file mode 100644 index 0000000000..1ec2534ab1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -0,0 +1,144 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.* +import chat.simplex.res.MR +import kotlinx.coroutines.* + +@Composable +fun ModalData.NewServerView( + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val testing = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val newServer = remember { mutableStateOf(UserServer.empty) } + + ModalView(close = { + addServer( + scope, + newServer.value, + userServers, + serverErrors, + rhId, + close = close + ) + }) { + Box { + NewServerLayout( + newServer, + testing.value, + testServer = { + testing.value = true + withLongRunningApi { + val res = testServerConnection(newServer.value, chatModel) + if (isActive) { + newServer.value = res.first + testing.value = false + } + } + }, + ) + + if (testing.value) { + DefaultProgressView(null) + } + } + } +} + +@Composable +private fun NewServerLayout( + server: MutableState, + testing: Boolean, + testServer: () -> Unit, +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.smp_servers_new_server)) + CustomServer(server, testing, testServer, onDelete = null) + SectionBottomSpacer() + } +} + +fun serverProtocolAndOperator( + server: UserServer, + userServers: List +): Pair? { + val serverAddress = parseServerAddress(server.server) + return if (serverAddress != null) { + val serverProtocol = serverAddress.serverProtocol + val hostnames = serverAddress.hostnames + val matchingOperator = userServers.mapNotNull { it.operator }.firstOrNull { op -> + op.serverDomains.any { domain -> + hostnames.any { hostname -> + hostname.endsWith(domain) + } + } + } + Pair(serverProtocol, matchingOperator) + } else { + null + } +} + +fun addServer( + scope: CoroutineScope, + server: UserServer, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val result = serverProtocolAndOperator(server, userServers.value) + if (result != null) { + val (serverProtocol, matchingOperator) = result + val operatorIndex = userServers.value.indexOfFirst { it.operator?.operatorId == matchingOperator?.operatorId } + if (operatorIndex != -1) { + // Create a mutable copy of the userServers list + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[operatorIndex] + // Create a mutable copy of the smpServers or xftpServers and add the server + when (serverProtocol) { + ServerProtocol.SMP -> { + val updatedSMPServers = operatorServers.smpServers.toMutableList() + updatedSMPServers.add(server) + updatedUserServers[operatorIndex] = operatorServers.copy(smpServers = updatedSMPServers) + } + + ServerProtocol.XFTP -> { + val updatedXFTPServers = operatorServers.xftpServers.toMutableList() + updatedXFTPServers.add(server) + updatedUserServers[operatorIndex] = operatorServers.copy(xftpServers = updatedXFTPServers) + } + } + + userServers.value = updatedUserServers + close() + matchingOperator?.let { op -> + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.operator_server_alert_title), + text = String.format(generalGetString(MR.strings.server_added_to_operator__name), op.tradeName) + ) + } + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_server)) + } + } else { + close() + if (server.server.trim().isNotEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt new file mode 100644 index 0000000000..cb02745511 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -0,0 +1,701 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionCustomFooter +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatController.getUsageConditions +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.net.URI + +@Composable +fun ModalData.OperatorView( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val operator = remember { userServers.value[operatorIndex].operator_ } + val currentUser = remember { chatModel.currentUser }.value + + LaunchedEffect(userServers) { + snapshotFlow { userServers.value } + .collect { updatedServers -> + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + } + } + + Box { + ColumnWithScrollBar(Modifier.alpha(if (testing.value) 0.6f else 1f)) { + AppBarTitle(String.format(stringResource(MR.strings.operator_servers_title), operator.tradeName)) + OperatorViewLayout( + currUserServers, + userServers, + serverErrors, + operatorIndex, + navigateToProtocolView = { serverIndex, server, protocol -> + navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + }, + currentUser, + rhId, + testing + ) + } + + if (testing.value) { + DefaultProgressView(null) + } + } +} + +fun navigateToProtocolView( + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long?, + serverIndex: Int, + server: UserServer, + protocol: ServerProtocol +) { + ModalManager.start.showCustomModal { close -> + ProtocolServerView( + m = chatModel, + server = server, + serverProtocol = protocol, + userServers = userServers, + serverErrors = serverErrors, + onDelete = { + if (protocol == ServerProtocol.SMP) { + deleteSMPServer(userServers, operatorIndex, serverIndex) + } else { + deleteXFTPServer(userServers, operatorIndex, serverIndex) + } + close() + }, + onUpdate = { updatedServer -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = if (protocol == ServerProtocol.SMP) { + this[operatorIndex].smpServers.toMutableList().apply { + this[serverIndex] = updatedServer + } + } else this[operatorIndex].smpServers, + xftpServers = if (protocol == ServerProtocol.XFTP) { + this[operatorIndex].xftpServers.toMutableList().apply { + this[serverIndex] = updatedServer + } + } else this[operatorIndex].xftpServers + ) + } + }, + close = close, + rhId = rhId + ) + } +} + +@Composable +fun OperatorViewLayout( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + val operator by remember { derivedStateOf { userServers.value[operatorIndex].operator_ } } + val scope = rememberCoroutineScope() + val duplicateHosts = findDuplicateHosts(serverErrors.value) + + Column { + SectionView(generalGetString(MR.strings.operator).uppercase()) { + SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { + Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) + } + UseOperatorToggle( + scope = scope, + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = operatorIndex, + rhId = rhId + ) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } else { + val footerText = when (val c = operator.conditionsAcceptance) { + is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { + String.format(generalGetString(MR.strings.operator_conditions_accepted_on), localDate(c.acceptedAt)) + } else null + is ConditionsAcceptance.Required -> if (operator.enabled && c.deadline != null) { + String.format(generalGetString(MR.strings.operator_conditions_will_be_accepted_on), localDate(c.deadline)) + } else null + } + if (footerText != null) { + SectionTextFooter(footerText) + } + } + + if (operator.enabled) { + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_messages_receiving), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.smpRoles.storage, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + ) + ) + } + } + ) + } + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_messages_private_routing), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.smpRoles.proxy, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled) + ) + ) + } + } + ) + } + + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } + } + + // Preset servers can't be deleted + if (userServers.value[operatorIndex].smpServers.any { it.preset }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (!server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.smp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (server.deleted || server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_for_sending), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator_.xftpRoles.storage, + onCheckedChange = { enabled -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy( + xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false) + ) + ) + } + } + ) + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } + } + + // Preset servers can't be deleted + if (userServers.value[operatorIndex].xftpServers.any { it.preset }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (!server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.xftp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (server.deleted || server.preset) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + } + + SectionDividerSpaced() + SectionView { + TestServersButton( + testing = testing, + smpServers = userServers.value[operatorIndex].smpServers, + xftpServers = userServers.value[operatorIndex].xftpServers, + ) { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } + + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + } + } + + SectionBottomSpacer() + } + } +} + +@Composable +private fun OperatorInfoView(serverOperator: ServerOperator) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.operator_info_title)) + + SectionView { + SectionItemView { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Image(painterResource(serverOperator.largeLogo), null, Modifier.height(48.dp)) + if (serverOperator.legalName != null) { + Text(serverOperator.legalName) + } + } + } + } + + SectionDividerSpaced(maxBottomPadding = false) + + SectionView { + SectionItemView { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + serverOperator.info.description.forEach { d -> + Text(d) + } + } + } + } + + SectionDividerSpaced() + + SectionView(generalGetString(MR.strings.operator_website).uppercase()) { + SectionItemView { + val website = serverOperator.info.website + val uriHandler = LocalUriHandler.current + Text(website, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(website) }) + } + } + } +} + +@Composable +private fun UseOperatorToggle( + scope: CoroutineScope, + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + stringResource(MR.strings.operator_use_operator_toggle_description), + Modifier.padding(end = 24.dp), + color = Color.Unspecified + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch( + checked = userServers.value[operatorIndex].operator?.enabled ?: false, + onCheckedChange = { enabled -> + val operator = userServers.value[operatorIndex].operator + if (enabled) { + when (val conditionsAcceptance = operator?.conditionsAcceptance) { + is ConditionsAcceptance.Accepted -> { + changeOperatorEnabled(userServers, operatorIndex, true) + } + + is ConditionsAcceptance.Required -> { + if (conditionsAcceptance.deadline == null) { + ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + SingleOperatorUsageConditionsView( + currUserServers = currUserServers, + userServers = userServers, + serverErrors = serverErrors, + operatorIndex = operatorIndex, + rhId = rhId, + close = close + ) + } + } else { + changeOperatorEnabled(userServers, operatorIndex, true) + } + } + + else -> {} + } + } else { + changeOperatorEnabled(userServers, operatorIndex, false) + } + }, + ) + } +} + +@Composable +private fun SingleOperatorUsageConditionsView( + currUserServers: MutableState>, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long?, + close: () -> Unit +) { + val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } } + val operator = remember { userServers.value[operatorIndex].operator_ } + val scope = rememberCoroutineScope() + + suspend fun acceptForOperators(rhId: Long?, operatorIds: List, operatorIndexToEnable: Int, close: () -> Unit) { + try { + val conditionsId = chatModel.conditions.value.currentConditions.conditionsId + val r = chatController.acceptConditions(rhId, conditionsId, operatorIds) ?: return + + chatModel.conditions.value = r + updateOperatorsConditionsAcceptance(currUserServers, r.serverOperators) + updateOperatorsConditionsAcceptance(userServers, r.serverOperators) + changeOperatorEnabled(userServers, operatorIndex, true) + close() + } catch (ex: Exception) { + Log.e(TAG, ex.stackTraceToString()) + } + } + + @Composable + fun AcceptConditionsButton(close: () -> Unit) { + // Opened operator or Other enabled operators with conditions not accepted + val operatorIds = chatModel.conditions.value.serverOperators + .filter { it.operatorId == operator.id || (it.enabled && !it.conditionsAcceptance.conditionsAccepted) } + .map { it.operatorId } + + Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingActionButton( + labelId = MR.strings.accept_conditions, + onboarding = null, + enabled = operatorIds.isNotEmpty(), + onclick = { + scope.launch { + acceptForOperators(rhId, operatorIds, operatorIndex, close) + } + } + ) + } + } + + @Composable + fun UsageConditionsDestinationView(close: () -> Unit) { + ColumnWithScrollBar(modifier = Modifier.fillMaxSize()) { + AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), enableAlphaChanges = false) + Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(rhId) + } + } + } + + @Composable + fun UsageConditionsNavLinkButton() { + Text( + stringResource(MR.strings.view_conditions), + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).clickable { + ModalManager.start.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close -> + UsageConditionsDestinationView(close) + } + } + ) + } + + ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false) + if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) { + // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted + Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) { + ConditionsTextView(rhId) + } + } else if (operatorsWithConditionsAccepted.isNotEmpty()) { + ReadableText( + MR.strings.operator_conditions_accepted_for_some, + args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ } + ) + ReadableText( + MR.strings.operator_same_conditions_will_be_applied, + args = operator.legalName_ + ) + ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex) + + UsageConditionsNavLinkButton() + Spacer(Modifier.fillMaxWidth().weight(1f)) + AcceptConditionsButton(close) + } else { + ReadableText( + MR.strings.operator_in_order_to_use_accept_conditions, + args = operator.legalName_ + ) + ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex) + Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) { + ConditionsTextView(rhId) + } + AcceptConditionsButton(close) + } + } +} + +@Composable +fun ConditionsTextView( + rhId: Long? +) { + val conditionsData = remember { mutableStateOf?>(null) } + val failedToLoad = remember { mutableStateOf(false) } + val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + scope.launch { + try { + val conditions = getUsageConditions(rh = rhId) + + if (conditions != null) { + conditionsData.value = conditions + } else { + failedToLoad.value = true + } + } catch (ex: Exception) { + failedToLoad.value = true + } + } + } + val conditions = conditionsData.value + + if (conditions != null) { + val (usageConditions, conditionsText, _) = conditions + + if (conditionsText != null) { + val scrollState = rememberScrollState() + Box( + modifier = Modifier + .fillMaxSize() + .border(border = BorderStroke(1.dp, CurrentColors.value.colors.secondary.copy(alpha = 0.6f)), shape = RoundedCornerShape(12.dp)) + .verticalScroll(scrollState) + .padding(8.dp) + ) { + Text( + text = conditionsText.trimIndent(), + modifier = Modifier.padding(8.dp) + ) + } + } else { + val conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/${usageConditions.conditionsCommit}/PRIVACY.md" + ConditionsLinkView(conditionsLink) + } + } else if (failedToLoad.value) { + ConditionsLinkView(defaultConditionsLink) + } else { + DefaultProgressView(null) + } +} + +@Composable +private fun ConditionsLinkView(conditionsLink: String) { + SectionItemView { + val uriHandler = LocalUriHandler.current + Text(stringResource(MR.strings.operator_conditions_failed_to_load), color = MaterialTheme.colors.onBackground) + Text(conditionsLink, color = MaterialTheme.colors.primary, modifier = Modifier.clickable { uriHandler.openUriCatching(conditionsLink) }) + } +} + +@Composable +private fun ConditionsAppliedToOtherOperatorsText(userServers: List, operatorIndex: Int) { + val otherOperatorsToApply = remember { + derivedStateOf { + chatModel.conditions.value.serverOperators.filter { + it.enabled && + !it.conditionsAcceptance.conditionsAccepted && + it.operatorId != userServers[operatorIndex].operator_.operatorId + } + } + } + + if (otherOperatorsToApply.value.isNotEmpty()) { + ReadableText(MR.strings.operator_conditions_will_be_applied) + } +} + +@Composable +fun ConditionsLinkButton() { + val showMenu = remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + val oneHandUI = remember { appPrefs.oneHandUI.state } + Column { + DefaultDropdownMenu(showMenu, offset = if (oneHandUI.value) DpOffset(0.dp, -AppBarHeight * fontSizeSqrtMultiplier * 3) else DpOffset.Zero) { + val commit = chatModel.conditions.value.currentConditions.conditionsCommit + ItemAction(stringResource(MR.strings.operator_open_conditions), painterResource(MR.images.ic_draft), onClick = { + val mdUrl = "https://github.com/simplex-chat/simplex-chat/blob/$commit/PRIVACY.md" + uriHandler.openUriCatching(mdUrl) + showMenu.value = false + }) + ItemAction(stringResource(MR.strings.operator_open_changes), painterResource(MR.images.ic_more_horiz), onClick = { + val commitUrl = "https://github.com/simplex-chat/simplex-chat/commit/$commit" + uriHandler.openUriCatching(commitUrl) + showMenu.value = false + }) + } + IconButton({ showMenu.value = true }) { + Icon(painterResource(MR.images.ic_outbound), null, tint = MaterialTheme.colors.primary) + } + } +} + +private fun changeOperatorEnabled(userServers: MutableState>, operatorIndex: Int, enabled: Boolean) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + operator = this[operatorIndex].operator?.copy(enabled = enabled) + ) + } +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt similarity index 51% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index be566e6c5a..bebc96a28c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -1,16 +1,14 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween import SectionView -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource @@ -26,62 +24,103 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable -fun ProtocolServerView(m: ChatModel, server: ServerCfg, serverProtocol: ServerProtocol, onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit) { - var testing by remember { mutableStateOf(false) } - ProtocolServerLayout( - testing, - server, - serverProtocol, - testServer = { - testing = true - withLongRunningApi { - val res = testServerConnection(server, m) - if (isActive) { - onUpdate(res.first) - testing = false +fun ProtocolServerView( + m: ChatModel, + server: UserServer, + serverProtocol: ServerProtocol, + userServers: MutableState>, + serverErrors: MutableState>, + onDelete: () -> Unit, + onUpdate: (UserServer) -> Unit, + close: () -> Unit, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val draftServer = remember { mutableStateOf(server) } + + ModalView( + close = { + scope.launch { + val draftResult = serverProtocolAndOperator(draftServer.value, userServers.value) + val savedResult = serverProtocolAndOperator(server, userServers.value) + + if (draftResult != null && savedResult != null) { + val (serverToEditProtocol, serverToEditOperator) = draftResult + val (svProtocol, serverOperator) = savedResult + + if (serverToEditProtocol != svProtocol) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_updating_server_title), + text = generalGetString(MR.strings.error_server_protocol_changed) + ) + } else if (serverToEditOperator != serverOperator) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_updating_server_title), + text = generalGetString(MR.strings.error_server_operator_changed) + ) + } else { + onUpdate(draftServer.value) + close() + } + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) } } - }, - onUpdate, - onDelete - ) - if (testing) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp + } + ) { + Box { + ProtocolServerLayout( + draftServer, + serverProtocol, + testing.value, + testServer = { + testing.value = true + withLongRunningApi { + val res = testServerConnection(draftServer.value, m) + if (isActive) { + draftServer.value = res.first + testing.value = false + } + } + }, + onDelete ) + + if (testing.value) { + DefaultProgressView(null) + } } } } @Composable private fun ProtocolServerLayout( - testing: Boolean, - server: ServerCfg, + server: MutableState, serverProtocol: ServerProtocol, + testing: Boolean, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, onDelete: () -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(if (server.preset) MR.strings.smp_servers_preset_server else MR.strings.smp_servers_your_server)) + AppBarTitle(stringResource(if (serverProtocol == ServerProtocol.XFTP) MR.strings.xftp_server else MR.strings.smp_server)) - if (server.preset) { - PresetServer(testing, server, testServer, onUpdate, onDelete) + if (server.value.preset) { + PresetServer(server, testing, testServer) } else { - CustomServer(testing, server, serverProtocol, testServer, onUpdate, onDelete) + CustomServer(server, testing, testServer, onDelete) } SectionBottomSpacer() } @@ -89,16 +128,14 @@ private fun ProtocolServerLayout( @Composable private fun PresetServer( + server: MutableState, testing: Boolean, - server: ServerCfg, - testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + testServer: () -> Unit ) { SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) { SelectionContainer { Text( - server.server, + server.value.server, Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), style = TextStyle( fontFamily = FontFamily.Monospace, fontSize = 16.sp, @@ -108,23 +145,21 @@ private fun PresetServer( } } SectionDividerSpaced() - UseServerSection(true, testing, server, testServer, onUpdate, onDelete) + UseServerSection(server, true, testing, testServer) } @Composable -private fun CustomServer( +fun CustomServer( + server: MutableState, testing: Boolean, - server: ServerCfg, - serverProtocol: ServerProtocol, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + onDelete: (() -> Unit)?, ) { - val serverAddress = remember { mutableStateOf(server.server) } + val serverAddress = remember { mutableStateOf(server.value.server) } val valid = remember { derivedStateOf { with(parseServerAddress(serverAddress.value)) { - this?.valid == true && this.serverProtocol == serverProtocol + this?.valid == true } } } @@ -142,13 +177,14 @@ private fun CustomServer( snapshotFlow { serverAddress.value } .distinctUntilChanged() .collect { - testedPreviously[server.server] = server.tested - onUpdate(server.copy(server = it, tested = testedPreviously[serverAddress.value])) + testedPreviously[server.value.server] = server.value.tested + server.value = server.value.copy(server = it, tested = testedPreviously[serverAddress.value]) } } } SectionDividerSpaced(maxTopPadding = true) - UseServerSection(valid.value, testing, server, testServer, onUpdate, onDelete) + + UseServerSection(server, valid.value, testing, testServer, onDelete) if (valid.value) { SectionDividerSpaced() @@ -160,43 +196,44 @@ private fun CustomServer( @Composable private fun UseServerSection( + server: MutableState, valid: Boolean, testing: Boolean, - server: ServerCfg, testServer: () -> Unit, - onUpdate: (ServerCfg) -> Unit, - onDelete: () -> Unit, + onDelete: (() -> Unit)? = null, ) { SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) { SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) - ShowTestStatus(server) + ShowTestStatus(server.value) } - val enabled = rememberUpdatedState(server.enabled) + val enabled = rememberUpdatedState(server.value.enabled) PreferenceToggle( stringResource(MR.strings.smp_servers_use_server_for_new_conn), - disabled = server.tested != true && !server.preset, + disabled = testing, checked = enabled.value ) { - onUpdate(server.copy(enabled = it)) + server.value = server.value.copy(enabled = it) } - - SectionItemView(onDelete, disabled = testing) { - Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) + + if (onDelete != null) { + SectionItemView(onDelete, disabled = testing) { + Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) + } } } } @Composable -fun ShowTestStatus(server: ServerCfg, modifier: Modifier = Modifier) = +fun ShowTestStatus(server: UserServer, modifier: Modifier = Modifier) = when (server.tested) { true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) } -suspend fun testServerConnection(server: ServerCfg, m: ChatModel): Pair = +suspend fun testServerConnection(server: UserServer, m: ChatModel): Pair = try { val r = m.controller.testProtoServer(server.remoteHostId, server.server) server.copy(tested = r == null) to r diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt new file mode 100644 index 0000000000..63bf8b1dc4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -0,0 +1,407 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionCustomFooter +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.res.MR +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun ModalData.YourServersView( + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + rhId: Long? +) { + val testing = remember { mutableStateOf(false) } + val currentUser = remember { chatModel.currentUser }.value + val scope = rememberCoroutineScope() + + LaunchedEffect(userServers) { + snapshotFlow { userServers.value } + .collect { updatedServers -> + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + } + } + + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.your_servers)) + YourServersViewLayout( + scope, + userServers, + serverErrors, + operatorIndex, + navigateToProtocolView = { serverIndex, server, protocol -> + navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + }, + currentUser, + rhId, + testing + ) + } + + if (testing.value) { + DefaultProgressView(null) + } + } +} + +@Composable +fun YourServersViewLayout( + scope: CoroutineScope, + userServers: MutableState>, + serverErrors: MutableState>, + operatorIndex: Int, + navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, + currentUser: User?, + rhId: Long?, + testing: MutableState +) { + val duplicateHosts = findDuplicateHosts(serverErrors.value) + + Column { + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.SMP, + duplicateHosts = duplicateHosts + ) + } + } + } + val smpErr = globalSMPServersError(serverErrors.value) + if (smpErr != null) { + SectionCustomFooter { + ServersErrorFooter(smpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.smp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> + if (server.deleted) return@forEachIndexed + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + ProtocolServerViewLink( + srv = server, + serverProtocol = ServerProtocol.XFTP, + duplicateHosts = duplicateHosts + ) + } + } + } + val xftpErr = globalXFTPServersError(serverErrors.value) + if (xftpErr != null) { + SectionCustomFooter { + ServersErrorFooter(xftpErr) + } + } else { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.xftp_servers_per_user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) + } + } + + if ( + userServers.value[operatorIndex].smpServers.any { !it.deleted } || + userServers.value[operatorIndex].xftpServers.any { !it.deleted } + ) { + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + } + + SectionView { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.smp_servers_add), + click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + disabled = testing.value, + textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + val serversErr = globalServersError(serverErrors.value) + if (serversErr != null) { + SectionCustomFooter { + ServersErrorFooter(serversErr) + } + } + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + + SectionView { + TestServersButton( + testing = testing, + smpServers = userServers.value[operatorIndex].smpServers, + xftpServers = userServers.value[operatorIndex].xftpServers, + ) { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } + + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + } + + HowToButton() + } + SectionBottomSpacer() + } +} + +@Composable +fun TestServersButton( + smpServers: List, + xftpServers: List, + testing: MutableState, + onUpdate: (ServerProtocol, List) -> Unit +) { + val scope = rememberCoroutineScope() + val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + + SectionItemView( + { + scope.launch { + testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + } + }, + disabled = disabled.value + ) { + Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) + } +} + +fun showAddServerDialog( + scope: CoroutineScope, + userServers: MutableState>, + serverErrors: MutableState>, + rhId: Long? +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.smp_servers_add), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewServerView(userServers, serverErrors, rhId, close) + } + }) { + Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (appPlatform.isAndroid) { + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showModalCloseable { close -> + ScanProtocolServer(rhId) { server -> + addServer( + scope, + server, + userServers, + serverErrors, + rhId, + close = close + ) + } + } + } + ) { + Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + } + ) +} + +@Composable +fun ProtocolServerViewLink(serverProtocol: ServerProtocol, srv: UserServer, duplicateHosts: Set) { + val address = parseServerAddress(srv.server) + when { + address == null || !address.valid || address.serverProtocol != serverProtocol || address.hostnames.any { it in duplicateHosts } -> InvalidServer() + !srv.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowTestStatus(srv) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val text = address?.hostnames?.firstOrNull() ?: srv.server + if (srv.enabled) { + Text(text, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(text, maxLines = 1, color = MaterialTheme.colors.secondary) + } +} + +@Composable +private fun HowToButton() { + val uriHandler = LocalUriHandler.current + SettingsActionItem( + painterResource(MR.images.ic_open_in_new), + stringResource(MR.strings.how_to_use_your_servers), + { uriHandler.openUriCatching("https://simplex.chat/docs/server.html") }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary + ) +} + +@Composable +fun InvalidServer() { + Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error) +} + +private suspend fun testServers( + testing: MutableState, + smpServers: List, + xftpServers: List, + m: ChatModel, + onUpdate: (ServerProtocol, List) -> Unit +) { + val smpResetStatus = resetTestStatus(smpServers) + onUpdate(ServerProtocol.SMP, smpResetStatus) + val xftpResetStatus = resetTestStatus(xftpServers) + onUpdate(ServerProtocol.XFTP, xftpResetStatus) + testing.value = true + val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } + val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } + testing.value = false + val fs = smpFailures + xftpFailures + if (fs.isNotEmpty()) { + val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_test_failed), + text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg + ) + } +} + +private fun resetTestStatus(servers: List): List { + val copy = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + copy.removeAt(index) + copy.add(index, server.copy(tested = null)) + } + } + return copy +} + +private suspend fun runServersTest(servers: List, m: ChatModel, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedServers = ArrayList(servers) + for ((index, server) in servers.withIndex()) { + if (server.enabled) { + interruptIfCancelled() + val (updatedServer, f) = testServerConnection(server, m) + updatedServers.removeAt(index) + updatedServers.add(index, updatedServer) + // toList() is important. Otherwise, Compose will not redraw the screen after first update + onUpdated(updatedServers.toList()) + if (f != null) { + fs[serverHostname(updatedServer.server)] = f + } + } + } + return fs +} + +fun deleteXFTPServer( + userServers: MutableState>, + operatorServersIndex: Int, + serverIndex: Int +) { + val serverIsSaved = userServers.value[operatorServersIndex].xftpServers[serverIndex].serverId != null + + if (serverIsSaved) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply { + this[serverIndex] = this[serverIndex].copy(deleted = true) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + xftpServers = this[operatorServersIndex].xftpServers.toMutableList().apply { + this.removeAt(serverIndex) + } + ) + } + } +} + +fun deleteSMPServer( + userServers: MutableState>, + operatorServersIndex: Int, + serverIndex: Int +) { + val serverIsSaved = userServers.value[operatorServersIndex].smpServers[serverIndex].serverId != null + + if (serverIsSaved) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + smpServers = this[operatorServersIndex].smpServers.toMutableList().apply { + this[serverIndex] = this[serverIndex].copy(deleted = true) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + smpServers = this[operatorServersIndex].smpServers.toMutableList().apply { + this.removeAt(serverIndex) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt similarity index 62% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index 966f44cac7..56f16d4eb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -1,29 +1,25 @@ -package chat.simplex.common.views.usersettings +package chat.simplex.common.views.usersettings.networkAndServers -import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.common.model.ServerCfg +import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -expect fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) +expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) @Composable -fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(rhId: Long?, onNext: (UserServer) -> Unit) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr)) QRCodeScanner { text -> val res = parseServerAddress(text) if (res != null) { - onNext(ServerCfg(remoteHostId = rhId, text, false, null, false)) + onNext(UserServer(remoteHostId = rhId, null, text, false, null, false, false)) } else { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_invalid_address), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7236b22563..3c1ede1d23 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -109,6 +109,16 @@ Invalid display name! This display name is invalid. Please choose another name. Error switching profile! + Error saving servers + No message servers. + No servers to receive messages. + No servers for private message routing. + No media & file servers. + No servers to send files. + No servers to receive files. + For chat profile %s: + Errors in servers configuration. + Error accepting conditions Connection timeout @@ -750,6 +760,7 @@ Some servers failed the test: Scan server QR code Enter server manually + New server Preset server Your server Your server address @@ -1038,6 +1049,19 @@ Random passphrase is stored in settings as plaintext.\nYou can change it later. Use random passphrase + + Choose operators + Network operators + When more than one network operator is enabled, the app will use the servers of different operators for each conversation. + For example, if you receive messages via SimpleX Chat server, the app will use one of Flux servers for private routing. + Select network operators to use. + You can configure servers via settings. + Conditions will be accepted for enabled operators after 30 days. + You can configure operators in Network & servers settings. + Review later + Update + Continue + Incoming video call Incoming audio call @@ -1667,6 +1691,59 @@ Save group profile Error saving group profile + + Preset servers + Review conditions + Accepted conditions + Conditions will be automatically accepted for enabled operators on: %s. + Your servers + %s.]]> + %s.]]> + + + Operator + %s servers + Network operator + Website + Conditions accepted on: %s. + Conditions will be accepted on: %s. + Operator + Use servers + Use %s + Current conditions text couldn\'t be loaded, you can review conditions via this link: + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + View conditions + Accept conditions + Conditions of use + %s, accept conditions of use.]]> + Use for messages + To receive + For private routing + Added message servers + Use for files + To send + The servers for new files of your current chat profile + Added media & file servers + Open conditions + Open changes + + + Error updating server + Server protocol changed. + Server operator changed. + + + Operator server + Server added to operator %s. + Error adding server + TCP connection Reset to defaults @@ -2059,6 +2136,13 @@ Better message dates. Forward up to 20 messages at once. Delete or moderate up to 200 messages. + Network decentralization + The second preset operator in the app! + Enable flux + for better metadata privacy + Improved chat navigation + - Open chat on the first unread message.\n- Jump to quoted messages. + View updated conditions seconds diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..87f1373d750bece6fbbd37fcdb4b115deaaeffbe GIT binary patch literal 34876 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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/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d6dda4fedd57195f0ee425a0a28a579f16b266 GIT binary patch literal 33847 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�>Y9E5G~yEbTaSm_@QckJ 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 E0Eh^ \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt deleted file mode 100644 index 2d436dbbf0..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt +++ /dev/null @@ -1,9 +0,0 @@ -package chat.simplex.common.views.usersettings - -import androidx.compose.runtime.Composable -import chat.simplex.common.model.ServerCfg - -@Composable -actual fun ScanProtocolServer(rhId: Long?, onNext: (ServerCfg) -> Unit) { - ScanProtocolServerLayout(rhId, onNext) -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt new file mode 100644 index 0000000000..7d6f305a83 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.UserServer + +@Composable +actual fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) { + ScanProtocolServerLayout(rhId, onNext) +} From b5170684adf312b914d95a403099f92e228b8ea7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:21:21 +0400 Subject: [PATCH 29/34] android: fix single operator conditions paddings --- .../views/usersettings/networkAndServers/OperatorView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index cb02745511..df6122024f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -556,7 +556,7 @@ private fun SingleOperatorUsageConditionsView( AppBarTitle(String.format(stringResource(MR.strings.use_servers_of_operator_x), operator.tradeName), enableAlphaChanges = false, withPadding = false) if (operator.conditionsAcceptance is ConditionsAcceptance.Accepted) { // In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted - Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) { + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) { ConditionsTextView(rhId) } } else if (operatorsWithConditionsAccepted.isNotEmpty()) { @@ -579,7 +579,7 @@ private fun SingleOperatorUsageConditionsView( args = operator.legalName_ ) ConditionsAppliedToOtherOperatorsText(userServers = userServers.value, operatorIndex = operatorIndex) - Column(modifier = Modifier.weight(1f).padding(end = DEFAULT_PADDING, start = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) { + Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING)) { ConditionsTextView(rhId) } AcceptConditionsButton(close) From 2adfa0c18b877457c2f68b295ef88e292035a338 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:33:49 +0400 Subject: [PATCH 30/34] android: information icon right of operator logo --- .../views/usersettings/networkAndServers/OperatorView.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index df6122024f..121b6535c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -141,7 +141,14 @@ fun OperatorViewLayout( Column { SectionView(generalGetString(MR.strings.operator).uppercase()) { SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { - Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + } } UseOperatorToggle( scope = scope, From e47b16f3b4b876bc4ab88ba954fb3de1d633876a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:40:41 +0400 Subject: [PATCH 31/34] android: improve layout of operator logo --- .../views/usersettings/networkAndServers/OperatorView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 121b6535c3..6f90836e89 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -147,11 +147,12 @@ fun OperatorViewLayout( ) { Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) Spacer(Modifier.fillMaxWidth().weight(1f)) - Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + Box(Modifier.padding(horizontal = 2.dp)) { + Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + } } } UseOperatorToggle( - scope = scope, currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, @@ -436,7 +437,6 @@ private fun OperatorInfoView(serverOperator: ServerOperator) { @Composable private fun UseOperatorToggle( - scope: CoroutineScope, currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, From a6f5ba541b88f4479dd62761b5ebff40c6556e9f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 22 Nov 2024 16:43:10 +0000 Subject: [PATCH 32/34] android, desktop: smaller info icon, corrections --- .../kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt | 2 +- .../common/views/usersettings/networkAndServers/OperatorView.kt | 2 +- .../common/src/commonMain/resources/MR/base/strings.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 6cf945bcba..e20a56c407 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -724,7 +724,7 @@ private val versionDescriptions: List = listOf( ), ), VersionDescription( - version = "v6.2 (beta.1)", + version = "v6.2-beta.1", post = "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html", features = listOf( VersionFeature.FeatureView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 6f90836e89..c61a9f5ef7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -148,7 +148,7 @@ fun OperatorViewLayout( Image(painterResource(operator.largeLogo), null, Modifier.height(48.dp)) Spacer(Modifier.fillMaxWidth().weight(1f)) Box(Modifier.padding(horizontal = 2.dp)) { - Icon(painterResource(MR.images.ic_info), null, Modifier.size(28.dp), tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_info), null, Modifier.size(24.dp), tint = MaterialTheme.colors.primaryVariant) } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 3c1ede1d23..8b379d8212 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2139,7 +2139,7 @@ Network decentralization The second preset operator in the app! Enable flux - for better metadata privacy + for better metadata privacy. Improved chat navigation - Open chat on the first unread message.\n- Jump to quoted messages. View updated conditions From 76aedb4a15f8ab3dd62b47146ad6e85744369868 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 22 Nov 2024 17:21:05 +0000 Subject: [PATCH 33/34] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index cfa9099517..793fc18952 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5e6fa7fb94ca54e3d6d68aa83e403f1182197081 + tag: 97104988a307bd27b8bf5da7ed67455f3531d7ae source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index a92a2b5ca5..e3985379d0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5e6fa7fb94ca54e3d6d68aa83e403f1182197081" = "1jwrk60nw9h8f5zxbcj57ybqdnfmchq65c07xybifcycid0016l3"; + "https://github.com/simplex-chat/simplexmq.git"."97104988a307bd27b8bf5da7ed67455f3531d7ae" = "1xhk8cg4338d0cfjhdm2460p6nbvxfra80qnab2607nvy8wpddvl"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 9b71702ac8c07d985c1fe83b2c3e56e64944dc0f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 22 Nov 2024 18:19:49 +0000 Subject: [PATCH 34/34] ios: move onboarding action cards, paddings (#5231) --- .../Shared/Views/ChatList/ChatListView.swift | 26 ++++++++++--------- .../Shared/Views/ChatList/OneHandUICard.swift | 1 - .../Onboarding/AddressCreationCard.swift | 1 - 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6da17fb312..b18e9295b9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -304,18 +304,6 @@ struct ChatListView: View { .padding(.top, oneHandUI ? 8 : 0) .id("searchBar") } - if !oneHandUICardShown { - OneHandUICard() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .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) @@ -341,6 +329,20 @@ struct ChatListView: View { .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) } } + if !oneHandUICardShown { + OneHandUICard() + .padding(.vertical, 6) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + if !addressCreationCardShown { + AddressCreationCard() + .padding(.vertical, 6) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } } .listStyle(.plain) .onChange(of: chatModel.chatId) { currentChatId in diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift index 636d165114..059f24cc82 100644 --- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift +++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift @@ -32,7 +32,6 @@ struct OneHandUICard: View { .background(theme.appColors.sentMessage) .cornerRadius(12) .frame(height: dynamicSize(userFont).rowHeight) - .padding(.vertical, 12) .alert(isPresented: $showOneHandUIAlert) { Alert( title: Text("Reachable chat toolbar"), diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift index e9a8fedaf9..eae64e4465 100644 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -65,7 +65,6 @@ struct AddressCreationCard: View { .background(theme.appColors.sentMessage) .cornerRadius(12) .frame(height: dynamicSize(userFont).rowHeight) - .padding(.vertical, 12) .alert(isPresented: $showAddressCreationAlert) { Alert( title: Text("SimpleX address"),