From fa9e0086f6609bf086310702d1b962d7a4a10b62 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 4 Jan 2023 21:06:28 +0400 Subject: [PATCH 01/59] core: multiple users api (#1679) * api * UCR * Revert "UCR" This reverts commit 1f98d2519243991c97a435c5f9243f63dfb94b52. * comment * events User * events in api User * CRActiveUser in APISetActiveUser * process message with/without connection * refactor * mute error * user in api responses * name * lost response * user in CRChatCmdError * compiles * user in CRChatError * -- UserId * mute unused warning * catch in withUser * remove comment Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 2 +- apps/ios/SimpleXChat/APITypes.swift | 2 +- .../typescript/src/command.ts | 2 +- src/Simplex/Chat.hs | 542 ++++++++++-------- src/Simplex/Chat/Bot.hs | 10 +- src/Simplex/Chat/Controller.hs | 263 ++++----- src/Simplex/Chat/Store.hs | 46 +- src/Simplex/Chat/Terminal/Input.hs | 7 +- src/Simplex/Chat/Terminal/Output.hs | 4 +- src/Simplex/Chat/Types.hs | 2 + src/Simplex/Chat/View.hs | 204 +++---- tests/MobileTests.hs | 8 +- 12 files changed, 602 insertions(+), 490 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 82505b3d72..e9864edfa1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1611,7 +1611,7 @@ sealed class CC { val cmdString: String get() = when (this) { is Console -> cmd is ShowActiveUser -> "/u" - is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" + is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 1c9f99185c..27f26a7a98 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -95,7 +95,7 @@ public enum ChatCommand { get { switch self { case .showActiveUser: return "/u" - case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index e3b017284f..2694e4d25d 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -450,7 +450,7 @@ export function cmdString(cmd: ChatCommand): string { case "showActiveUser": return "/u" case "createActiveUser": - return `/u ${JSON.stringify(cmd.profile)}` + return `/create user ${JSON.stringify(cmd.profile)}` case "startChat": return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.expireChatItems ? "on" : "off"}` case "apiStopChat": diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ec7a5c0d7b..b36c634f8a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -213,7 +213,8 @@ startChatController user subConns enableExpireCIs = do setExpireCIs True _ -> setExpireCIs True runExpireCIs = forever $ do - flip catchError (toView . CRChatError) $ do + -- TODO per user + flip catchError (toView . CRChatError (Just user)) $ do expire <- asks expireCIs atomically $ readTVar expire >>= \b -> unless b retry ttl <- withStore' (`getChatItemTTL` user) @@ -237,8 +238,10 @@ stopChatController ChatController {smpAgent, agentAsync = s, expireCIs} = do execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse execChatCommand s = case parseChatCommand s of - Left e -> pure $ chatCmdError e - Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) + Left e -> do + u <- readTVarIO =<< asks currentUser + pure $ chatCmdError u e + Right cmd -> either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -253,10 +256,24 @@ processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser p -> do u <- asks currentUser - whenM (isJust <$> readTVarIO u) $ throwChatError CEActiveUserExists user <- withStore $ \db -> createUser db p True atomically . writeTVar u $ Just user pure $ CRActiveUser user + ListUsers -> do + users <- withStore' $ \db -> getUsers db + pure $ CRUsersList users + APISetActiveUser userId -> do + u <- asks currentUser + user <- withStore $ \db -> getSetActiveUser db userId + atomically . writeTVar u $ Just user + pure $ CRActiveUser user + SetActiveUser uName -> withUserName uName APISetActiveUser + APIDeleteUser _userId -> do + -- prohibit to delete active user + -- withStore' $ \db -> deleteUser db userId + -- ? other cleanup + pure $ CRCmdOk Nothing + DeleteUser uName -> withUserName uName APIDeleteUser StartChat subConns enableExpireCIs -> withUser' $ \user -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning @@ -264,48 +281,54 @@ processChatCommand = \case APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> do - withUser $ \user -> restoreCalls user + APIActivateChat -> withUser $ \user -> do + restoreCalls user withAgent activateAgent setExpireCIs True - pure CRCmdOk + pure $ CRCmdOk Nothing APISuspendChat t -> do setExpireCIs False withAgent (`suspendAgent` t) - pure CRCmdOk - ResubscribeAllConnections -> withUser (subscribeUserConnections Agent.resubscribeConnections) $> CRCmdOk + pure $ CRCmdOk Nothing + ResubscribeAllConnections -> withUser $ \user -> do + subscribeUserConnections Agent.resubscribeConnections user + pure $ CRCmdOk Nothing SetFilesFolder filesFolder' -> do createDirectoryIfMissing True filesFolder' ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' - pure CRCmdOk + pure $ CRCmdOk Nothing SetIncognito onOff -> do incognito <- asks incognitoMode atomically . writeTVar incognito $ onOff - pure CRCmdOk - APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk + pure $ CRCmdOk Nothing + APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk Nothing APIImportArchive cfg -> withStoreChanged $ importArchive cfg APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) - APIGetChats withPCC -> CRApiChats <$> withUser' (\user -> withStore' $ \db -> getChatPreviews db user withPCC) + APIGetChats withPCC -> withUser' $ \user -> do + chats <- withStore' $ \db -> getChatPreviews db user withPCC + pure $ CRApiChats user chats APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do directChat <- withStore (\db -> getDirectChat db user cId pagination search) - pure . CRApiChat $ AChat SCTDirect directChat - CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search) - CTContactRequest -> pure $ chatCmdError "not implemented" - CTContactConnection -> pure $ chatCmdError "not supported" - APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" + pure $ CRApiChat user (AChat SCTDirect directChat) + CTGroup -> do + groupChat <- withStore (\db -> getGroupChat db user cId pagination search) + pure $ CRApiChat user (AChat SCTGroup groupChat) + CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + APIGetChatItems _pagination -> pure $ chatCmdError Nothing "not implemented" APISendMessage (ChatRef cType chatId) live (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) - then pure $ chatCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice) + then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) else do (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct timed_ <- sndContactCITimed live ct @@ -319,7 +342,7 @@ processChatCommand = \case forM_ (timed_ >>= deleteAt) $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer ct = forM file_ $ \file -> do @@ -358,7 +381,7 @@ processChatCommand = \case Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved if isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) - then pure $ chatCmdError $ "feature not allowed " <> T.unpack (groupFeatureNameText GFVoice) + then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFVoice)) else do (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo @@ -369,7 +392,7 @@ processChatCommand = \case forM_ (timed_ >>= deleteAt) $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) where setupSndFileTransfer :: GroupInfo -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer gInfo n = forM file_ $ \file -> do @@ -406,8 +429,8 @@ processChatCommand = \case quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ = throwChatError CEInvalidQuote - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent qmc ciFile_ @@ -446,7 +469,7 @@ processChatCommand = \case ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' setActive $ ActiveC c - pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci' + pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do @@ -461,11 +484,11 @@ processChatCommand = \case ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' setActive $ ActiveG gName - pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci' + pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do (ct@Contact {localDisplayName = c}, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId @@ -492,8 +515,8 @@ processChatCommand = \case then deleteGroupCI user gInfo ci True False else markGroupCIDeleted user gInfo ci msgId True (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \user@User {userId} -> case cType of CTDirect -> do timedItems <- withStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds @@ -503,7 +526,7 @@ processChatCommand = \case withStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt withStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds - pure CRCmdOk + pure $ CRCmdOk (Just user) CTGroup -> do timedItems <- withStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime @@ -512,21 +535,21 @@ processChatCommand = \case withStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt withStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds - pure CRCmdOk - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + pure $ CRCmdOk (Just user) + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withStore $ \db -> do ct <- getContact db user chatId liftIO $ updateContactUnreadChat db user ct unreadChat - pure CRCmdOk + pure $ CRCmdOk (Just user) CTGroup -> do withStore $ \db -> do Group {groupInfo} <- getGroup db user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat - pure CRCmdOk - _ -> pure $ chatCmdError "not supported" + pure $ CRCmdOk (Just user) + _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId @@ -540,12 +563,12 @@ processChatCommand = \case withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContact db user ct unsetActive $ ActiveC localDisplayName - pure $ CRContactDeleted ct + pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccConnId, pccAgentConnId} <- withStore $ \db -> getPendingContactConnection db userId chatId deleteAgentConnectionAsync' user pccConnId pccAgentConnId withStore' $ \db -> deletePendingContactConnection db userId chatId - pure $ CRContactConnectionDeleted conn + pure $ CRContactConnectionDeleted user conn CTGroup -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user chatId let canDelete = memberRole (membership :: GroupMember) == GROwner || not (memberCurrent membership) @@ -563,8 +586,8 @@ processChatCommand = \case withStore' $ \db -> deleteGroup db user gInfo let contactIds = mapMaybe memberContactId members forM_ contactIds $ \ctId -> - deleteUnusedContact ctId `catchError` (toView . CRChatError) - pure $ CRGroupDeletedUser gInfo + deleteUnusedContact ctId `catchError` (toView . CRChatError (Just user)) + pure $ CRGroupDeletedUser user gInfo where deleteUnusedContact contactId = do ct <- withStore $ \db -> getContact db user contactId @@ -574,7 +597,7 @@ processChatCommand = \case conns <- withStore $ \db -> getContactConnections db userId ct forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () withStore' $ \db -> deleteContactWithoutGroups db user ct - CTContactRequest -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> getContact db user chatId @@ -589,7 +612,7 @@ processChatCommand = \case withStore' $ \db -> updateContactTs db user ct ts pure (ct :: Contact) {updatedAt = ts} _ -> pure ct - pure $ CRChatCleared (AChatInfo SCTDirect (DirectChat ct')) + pure $ CRChatCleared user (AChatInfo SCTDirect (DirectChat ct')) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo @@ -605,23 +628,23 @@ processChatCommand = \case withStore' $ \db -> updateGroupTs db user gInfo ts pure (gInfo :: GroupInfo) {updatedAt = ts} _ -> pure gInfo - pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo')) - CTContactConnection -> pure $ chatCmdError "not supported" - CTContactRequest -> pure $ chatCmdError "not supported" + pure $ CRChatCleared user (AChatInfo SCTGroup (GroupChat gInfo')) + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock "acceptContact" $ do cReq <- withStore $ \db -> getContactRequest db userId connReqId -- [incognito] generate profile to send, create connection with incognito profile incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequest user cReq incognitoProfile - pure $ CRAcceptingContactRequest ct - APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock "rejectContact" $ do + pure $ CRAcceptingContactRequest user ct + APIRejectContact connReqId -> withUser $ \user@User {userId} -> withChatLock "rejectContact" $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \db -> getContactRequest db userId connReqId `E.finally` liftIO (deleteContactRequest db userId connReqId) withAgent $ \a -> rejectContact a connId invId - pure $ CRContactRequestRejected cReq + pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withStore $ \db -> getContact db user contactId @@ -637,8 +660,8 @@ processChatCommand = \case let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - pure CRCmdOk + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + pure $ CRCmdOk (Just user) SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType @@ -696,7 +719,8 @@ processChatCommand = \case APIGetCallInvitations -> withUser $ \user -> do calls <- asks currentCalls >>= readTVarIO let invs = mapMaybe callInvitation $ M.elems calls - CRCallInvitations <$> mapM (rcvCallInvitation user) invs + rcvCallInvitations <- mapM (rcvCallInvitation user) invs + pure $ CRCallInvitations user rcvCallInvitations where callInvitation Call {contactId, callState, callTs} = case callState of CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey) @@ -715,35 +739,37 @@ processChatCommand = \case ct' <- withStore $ \db -> do ct <- getContact db user contactId liftIO $ updateContactAlias db userId ct localAlias - pure $ CRContactAliasUpdated ct' - APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do + pure $ CRContactAliasUpdated user ct' + APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do conn' <- withStore $ \db -> do conn <- getPendingContactConnection db userId connId liftIO $ updateContactConnectionAlias db userId conn localAlias - pure $ CRConnectionAliasUpdated conn' + pure $ CRConnectionAliasUpdated user conn' APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken - APIRegisterToken token mode -> CRNtfTokenStatus <$> withUser (\_ -> withAgent $ \a -> registerNtfToken a token mode) - APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk - APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk + APIRegisterToken token mode -> withUser $ \_ -> do + tokenStatus <- withAgent $ \a -> registerNtfToken a token mode + pure $ CRNtfTokenStatus tokenStatus + APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk Nothing + APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk Nothing APIGetNtfMessage nonce encNtfInfo -> withUser $ \user -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing - pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages} - GetUserSMPServers -> do + pure CRNtfMessages {user, connEntity, msgTs = msgTs', ntfMessages} + GetUserSMPServers -> withUser $ \user -> do ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config - smpServers <- withUser (\user -> withStore' (`getSMPServers` user)) + smpServers <- withStore' (`getSMPServers` user) let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers - pure $ CRUserSMPServers smpServers' defaultSMPServers + pure $ CRUserSMPServers user smpServers' defaultSMPServers where toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers - pure CRCmdOk + pure $ CRCmdOk (Just user) TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer) APISetChatItemTTL newTTL_ -> withUser' $ \user -> checkStoreNotChanged $ @@ -759,10 +785,14 @@ processChatCommand = \case expireChatItems user newTTL True withStore' $ \db -> setChatItemTTL db user newTTL_ whenM chatStarted $ setExpireCIs True - pure CRCmdOk - APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user)) - APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk - APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig) + pure $ CRCmdOk (Just user) + APIGetChatItemTTL -> withUser $ \user -> do + ttl <- withStore' (`getChatItemTTL` user) + pure $ CRChatItemTTL user ttl + APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk Nothing + APIGetNetworkConfig -> withUser' $ \_ -> do + networkConfig <- withAgent getNetworkConfig + pure $ CRNetworkConfig networkConfig APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> do @@ -770,34 +800,34 @@ processChatCommand = \case liftIO $ updateContactSettings db user chatId chatSettings pure ct withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) - pure CRCmdOk + pure $ CRCmdOk (Just user) CTGroup -> do ms <- withStore $ \db -> do Group _ ms <- getGroup db user chatId liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError) - pure CRCmdOk - _ -> pure $ chatCmdError "not supported" + withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError (Just user)) + pure $ CRCmdOk (Just user) + _ -> pure $ chatCmdError (Just user) "not supported" APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) connectionStats <- withAgent (`getConnectionServers` contactConnId ct) - pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile) + pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) - pure $ CRGroupMemberInfo g m connectionStats + pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct - pure CRCmdOk + pure $ CRCmdOk (Just user) APISwitchGroupMember gId gMemberId -> withUser $ \user -> do m <- withStore $ \db -> getGroupMember db user gId gMemberId case memberConnId m of - Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) $> CRCmdOk + Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) $> CRCmdOk (Just user) _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId @@ -809,7 +839,7 @@ processChatCommand = \case withStore' $ \db -> setConnectionVerified db user connId Nothing pure (ct :: Contact) {activeConn = conn {connectionCode = Nothing}} _ -> pure ct - pure $ CRContactCode ct' code + pure $ CRContactCode user ct' code APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case activeConn of @@ -822,7 +852,7 @@ processChatCommand = \case withStore' $ \db -> setConnectionVerified db user connId Nothing pure (m :: GroupMember) {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}} _ -> pure m - pure $ CRGroupMemberCode g m' code + pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do Contact {activeConn} <- withStore $ \db -> getContact db user contactId @@ -848,14 +878,14 @@ processChatCommand = \case VerifyGroupMember gName mName code -> withMemberName gName mName $ \gId mId -> APIVerifyGroupMember gId mId code ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - AddContact -> withUser $ \User {userId} -> withChatLock "addContact" . procCmd $ do + AddContact -> withUser $ \user@User {userId} -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing (connId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile - toView $ CRNewContactConnection conn - pure $ CRInvitation cReq + toView $ CRNewContactConnection user conn + pure $ CRInvitation user cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \user@User {userId} -> withChatLock "connect" . procCmd $ do -- [incognito] generate profile to send incognito <- readTVarIO =<< asks incognitoMode @@ -863,8 +893,8 @@ processChatCommand = \case let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a True cReq . directMessage $ XInfo profileToSend conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnJoined $ incognitoProfile $> profileToSend - toView $ CRNewContactConnection conn - pure CRSentConfirmation + toView $ CRNewContactConnection user conn + pure $ CRSentConfirmation user Connect (Just (ACR SCMContact cReq)) -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user cReq @@ -874,21 +904,25 @@ processChatCommand = \case connectViaContact user adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect - ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user) - CreateMyAddress -> withUser $ \User {userId} -> withChatLock "createMyAddress" . procCmd $ do + ListContacts -> withUser $ \user -> do + contacts <- withStore' (`getUserContacts` user) + pure $ CRContactsList user contacts + CreateMyAddress -> withUser $ \user@User {userId} -> withChatLock "createMyAddress" . procCmd $ do (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing withStore $ \db -> createUserContactLink db userId connId cReq - pure $ CRUserContactLinkCreated cReq + pure $ CRUserContactLinkCreated user cReq DeleteMyAddress -> withUser $ \user -> withChatLock "deleteMyAddress" $ do conns <- withStore (`getUserAddressConnections` user) procCmd $ do forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () withStore' (`deleteUserAddress` user) - pure CRUserContactLinkDeleted - ShowMyAddress -> withUser $ \User {userId} -> - CRUserContactLink <$> withStore (`getUserAddress` userId) - AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> do - CRUserContactLinkUpdated <$> withStore (\db -> updateUserAddressAutoAccept db userId autoAccept_) + pure $ CRUserContactLinkDeleted user + ShowMyAddress -> withUser $ \user@User {userId} -> do + contactLink <- withStore (`getUserAddress` userId) + pure $ CRUserContactLink user contactLink + AddressAutoAccept autoAccept_ -> withUser $ \user@User {userId} -> do + contactLink <- withStore (\db -> updateUserAddressAutoAccept db userId autoAccept_) + pure $ CRUserContactLinkUpdated user contactLink AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIAcceptContact connReqId @@ -908,8 +942,8 @@ processChatCommand = \case (sndMsg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) ) - `catchError` (toView . CRChatError) - CRBroadcastSent mc (length cts) <$> liftIO getZonedTime + `catchError` (toView . CRChatError (Just user)) + CRBroadcastSent user mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg) @@ -931,7 +965,7 @@ processChatCommand = \case NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) - pure $ CRGroupCreated groupInfo + pure $ CRGroupCreated user groupInfo APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -953,12 +987,14 @@ processChatCommand = \case (agentConnId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq sendInvitation member cReq - pure $ CRSentGroupInvitation gInfo contact member + pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} | memberStatus == GSMemInvited -> do unless (mRole == memRole) $ withStore' $ \db -> updateGroupMemberRole db user member memRole withStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case - Just cReq -> sendInvitation member {memberRole = memRole} cReq $> CRSentGroupInvitation gInfo contact member {memberRole = memRole} + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do @@ -970,7 +1006,7 @@ processChatCommand = \case updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user - pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}} Nothing + pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing where updateCIGroupInvitationStatus user = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId @@ -1003,8 +1039,8 @@ processChatCommand = \case _ -> do msg <- sendGroupMessage gInfo members $ XGrpMemRole mId memRole ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - pure CRMemberRoleUser {groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} APIRemoveMember groupId memberId -> withUser $ \user -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId case find ((== memberId) . groupMemberId') members of @@ -1021,24 +1057,26 @@ processChatCommand = \case _ -> do msg <- sendGroupMessage gInfo members $ XGrpMemDel mId ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) deleteMemberConnection user m -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user m - pure $ CRUserDeletedMember gInfo m {memberStatus = GSMemRemoved} + pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId withChatLock "leaveGroup" . procCmd $ do msg <- sendGroupMessage gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) -- TODO delete direct connections that were unused deleteGroupLink' user gInfo `catchError` \_ -> pure () -- member records are not deleted to keep history forM_ members $ deleteMemberConnection user withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser gInfo {membership = membership {memberStatus = GSMemLeft}} - APIListMembers groupId -> CRGroupMembers <$> withUser (\user -> withStore (\db -> getGroup db user groupId)) + pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} + APIListMembers groupId -> withUser $ \user -> do + group <- withStore $ \db -> getGroup db user groupId + pure $ CRGroupMembers user group AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole @@ -1059,14 +1097,17 @@ processChatCommand = \case ListMembers gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId - ListGroups -> CRGroupsList <$> withUser (\user -> withStore' (`getUserGroupDetails` user)) + ListGroups -> withUser $ \user -> do + groups <- withStore' (`getUserGroupDetails` user) + pure $ CRGroupsList user groups APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db user groupId runUpdateGroupProfile user g p' UpdateGroupNames gName GroupProfile {displayName, fullName} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName} - ShowGroupProfile gName -> withUser $ \user -> - CRGroupProfile <$> withStore (\db -> getGroupInfoByName db user gName) + ShowGroupProfile gName -> withUser $ \user -> do + groupProfile <- withStore $ \db -> getGroupInfoByName db user gName + pure $ CRGroupProfile user groupProfile UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do @@ -1078,14 +1119,15 @@ processChatCommand = \case let crClientData = encodeJSON $ CRDataGroup groupLinkId (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact $ Just crClientData withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId - pure $ CRGroupLinkCreated gInfo cReq + pure $ CRGroupLinkCreated user gInfo cReq APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId deleteGroupLink' user gInfo - pure $ CRGroupLinkDeleted gInfo + pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId - CRGroupLink gInfo <$> withStore (\db -> getGroupLink db user gInfo) + groupLink <- withStore (\db -> getGroupLink db user gInfo) + pure $ CRGroupLink user gInfo groupLink CreateGroupLink gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APICreateGroupLink groupId @@ -1102,21 +1144,27 @@ processChatCommand = \case processChatCommand . APISendMessage (ChatRef CTGroup groupId) False $ ComposedMessage Nothing (Just quotedItemId) mc LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - CRChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search) - LastMessages Nothing count search -> withUser $ \user -> withStore $ \db -> - CRChatItems <$> getAllChatItems db user (CPLast count) search + chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search + pure $ CRChatItems user (aChatItems . chat $ chatResp) + LastMessages Nothing count search -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search + pure $ CRChatItems user chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - CRChatItemId . fmap aChatItemId . listToMaybe . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) - LastChatItemId Nothing index -> withUser $ \user -> withStore $ \db -> - CRChatItemId . fmap aChatItemId . listToMaybe <$> getAllChatItems db user (CPLast $ index + 1) Nothing - ShowChatItem (Just itemId) -> withUser $ \user -> withStore $ \db -> - CRChatItems . (: []) <$> getAChatItem db user itemId - ShowChatItem Nothing -> withUser $ \user -> withStore $ \db -> - CRChatItems <$> getAllChatItems db user (CPLast 1) Nothing + chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) + pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) + LastChatItemId Nothing index -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast $ index + 1) Nothing + pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) + ShowChatItem (Just itemId) -> withUser $ \user -> do + chatItem <- withStore $ \db -> getAChatItem db user itemId + pure $ CRChatItems user ((: []) chatItem) + ShowChatItem Nothing -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing + pure $ CRChatItems user chatItems ShowLiveItems on -> withUser $ \_ -> do asks showLiveItems >>= atomically . (`writeTVar` on) - pure CRCmdOk + pure $ CRCmdOk Nothing SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCFile "") @@ -1132,12 +1180,12 @@ processChatCommand = \case ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \user -> withChatLock "receiveFile" . procCmd $ do ft <- withStore $ \db -> getRcvFileTransfer db user fileId - (CRRcvFileAccepted <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError ft + (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError user ft where - processError ft = \case + processError user ft = \case -- TODO AChatItem in Cancelled events - ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft - ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft + ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled user ft + ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled user ft e -> throwError e CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ @@ -1155,13 +1203,14 @@ processChatCommand = \case void . sendGroupMessage gInfo ms $ XFileCancel sharedMsgId _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" ci <- withStore $ \db -> getChatItemByFileId db user fileId - pure $ CRSndGroupFileCancelled ci ftm fts + pure $ CRSndGroupFileCancelled user ci ftm fts FTRcv ftr@RcvFileTransfer {cancelled} -> do unless cancelled $ cancelRcvFileTransfer user ftr - pure $ CRRcvFileCancelled ftr - FileStatus fileId -> - CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId) - ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile (fromLocalProfile profile) + pure $ CRRcvFileCancelled user ftr + FileStatus fileId -> withUser $ \user -> do + fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId + pure $ CRFileTransferStatus user fileStatus + ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName} updateProfile user p @@ -1203,7 +1252,7 @@ processChatCommand = \case where stat (AgentStatsKey {host, clientTs, cmd, res}, count) = map B.unpack [host, clientTs, cmd, res, bshow count] - ResetAgentStats -> CRCmdOk <$ withAgent resetAgentStats + ResetAgentStats -> withAgent resetAgentStats $> CRCmdOk Nothing where withChatLock name action = asks chatLock >>= \l -> withLock l name action -- below code would make command responses asynchronous where they can be slow @@ -1230,9 +1279,11 @@ processChatCommand = \case setStoreChanged :: m () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) withStoreChanged :: m () -> m ChatResponse - withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk + withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk Nothing checkStoreNotChanged :: m ChatResponse -> m ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) + withUserName :: UserName -> (UserId -> ChatCommand) -> m ChatResponse + withUserName uName cmd = withStore (`getUserIdByName` uName) >>= processChatCommand . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> m ChatResponse withContactName cName cmd = withUser $ \user -> withStore (\db -> getContactIdByName db user cName) >>= processChatCommand . cmd @@ -1246,11 +1297,11 @@ processChatCommand = \case code' <- getConnectionCode $ aConnId conn let verified = sameVerificationCode code code' when verified . withStore' $ \db -> setConnectionVerified db user connId $ Just code' - pure $ CRConnectionVerified verified code' + pure $ CRConnectionVerified user verified code' verifyConnectionCode user conn@Connection {connId} _ = do code' <- getConnectionCode $ aConnId conn withStore' $ \db -> setConnectionVerified db user connId Nothing - pure $ CRConnectionVerified False code' + pure $ CRConnectionVerified user False code' getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg) @@ -1260,7 +1311,7 @@ processChatCommand = \case connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case - (Just contact, _) -> pure $ CRContactAlreadyExists contact + (Just contact, _) -> pure $ CRContactAlreadyExists user contact (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ @@ -1275,8 +1326,8 @@ processChatCommand = \case connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId) let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId - toView $ CRNewContactConnection conn - pure $ CRSentInvitation incognitoProfile + toView $ CRNewContactConnection user conn + pure $ CRSentInvitation user incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -1295,7 +1346,7 @@ processChatCommand = \case | otherwise = Just IFMOffer updateProfile :: User -> Profile -> m ChatResponse updateProfile user@User {profile = p} p' - | p' == fromLocalProfile p = pure CRUserProfileNoChange + | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | otherwise = do -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections @@ -1310,12 +1361,12 @@ processChatCommand = \case ct' = updateMergedPreferences user' ct mergedProfile' = userProfileToSend user' Nothing $ Just ct' when (mergedProfile' /= mergedProfile) $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError) + void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user' ct ct' - pure $ CRUserProfileUpdated (fromLocalProfile p) p' + pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' - | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct + | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated user ct ct | otherwise = do assertDirectAllowed user MDSnd ct XInfo_ ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs' @@ -1324,9 +1375,9 @@ processChatCommand = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') when (mergedProfile' /= mergedProfile) $ withChatLock "updateProfile" $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError) + void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user ct ct' - pure $ CRContactPrefsUpdated ct ct' + pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do let s = memberStatus $ membership g @@ -1339,9 +1390,9 @@ processChatCommand = \case let cd = CDGroupSnd g' unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat g') ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci) createGroupFeatureChangedItems user cd CISndGroupFeature g g' - pure $ CRGroupUpdated g g' Nothing + pure $ CRGroupUpdated user g g' Nothing updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> m ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -1368,7 +1419,7 @@ processChatCommand = \case _ -> do withStore' $ \db -> deleteCalls db user ctId atomically $ TM.delete ctId calls - pure CRCmdOk + pure $ CRCmdOk (Just user) | otherwise -> throwChatError $ CECallContact contactId forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do @@ -1391,7 +1442,7 @@ processChatCommand = \case (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) setActive $ ActiveG localDisplayName sendTextMessage chatName msg live = withUser $ \user -> do chatRef <- getChatRef user chatName @@ -1430,7 +1481,7 @@ setExpireCIs b = do deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m () deleteFile user CIFileInfo {filePath, fileId, fileStatus} = - (cancel' >> delete) `catchError` (toView . CRChatError) + (cancel' >> delete) `catchError` (toView . CRChatError (Just user)) where cancel' = forM_ fileStatus $ \(AFS dir status) -> unless (ciFileEnded status) $ @@ -1458,7 +1509,7 @@ updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do updateDirectChatItemView :: ChatMonad m => User -> Contact -> ChatItemId -> ACIContent -> Bool -> Maybe MessageId -> m () updateDirectChatItemView user ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) live msgId_ = do ci' <- withStore $ \db -> updateDirectChatItem db user contactId chatItemId ciContent live msgId_ - toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: ChatMonad m => User -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do @@ -1620,10 +1671,9 @@ agentSubscriber = do l <- asks chatLock forever $ do (corrId, connId, msg) <- atomically $ readTBQueue q - u <- readTVarIO =<< asks currentUser let name = "agentSubscriber connId=" <> str connId <> " corrId=" <> str corrId <> " msg=" <> str (aCommandTag msg) withLock l name . void . runExceptT $ - processAgentMessage u corrId connId msg `catchError` (toView . CRChatError) + processAgentMessage corrId connId msg `catchError` (toView . CRChatError Nothing) where str :: StrEncoding a => a -> String str = B.unpack . strEncode @@ -1744,7 +1794,7 @@ cleanupManagerInterval = 1800 -- 30 minutes cleanupManager :: forall m. ChatMonad m => User -> m () cleanupManager user = do forever $ do - flip catchError (toView . CRChatError) $ do + flip catchError (toView . CRChatError (Just user)) $ do waitChatStarted cleanupTimedItems threadDelay $ cleanupManagerInterval * 1000000 @@ -1788,7 +1838,7 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do CTGroup -> do (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId deleteGroupCI user gInfo ci True True >>= toView - _ -> toView . CRChatError . ChatError $ CEInternalError "bad deleteTimedItem cType" + _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: ChatMonad m => User -> ChatRef -> ChatItem c d -> ChatItem c d -> m () startUpdatedTimedItemThread user chatRef ci ci' = @@ -1812,7 +1862,7 @@ expireChatItems user ttl sync = do loop :: TVar Bool -> [a] -> (a -> m ()) -> m () loop _ [] _ = pure () loop expire (a : as) process = continue expire $ do - process a `catchError` (toView . CRChatError) + process a `catchError` (toView . CRChatError (Just user)) loop expire as process continue :: TVar Bool -> m () -> m () continue expire = if sync then id else \a -> whenM (readTVarIO expire) $ threadDelay 100000 >> a @@ -1845,9 +1895,18 @@ expireChatItems user ttl sync = do (Just ts, Just count) -> when (count == 0) $ updateGroupTs db user gInfo ts _ -> pure () -processAgentMessage :: forall m. ChatMonad m => Maybe User -> ACorrId -> ConnId -> ACommand 'Agent -> m () -processAgentMessage Nothing _ _ _ = throwChatError CENoActiveUser -processAgentMessage (Just User {userId}) _ "" agentMessage = case agentMessage of +processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent -> m () +processAgentMessage _ "" msg = + asks currentUser >>= readTVarIO >>= \case + Just user -> processAgentMessageNoConn user msg `catchError` (toView . CRChatError (Just user)) + _ -> throwChatError CENoActiveUser +processAgentMessage corrId connId msg = + withStore' (`getUserByAConnId` AgentConnId connId) >>= \case + Just user -> processAgentMessageConn user corrId connId msg `catchError` (toView . CRChatError (Just user)) + _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) + +processAgentMessageNoConn :: forall m. ChatMonad m => User -> ACommand 'Agent -> m () +processAgentMessageNoConn user@User {userId} = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" @@ -1858,16 +1917,18 @@ processAgentMessage (Just User {userId}) _ "" agentMessage = case agentMessage o hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent srv@(SMPServer host _ _) conns event str = do cs <- withStore' $ \db -> getConnectionsContacts db userId conns - toView $ event srv cs + toView $ event user srv cs showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) -processAgentMessage (Just user) _ agentConnId END = + +processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent -> m () +processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do - toView $ CRContactAnotherClient ct + toView $ CRContactAnotherClient user ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c - entity -> toView $ CRSubscriptionEnd entity -processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = + entity -> toView $ CRSubscriptionEnd user entity +processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = (withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus) >>= \case RcvDirectMsgConnection conn contact_ -> processDirectMessage agentMessage conn contact_ @@ -1922,9 +1983,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId - MERR _ err -> toView . CRChatError $ ChatErrorAgent err -- ? updateDirectChatItemStatus + MERR _ err -> toView $ CRChatError (Just user) (ChatErrorAgent err) -- ? updateDirectChatItemStatus ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1993,7 +2054,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - toView $ CRContactConnected ct (fmap fromLocalProfile incognitoProfile) + toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct setActive $ ActiveC c showToast (c <> "> ") "connected" @@ -2004,7 +2065,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = forM_ mc_ $ \mc -> do (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) forM_ groupId_ $ \groupId -> do gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation @@ -2021,10 +2082,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case Just (CChatItem SMDSnd ci) -> do chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) CISSndSent - toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () SWITCH qd phase cStats -> do - toView . CRContactSwitch ct $ SwitchProgress qd phase cStats + toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) when (phase /= SPConfirmed) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing @@ -2036,9 +2097,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId forM_ chatItemId_ $ \chatItemId -> do chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId chatItemId (agentErrToItemStatus err) - toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2065,7 +2126,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation gInfo ct m + toView $ CRSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -2118,7 +2179,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m @@ -2127,7 +2188,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} + toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m @@ -2175,7 +2236,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId SWITCH qd phase cStats -> do - toView . CRGroupMemberSwitch gInfo m $ SwitchProgress qd phase cStats + toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) when (phase /= SPConfirmed) $ 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 @@ -2183,9 +2244,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId - MERR _ err -> toView . CRChatError $ ChatErrorAgent err + MERR _ err -> toView $ CRChatError (Just user) (ChatErrorAgent err) ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2210,7 +2271,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db user fileId CIFSSndTransfer - toView $ CRSndFileStart ci ft + toView $ CRSndFileStart user ci ft sendFileChunk user ft SENT msgId -> do withStore' $ \db -> updateSndFileChunkSent db ft msgId @@ -2220,7 +2281,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> getChatItemByFileId db user fileId - toView $ CRSndFileRcvCancelled ci ft + toView $ CRSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> do cmdId <- createAckCmd conn @@ -2229,7 +2290,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \_cmdData -> pure () ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2274,9 +2335,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \_cmdData -> pure () - MERR _ err -> toView . CRChatError $ ChatErrorAgent err + MERR _ err -> toView $ CRChatError (Just user) (ChatErrorAgent err) ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2287,14 +2348,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = liftIO $ updateRcvFileStatus db ft FSConnected liftIO $ updateCIFileStatus db user fileId CIFSRcvTransfer getChatItemByFileId db user fileId - toView $ CRRcvFileStart ci + toView $ CRRcvFileStart user ci receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> m () receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize, cancelled} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless cancelled $ do cancelRcvFileTransfer user ft - toView (CRRcvFileSndCancelled ft) + toView $ CRRcvFileSndCancelled user ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -2317,7 +2378,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft getChatItemByFileId db user fileId - toView $ CRRcvFileComplete ci + toView $ CRRcvFileComplete user ci closeFileHandle fileId rcvFiles mapM_ (deleteAgentConnectionAsync user) conn_ RcvChunkDuplicate -> pure () @@ -2332,9 +2393,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = XInfo p -> profileContactRequest invId p Nothing -- TODO show/log error, other events in contact request _ -> pure () - MERR _ err -> toView . CRChatError $ ChatErrorAgent err + MERR _ err -> toView $ CRChatError (Just user) (ChatErrorAgent err) ERR err -> do - toView . CRChatError $ ChatErrorAgent err + toView $ CRChatError (Just user) (ChatErrorAgent err) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2342,7 +2403,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () profileContactRequest invId p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case - CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact + CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq@UserContactRequest {localDisplayName} -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept}, groupId_) -> @@ -2352,14 +2413,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequestAsync user cReq incognitoProfile - toView $ CRAcceptingContactRequest ct + toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo@GroupInfo {membership = membership@GroupMember {memberProfile}} <- withStore $ \db -> getGroupInfo db user groupId let profileMode = if memberIncognito membership then Just $ ExistingIncognito memberProfile else Nothing ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest gInfo ct + toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> do - toView $ CRReceivedContactRequest cReq + toView $ CRReceivedContactRequest user cReq showToast (localDisplayName <> "> ") "wants to connect to you" _ -> pure () @@ -2426,7 +2487,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do memberConnectedChatItem gInfo m - toView $ CRConnectedToGroupMember gInfo m + toView $ CRConnectedToGroupMember user gInfo m let g = groupName' gInfo setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" @@ -2449,10 +2510,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> createSentProbeHash db userId probeId c messageWarning :: Text -> m () - messageWarning = toView . CRMessageError "warning" + messageWarning = toView . CRMessageError user "warning" messageError :: Text -> m () - messageError = toView . CRMessageError "error" + messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg@RcvMessage {sharedMsgId_} msgMeta = do @@ -2475,7 +2536,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) pure ci processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> IO RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) @@ -2503,7 +2564,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let timed_ = rcvContactCITimed ct ttl ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci content live Nothing - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') setActive $ ActiveC c _ -> throwError e where @@ -2514,7 +2575,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = case msgDir of SMDRcv -> do ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci content live $ Just msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" @@ -2523,7 +2584,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = checkIntegrityCreateItem (CDDirectRcv ct) msgMeta deleteRcvChatItem `catchError` \e -> case e of - (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound ct sMsgId + (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound user ct sMsgId _ -> throwError e where deleteRcvChatItem = do @@ -2567,7 +2628,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let timed_ = rcvGroupCITimed gInfo ttl_ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci content live Nothing - toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') setActive $ ActiveG g _ -> throwError e where @@ -2580,7 +2641,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = if sameMemberId memberId m' then do ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci content live $ Just msgId - toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') setActive $ ActiveG g startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else messageError "x.msg.update: group member attempted to update a message of another member" -- shouldn't happen now that query includes group member id @@ -2608,7 +2669,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = RcvFileTransfer {fileId} <- withStore' $ \db -> createRcvFileTransfer db userId ct fInv inline chSize let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c @@ -2641,7 +2702,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft - toView $ CRRcvFileSndCancelled ft + toView $ CRRcvFileSndCancelled user ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta = do @@ -2660,7 +2721,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = event <- withStore $ \db -> do ci <- updateDirectCIFileStatus db user fileId CIFSSndTransfer sft <- liftIO $ createSndDirectInlineFT db ct ft - pure $ CRSndFileStart ci sft + pure $ CRSndFileStart user ci sft toView event ifM (allowSendInline fileSize fileInline) @@ -2676,7 +2737,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db user fileId CIFSSndComplete - toView $ CRSndFileComplete ci ft + toView $ CRSndFileComplete user ci ft allowSendInline :: Integer -> Maybe InlineFileMode -> m Bool allowSendInline fileSize = \case @@ -2717,7 +2778,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do cancelRcvFileTransfer user ft - toView $ CRRcvFileSndCancelled ft + toView $ CRRcvFileSndCancelled user ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -2739,7 +2800,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = event <- withStore $ \db -> do ci <- updateDirectCIFileStatus db user fileId CIFSSndTransfer sft <- liftIO $ createSndGroupInlineFT db m conn ft - pure $ CRSndFileStart ci sft + pure $ CRSndFileStart user ci sft toView event ifM (allowSendInline fileSize fileInline) @@ -2751,7 +2812,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo m ci msgMeta = do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} msg msgMeta = do @@ -2767,13 +2828,13 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = createMemberConnectionAsync db user hostId connIds updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CRUserAcceptedGroupSent gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - toView $ CRReceivedGroupInvitation gInfo ct memRole + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + toView $ CRReceivedGroupInvitation user gInfo ct memRole showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool @@ -2785,7 +2846,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = MsgOk -> pure () MsgError e -> case e of MsgSkipped {} -> createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) - _ -> toView $ CRMsgIntegrityError e + _ -> toView $ CRMsgIntegrityError user e xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do @@ -2796,7 +2857,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' updateContactProfile db user c' p' when (directOrUsed c') $ createRcvFeatureItems user c c' - toView $ CRContactUpdated c c' + toView $ CRContactUpdated user c c' where Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2871,8 +2932,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView . CRCallInvitation $ RcvCallInvitation {contact = ct, callType, sharedKey, callTs = chatItemTs' ci} - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRCallInvitation user (RcvCallInvitation {contact = ct, callType, sharedKey, callTs = chatItemTs' ci}) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) @@ -2885,7 +2946,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} askConfirmation = encryptedCall localCallType && not (encryptedCall callType) - toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} + toView CRCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) _ -> do msgCallStateError "x.call.offer" call @@ -2898,7 +2959,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = \call -> case callState call of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} - toView $ CRCallAnswer ct rtcSession + toView $ CRCallAnswer user ct rtcSession pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) _ -> do msgCallStateError "x.call.answer" call @@ -2912,12 +2973,12 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} - toView $ CRCallExtraInfo ct rtcExtraInfo + toView $ CRCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} - toView $ CRCallExtraInfo ct rtcExtraInfo + toView $ CRCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) _ -> do msgCallStateError "x.call.extra" call @@ -2927,7 +2988,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () xCallEnd ct callId msg msgMeta = msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do - toView $ CRCallEnded ct + toView $ CRCallEnded user ct (Nothing,) <$> callStatusItemContent user ct chatItemId WCSDisconnected msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () @@ -2957,7 +3018,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore' $ \db -> mergeContactRecords db userId to from - toView $ CRContactsMerged to from + toView $ CRContactsMerged user to from saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do @@ -2965,7 +3026,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = case chatMsgEvent of XInfo p -> do ct <- withStore $ \db -> createDirectContact db user activeConn p - toView $ CRContactConnecting ct + toView $ CRContactConnecting user ct -- TODO show/log error, other events in SMP confirmation _ -> pure () @@ -2980,7 +3041,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) groupMsgToView gInfo m ci msgMeta - toView $ CRJoinedGroupMemberConnecting gInfo m newMember + toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ _) = do @@ -3056,7 +3117,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberRole db user member memRole ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) groupMsgToView gInfo m ci msgMeta - toView CRMemberRole {groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} + toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> m () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -3072,7 +3133,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = forM_ members $ deleteMemberConnection user withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted - toView $ CRDeletedMemberUser gInfo {membership = membership {memberStatus = GSMemRemoved}} m + toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" Just member@GroupMember {groupMemberId, memberProfile} -> @@ -3081,7 +3142,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user member deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved} + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = @@ -3101,7 +3162,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo m ci msgMeta - toView $ CRLeftMember gInfo m {memberStatus = GSMemLeft} + toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg msgMeta = do @@ -3114,14 +3175,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = forM_ ms $ deleteMemberConnection user ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo m ci msgMeta - toView $ CRGroupDeleted gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m + toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> MsgMeta -> m () xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg msgMeta | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" | otherwise = unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - toView . CRGroupUpdated g g' $ Just m + toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') @@ -3166,7 +3227,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, connId, agentConnId} liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db user fileId CIFSSndComplete - toView $ CRSndFileComplete ci ft + toView $ CRSndFileComplete user ci ft closeFileHandle fileId sndFiles deleteAgentConnectionAsync' user connId agentConnId @@ -3382,13 +3443,13 @@ deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do deleteCIFile user file withStore' $ \db -> deleteDirectChatItem db user ct ci - pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed deleteGroupCI :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> Bool -> Bool -> m ChatResponse deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do deleteCIFile user file withStore' $ \db -> deleteGroupChatItem db user gInfo ci - pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) Nothing byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file = @@ -3399,12 +3460,12 @@ deleteCIFile user file = markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> m ChatResponse markDirectCIDeleted user ct ci@(CChatItem msgDir deletedItem) msgId byUser = do toCi <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId - pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) (Just toCi) byUser False + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) (Just toCi) byUser False markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> m ChatResponse markGroupCIDeleted user gInfo ci@(CChatItem msgDir deletedItem) msgId byUser = do toCi <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId - pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) (Just toCi) byUser False + pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) (Just toCi) byUser False createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode = do @@ -3507,7 +3568,7 @@ createInternalChatItem user cd content itemTs_ = do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt createNewChatItemNoMsg db user cd content itemTs createdAt ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt - toView $ CRNewChatItem $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci + toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci) getCreateActiveUser :: SQLiteStore -> IO User getCreateActiveUser st = do @@ -3538,7 +3599,7 @@ getCreateActiveUser st = do Right user -> pure user selectUser :: [User] -> IO User selectUser [user] = do - withTransaction st (`setActiveUser` userId user) + withTransaction st (`setActiveUser` userId (user :: User)) pure user selectUser users = do putStrLn "Select user profile:" @@ -3553,7 +3614,7 @@ getCreateActiveUser st = do | n <= 0 || n > length users -> putStrLn "invalid user number" >> loop | otherwise -> do let user = users !! (n - 1) - withTransaction st (`setActiveUser` userId user) + withTransaction st (`setActiveUser` userId (user :: User)) pure user userStr :: User -> String userStr User {localDisplayName, profile = LocalProfile {fullName}} = @@ -3582,13 +3643,13 @@ notificationSubscriber = do ChatController {notifyQ, sendNotification} <- ask forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification -withUser' :: ChatMonad m => (User -> m a) -> m a +withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser >>= readTVarIO - >>= maybe (throwChatError CENoActiveUser) action + >>= maybe (throwChatError CENoActiveUser) (\u -> action u `catchError` (pure . CRChatError (Just u))) -withUser :: ChatMonad m => (User -> m a) -> m a +withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) @@ -3626,7 +3687,12 @@ chatCommandP = choice [ "/mute " *> ((`ShowMessages` False) <$> chatNameP'), "/unmute " *> ((`ShowMessages` True) <$> chatNameP'), - ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile), + "/create user " *> (CreateActiveUser <$> userProfile), + "/users" $> ListUsers, + "/_user " *> (APISetActiveUser <$> A.decimal), + ("/user " <|> "/u ") *> (SetActiveUser <$> displayName), + "/_delete user " *> (APIDeleteUser <$> A.decimal), + "/delete user " *> (DeleteUser <$> displayName), ("/user" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), "/_start" $> StartChat True True, diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 9368b36450..e930bfb7c4 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -24,10 +24,10 @@ chatBotRepl welcome answer _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact _ -> do + CRContactConnected _ contact _ -> do contactConnected contact void $ sendMsg contact welcome - CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do + CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do let msg = T.unpack $ ciContentToText content void . sendMsg contact $ answer msg _ -> pure () @@ -38,11 +38,11 @@ chatBotRepl welcome answer _user cc = do initializeBotAddress :: ChatController -> IO () initializeBotAddress cc = do sendChatCmd cc "/show_address" >>= \case - CRUserContactLink UserContactLink {connReqContact} -> showBotAddress connReqContact - CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do + CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact + CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do putStrLn "No bot address, creating..." sendChatCmd cc "/address" >>= \case - CRUserContactLinkCreated uri -> showBotAddress uri + CRUserContactLinkCreated _ uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bae3ca83e0..3ad58bcc30 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -140,6 +140,11 @@ instance ToJSON HelpSection where data ChatCommand = ShowActiveUser | CreateActiveUser Profile + | ListUsers + | APISetActiveUser UserId + | SetActiveUser UserName + | APIDeleteUser UserId + | DeleteUser UserName | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool} | APIStopChat | APIActivateChat @@ -153,7 +158,7 @@ data ChatCommand | APIStorageEncryption DBEncryptionConfig | ExecChatStoreSQL Text | ExecAgentStoreSQL Text - | APIGetChats {pendingConnections :: Bool} + | APIGetChats {pendingConnections :: Bool} -- UserId | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems Int | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, composedMessage :: ComposedMessage} @@ -172,9 +177,9 @@ data ChatCommand | APISendCallAnswer ContactId WebRTCSession | APISendCallExtraInfo ContactId WebRTCExtraInfo | APIEndCall ContactId - | APIGetCallInvitations + | APIGetCallInvitations -- UserId | APICallStatus ContactId WebRTCCallStatus - | APIUpdateProfile Profile + | APIUpdateProfile Profile -- UserId | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias | APISetConnectionAlias Int64 LocalAlias @@ -183,7 +188,7 @@ data ChatCommand | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString | APIDeleteToken DeviceToken - | APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString} + | APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString} -- UserId | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup GroupId | APIMemberRole GroupId GroupMemberId GroupMemberRole @@ -194,11 +199,11 @@ data ChatCommand | APICreateGroupLink GroupId | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId - | GetUserSMPServers - | SetUserSMPServers SMPServersConfig + | GetUserSMPServers -- UserId + | SetUserSMPServers SMPServersConfig -- UserId | TestSMPServer SMPServerWithAuth - | APISetChatItemTTL (Maybe Int64) - | APIGetChatItemTTL + | APISetChatItemTTL (Maybe Int64) -- UserId + | APIGetChatItemTTL -- UserId | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | APISetChatSettings ChatRef ChatSettings @@ -221,26 +226,26 @@ data ChatCommand | VerifyGroupMember GroupName ContactName (Maybe Text) | ChatHelp HelpSection | Welcome - | AddContact - | Connect (Maybe AConnectionRequestUri) - | ConnectSimplex + | AddContact -- UserId + | Connect (Maybe AConnectionRequestUri) -- UserId + | ConnectSimplex -- UserId | DeleteContact ContactName | ClearContact ContactName - | ListContacts - | CreateMyAddress - | DeleteMyAddress - | ShowMyAddress - | AddressAutoAccept (Maybe AutoAccept) + | ListContacts -- UserId + | CreateMyAddress -- UserId + | DeleteMyAddress -- UserId + | ShowMyAddress -- UserId + | AddressAutoAccept (Maybe AutoAccept) -- UserId | AcceptContact ContactName | RejectContact ContactName | SendMessage ChatName ByteString | SendLiveMessage ChatName ByteString | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} - | SendMessageBroadcast ByteString + | SendMessageBroadcast ByteString -- UserId | DeleteMessage ChatName ByteString | EditMessage {chatName :: ChatName, editedMsg :: ByteString, message :: ByteString} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: ByteString} - | NewGroup GroupProfile + | NewGroup GroupProfile -- UserId | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName | MemberRole GroupName ContactName GroupMemberRole @@ -249,7 +254,7 @@ data ChatCommand | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName - | ListGroups + | ListGroups -- UserId | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -257,9 +262,9 @@ data ChatCommand | DeleteGroupLink GroupName | ShowGroupLink GroupName | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} - | LastMessages (Maybe ChatName) Int (Maybe String) - | LastChatItemId (Maybe ChatName) Int - | ShowChatItem (Maybe ChatItemId) + | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId + | LastChatItemId (Maybe ChatName) Int -- UserId + | ShowChatItem (Maybe ChatItemId) -- UserId | ShowLiveItems Bool | SendFile ChatName FilePath | SendImage ChatName FilePath @@ -268,13 +273,13 @@ data ChatCommand | ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath} | CancelFile FileTransferId | FileStatus FileTransferId - | ShowProfile - | UpdateProfile ContactName Text - | UpdateProfileImage (Maybe ImageData) - | SetUserFeature AChatFeature FeatureAllowed + | ShowProfile -- UserId + | UpdateProfile ContactName Text -- UserId + | UpdateProfileImage (Maybe ImageData) -- UserId + | SetUserFeature AChatFeature FeatureAllowed -- UserId | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) | SetGroupFeature AGroupFeature GroupName GroupFeatureEnabled - | SetUserTimedMessages Bool + | SetUserTimedMessages Bool -- UserId | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | QuitChat @@ -286,137 +291,138 @@ data ChatCommand data ChatResponse = CRActiveUser {user :: User} + | CRUsersList {users :: [User]} | CRChatStarted | CRChatRunning | CRChatStopped | CRChatSuspended - | CRApiChats {chats :: [AChat]} - | CRApiChat {chat :: AChat} - | CRChatItems {chatItems :: [AChatItem]} - | CRChatItemId (Maybe ChatItemId) + | CRApiChats {user :: User, chats :: [AChat]} + | CRApiChat {user :: User, chat :: AChat} + | CRChatItems {user :: User, chatItems :: [AChatItem]} + | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} + | CRUserSMPServers {user :: User, smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} | CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure} - | CRChatItemTTL {chatItemTTL :: Maybe Int64} + | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} - | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} - | CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} - | CRContactSwitch {contact :: Contact, switchProgress :: SwitchProgress} - | CRGroupMemberSwitch {groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} - | CRContactCode {contact :: Contact, connectionCode :: Text} - | CRGroupMemberCode {groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} - | CRConnectionVerified {verified :: Bool, expectedCode :: Text} - | CRNewChatItem {chatItem :: AChatItem} - | CRChatItemStatusUpdated {chatItem :: AChatItem} - | CRChatItemUpdated {chatItem :: AChatItem} - | CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} - | CRChatItemDeletedNotFound {contact :: Contact, sharedMsgId :: SharedMsgId} - | CRBroadcastSent MsgContent Int ZonedTime - | CRMsgIntegrityError {msgError :: MsgErrorType} + | CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} + | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} + | CRContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} + | CRGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} + | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} + | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} + | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} + | CRNewChatItem {user :: User, chatItem :: AChatItem} + | CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem} + | CRChatItemUpdated {user :: User, chatItem :: AChatItem} + | CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} + | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} + | CRBroadcastSent User MsgContent Int ZonedTime + | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} | CRCmdAccepted {corr :: CorrId} - | CRCmdOk + | CRCmdOk {user_ :: Maybe User} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} - | CRGroupCreated {groupInfo :: GroupInfo} - | CRGroupMembers {group :: Group} - | CRContactsList {contacts :: [Contact]} - | CRUserContactLink {contactLink :: UserContactLink} - | CRUserContactLinkUpdated {contactLink :: UserContactLink} - | CRContactRequestRejected {contactRequest :: UserContactRequest} - | CRUserAcceptedGroupSent {groupInfo :: GroupInfo, hostContact :: Maybe Contact} - | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupsList {groups :: [GroupInfo]} - | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} - | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus - | CRUserProfile {profile :: Profile} - | CRUserProfileNoChange + | CRGroupCreated {user :: User, groupInfo :: GroupInfo} + | CRGroupMembers {user :: User, group :: Group} + | CRContactsList {user :: User, contacts :: [Contact]} + | CRUserContactLink {user :: User, contactLink :: UserContactLink} + | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} + | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} + | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} + | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupsList {user :: User, groups :: [GroupInfo]} + | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} + | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus + | CRUserProfile {user :: User, profile :: Profile} + | CRUserProfileNoChange {user :: User} | CRVersionInfo {version :: String} - | CRInvitation {connReqInvitation :: ConnReqInvitation} - | CRSentConfirmation - | CRSentInvitation {customUserProfile :: Maybe Profile} - | CRContactUpdated {fromContact :: Contact, toContact :: Contact} - | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} - | CRContactDeleted {contact :: Contact} - | CRChatCleared {chatInfo :: AChatInfo} - | CRUserContactLinkCreated {connReqContact :: ConnReqContact} - | CRUserContactLinkDeleted - | CRReceivedContactRequest {contactRequest :: UserContactRequest} - | CRAcceptingContactRequest {contact :: Contact} - | CRContactAlreadyExists {contact :: Contact} - | CRContactRequestAlreadyAccepted {contact :: Contact} - | CRLeftMemberUser {groupInfo :: GroupInfo} - | CRGroupDeletedUser {groupInfo :: GroupInfo} - | CRRcvFileAccepted {chatItem :: AChatItem} - | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileStart {chatItem :: AChatItem} - | CRRcvFileComplete {chatItem :: AChatItem} - | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} + | CRSentConfirmation {user :: User} + | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} + | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} + | CRContactDeleted {user :: User, contact :: Contact} + | CRChatCleared {user :: User, chatInfo :: AChatInfo} + | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} + | CRUserContactLinkDeleted {user :: User} + | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} + | CRAcceptingContactRequest {user :: User, contact :: Contact} + | CRContactAlreadyExists {user :: User, contact :: Contact} + | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} + | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} + | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} + | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} + | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileStart {user :: User, chatItem :: AChatItem} + | CRRcvFileComplete {user :: User, chatItem :: AChatItem} + | CRRcvFileCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} - | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} - | CRContactAliasUpdated {toContact :: Contact} - | CRConnectionAliasUpdated {toConnection :: PendingContactConnection} - | CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact} - | CRContactConnecting {contact :: Contact} - | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile} - | CRContactAnotherClient {contact :: Contact} - | CRSubscriptionEnd {connectionEntity :: ConnectionEntity} - | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} + | CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndGroupFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile} + | CRContactAliasUpdated {user :: User, toContact :: Contact} + | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} + | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRContactConnecting {user :: User, contact :: Contact} + | CRContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} + | CRContactAnotherClient {user :: User, contact :: Contact} + | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} + | CRContactsDisconnected {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} + | CRContactsSubscribed {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} | CRContactSubError {contact :: Contact, chatError :: ChatError} | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {userContactSubscriptions :: [UserContactSubStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {groupInfo :: GroupInfo} - | CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} - | CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} - | CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} - | CRMemberRole {groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberRoleUser {groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser {groupInfo :: GroupInfo, member :: GroupMember} - | CRLeftMember {groupInfo :: GroupInfo, member :: GroupMember} + | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} + | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} + | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupEmpty {groupInfo :: GroupInfo} | CRGroupRemoved {groupInfo :: GroupInfo} - | CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupUpdated {fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} - | CRGroupProfile {groupInfo :: GroupInfo} - | CRGroupLinkCreated {groupInfo :: GroupInfo, connReqContact :: ConnReqContact} - | CRGroupLink {groupInfo :: GroupInfo, connReqContact :: ConnReqContact} - | CRGroupLinkDeleted {groupInfo :: GroupInfo} - | CRAcceptingGroupJoinRequest {groupInfo :: GroupInfo, contact :: Contact} + | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} + | CRGroupProfile {user :: User, groupInfo :: GroupInfo} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} + | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} + | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} | CRMemberSubError {groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} | CRMemberSubSummary {memberSubscriptions :: [MemberSubStatus]} | CRGroupSubscribed {groupInfo :: GroupInfo} | CRPendingSubSummary {pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {callInvitation :: RcvCallInvitation} - | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} - | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} - | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} - | CRCallEnded {contact :: Contact} - | CRCallInvitations {callInvitations :: [RcvCallInvitation]} + | CRCallInvitation {user :: User, callInvitation :: RcvCallInvitation} + | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} + | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} + | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CRCallEnded {user :: User, contact :: Contact} + | CRCallInvitations {user :: User, callInvitations :: [RcvCallInvitation]} | CRUserContactLinkSubscribed | CRUserContactLinkSubError {chatError :: ChatError} | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} - | CRNewContactConnection {connection :: PendingContactConnection} - | CRContactConnectionDeleted {connection :: PendingContactConnection} + | CRNtfMessages {user :: User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNewContactConnection {user :: User, connection :: PendingContactConnection} + | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRSQLResult {rows :: [Text]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} - | CRMessageError {severity :: Text, errorMessage :: Text} - | CRChatCmdError {chatError :: ChatError} - | CRChatError {chatError :: ChatError} + | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} + | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} + | CRChatError {user_ :: Maybe User, chatError :: ChatError} deriving (Show, Generic) instance ToJSON ChatResponse where @@ -551,7 +557,8 @@ instance ToJSON ChatError where data ChatErrorType = CENoActiveUser - | CEActiveUserExists + | CENoConnectionUser {agentConnId :: AgentConnId} + | CEActiveUserExists -- TODO delete | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged @@ -627,8 +634,8 @@ throwDBError = throwError . ChatErrorDatabase type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) -chatCmdError :: String -> ChatResponse -chatCmdError = CRChatCmdError . ChatError . CECommandError +chatCmdError :: Maybe User -> String -> ChatResponse +chatCmdError user = CRChatCmdError user . ChatError . CECommandError setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () setActive to = asks activeTo >>= atomically . (`writeTVar` to) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 796ba00530..e007222363 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -28,6 +28,9 @@ module Simplex.Chat.Store createUser, getUsers, setActiveUser, + getSetActiveUser, + getUserIdByName, + getUserByAConnId, createDirectConnection, createConnReqConnection, getProfileById, @@ -440,15 +443,16 @@ createUser db Profile {displayName, fullName, image, preferences = userPreferenc getUsers :: DB.Connection -> IO [User] getUsers db = - map toUser - <$> DB.query_ - db - [sql| - SELECT u.user_id, u.contact_id, p.contact_profile_id, u.active_user, u.local_display_name, p.full_name, p.image, p.preferences - FROM users u - JOIN contacts c ON u.contact_id = c.contact_id - JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - |] + map toUser <$> DB.query_ db userQuery + +userQuery :: Query +userQuery = + [sql| + SELECT u.user_id, u.contact_id, cp.contact_profile_id, u.active_user, u.local_display_name, cp.full_name, cp.image, cp.preferences + FROM users u + JOIN contacts ct ON ct.contact_id = u.contact_id + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + |] toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = @@ -460,6 +464,26 @@ setActiveUser db userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) +getSetActiveUser :: DB.Connection -> UserId -> ExceptT StoreError IO User +getSetActiveUser db userId = do + liftIO $ setActiveUser db userId + getUser_ db userId + +getUser_ :: DB.Connection -> UserId -> ExceptT StoreError IO User +getUser_ db userId = + ExceptT . firstRow toUser (SEUserNotFound userId) $ + DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) + +getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 +getUserIdByName db uName = + ExceptT . firstRow fromOnly (SEUserNotFoundByName uName) $ + DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) + +getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) +getUserByAConnId db agentConnId = + maybeFirstRow toUser $ + DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime @@ -4803,7 +4827,9 @@ randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate -- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName - | SEContactNotFound {contactId :: Int64} + | SEUserNotFound {userId :: UserId} + | SEUserNotFoundByName {contactName :: ContactName} + | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e5b5317620..1ea3dc0cd0 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -43,7 +43,8 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc case r of - CRChatCmdError _ -> when (isMessage cmd) $ echo s + CRChatCmdError _ _ -> when (isMessage cmd) $ echo s + CRChatError _ _ -> when (isMessage cmd) $ echo s _ -> pure () printRespToTerminal ct cc False r startLiveMessage cmd r @@ -58,7 +59,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do Right SendMessageBroadcast {} -> True _ -> False startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO () - startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do + startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do whenM (isNothing <$> readTVarIO liveMessageState) $ do let s = T.unpack $ safeDecodeUtf8 msg int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int @@ -111,7 +112,7 @@ sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let bs = encodeUtf8 $ T.pack sentMsg cmd = UpdateLiveMessage chatName chatItemId live bs - either CRChatCmdError id <$> runExceptT (processChatCommand cmd) `runReaderT` cc + either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc runTerminalInput :: ChatTerminal -> ChatController -> IO () runTerminalInput ct cc = withChatTerm ct $ do diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 854bc3898c..32f2aa91c6 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -95,8 +95,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems} = do forever $ do (_, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem ci -> markChatItemRead ci - CRChatItemUpdated ci -> markChatItemRead ci + CRNewChatItem _ ci -> markChatItemRead ci + CRChatItemUpdated _ ci -> markChatItemRead ci _ -> pure () liveItems <- readTVarIO showLiveItems printRespToTerminal ct cc liveItems r diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 30ede6ee8d..d89a78029f 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -216,6 +216,8 @@ instance ToJSON ConnReqUriHash where data ContactOrRequest = CORContact Contact | CORRequest UserContactRequest +type UserName = Text + type ContactName = Text type GroupName = Text diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b4cab70b78..ef7dac8411 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -59,35 +59,36 @@ serializeChatResponse user_ ts = unlines . map unStyle . responseToView user_ Fa responseToView :: Maybe User -> Bool -> Bool -> CurrentTime -> ChatResponse -> [StyledString] responseToView user_ testView liveItems ts = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile + CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] CRChatSuspended -> ["chat suspended"] - CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] - CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiChats _u chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] + CRApiChat _u chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] - CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView + CRUserSMPServers _u smpServers _ -> viewSMPServers (L.toList smpServers) testView CRSmpTestResult testFailure -> viewSMPTestResult testFailure - CRChatItemTTL ttl -> viewChatItemTTL ttl + CRChatItemTTL _u ttl -> viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg - CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile - CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats - CRContactSwitch ct progress -> viewContactSwitch ct progress - CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress - CRConnectionVerified verified code -> [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] - CRContactCode ct code -> viewContactCode ct code testView - CRGroupMemberCode g m code -> viewGroupMemberCode g m code testView - CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts - CRChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems - CRChatItemId itemId -> [plain $ maybe "no item" show itemId] - CRChatItemStatusUpdated _ -> [] - CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item liveItems ts - CRChatItemDeleted (AChatItem _ _ chat deletedItem) toItem byUser timed -> unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts - CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] - CRBroadcastSent mc n t -> viewSentBroadcast mc n ts t - CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr + CRContactInfo _u ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile + CRGroupMemberInfo _u g m cStats -> viewGroupMemberInfo g m cStats + CRContactSwitch _u ct progress -> viewContactSwitch ct progress + CRGroupMemberSwitch _u g m progress -> viewGroupMemberSwitch g m progress + CRConnectionVerified _u verified code -> [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] + CRContactCode _u ct code -> viewContactCode ct code testView + CRGroupMemberCode _u g m code -> viewGroupMemberCode g m code testView + CRNewChatItem _u (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts + CRChatItems _u chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems + CRChatItemId _u itemId -> [plain $ maybe "no item" show itemId] + CRChatItemStatusUpdated _u _ -> [] + CRChatItemUpdated _u (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemDeleted _u (AChatItem _ _ chat deletedItem) toItem byUser timed -> unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts + CRChatItemDeletedNotFound _u Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] + CRBroadcastSent _u mc n t -> viewSentBroadcast mc n ts t + CRMsgIntegrityError _u mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> [] - CRCmdOk -> ["ok"] + CRCmdOk _u -> ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo HSFiles -> filesHelpInfo @@ -97,65 +98,64 @@ responseToView user_ testView liveItems ts = \case HSMarkdown -> markdownInfo HSSettings -> settingsInfo CRWelcome user -> chatWelcome user - CRContactsList cs -> viewContactsList cs - CRUserContactLink UserContactLink {connReqContact, autoAccept} -> connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept - CRUserContactLinkUpdated UserContactLink {autoAccept} -> autoAcceptStatus_ autoAccept - CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] - CRGroupCreated g -> viewGroupCreated g - CRGroupMembers g -> viewGroupMembers g - CRGroupsList gs -> viewGroupsList gs - CRSentGroupInvitation g c _ -> + CRContactsList _u cs -> viewContactsList cs + CRUserContactLink _u UserContactLink {connReqContact, autoAccept} -> connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLinkUpdated _u UserContactLink {autoAccept} -> autoAcceptStatus_ autoAccept + CRContactRequestRejected _u UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] + CRGroupCreated _u g -> viewGroupCreated g + CRGroupMembers _u g -> viewGroupMembers g + CRGroupsList _u gs -> viewGroupsList gs + CRSentGroupInvitation _u g c _ -> if viaGroupLink . contactConn $ c then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus - CRUserProfile p -> viewUserProfile p - CRUserProfileNoChange -> ["user profile did not change"] + CRFileTransferStatus _u ftStatus -> viewFileTransferStatus ftStatus + CRUserProfile _u p -> viewUserProfile p + CRUserProfileNoChange _u -> ["user profile did not change"] CRVersionInfo _ -> [plain versionStr, plain updateStr] - CRChatCmdError e -> viewChatError e - CRInvitation cReq -> viewConnReqInvitation cReq - CRSentConfirmation -> ["confirmation sent!"] - CRSentInvitation customUserProfile -> viewSentInvitation customUserProfile testView - CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"] - CRChatCleared chatInfo -> viewChatCleared chatInfo - CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."] - CRContactAlreadyExists c -> [ttyFullContact c <> ": contact already exists"] - CRContactRequestAlreadyAccepted c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] - CRUserContactLinkCreated cReq -> connReqContact_ "Your new chat address is created!" cReq - CRUserContactLinkDeleted -> viewUserContactLinkDeleted - CRUserAcceptedGroupSent _g _ -> [] -- [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] - CRLeftMemberUser g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted ci -> savingFile' ci - CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts - CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft - CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' - CRContactPrefsUpdated {fromContact, toContact} -> case user_ of + CRInvitation _u cReq -> viewConnReqInvitation cReq + CRSentConfirmation _u -> ["confirmation sent!"] + CRSentInvitation _u customUserProfile -> viewSentInvitation customUserProfile testView + CRContactDeleted _u c -> [ttyContact' c <> ": contact is deleted"] + CRChatCleared _u chatInfo -> viewChatCleared chatInfo + CRAcceptingContactRequest _u c -> [ttyFullContact c <> ": accepting contact request..."] + CRContactAlreadyExists _u c -> [ttyFullContact c <> ": contact already exists"] + CRContactRequestAlreadyAccepted _u c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CRUserContactLinkCreated _u cReq -> connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted _u -> viewUserContactLinkDeleted + CRUserAcceptedGroupSent _u _g _ -> [] -- [ttyGroup' g <> ": joining the group..."] + CRUserDeletedMember _u g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRLeftMemberUser _u g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRGroupDeletedUser _u g -> [ttyGroup' g <> ": you deleted the group"] + CRRcvFileAccepted _u ci -> savingFile' ci + CRRcvFileAcceptedSndCancelled _u ft -> viewRcvFileSndCancelled ft + CRSndGroupFileCancelled _u _ ftm fts -> viewSndGroupFileCancelled ftm fts + CRRcvFileCancelled _u ft -> receivingFile_ "cancelled" ft + CRUserProfileUpdated _u p p' -> viewUserProfileUpdated p p' + CRContactPrefsUpdated {user = _u, fromContact, toContact} -> case user_ of Just user -> viewUserContactPrefsUpdated user fromContact toContact _ -> ["unexpected chat event CRContactPrefsUpdated without current user"] - CRContactAliasUpdated c -> viewContactAliasUpdated c - CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c - CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of + CRContactAliasUpdated _u c -> viewContactAliasUpdated c + CRConnectionAliasUpdated _u c -> viewConnectionAliasUpdated c + CRContactUpdated {user = _u, fromContact = c, toContact = c'} -> case user_ of Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' _ -> ["unexpected chat event CRContactUpdated without current user"] - CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt - CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile - CRRcvFileStart ci -> receivingFile_' "started" ci - CRRcvFileComplete ci -> receivingFile_' "completed" ci - CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndFileStart _ ft -> sendingFile_ "started" ft - CRSndFileComplete _ ft -> sendingFile_ "completed" ft + CRContactsMerged _u intoCt mergedCt -> viewContactsMerged intoCt mergedCt + CRReceivedContactRequest _u UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile + CRRcvFileStart _u ci -> receivingFile_' "started" ci + CRRcvFileComplete _u ci -> receivingFile_' "completed" ci + CRRcvFileSndCancelled _u ft -> viewRcvFileSndCancelled ft + CRSndFileStart _u _ ft -> sendingFile_ "started" ft + CRSndFileComplete _u _ ft -> sendingFile_ "completed" ft CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft - CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> + CRSndFileRcvCancelled _u _ ft@SndFileTransfer {recipientDisplayName = c} -> [ttyContact c <> " cancelled receiving " <> sndFile ft] - CRContactConnecting _ -> [] - CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView - CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd acEntity -> [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] - CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactConnecting _u _ -> [] + CRContactConnected _u ct userCustomProfile -> viewContactConnected ct userCustomProfile testView + CRContactAnotherClient _u c -> [ttyContact' c <> ": contact is connected to another client"] + CRSubscriptionEnd _u acEntity -> [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] + CRContactsDisconnected _u srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed _u srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" @@ -169,27 +169,27 @@ responseToView user_ testView liveItems ts = \case addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRGroupInvitation g -> [groupInvitation' g] - CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role - CRUserJoinedGroup g _ -> viewUserJoinedGroup g - CRJoinedGroupMember g m -> viewJoinedGroupMember g m + CRReceivedGroupInvitation _u g c role -> viewReceivedGroupInvitation g c role + CRUserJoinedGroup _u g _ -> viewUserJoinedGroup g + CRJoinedGroupMember _u g m -> viewJoinedGroupMember g m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] - CRMemberRole g by m r r' -> viewMemberRoleChanged g by m r r' - CRMemberRoleUser g m r r' -> viewMemberRoleUserChanged g m r r' - CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember g by m -> [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] - CRLeftMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CRJoinedGroupMemberConnecting _u g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + CRConnectedToGroupMember _u g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRMemberRole _u g by m r r' -> viewMemberRoleChanged g by m r r' + CRMemberRoleUser _u g m r r' -> viewMemberRoleUserChanged g m r r' + CRDeletedMemberUser _u g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g + CRDeletedMember _u g by m -> [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRLeftMember _u g m -> [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] - CRGroupUpdated g g' m -> viewGroupUpdated g g' m - CRGroupProfile g -> viewGroupProfile g - CRGroupLinkCreated g cReq -> groupLink_ "Group link is created!" g cReq - CRGroupLink g cReq -> groupLink_ "Group link:" g cReq - CRGroupLinkDeleted g -> viewGroupLinkDeleted g - CRAcceptingGroupJoinRequest g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CRGroupDeleted _u g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRGroupUpdated _u g g' m -> viewGroupUpdated g g' m + CRGroupProfile _u g -> viewGroupProfile g + CRGroupLinkCreated _u g cReq -> groupLink_ "Group link is created!" g cReq + CRGroupLink _u g cReq -> groupLink_ "Group link:" g cReq + CRGroupLinkDeleted _u g -> viewGroupLinkDeleted g + CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" CRGroupSubscribed g -> viewGroupSubscribed g @@ -198,16 +198,16 @@ responseToView user_ testView liveItems ts = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation RcvCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey - CRCallOffer {contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey - CRCallAnswer {contact, answer} -> viewCallAnswer contact answer - CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact] - CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"] - CRCallInvitations _ -> [] + CRCallInvitation _u RcvCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey + CRCallOffer {user = _u, contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey + CRCallAnswer {user = _u, contact, answer} -> viewCallAnswer contact answer + CRCallExtraInfo {user = _u, contact} -> ["call extra info from " <> ttyContact' contact] + CRCallEnded {user = _u, contact} -> ["call with " <> ttyContact' contact <> " ended"] + CRCallInvitations _u _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection _ -> [] - CRContactConnectionDeleted PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] + CRNewContactConnection _u _ -> [] + CRContactConnectionDeleted _u PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] @@ -217,8 +217,9 @@ responseToView user_ testView liveItems ts = \case plain $ "agent locks: " <> LB.unpack (J.encode agentLocks) ] CRAgentStats stats -> map (plain . intercalate ",") stats - CRMessageError prefix err -> [plain prefix <> ": " <> plain err] - CRChatError e -> viewChatError e + CRMessageError _u prefix err -> [plain prefix <> ": " <> plain err] + CRChatCmdError _u e -> viewChatError e + CRChatError _u e -> viewChatError e where testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] @@ -256,6 +257,13 @@ responseToView user_ testView liveItems ts = \case | muted chat chatItem = [] | otherwise = s +viewUsersList :: [User] -> [StyledString] +viewUsersList = + let ldn = T.toLower . (localDisplayName :: User -> ContactName) + in map (\user@User {profile = LocalProfile {displayName, fullName}} -> ttyFullName displayName fullName <> active user) . sortOn ldn + where + active User {activeUser} = if activeUser then highlight' " (active)" else "" + muted :: ChatInfo c -> ChatItem c d -> Bool muted chat ChatItem {chatDir} = case (chat, chatDir) of (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True @@ -1130,6 +1138,7 @@ viewChatError :: ChatError -> [StyledString] viewChatError = \case ChatError err -> case err of CENoActiveUser -> ["error: active user is required"] + CENoConnectionUser _agentConnId -> [] -- ["error: connection has no user, conn id: " <> sShow agentConnId] CEActiveUserExists -> ["error: active user already exists"] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] @@ -1179,6 +1188,7 @@ viewChatError = \case -- e -> ["chat error: " <> sShow e] ChatErrorStore err -> case err of SEDuplicateName -> ["this display name is already used by user, contact or group"] + SEUserNotFoundByName u -> ["no user " <> ttyContact u] SEContactNotFoundByName c -> ["no contact " <> ttyContact c] SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] SEGroupNotFoundByName g -> ["no group " <> ttyGroup g] diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 29c3f2d988..ada96f42b6 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,9 +25,9 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e activeUserExists :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"activeUserExists\":{}}}}}}}" +activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"errorStore\":{\"storeError\":{\"duplicateName\":{}}}}}}}" #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"activeUserExists\"}}}}" +activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"errorStore\",\"storeError\":{\"type\":\"duplicateName\"}}}}" #endif activeUser :: String @@ -85,7 +85,7 @@ testChatApiNoUser = withTmpFiles $ do Left (DBMErrorNotADatabase _) <- chatMigrateInit testDBPrefix "myKey" chatSendCmd cc "/u" `shouldReturn` noActiveUser chatSendCmd cc "/_start" `shouldReturn` noActiveUser - chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser + chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUser chatSendCmd cc "/_start" `shouldReturn` chatStarted testChatApi :: IO () @@ -98,7 +98,7 @@ testChatApi = withTmpFiles $ do Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" chatSendCmd cc "/u" `shouldReturn` activeUser - chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists + chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` contactSubSummary chatRecvMsg cc `shouldReturn` userContactSubSummary From bb0482104c234c08bc4d62b520d514e7c0c11812 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 5 Jan 2023 20:38:31 +0400 Subject: [PATCH 02/59] core, ios, android: add UserId to api commands (#1696) --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 158 ++++++++++++------ apps/ios/Shared/Model/SimpleXAPI.swift | 63 +++++-- .../ios/SimpleX NSE/NotificationService.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 84 +++++----- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- apps/simplex-bot-advanced/Main.hs | 4 +- apps/simplex-chat/Server.hs | 4 +- src/Simplex/Chat.hs | 107 +++++++++--- src/Simplex/Chat/Controller.hs | 67 +++++--- src/Simplex/Chat/View.hs | 1 + tests/ChatTests.hs | 34 ++-- 11 files changed, 341 insertions(+), 189 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e9864edfa1..cb92dc2f74 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -33,8 +33,6 @@ import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.nullable import kotlinx.serialization.json.* import java.util.Date @@ -405,7 +403,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiGetChats(): List { - val r = sendCmd(CC.ApiGetChats()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiGetChats: no current user") + return emptyList() + } + val r = sendCmd(CC.ApiGetChats(userId)) if (r is CR.ApiChats) return r.chats Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chats_title), generalGetString(R.string.contact_developers)) @@ -449,14 +451,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } private suspend fun getUserSMPServers(): Pair, List>? { - val r = sendCmd(CC.GetUserSMPServers()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "getUserSMPServers: no current user") + return null + } + val r = sendCmd(CC.APIGetUserSMPServers(userId)) if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}") return null } suspend fun setUserSMPServers(smpServers: List): Boolean { - val r = sendCmd(CC.SetUserSMPServers(smpServers)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "setUserSMPServers: no current user") + return false + } + val r = sendCmd(CC.APISetUserSMPServers(userId, smpServers)) return when (r) { is CR.CmdOk -> true else -> { @@ -482,13 +492,15 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun getChatItemTTL(): ChatItemTTL { - val r = sendCmd(CC.APIGetChatItemTTL()) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("getChatItemTTL: no current user") } + val r = sendCmd(CC.APIGetChatItemTTL(userId)) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) { - val r = sendCmd(CC.APISetChatItemTTL(chatItemTTL.seconds)) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("setChatItemTTL: no current user") } + val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) if (r is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } @@ -587,7 +599,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun apiAddContact(): String? { - val r = sendCmd(CC.AddContact()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiAddContact: no current user") + return null + } + val r = sendCmd(CC.APIAddContact(userId)) return when (r) { is CR.Invitation -> r.connReqInvitation else -> { @@ -600,7 +616,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiConnect(connReq: String): Boolean { - val r = sendCmd(CC.Connect(connReq)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiConnect: no current user") + return false + } + val r = sendCmd(CC.APIConnect(userId, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -663,14 +683,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiListContacts(): List? { - val r = sendCmd(CC.ListContacts()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiListContacts: no current user") + return null + } + val r = sendCmd(CC.ApiListContacts(userId)) if (r is CR.ContactsList) return r.contacts Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") return null } suspend fun apiUpdateProfile(profile: Profile): Profile? { - val r = sendCmd(CC.ApiUpdateProfile(profile)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiUpdateProfile: no current user") + return null + } + val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile if (r is CR.UserProfileUpdated) return r.toProfile Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") @@ -706,7 +734,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiCreateUserAddress(): String? { - val r = sendCmd(CC.CreateMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiCreateUserAddress: no current user") + return null + } + val r = sendCmd(CC.ApiCreateMyAddress(userId)) return when (r) { is CR.UserContactLinkCreated -> r.connReqContact else -> { @@ -719,14 +751,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiDeleteUserAddress(): Boolean { - val r = sendCmd(CC.DeleteMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiDeleteUserAddress: no current user") + return false + } + val r = sendCmd(CC.ApiDeleteMyAddress(userId)) if (r is CR.UserContactLinkDeleted) return true Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return false } private suspend fun apiGetUserAddress(): UserContactLinkRec? { - val r = sendCmd(CC.ShowMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiGetUserAddress: no current user") + return null + } + val r = sendCmd(CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound) { @@ -737,7 +777,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? { - val r = sendCmd(CC.AddressAutoAccept(autoAccept)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "userAddressAutoAccept: no current user") + return null + } + val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound) { @@ -856,7 +900,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { - val r = sendCmd(CC.NewGroup(p)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiNewGroup: no current user") + return null + } + val r = sendCmd(CC.ApiNewGroup(userId, p)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1549,12 +1597,12 @@ sealed class CC { class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiStorageEncryption(val config: DBEncryptionConfig): CC() - class ApiGetChats: CC() + class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() - class NewGroup(val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1565,11 +1613,11 @@ sealed class CC { class APICreateGroupLink(val groupId: Long): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() - class GetUserSMPServers: CC() - class SetUserSMPServers(val smpServers: List): CC() + class APIGetUserSMPServers(val userId: Long): CC() + class APISetUserSMPServers(val userId: Long, val smpServers: List): CC() class TestSMPServer(val smpServer: String): CC() - class APISetChatItemTTL(val seconds: Long?): CC() - class APIGetChatItemTTL: CC() + class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() + class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() @@ -1581,20 +1629,20 @@ sealed class CC { class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() - class AddContact: CC() - class Connect(val connReq: String): CC() + class APIAddContact(val userId: Long): CC() + class APIConnect(val userId: Long, val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() - class ListContacts: CC() - class ApiUpdateProfile(val profile: Profile): CC() + class ApiListContacts(val userId: Long): CC() + class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiParseMarkdown(val text: String): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() - class CreateMyAddress: CC() - class DeleteMyAddress: CC() - class ShowMyAddress: CC() - class AddressAutoAccept(val autoAccept: AutoAccept?): CC() + class ApiCreateMyAddress(val userId: Long): CC() + class ApiDeleteMyAddress(val userId: Long): CC() + class ApiShowMyAddress(val userId: Long): CC() + class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() class ApiRejectCall(val contact: Contact): CC() class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC() @@ -1620,12 +1668,12 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" - is ApiGetChats -> "/_get chats pcc=on" + is ApiGetChats -> "/_get $userId chats pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" - is NewGroup -> "/_group ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" @@ -1636,11 +1684,11 @@ sealed class CC { is APICreateGroupLink -> "/_create link #$groupId" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" - is GetUserSMPServers -> "/smp" - is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}" + is APIGetUserSMPServers -> "/_smp $userId" + is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}" is TestSMPServer -> "/smp test $smpServer" - is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}" - is APIGetChatItemTTL -> "/ttl" + is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" + is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" @@ -1652,20 +1700,20 @@ sealed class CC { is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" - is AddContact -> "/connect" - is Connect -> "/connect $connReq" + is APIAddContact -> "/_connect $userId" + is APIConnect -> "/_connect $userId $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" - is ListContacts -> "/contacts" - is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiListContacts -> "/_contacts $userId" + is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiParseMarkdown -> "/_parse $text" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" - is CreateMyAddress -> "/address" - is DeleteMyAddress -> "/delete_address" - is ShowMyAddress -> "/show_address" - is AddressAutoAccept -> "/auto_accept ${AutoAccept.cmdString(autoAccept)}" + is ApiCreateMyAddress -> "/_address $userId" + is ApiDeleteMyAddress -> "/_delete_address $userId" + is ApiShowMyAddress -> "/_show_address $userId" + is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" is ApiAcceptContact -> "/_accept $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}" @@ -1697,7 +1745,7 @@ sealed class CC { is ApiSendMessage -> "apiSendMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" - is NewGroup -> "newGroup" + is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiMemberRole -> "apiMemberRole" @@ -1708,8 +1756,8 @@ sealed class CC { is APICreateGroupLink -> "apiCreateGroupLink" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" - is GetUserSMPServers -> "getUserSMPServers" - is SetUserSMPServers -> "setUserSMPServers" + is APIGetUserSMPServers -> "apiGetUserSMPServers" + is APISetUserSMPServers -> "apiSetUserSMPServers" is TestSMPServer -> "testSMPServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" @@ -1724,20 +1772,20 @@ sealed class CC { is APIGetGroupMemberCode -> "apiGetGroupMemberCode" is APIVerifyContact -> "apiVerifyContact" is APIVerifyGroupMember -> "apiVerifyGroupMember" - is AddContact -> "addContact" - is Connect -> "connect" + is APIAddContact -> "apiAddContact" + is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" - is ListContacts -> "listContacts" - is ApiUpdateProfile -> "updateProfile" + is ApiListContacts -> "apiListContacts" + is ApiUpdateProfile -> "apiUpdateProfile" is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiParseMarkdown -> "apiParseMarkdown" is ApiSetContactAlias -> "apiSetContactAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" - is CreateMyAddress -> "createMyAddress" - is DeleteMyAddress -> "deleteMyAddress" - is ShowMyAddress -> "showMyAddress" - is AddressAutoAccept -> "addressAutoAccept" + is ApiCreateMyAddress -> "apiCreateMyAddress" + is ApiDeleteMyAddress -> "apiDeleteMyAddress" + is ApiShowMyAddress -> "apiShowMyAddress" + is ApiAddressAutoAccept -> "apiAddressAutoAccept" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" is ApiSendCallInvitation -> "apiSendCallInvitation" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 8cf06ba065..382237ecad 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -189,7 +189,8 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th } func apiGetChats() throws -> [ChatData] { - let r = chatSendCmdSync(.apiGetChats) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") } + let r = chatSendCmdSync(.apiGetChats(userId: userId)) if case let .apiChats(chats) = r { return chats } throw r } @@ -310,13 +311,15 @@ func apiDeleteToken(token: DeviceToken) async throws { } func getUserSMPServers() throws -> ([ServerCfg], [String]) { - let r = chatSendCmdSync(.getUserSMPServers) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") } + let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId)) if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) } throw r } func setUserSMPServers(smpServers: [ServerCfg]) async throws { - try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setUserSMPServers: no current user") } + try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers)) } func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { @@ -331,13 +334,15 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> } func getChatItemTTL() throws -> ChatItemTTL { - let r = chatSendCmdSync(.apiGetChatItemTTL) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") } + let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId)) if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } throw r } func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { - try await sendCommandOkResp(.apiSetChatItemTTL(seconds: chatItemTTL.seconds)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setChatItemTTL: no current user") } + try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } func getNetworkConfig() async throws -> NetCfg? { @@ -403,14 +408,22 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo } func apiAddContact() async -> String? { - let r = await chatSendCmd(.addContact, bgTask: false) + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiAddContact: no current user") + return nil + } + let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) if case let .invitation(connReqInvitation) = r { return connReqInvitation } connectionErrorAlert(r) return nil } func apiConnect(connReq: String) async -> ConnReqType? { - let r = await chatSendCmd(.connect(connReq: connReq)) + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnect: no current user") + return nil + } + let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq)) let am = AlertManager.shared switch r { case .sentConfirmation: return .invitation @@ -499,13 +512,15 @@ func clearChat(_ chat: Chat) async { } func apiListContacts() throws -> [Contact] { - let r = chatSendCmdSync(.listContacts) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") } + let r = chatSendCmdSync(.apiListContacts(userId: userId)) if case let .contactsList(contacts) = r { return contacts } throw r } func apiUpdateProfile(profile: Profile) async throws -> Profile? { - let r = await chatSendCmd(.apiUpdateProfile(profile: profile)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiUpdateProfile: no current user") } + let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { case .userProfileNoChange: return nil case let .userProfileUpdated(_, toProfile): return toProfile @@ -532,19 +547,22 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe } func apiCreateUserAddress() async throws -> String { - let r = await chatSendCmd(.createMyAddress) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") } + let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) if case let .userContactLinkCreated(connReq) = r { return connReq } throw r } func apiDeleteUserAddress() async throws { - let r = await chatSendCmd(.deleteMyAddress) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiDeleteUserAddress: no current user") } + let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId)) if case .userContactLinkDeleted = r { return } throw r } func apiGetUserAddress() throws -> UserContactLink? { - let r = chatSendCmdSync(.showMyAddress) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") } + let r = chatSendCmdSync(.apiShowMyAddress(userId: userId)) switch r { case let .userContactLink(contactLink): return contactLink case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil @@ -553,7 +571,8 @@ func apiGetUserAddress() throws -> UserContactLink? { } func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { - let r = await chatSendCmd(.addressAutoAccept(autoAccept: autoAccept)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") } + let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { case let .userContactLinkUpdated(contactLink): return contactLink case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil @@ -691,7 +710,8 @@ func apiEndCall(_ contact: Contact) async throws { } func apiGetCallInvitations() throws -> [RcvCallInvitation] { - let r = chatSendCmdSync(.apiGetCallInvitations) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetCallInvitations: no current user") } + let r = chatSendCmdSync(.apiGetCallInvitations(userId: userId)) if case let .callInvitations(invs) = r { return invs } throw r } @@ -748,7 +768,8 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { } func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { - let r = chatSendCmdSync(.newGroup(groupProfile: p)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") } + let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) if case let .groupCreated(groupInfo) = r { return groupInfo } throw r } @@ -1209,3 +1230,15 @@ private struct UserResponse: Decodable { var user: User? var error: String? } + +struct RuntimeError: Error { + let message: String + + init(_ message: String) { + self.message = message + } + + public var localizedDescription: String { + return message + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 7d6c6c485e..779b61ad85 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -275,7 +275,11 @@ func apiSetIncognito(incognito: Bool) throws { } func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { - let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) + guard let user = apiGetActiveUser() else { + logger.debug("no active user") + return nil + } + let r = sendSimpleXCmd(.apiGetNtfMessage(userId: user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 27f26a7a98..ad44234e77 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -25,7 +25,7 @@ public enum ChatCommand { case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage case apiStorageEncryption(config: DBEncryptionConfig) - case apiGetChats + case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) @@ -34,8 +34,8 @@ public enum ChatCommand { case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) - case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case newGroup(groupProfile: GroupProfile) + case apiGetNtfMessage(userId: Int64, nonce: String, encNtfInfo: String) + case apiNewGroup(userId: Int64, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -46,11 +46,11 @@ public enum ChatCommand { case apiCreateGroupLink(groupId: Int64) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) - case getUserSMPServers - case setUserSMPServers(smpServers: [ServerCfg]) + case apiGetUserSMPServers(userId: Int64) + case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg]) case testSMPServer(smpServer: String) - case apiSetChatItemTTL(seconds: Int64?) - case apiGetChatItemTTL + case apiSetChatItemTTL(userId: Int64, seconds: Int64?) + case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) @@ -62,19 +62,19 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case addContact - case connect(connReq: String) + case apiAddContact(userId: Int64) + case apiConnect(userId: Int64, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) - case listContacts - case apiUpdateProfile(profile: Profile) + case apiListContacts(userId: Int64) + case apiUpdateProfile(userId: Int64, profile: Profile) case apiSetContactPrefs(contactId: Int64, preferences: Preferences) case apiSetContactAlias(contactId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String) - case createMyAddress - case deleteMyAddress - case showMyAddress - case addressAutoAccept(autoAccept: AutoAccept?) + case apiCreateMyAddress(userId: Int64) + case apiDeleteMyAddress(userId: Int64) + case apiShowMyAddress(userId: Int64) + case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) case apiAcceptContact(contactReqId: Int64) case apiRejectContact(contactReqId: Int64) // WebRTC calls @@ -84,7 +84,7 @@ public enum ChatCommand { case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case apiEndCall(contact: Contact) - case apiGetCallInvitations + case apiGetCallInvitations(userId: Int64) case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -106,7 +106,7 @@ public enum ChatCommand { case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" - case .apiGetChats: return "/_get chats pcc=on" + case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiSendMessage(type, id, file, quotedItemId, mc, live): @@ -118,8 +118,8 @@ public enum ChatCommand { case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" - case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .newGroup(groupProfile): return "/_group \(encodeJSON(groupProfile))" + case let .apiGetNtfMessage(userId, nonce, encNtfInfo): return "/_ntf message \(userId) \(nonce) \(encNtfInfo)" + case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" @@ -130,11 +130,11 @@ public enum ChatCommand { case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" - case .getUserSMPServers: return "/smp" - case let .setUserSMPServers(smpServers): return "/_smp \(smpServersStr(smpServers: smpServers))" + case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" + case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))" case let .testSMPServer(smpServer): return "/smp test \(smpServer)" - case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))" - case .apiGetChatItemTTL: return "/ttl" + 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))" case .apiGetNetworkConfig: return "/network" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" @@ -148,19 +148,19 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case .addContact: return "/connect" - case let .connect(connReq): return "/connect \(connReq)" + case let .apiAddContact(userId): return "/_connect \(userId)" + case let .apiConnect(userId, connReq): return "/_connect \(userId) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" - case .listContacts: return "/contacts" - case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiListContacts(userId): return "/_contacts \(userId)" + case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case .createMyAddress: return "/address" - case .deleteMyAddress: return "/delete_address" - case .showMyAddress: return "/show_address" - case let .addressAutoAccept(autoAccept): return "/auto_accept \(AutoAccept.cmdString(autoAccept))" + case let .apiCreateMyAddress(userId): return "/_address \(userId)" + case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" + case let .apiShowMyAddress(userId): return "/_show_address \(userId)" + case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" @@ -169,7 +169,7 @@ public enum ChatCommand { case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" - case .apiGetCallInvitations: return "/_call get" + case let .apiGetCallInvitations(userId): return "/_call get \(userId)" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" @@ -204,7 +204,7 @@ public enum ChatCommand { case .apiVerifyToken: return "apiVerifyToken" case .apiDeleteToken: return "apiDeleteToken" case .apiGetNtfMessage: return "apiGetNtfMessage" - case .newGroup: return "newGroup" + case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiMemberRole: return "apiMemberRole" @@ -215,8 +215,8 @@ public enum ChatCommand { case .apiCreateGroupLink: return "apiCreateGroupLink" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" - case .getUserSMPServers: return "getUserSMPServers" - case .setUserSMPServers: return "setUserSMPServers" + case .apiGetUserSMPServers: return "apiGetUserSMPServers" + case .apiSetUserSMPServers: return "apiSetUserSMPServers" case .testSMPServer: return "testSMPServer" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" @@ -231,19 +231,19 @@ public enum ChatCommand { case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" case .apiVerifyContact: return "apiVerifyContact" case .apiVerifyGroupMember: return "apiVerifyGroupMember" - case .addContact: return "addContact" - case .connect: return "connect" + case .apiAddContact: return "apiAddContact" + case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" - case .listContacts: return "listContacts" + case .apiListContacts: return "apiListContacts" case .apiUpdateProfile: return "apiUpdateProfile" case .apiSetContactPrefs: return "apiSetContactPrefs" case .apiSetContactAlias: return "apiSetContactAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias" - case .createMyAddress: return "createMyAddress" - case .deleteMyAddress: return "deleteMyAddress" - case .showMyAddress: return "showMyAddress" - case .addressAutoAccept: return "addressAutoAccept" + case .apiCreateMyAddress: return "apiCreateMyAddress" + case .apiDeleteMyAddress: return "apiDeleteMyAddress" + case .apiShowMyAddress: return "apiShowMyAddress" + case .apiAddressAutoAccept: return "apiAddressAutoAccept" case .apiAcceptContact: return "apiAcceptContact" case .apiRejectContact: return "apiRejectContact" case .apiSendCallInvitation: return "apiSendCallInvitation" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f7f53005fe..1e918cfcc9 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI public struct User: Decodable, NamedChat { - var userId: Int64 + public var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index f5acebe96a..db370cdfa0 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -39,10 +39,10 @@ mySquaringBot _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact _ -> do + CRContactConnected _ contact _ -> do contactConnected contact void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" - CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do + CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do let msg = T.unpack $ ciContentToText content number_ = readMaybe msg :: Maybe Integer void . sendMsg contact $ case number_ of diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 481e03b01e..d59adc04e7 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -95,11 +95,11 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do Left e -> sendError (Just corrId) e Nothing -> sendError Nothing "invalid request" where - sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError e} + sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError Nothing e} processCommand (corrId, cmd) = runReaderT (runExceptT $ processChatCommand cmd) cc >>= \case Right resp -> response resp - Left e -> response $ CRChatCmdError e + Left e -> response $ CRChatCmdError Nothing e where response resp = pure ChatSrvResponse {corrId = Just corrId, resp} clientDisconnected _ = pure () diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b36c634f8a..4784964df7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -308,7 +308,8 @@ processChatCommand = \case APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) - APIGetChats withPCC -> withUser' $ \user -> do + APIGetChats cmdUserId withPCC -> withUser' $ \user -> do + checkCorrectCmdUser cmdUserId user chats <- withStore' $ \db -> getChatPreviews db user withPCC pure $ CRApiChats user chats APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of @@ -716,7 +717,8 @@ processChatCommand = \case (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) updateCallItemStatus userId ct call WCSDisconnected $ Just msgId pure Nothing - APIGetCallInvitations -> withUser $ \user -> do + APIGetCallInvitations cmdUserId -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user calls <- asks currentCalls >>= readTVarIO let invs = mapMaybe callInvitation $ M.elems calls rcvCallInvitations <- mapM (rcvCallInvitation user) invs @@ -731,7 +733,9 @@ processChatCommand = \case APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \userId ct call -> updateCallItemStatus userId ct call receivedStatus Nothing $> Just call - APIUpdateProfile profile -> withUser (`updateProfile` profile) + APIUpdateProfile cmdUserId profile -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user + updateProfile user profile APISetContactPrefs contactId prefs' -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId updateContactPrefs user ct prefs' @@ -752,26 +756,34 @@ processChatCommand = \case pure $ CRNtfTokenStatus tokenStatus APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk Nothing APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk Nothing - APIGetNtfMessage nonce encNtfInfo -> withUser $ \user -> do + APIGetNtfMessage cmdUserId nonce encNtfInfo -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing pure CRNtfMessages {user, connEntity, msgTs = msgTs', ntfMessages} - GetUserSMPServers -> withUser $ \user -> do + APIGetUserSMPServers cmdUserId -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config smpServers <- withStore' (`getSMPServers` user) let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers pure $ CRUserSMPServers user smpServers' defaultSMPServers where toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} - SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do + GetUserSMPServers -> withUser $ \User {userId} -> + processChatCommand $ APIGetUserSMPServers userId + APISetUserSMPServers cmdUserId (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do + checkCorrectCmdUser cmdUserId user withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers pure $ CRCmdOk (Just user) + SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> + processChatCommand $ APISetUserSMPServers userId smpServersConfig TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer) - APISetChatItemTTL newTTL_ -> withUser' $ \user -> + APISetChatItemTTL cmdUserId newTTL_ -> withUser' $ \user -> do + checkCorrectCmdUser cmdUserId user checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do case newTTL_ of @@ -786,9 +798,14 @@ processChatCommand = \case withStore' $ \db -> setChatItemTTL db user newTTL_ whenM chatStarted $ setExpireCIs True pure $ CRCmdOk (Just user) - APIGetChatItemTTL -> withUser $ \user -> do + SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do + processChatCommand $ APISetChatItemTTL userId newTTL_ + APIGetChatItemTTL cmdUserId -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user ttl <- withStore' (`getChatItemTTL` user) pure $ CRChatItemTTL user ttl + GetChatItemTTL -> withUser' $ \User {userId} -> do + processChatCommand $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk Nothing APIGetNetworkConfig -> withUser' $ \_ -> do networkConfig <- withAgent getNetworkConfig @@ -878,7 +895,8 @@ processChatCommand = \case VerifyGroupMember gName mName code -> withMemberName gName mName $ \gId mId -> APIVerifyGroupMember gId mId code ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - AddContact -> withUser $ \user@User {userId} -> withChatLock "addContact" . procCmd $ do + APIAddContact cmdUserId -> withUser $ \user@User {userId} -> withChatLock "addContact" . procCmd $ do + checkCorrectCmdUser cmdUserId user -- [incognito] generate profile for connection incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -886,7 +904,10 @@ processChatCommand = \case conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq - Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \user@User {userId} -> withChatLock "connect" . procCmd $ do + AddContact -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId + APIConnect cmdUserId (Just (ACR SCMInvitation cReq)) -> withUser $ \user@User {userId} -> withChatLock "connect" . procCmd $ do + checkCorrectCmdUser cmdUserId user -- [incognito] generate profile to send incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -895,34 +916,52 @@ processChatCommand = \case conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user - Connect (Just (ACR SCMContact cReq)) -> withUser $ \user -> + APIConnect cmdUserId (Just (ACR SCMContact cReq)) -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user -- [incognito] generate profile to send connectViaContact user cReq - Connect Nothing -> throwChatError CEInvalidConnReq + APIConnect _ Nothing -> throwChatError CEInvalidConnReq + Connect cReqUri -> withUser $ \User {userId} -> + processChatCommand $ APIConnect userId cReqUri ConnectSimplex -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect - ListContacts -> withUser $ \user -> do + APIListContacts cmdUserId -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user contacts <- withStore' (`getUserContacts` user) pure $ CRContactsList user contacts - CreateMyAddress -> withUser $ \user@User {userId} -> withChatLock "createMyAddress" . procCmd $ do + ListContacts -> withUser $ \User {userId} -> + processChatCommand $ APIListContacts userId + APICreateMyAddress cmdUserId -> withUser $ \user@User {userId} -> withChatLock "createMyAddress" . procCmd $ do + checkCorrectCmdUser cmdUserId user (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing withStore $ \db -> createUserContactLink db userId connId cReq pure $ CRUserContactLinkCreated user cReq - DeleteMyAddress -> withUser $ \user -> withChatLock "deleteMyAddress" $ do + CreateMyAddress -> withUser $ \User {userId} -> + processChatCommand $ APICreateMyAddress userId + APIDeleteMyAddress cmdUserId -> withUser $ \user -> withChatLock "deleteMyAddress" $ do + checkCorrectCmdUser cmdUserId user conns <- withStore (`getUserAddressConnections` user) procCmd $ do forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () withStore' (`deleteUserAddress` user) pure $ CRUserContactLinkDeleted user - ShowMyAddress -> withUser $ \user@User {userId} -> do + DeleteMyAddress -> withUser $ \User {userId} -> + processChatCommand $ APIDeleteMyAddress userId + APIShowMyAddress cmdUserId -> withUser $ \user@User {userId} -> do + checkCorrectCmdUser cmdUserId user contactLink <- withStore (`getUserAddress` userId) pure $ CRUserContactLink user contactLink - AddressAutoAccept autoAccept_ -> withUser $ \user@User {userId} -> do + ShowMyAddress -> withUser $ \User {userId} -> + processChatCommand $ APIShowMyAddress userId + APIAddressAutoAccept cmdUserId autoAccept_ -> withUser $ \user@User {userId} -> do + checkCorrectCmdUser cmdUserId user contactLink <- withStore (\db -> updateUserAddressAutoAccept db userId autoAccept_) pure $ CRUserContactLinkUpdated user contactLink + AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> + processChatCommand $ APIAddressAutoAccept userId autoAccept_ AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIAcceptContact connReqId @@ -962,10 +1001,13 @@ processChatCommand = \case chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc - NewGroup gProfile -> withUser $ \user -> do + APINewGroup cmdUserId gProfile -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user gVar <- asks idsDrg groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) pure $ CRGroupCreated user groupInfo + NewGroup gProfile -> withUser $ \User {userId} -> + processChatCommand $ APINewGroup userId gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -1282,6 +1324,8 @@ processChatCommand = \case withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk Nothing checkStoreNotChanged :: m ChatResponse -> m ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) + checkCorrectCmdUser :: UserId -> User -> m () + checkCorrectCmdUser cmdUserId User {userId = activeUserId} = when (cmdUserId /= activeUserId) $ throwChatError (CEDifferentActiveUser cmdUserId activeUserId) withUserName :: UserName -> (UserId -> ChatCommand) -> m ChatResponse withUserName uName cmd = withStore (`getUserIdByName` uName) >>= processChatCommand . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> m ChatResponse @@ -3710,7 +3754,7 @@ chatCommandP = "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), - "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), + "/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items count=" *> (APIGetChatItems <$> A.decimal), "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), @@ -3730,8 +3774,8 @@ chatCommandP = "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP), "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), - "/_call get" $> APIGetCallInvitations, - "/_profile " *> (APIUpdateProfile <$> jsonP), + "/_call get " *> (APIGetCallInvitations <$> A.decimal), + "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), @@ -3740,7 +3784,7 @@ chatCommandP = "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), - "/_ntf message " *> (APIGetNtfMessage <$> strP <* A.space <*> strP), + "/_ntf message " *> (APIGetNtfMessage <$> A.decimal <* A.space <*> strP <* A.space <*> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal), "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), @@ -3753,12 +3797,14 @@ chatCommandP = "/smp_servers" $> GetUserSMPServers, "/smp default" $> SetUserSMPServers (SMPServersConfig []), "/smp test " *> (TestSMPServer <$> strP), - "/_smp " *> (SetUserSMPServers <$> jsonP), + "/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP), "/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), + "/_smp " *> (APIGetUserSMPServers <$> A.decimal), "/smp" $> GetUserSMPServers, - "/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal), - "/ttl " *> (APISetChatItemTTL <$> ciTTL), - "/ttl" $> APIGetChatItemTTL, + "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), + "/ttl " *> (SetChatItemTTL <$> ciTTL), + "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), + "/ttl" $> GetChatItemTTL, "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (APISetNetworkConfig <$> netCfgP), ("/network" <|> "/net") $> APIGetNetworkConfig, @@ -3786,7 +3832,7 @@ chatCommandP = ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), - "/_group " *> (NewGroup <$> jsonP), + "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), @@ -3810,7 +3856,10 @@ chatCommandP = "/show link #" *> (ShowGroupLink <$> displayName), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString), + "/_contacts " *> (APIListContacts <$> A.decimal), ("/contacts" <|> "/cs") $> ListContacts, + "/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + "/_connect " *> (APIAddContact <$> A.decimal), ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)), ("/connect" <|> "/c") $> AddContact, SendMessage <$> chatNameP <* A.space <*> A.takeByteString, @@ -3833,9 +3882,13 @@ chatCommandP = ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" $> ConnectSimplex, + "/_address " *> (APICreateMyAddress <$> A.decimal), ("/address" <|> "/ad") $> CreateMyAddress, + "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), ("/delete_address" <|> "/da") $> DeleteMyAddress, + "/_show_address " *> (APIShowMyAddress <$> A.decimal), ("/show_address" <|> "/sa") $> ShowMyAddress, + "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), ("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName), ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3ad58bcc30..a2b5790943 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -158,7 +158,7 @@ data ChatCommand | APIStorageEncryption DBEncryptionConfig | ExecChatStoreSQL Text | ExecAgentStoreSQL Text - | APIGetChats {pendingConnections :: Bool} -- UserId + | APIGetChats {userId :: UserId, pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems Int | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, composedMessage :: ComposedMessage} @@ -177,9 +177,9 @@ data ChatCommand | APISendCallAnswer ContactId WebRTCSession | APISendCallExtraInfo ContactId WebRTCExtraInfo | APIEndCall ContactId - | APIGetCallInvitations -- UserId + | APIGetCallInvitations UserId | APICallStatus ContactId WebRTCCallStatus - | APIUpdateProfile Profile -- UserId + | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias | APISetConnectionAlias Int64 LocalAlias @@ -188,7 +188,7 @@ data ChatCommand | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString | APIDeleteToken DeviceToken - | APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString} -- UserId + | APIGetNtfMessage {userId :: UserId, nonce :: C.CbNonce, encNtfInfo :: ByteString} | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup GroupId | APIMemberRole GroupId GroupMemberId GroupMemberRole @@ -199,11 +199,15 @@ data ChatCommand | APICreateGroupLink GroupId | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId - | GetUserSMPServers -- UserId - | SetUserSMPServers SMPServersConfig -- UserId + | APIGetUserSMPServers UserId + | GetUserSMPServers + | APISetUserSMPServers UserId SMPServersConfig + | SetUserSMPServers SMPServersConfig | TestSMPServer SMPServerWithAuth - | APISetChatItemTTL (Maybe Int64) -- UserId - | APIGetChatItemTTL -- UserId + | APISetChatItemTTL UserId (Maybe Int64) + | SetChatItemTTL (Maybe Int64) + | APIGetChatItemTTL UserId + | GetChatItemTTL | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | APISetChatSettings ChatRef ChatSettings @@ -226,26 +230,34 @@ data ChatCommand | VerifyGroupMember GroupName ContactName (Maybe Text) | ChatHelp HelpSection | Welcome - | AddContact -- UserId - | Connect (Maybe AConnectionRequestUri) -- UserId - | ConnectSimplex -- UserId + | APIAddContact UserId + | AddContact + | APIConnect UserId (Maybe AConnectionRequestUri) + | Connect (Maybe AConnectionRequestUri) + | ConnectSimplex -- UserId (not used in UI) | DeleteContact ContactName | ClearContact ContactName - | ListContacts -- UserId - | CreateMyAddress -- UserId - | DeleteMyAddress -- UserId - | ShowMyAddress -- UserId - | AddressAutoAccept (Maybe AutoAccept) -- UserId + | APIListContacts UserId + | ListContacts + | APICreateMyAddress UserId + | CreateMyAddress + | APIDeleteMyAddress UserId + | DeleteMyAddress + | APIShowMyAddress UserId + | ShowMyAddress + | APIAddressAutoAccept UserId (Maybe AutoAccept) + | AddressAutoAccept (Maybe AutoAccept) | AcceptContact ContactName | RejectContact ContactName | SendMessage ChatName ByteString | SendLiveMessage ChatName ByteString | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} - | SendMessageBroadcast ByteString -- UserId + | SendMessageBroadcast ByteString -- UserId (not used in UI) | DeleteMessage ChatName ByteString | EditMessage {chatName :: ChatName, editedMsg :: ByteString, message :: ByteString} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: ByteString} - | NewGroup GroupProfile -- UserId + | APINewGroup UserId GroupProfile + | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName | MemberRole GroupName ContactName GroupMemberRole @@ -254,7 +266,7 @@ data ChatCommand | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName - | ListGroups -- UserId + | ListGroups -- UserId (not used in UI) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -262,9 +274,9 @@ data ChatCommand | DeleteGroupLink GroupName | ShowGroupLink GroupName | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} - | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId - | LastChatItemId (Maybe ChatName) Int -- UserId - | ShowChatItem (Maybe ChatItemId) -- UserId + | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI) + | LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI) + | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowLiveItems Bool | SendFile ChatName FilePath | SendImage ChatName FilePath @@ -273,13 +285,13 @@ data ChatCommand | ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath} | CancelFile FileTransferId | FileStatus FileTransferId - | ShowProfile -- UserId - | UpdateProfile ContactName Text -- UserId - | UpdateProfileImage (Maybe ImageData) -- UserId - | SetUserFeature AChatFeature FeatureAllowed -- UserId + | ShowProfile -- UserId (not used in UI) + | UpdateProfile ContactName Text -- UserId (not used in UI) + | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) | SetGroupFeature AGroupFeature GroupName GroupFeatureEnabled - | SetUserTimedMessages Bool -- UserId + | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | QuitChat @@ -559,6 +571,7 @@ data ChatErrorType = CENoActiveUser | CENoConnectionUser {agentConnId :: AgentConnId} | CEActiveUserExists -- TODO delete + | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ef7dac8411..42bce90491 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1140,6 +1140,7 @@ viewChatError = \case CENoActiveUser -> ["error: active user is required"] CENoConnectionUser _agentConnId -> [] -- ["error: connection has no user, conn id: " <> sShow agentConnId] CEActiveUserExists -> ["error: active user already exists"] + CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index f4dbb9633c..0c7f04897c 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -241,9 +241,9 @@ testAddContact :: Spec testAddContact = versionTestMatrix2 runTestAddContact where runTestAddContact alice bob = do - alice ##> "/c" + alice ##> "/_connect 1" inv <- getInvitation alice - bob ##> ("/c " <> inv) + bob ##> ("/_connect 1 " <> inv) bob <## "confirmation sent!" concurrently_ (bob <## "alice (Alice): contact is connected") @@ -324,7 +324,7 @@ testDeleteContactDeletesProfile = -- alice deletes contact, profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" - alice ##> "/cs" + alice ##> "/_contacts 1" (alice "/profile_image" alice <## "profile image removed" - alice ##> "/_profile {\"displayName\": \"alice2\", \"fullName\": \"\"}" + alice ##> "/_profile 1 {\"displayName\": \"alice2\", \"fullName\": \"\"}" alice <## "user profile is changed to alice2 (your contacts are notified)" bob <## "contact alice changed to alice2" bob <## "use @alice2 to send messages" @@ -2700,7 +2700,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile testRejectContactAndDeleteUserContact :: IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice ##> "/ad" + alice ##> "/_address 1" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob @@ -2708,12 +2708,12 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr alice <## "bob: contact request rejected" (bob "/sa" + alice ##> "/_show_address 1" cLink' <- getContactLink alice False alice <## "auto_accept off" cLink' `shouldBe` cLink - alice ##> "/da" + alice ##> "/_delete_address 1" alice <## "Your chat address is deleted - accepted contacts will remain connected." alice <## "To create a new chat address use /ad" @@ -2747,7 +2747,7 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" cLink <- getContactLink alice True - alice ##> "/auto_accept on incognito=off text hello!" + alice ##> "/_auto_accept 1 on incognito=off text hello!" alice <## "auto_accept on" alice <## "auto reply:" alice <## "hello!" @@ -3182,7 +3182,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice (Alice): contact is connected" ] alice <## "cath (Catherine): contact is connected" - alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"always\"}}}" + alice ##> "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"always\"}}}" alice <## "user full name removed (your contacts are notified)" alice <## "updated preferences:" alice <## "Full deletion allowed: always" @@ -3353,7 +3353,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ createDirectoryIfMissing True "./tests/tmp/bob" copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test.txt" copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test.txt" - bob ##> "/_profile {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" + bob ##> "/_profile 1 {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" bob <## "profile image removed" bob <## "updated preferences:" bob <## "Voice messages allowed: no" @@ -3390,7 +3390,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice <## "started receiving file 1 (test.txt) from bob" alice <## "completed receiving file 1 (test.txt) from bob" (bob "/_profile {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" + -- alice ##> "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" alice ##> "/set voice no" alice <## "updated preferences:" alice <## "Voice messages allowed: no" @@ -3403,7 +3403,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob <## "Voice messages: off (you allow: default (no), contact allows: yes)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off")]) (bob "/_profile {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"allow\": \"yes\"}}}" + bob ##> "/_profile 1 {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"allow\": \"yes\"}}}" bob <## "user full name removed (your contacts are notified)" bob <## "updated preferences:" bob <## "Voice messages allowed: yes" @@ -3716,7 +3716,7 @@ testGetSetSMPServers :: IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + alice #$> ("/_smp 1", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") @@ -4081,7 +4081,7 @@ testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob -- just for testing db query - alice ##> "/_call get" + alice ##> "/_call get 1" -- alice invite bob to call alice ##> ("/_call invite @2 " <> serialize testCallType) alice <## "ok" @@ -4308,10 +4308,10 @@ testSetChatItemTTL = alice <# "bob> 4" alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, ""), Just "test.jpg"), ((1, "3"), Nothing), ((0, "4"), Nothing)]) checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ - alice #$> ("/_ttl 2", id, "ok") + alice #$> ("/_ttl 1 2", id, "ok") alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")]) -- when expiration is turned on, first cycle is synchronous bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, ""), (0, "3"), (1, "4")]) - alice #$> ("/ttl", id, "old messages are set to be deleted after: 2 second(s)") + alice #$> ("/_ttl 1", id, "old messages are set to be deleted after: 2 second(s)") alice #$> ("/ttl week", id, "ok") alice #$> ("/ttl", id, "old messages are set to be deleted after: one week") alice #$> ("/ttl none", id, "ok") @@ -5042,7 +5042,7 @@ itemId i = show $ length chatFeatures + i getChats :: (Eq a, Show a) => ([(String, String, Maybe ConnStatus)] -> [a]) -> TestCC -> [a] -> Expectation getChats f cc res = do - cc ##> "/_get chats pcc=on" + cc ##> "/_get chats 1 pcc=on" line <- getTermLine cc f (read line) `shouldMatchList` res From 41e873d5ca41217953adf5fe758efe8831d67e0a Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 11 Jan 2023 11:00:28 +0400 Subject: [PATCH 03/59] core: multiple users view, tests (#1710) --- src/Simplex/Chat/Store.hs | 1 + src/Simplex/Chat/Types.hs | 2 +- src/Simplex/Chat/View.hs | 213 +++++++++++++++++++------------------- tests/ChatTests.hs | 154 ++++++++++++++++++++++++++- 4 files changed, 263 insertions(+), 107 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index e4a43bd73d..0e6d302044 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -423,6 +423,7 @@ createUser :: DB.Connection -> Profile -> Bool -> ExceptT StoreError IO User createUser db Profile {displayName, fullName, image, preferences = userPreferences} activeUser = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime + when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "INSERT INTO users (local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,0,?,?)" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1e34018551..da9e0411f5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -88,7 +88,7 @@ data User = User fullPreferences :: FullPreferences, activeUser :: Bool } - deriving (Show, Generic, FromJSON) + deriving (Eq, Show, Generic, FromJSON) instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 73ec7eb7e3..98979dd620 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -64,31 +64,31 @@ responseToView user_ testView liveItems ts = \case CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] CRChatSuspended -> ["chat suspended"] - CRApiChats _u chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] - CRApiChat _u chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiChats u chats -> withOtherUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] + CRApiChat u chat -> withOtherUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] - CRUserSMPServers _u smpServers _ -> viewSMPServers (L.toList smpServers) testView + CRUserSMPServers u smpServers _ -> withOtherUser u $ viewSMPServers (L.toList smpServers) testView CRSmpTestResult testFailure -> viewSMPTestResult testFailure - CRChatItemTTL _u ttl -> viewChatItemTTL ttl + CRChatItemTTL u ttl -> withOtherUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg - CRContactInfo _u ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile - CRGroupMemberInfo _u g m cStats -> viewGroupMemberInfo g m cStats - CRContactSwitch _u ct progress -> viewContactSwitch ct progress - CRGroupMemberSwitch _u g m progress -> viewGroupMemberSwitch g m progress - CRConnectionVerified _u verified code -> [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] - CRContactCode _u ct code -> viewContactCode ct code testView - CRGroupMemberCode _u g m code -> viewGroupMemberCode g m code testView - CRNewChatItem _u (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts - CRChatItems _u chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems - CRChatItemId _u itemId -> [plain $ maybe "no item" show itemId] - CRChatItemStatusUpdated _u _ -> [] - CRChatItemUpdated _u (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item liveItems ts - CRChatItemDeleted _u (AChatItem _ _ chat deletedItem) toItem byUser timed -> unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts - CRChatItemDeletedNotFound _u Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] - CRBroadcastSent _u mc n t -> viewSentBroadcast mc n ts t - CRMsgIntegrityError _u mErr -> viewMsgIntegrityError mErr + CRContactInfo u ct cStats customUserProfile -> withOtherUser u $ viewContactInfo ct cStats customUserProfile + CRGroupMemberInfo u g m cStats -> withOtherUser u $ viewGroupMemberInfo g m cStats + CRContactSwitch u ct progress -> withOtherUser u $ viewContactSwitch ct progress + CRGroupMemberSwitch u g m progress -> withOtherUser u $ viewGroupMemberSwitch g m progress + CRConnectionVerified u verified code -> withOtherUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] + CRContactCode u ct code -> withOtherUser u $ viewContactCode ct code testView + CRGroupMemberCode u g m code -> withOtherUser u $ viewGroupMemberCode g m code testView + CRNewChatItem u (AChatItem _ _ chat item) -> withOtherUser u $ unmuted chat item $ viewChatItem chat item False ts + CRChatItems u chatItems -> withOtherUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems + CRChatItemId u itemId -> withOtherUser u [plain $ maybe "no item" show itemId] + CRChatItemStatusUpdated u _ -> withOtherUser u [] + CRChatItemUpdated u (AChatItem _ _ chat item) -> withOtherUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> withOtherUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts + CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> withOtherUser u [ttyFrom $ c <> "> [deleted - original message not found]"] + CRBroadcastSent u mc n t -> withOtherUser u $ viewSentBroadcast mc n ts t + CRMsgIntegrityError u mErr -> withOtherUser u $ viewMsgIntegrityError mErr CRCmdAccepted _ -> [] - CRCmdOk _u -> ["ok"] + CRCmdOk u_ -> withOtherUser' u_ ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo HSFiles -> filesHelpInfo @@ -98,64 +98,61 @@ responseToView user_ testView liveItems ts = \case HSMarkdown -> markdownInfo HSSettings -> settingsInfo CRWelcome user -> chatWelcome user - CRContactsList _u cs -> viewContactsList cs - CRUserContactLink _u UserContactLink {connReqContact, autoAccept} -> connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept - CRUserContactLinkUpdated _u UserContactLink {autoAccept} -> autoAcceptStatus_ autoAccept - CRContactRequestRejected _u UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] - CRGroupCreated _u g -> viewGroupCreated g - CRGroupMembers _u g -> viewGroupMembers g - CRGroupsList _u gs -> viewGroupsList gs - CRSentGroupInvitation _u g c _ -> - if viaGroupLink . contactConn $ c - then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] - else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - CRFileTransferStatus _u ftStatus -> viewFileTransferStatus ftStatus - CRUserProfile _u p -> viewUserProfile p - CRUserProfileNoChange _u -> ["user profile did not change"] + CRContactsList u cs -> withOtherUser u $ viewContactsList cs + CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> withOtherUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLinkUpdated u UserContactLink {autoAccept} -> withOtherUser u $ autoAcceptStatus_ autoAccept + CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> withOtherUser u [ttyContact c <> ": contact request rejected"] + CRGroupCreated u g -> withOtherUser u $ viewGroupCreated g + CRGroupMembers u g -> withOtherUser u $ viewGroupMembers g + CRGroupsList u gs -> withOtherUser u $ viewGroupsList gs + CRSentGroupInvitation u g c _ -> + withOtherUser u $ + if viaGroupLink . contactConn $ c + then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] + else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + CRFileTransferStatus u ftStatus -> withOtherUser u $ viewFileTransferStatus ftStatus + CRUserProfile u p -> withOtherUser u $ viewUserProfile p + CRUserProfileNoChange u -> withOtherUser u ["user profile did not change"] CRVersionInfo _ -> [plain versionStr, plain updateStr] - CRInvitation _u cReq -> viewConnReqInvitation cReq - CRSentConfirmation _u -> ["confirmation sent!"] - CRSentInvitation _u customUserProfile -> viewSentInvitation customUserProfile testView - CRContactDeleted _u c -> [ttyContact' c <> ": contact is deleted"] - CRChatCleared _u chatInfo -> viewChatCleared chatInfo - CRAcceptingContactRequest _u c -> [ttyFullContact c <> ": accepting contact request..."] - CRContactAlreadyExists _u c -> [ttyFullContact c <> ": contact already exists"] - CRContactRequestAlreadyAccepted _u c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] - CRUserContactLinkCreated _u cReq -> connReqContact_ "Your new chat address is created!" cReq - CRUserContactLinkDeleted _u -> viewUserContactLinkDeleted - CRUserAcceptedGroupSent _u _g _ -> [] -- [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember _u g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] - CRLeftMemberUser _u g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser _u g -> [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted _u ci -> savingFile' ci - CRRcvFileAcceptedSndCancelled _u ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled _u _ ftm fts -> viewSndGroupFileCancelled ftm fts - CRRcvFileCancelled _u ft -> receivingFile_ "cancelled" ft - CRUserProfileUpdated _u p p' -> viewUserProfileUpdated p p' - CRContactPrefsUpdated {user = _u, fromContact, toContact} -> case user_ of - Just user -> viewUserContactPrefsUpdated user fromContact toContact - _ -> ["unexpected chat event CRContactPrefsUpdated without current user"] - CRContactAliasUpdated _u c -> viewContactAliasUpdated c - CRConnectionAliasUpdated _u c -> viewConnectionAliasUpdated c - CRContactUpdated {user = _u, fromContact = c, toContact = c'} -> case user_ of - Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' - _ -> ["unexpected chat event CRContactUpdated without current user"] - CRContactsMerged _u intoCt mergedCt -> viewContactsMerged intoCt mergedCt - CRReceivedContactRequest _u UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile - CRRcvFileStart _u ci -> receivingFile_' "started" ci - CRRcvFileComplete _u ci -> receivingFile_' "completed" ci - CRRcvFileSndCancelled _u ft -> viewRcvFileSndCancelled ft - CRSndFileStart _u _ ft -> sendingFile_ "started" ft - CRSndFileComplete _u _ ft -> sendingFile_ "completed" ft + CRInvitation u cReq -> withOtherUser u $ viewConnReqInvitation cReq + CRSentConfirmation u -> withOtherUser u ["confirmation sent!"] + CRSentInvitation u customUserProfile -> withOtherUser u $ viewSentInvitation customUserProfile testView + CRContactDeleted u c -> withOtherUser u [ttyContact' c <> ": contact is deleted"] + CRChatCleared u chatInfo -> withOtherUser u $ viewChatCleared chatInfo + CRAcceptingContactRequest u c -> withOtherUser u [ttyFullContact c <> ": accepting contact request..."] + CRContactAlreadyExists u c -> withOtherUser u [ttyFullContact c <> ": contact already exists"] + CRContactRequestAlreadyAccepted u c -> withOtherUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CRUserContactLinkCreated u cReq -> withOtherUser u $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted u -> withOtherUser u viewUserContactLinkDeleted + CRUserAcceptedGroupSent u _g _ -> withOtherUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRUserDeletedMember u g m -> withOtherUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRLeftMemberUser u g -> withOtherUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRGroupDeletedUser u g -> withOtherUser u [ttyGroup' g <> ": you deleted the group"] + CRRcvFileAccepted u ci -> withOtherUser u $ savingFile' ci + CRRcvFileAcceptedSndCancelled u ft -> withOtherUser u $ viewRcvFileSndCancelled ft + CRSndGroupFileCancelled u _ ftm fts -> withOtherUser u $ viewSndGroupFileCancelled ftm fts + CRRcvFileCancelled u ft -> withOtherUser u $ receivingFile_ "cancelled" ft + CRUserProfileUpdated u p p' -> withOtherUser u $ viewUserProfileUpdated p p' + CRContactPrefsUpdated {user = u, fromContact, toContact} -> withOtherUser u $ viewUserContactPrefsUpdated u fromContact toContact + CRContactAliasUpdated u c -> withOtherUser u $ viewContactAliasUpdated c + CRConnectionAliasUpdated u c -> withOtherUser u $ viewConnectionAliasUpdated c + CRContactUpdated {user = u, fromContact = c, toContact = c'} -> withOtherUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CRContactsMerged u intoCt mergedCt -> withOtherUser u $ viewContactsMerged intoCt mergedCt + CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> withOtherUser u $ viewReceivedContactRequest c profile + CRRcvFileStart u ci -> withOtherUser u $ receivingFile_' "started" ci + CRRcvFileComplete u ci -> withOtherUser u $ receivingFile_' "completed" ci + CRRcvFileSndCancelled u ft -> withOtherUser u $ viewRcvFileSndCancelled ft + CRSndFileStart u _ ft -> withOtherUser u $ sendingFile_ "started" ft + CRSndFileComplete u _ ft -> withOtherUser u $ sendingFile_ "completed" ft CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft - CRSndFileRcvCancelled _u _ ft@SndFileTransfer {recipientDisplayName = c} -> - [ttyContact c <> " cancelled receiving " <> sndFile ft] - CRContactConnecting _u _ -> [] - CRContactConnected _u ct userCustomProfile -> viewContactConnected ct userCustomProfile testView - CRContactAnotherClient _u c -> [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd _u acEntity -> [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] - CRContactsDisconnected _u srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed _u srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> + withOtherUser u $ [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRContactConnecting u _ -> withOtherUser u [] + CRContactConnected u ct userCustomProfile -> withOtherUser u $ viewContactConnected ct userCustomProfile testView + CRContactAnotherClient u c -> withOtherUser u [ttyContact' c <> ": contact is connected to another client"] + CRSubscriptionEnd u acEntity -> withOtherUser u [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] + CRContactsDisconnected u srv cs -> withOtherUser u [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed u srv cs -> withOtherUser u [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" @@ -169,26 +166,26 @@ responseToView user_ testView liveItems ts = \case addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRGroupInvitation g -> [groupInvitation' g] - CRReceivedGroupInvitation _u g c role -> viewReceivedGroupInvitation g c role - CRUserJoinedGroup _u g _ -> viewUserJoinedGroup g - CRJoinedGroupMember _u g m -> viewJoinedGroupMember g m + CRReceivedGroupInvitation u g c role -> withOtherUser u $ viewReceivedGroupInvitation g c role + CRUserJoinedGroup u g _ -> withOtherUser u $ viewUserJoinedGroup g + CRJoinedGroupMember u g m -> withOtherUser u $ viewJoinedGroupMember g m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting _u g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember _u g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] - CRMemberRole _u g by m r r' -> viewMemberRoleChanged g by m r r' - CRMemberRoleUser _u g m r r' -> viewMemberRoleUserChanged g m r r' - CRDeletedMemberUser _u g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember _u g by m -> [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] - CRLeftMember _u g m -> [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CRJoinedGroupMemberConnecting u g host m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + CRConnectedToGroupMember u g m -> withOtherUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRMemberRole u g by m r r' -> withOtherUser u $ viewMemberRoleChanged g by m r r' + CRMemberRoleUser u g m r r' -> withOtherUser u $ viewMemberRoleUserChanged g m r r' + CRDeletedMemberUser u g by -> withOtherUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g + CRDeletedMember u g by m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRLeftMember u g m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted _u g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] - CRGroupUpdated _u g g' m -> viewGroupUpdated g g' m - CRGroupProfile _u g -> viewGroupProfile g - CRGroupLinkCreated _u g cReq -> groupLink_ "Group link is created!" g cReq - CRGroupLink _u g cReq -> groupLink_ "Group link:" g cReq - CRGroupLinkDeleted _u g -> viewGroupLinkDeleted g + CRGroupDeleted u g m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRGroupUpdated u g g' m -> withOtherUser u $ viewGroupUpdated g g' m + CRGroupProfile u g -> withOtherUser u $ viewGroupProfile g + CRGroupLinkCreated u g cReq -> withOtherUser u $ groupLink_ "Group link is created!" g cReq + CRGroupLink u g cReq -> withOtherUser u $ groupLink_ "Group link:" g cReq + CRGroupLinkDeleted u g -> withOtherUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" @@ -198,16 +195,16 @@ responseToView user_ testView liveItems ts = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation _u RcvCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey - CRCallOffer {user = _u, contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey - CRCallAnswer {user = _u, contact, answer} -> viewCallAnswer contact answer - CRCallExtraInfo {user = _u, contact} -> ["call extra info from " <> ttyContact' contact] - CRCallEnded {user = _u, contact} -> ["call with " <> ttyContact' contact <> " ended"] - CRCallInvitations _u _ -> [] + CRCallInvitation u RcvCallInvitation {contact, callType, sharedKey} -> withOtherUser u $ viewCallInvitation contact callType sharedKey + CRCallOffer {user = u, contact, callType, offer, sharedKey} -> withOtherUser u $ viewCallOffer contact callType offer sharedKey + CRCallAnswer {user = u, contact, answer} -> withOtherUser u $ viewCallAnswer contact answer + CRCallExtraInfo {user = u, contact} -> withOtherUser u ["call extra info from " <> ttyContact' contact] + CRCallEnded {user = u, contact} -> withOtherUser u ["call with " <> ttyContact' contact <> " ended"] + CRCallInvitations u _ -> withOtherUser u [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection _u _ -> [] - CRContactConnectionDeleted _u PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] + CRNewContactConnection u _ -> withOtherUser u [] + CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> withOtherUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] @@ -218,10 +215,18 @@ responseToView user_ testView liveItems ts = \case ] CRAgentStats stats -> map (plain . intercalate ",") stats CRConnectionDisabled entity -> viewConnectionEntityDisabled entity - CRMessageError _u prefix err -> [plain prefix <> ": " <> plain err] - CRChatCmdError _u e -> viewChatError e - CRChatError _u e -> viewChatError e + CRMessageError u prefix err -> withOtherUser u [plain prefix <> ": " <> plain err] + CRChatCmdError u e -> withOtherUser' u $ viewChatError e + CRChatError u e -> withOtherUser' u $ viewChatError e where + withOtherUser :: User -> [StyledString] -> [StyledString] + withOtherUser = withOtherUser' . Just + withOtherUser' :: Maybe User -> [StyledString] -> [StyledString] + withOtherUser' cmdUser@(Just User {localDisplayName = u}) ss@(s : ss') + | cmdUser /= user_ = "[user: " <> highlight u <> "] " <> s : ss' + | otherwise = ss + withOtherUser' (Just _) [] = [] + withOtherUser' Nothing ss = ss testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index c07a69ade3..6ca6f940bf 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -172,6 +172,9 @@ chatTests = do describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact it "mute/unmute group" testMuteGroup + describe "multiple users" $ do + it "create second user" testCreateSecondUser + it "both users have contact link" testMultipleUserAddresses describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL describe "queue rotation" $ do @@ -4348,6 +4351,126 @@ testMuteGroup = bob ##> "/gs" bob <## "#team" +testCreateSecondUser :: IO () +testCreateSecondUser = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + -- connect using second user + connectUsers alice bob + alice #> "@bob hello" + bob <# "alisa> hello" + bob #> "@alisa hey" + alice <# "bob> hey" + + alice ##> "/user" + showActiveUser alice "alisa" + + alice ##> "/users" + alice <## "alice (Alice)" + alice <## "alisa (active)" + + -- receive message to first user + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + + connectUsers alice cath + + -- set active user to first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/user" + showActiveUser alice "alice (Alice)" + + alice ##> "/users" + alice <## "alice (Alice) (active)" + alice <## "alisa" + + alice <##> bob + + cath #> "@alisa hey alisa" + (alice, "alisa") $<# "cath> hey alisa" + alice ##> "@cath hi cath" + alice <## "no contact cath" + + -- set active user to second user + alice ##> "/_user 2" + showActiveUser alice "alisa" + +testMultipleUserAddresses :: IO () +testMultipleUserAddresses = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLinkAlice <- getContactLink alice True + bob ##> ("/c " <> cLinkAlice) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice @@@ [("@bob", "Voice messages: enabled")] + alice <##> bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + -- connect using second user address + alice ##> "/ad" + cLinkAlisa <- getContactLink alice True + bob ##> ("/c " <> cLinkAlisa) + alice <#? bob + alice #$> ("/_get chats 2 pcc=on", chats, [("<@bob", "")]) + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alisa: contact is connected") + (alice <## "bob (Bob): contact is connected") + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Voice messages: enabled")]) + alice <##> bob + + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + + -- delete first user address + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/da" + alice <## "Your chat address is deleted - accepted contacts will remain connected." + alice <## "To create a new chat address use /ad" + + -- second user receives request when not active + cath ##> ("/c " <> cLinkAlisa) + cath <## "connection request sent!" + alice <## "[user: alisa] cath (Catherine) wants to connect to you!" + alice <## "to accept: /ac cath" + alice <## "to reject: /rc cath (the sender will NOT be notified)" + + -- accept request + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alisa: contact is connected") + (alice <## "cath (Catherine): contact is connected") + alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", "Voice messages: enabled"), ("@bob", "hey")]) + alice <##> cath + + -- first user doesn't have cath as contact + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice @@@ [("@bob", "hey alice")] + testSetChatItemTTL :: IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ @@ -4990,7 +5113,7 @@ connectUsers cc1 cc2 = do showName :: TestCC -> IO String showName (TestCC ChatController {currentUser} _ _ _ _) = do Just User {localDisplayName, profile = LocalProfile {fullName}} <- readTVarIO currentUser - pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")" + pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: String -> TestCC -> TestCC -> IO () createGroup2 gName cc1 cc2 = do @@ -5100,7 +5223,13 @@ itemId :: Int -> String itemId i = show $ length chatFeatures + i (@@@) :: TestCC -> [(String, String)] -> Expectation -(@@@) = getChats . map $ \(ldn, msg, _) -> (ldn, msg) +(@@@) = getChats mapChats + +mapChats :: [(String, String, Maybe ConnStatus)] -> [(String, String)] +mapChats = map $ \(ldn, msg, _) -> (ldn, msg) + +chats :: String -> [(String, String)] +chats = mapChats . read (@@@!) :: TestCC -> [(String, String, Maybe ConnStatus)] -> Expectation (@@@!) = getChats id @@ -5174,6 +5303,9 @@ cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line (?<#) :: TestCC -> String -> Expectation cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line +($<#) :: (TestCC, String) -> String -> Expectation +(cc, uName) $<# line = (dropTime . dropUser uName <$> getTermLine cc) `shouldReturn` line + ( Expectation ( name) cc1 <## ("to reject: /rc " <> name <> " (the sender will NOT be notified)") +dropUser :: String -> String -> String +dropUser uName msg = fromMaybe err $ dropUser_ uName msg + where + err = error $ "invalid user: " <> msg + +dropUser_ :: String -> String -> Maybe String +dropUser_ uName msg = do + let userPrefix = "[user: " <> uName <> "] " + if userPrefix `isPrefixOf` msg + then Just $ drop (length userPrefix) msg + else Nothing + dropTime :: String -> String dropTime msg = fromMaybe err $ dropTime_ msg where @@ -5245,3 +5389,9 @@ lastItemId :: TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" getTermLine cc + +showActiveUser :: TestCC -> String -> Expectation +showActiveUser cc name = do + cc <## ("user profile: " <> name) + cc <## "use /p [] to change it" + cc <## "(the updated profile will be sent to all your contacts)" From 424328b9d101c5d319a50b558b1533a7e33ee34f Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 13 Jan 2023 13:54:07 +0400 Subject: [PATCH 04/59] core: agent users (#1727) --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 7 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- cabal.project | 7 ++- scripts/nix/sha256map.nix | 3 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 62 ++++++++++++------- src/Simplex/Chat/Controller.hs | 14 +++-- .../M20230111_users_agent_user_id.hs | 17 +++++ src/Simplex/Chat/Migrations/chat_schema.sql | 3 +- src/Simplex/Chat/Store.hs | 24 +++---- src/Simplex/Chat/Terminal.hs | 3 +- src/Simplex/Chat/Types.hs | 25 +++++++- stack.yaml | 2 +- tests/ChatClient.hs | 4 +- tests/ChatTests.hs | 6 +- tests/MobileTests.hs | 8 +-- 17 files changed, 132 insertions(+), 61 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index cb92dc2f74..14c921a970 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -481,7 +481,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun testSMPServer(smpServer: String): SMPTestFailure? { - val r = sendCmd(CC.TestSMPServer(smpServer)) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") } + val r = sendCmd(CC.TestSMPServer(userId, smpServer)) return when (r) { is CR.SmpTestResult -> r.smpTestFailure else -> { @@ -1615,7 +1616,7 @@ sealed class CC { class APIGetGroupLink(val groupId: Long): CC() class APIGetUserSMPServers(val userId: Long): CC() class APISetUserSMPServers(val userId: Long, val smpServers: List): CC() - class TestSMPServer(val smpServer: String): CC() + class TestSMPServer(val userId: Long, val smpServer: String): CC() class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() @@ -1686,7 +1687,7 @@ sealed class CC { is APIGetGroupLink -> "/_get link #$groupId" is APIGetUserSMPServers -> "/_smp $userId" is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}" - is TestSMPServer -> "/smp test $smpServer" + is TestSMPServer -> "/smp test $userId $smpServer" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 382237ecad..a7f41b570e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -323,7 +323,8 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws { } func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { - let r = await chatSendCmd(.testSMPServer(smpServer: smpServer)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } + let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) if case let .smpTestResult(testFailure) = r { if let t = testFailure { return .failure(t) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ad44234e77..789aaf2d94 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -48,7 +48,7 @@ public enum ChatCommand { case apiGetGroupLink(groupId: Int64) case apiGetUserSMPServers(userId: Int64) case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg]) - case testSMPServer(smpServer: String) + case testSMPServer(userId: Int64, smpServer: String) case apiSetChatItemTTL(userId: Int64, seconds: Int64?) case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) @@ -132,7 +132,7 @@ public enum ChatCommand { case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))" - case let .testSMPServer(smpServer): return "/smp test \(smpServer)" + case let .testSMPServer(userId, smpServer): return "/smp test \(userId) \(smpServer)" 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))" diff --git a/cabal.project b/cabal.project index cc20078929..35fa723daa 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,12 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 058e3ac55e8577280267f9341ccd7d3e971bc51a + tag: 8e024590bc2b4428e64e625a9c2392908fc5912e + +source-repository-package + type: git + location: https://github.com/simplex-chat/hs-socks.git + tag: a30cc7a79a08d8108316094f8f2f82a0c5e1ac51 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 27b95fbfd0..b47943f56e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,6 @@ { - "https://github.com/simplex-chat/simplexmq.git"."058e3ac55e8577280267f9341ccd7d3e971bc51a" = "1rw0j3d5higdrq5klsgnj8b8zfh08g5zv72hqcm7wkw1mmllpfrk"; + "https://github.com/simplex-chat/simplexmq.git"."8e024590bc2b4428e64e625a9c2392908fc5912e" = "0rgsf1jz2dpqbdpdfpajsi8gry47jl8jqgw13dfxr3ll9v7pr4sf"; + "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 93c734e06a..8747829b4f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -74,6 +74,7 @@ library Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status Simplex.Chat.Migrations.M20221230_idxs Simplex.Chat.Migrations.M20230107_connections_auth_err_counter + Simplex.Chat.Migrations.M20230111_users_agent_user_id Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2642a856ea..011576dfad 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -94,7 +94,7 @@ defaultChatConfig = }, yesToMigrations = False, defaultServers = - InitialAgentServers + DefaultAgentServers { smp = _defaultSMPServers, ntf = _defaultNtfServers, netCfg = defaultNetworkConfig @@ -162,19 +162,25 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs, cleanupManagerAsync, timedItemThreads, showLiveItems} where - configServers :: InitialAgentServers + configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe (smp defaultServers) (nonEmpty smpServers) + let smp' = fromMaybe (smp (defaultServers :: DefaultAgentServers)) (nonEmpty smpServers) in defaultServers {smp = smp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers - agentServers config@ChatConfig {defaultServers = ss@InitialAgentServers {smp}} = do - smp' <- maybe (pure smp) userServers user - pure ss {smp = smp'} + agentServers config@ChatConfig {defaultServers = DefaultAgentServers {smp, ntf, netCfg}} = do + users <- withTransaction chatStore getUsers + smp' <- case users of + [] -> pure $ M.fromList [(1, smp)] + _ -> M.fromList <$> initialServers users + pure InitialAgentServers {smp = smp', ntf, netCfg} where + initialServers :: [User] -> IO [(UserId, NonEmpty SMPServerWithAuth)] + initialServers = mapM (\u -> (aUserId u,) <$> userServers u) + userServers :: User -> IO (NonEmpty SMPServerWithAuth) userServers user' = activeAgentServers config <$> withTransaction chatStore (`getSMPServers` user') activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth -activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp}} = +activeAgentServers ChatConfig {defaultServers = DefaultAgentServers {smp}} = fromMaybe smp . nonEmpty . map (\ServerCfg {server} -> server) @@ -264,11 +270,17 @@ processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser p -> do u <- asks currentUser - user <- withStore $ \db -> createUser db p True + -- TODO option to choose current user servers + DefaultAgentServers {smp} <- asks $ defaultServers . config + auId <- + withStore' getUsers >>= \case + [] -> pure 1 + _ -> withAgent (`createUser` smp) + user <- withStore $ \db -> createUserRecord db (AgentUserId auId) p True atomically . writeTVar u $ Just user pure $ CRActiveUser user ListUsers -> do - users <- withStore' $ \db -> getUsers db + users <- withStore' getUsers pure $ CRUsersList users APISetActiveUser userId -> do u <- asks currentUser @@ -359,7 +371,7 @@ processChatCommand = \case (agentConnId_, fileConnReq) <- if isJust fileInline then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a True SCMInvitation Nothing) + else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing) let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq, fileInline} withStore' $ \db -> do @@ -773,7 +785,7 @@ processChatCommand = \case pure CRNtfMessages {user, connEntity, msgTs = msgTs', ntfMessages} APIGetUserSMPServers cmdUserId -> withUser $ \user -> do checkCorrectCmdUser cmdUserId user - ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config + ChatConfig {defaultServers = DefaultAgentServers {smp = defaultSMPServers}} <- asks config smpServers <- withStore' (`getSMPServers` user) let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers pure $ CRUserSMPServers user smpServers' defaultSMPServers @@ -785,11 +797,13 @@ processChatCommand = \case checkCorrectCmdUser cmdUserId user withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config - withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers + withAgent $ \a -> setSMPServers a (aUserId user) $ activeAgentServers cfg smpServers pure $ CRCmdOk (Just user) SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig - TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer) + TestSMPServer cmdUserId smpServer -> withUser $ \user -> do + checkCorrectCmdUser cmdUserId user + CRSmpTestResult <$> (withAgent $ \a -> testSMPServerConnection a (aUserId user) smpServer) APISetChatItemTTL cmdUserId newTTL_ -> withUser' $ \user -> do checkCorrectCmdUser cmdUserId user checkStoreNotChanged $ @@ -921,7 +935,7 @@ processChatCommand = \case -- [incognito] generate profile for connection incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq @@ -933,7 +947,7 @@ processChatCommand = \case incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a True cReq . directMessage $ XInfo profileToSend + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user @@ -957,7 +971,7 @@ processChatCommand = \case processChatCommand $ APIListContacts userId APICreateMyAddress cmdUserId -> withUser $ \user@User {userId} -> withChatLock "createMyAddress" . procCmd $ do checkCorrectCmdUser cmdUserId user - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing withStore $ \db -> createUserContactLink db userId connId cReq pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> @@ -1047,7 +1061,7 @@ processChatCommand = \case case contactMember contact members of Nothing -> do gVar <- asks idsDrg - (agentConnId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -1063,7 +1077,7 @@ processChatCommand = \case APIJoinGroup groupId -> withUser $ \user@User {userId} -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock "joinGroup" . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -1180,7 +1194,7 @@ processChatCommand = \case unless (memberActive membership) $ throwChatError CEGroupMemberNotActive groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact $ Just crClientData + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId pure $ CRGroupLinkCreated user gInfo cReq APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do @@ -1388,7 +1402,7 @@ processChatCommand = \case incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId) + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId toView $ CRNewContactConnection user conn @@ -3563,13 +3577,13 @@ markGroupCIDeleted user gInfo ci@(CChatItem msgDir deletedItem) msgId byUser = d createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) enableNtfs cMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode pure (cmdId, connId) joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> m (CommandId, ConnId) joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) enableNtfs cReqUri cInfo + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () @@ -3684,7 +3698,7 @@ getCreateActiveUser st = do loop = do displayName <- getContactName fullName <- T.pack <$> getWithPrompt "full name (optional)" - withTransaction st (\db -> runExceptT $ createUser db Profile {displayName, fullName, image = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName, image = Nothing, preferences = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -3848,7 +3862,7 @@ chatCommandP = "/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/smp_servers" $> GetUserSMPServers, "/smp default" $> SetUserSMPServers (SMPServersConfig []), - "/smp test " *> (TestSMPServer <$> strP), + "/smp test " *> (TestSMPServer <$> A.decimal <* A.space <*> strP), "/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP), "/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/_smp " *> (APIGetUserSMPServers <$> A.decimal), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 024b5d62a3..fae79d8ca9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -43,7 +43,7 @@ import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) @@ -51,7 +51,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags) +import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags, NtfServer) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport.Client (TransportHost) import System.IO (Handle) @@ -70,7 +70,7 @@ updateStr = "To update run: curl -o- https://raw.githubusercontent.com/simplex-c data ChatConfig = ChatConfig { agentConfig :: AgentConfig, yesToMigrations :: Bool, - defaultServers :: InitialAgentServers, + defaultServers :: DefaultAgentServers, tbqSize :: Natural, fileChunkSize :: Integer, inlineFiles :: InlineFilesConfig, @@ -80,6 +80,12 @@ data ChatConfig = ChatConfig testView :: Bool } +data DefaultAgentServers = DefaultAgentServers + { smp :: NonEmpty SMPServerWithAuth, + ntf :: [NtfServer], + netCfg :: NetworkConfig + } + data InlineFilesConfig = InlineFilesConfig { offerChunks :: Integer, sendChunks :: Integer, @@ -203,7 +209,7 @@ data ChatCommand | GetUserSMPServers | APISetUserSMPServers UserId SMPServersConfig | SetUserSMPServers SMPServersConfig - | TestSMPServer SMPServerWithAuth + | TestSMPServer UserId SMPServerWithAuth | APISetChatItemTTL UserId (Maybe Int64) | SetChatItemTTL (Maybe Int64) | APIGetChatItemTTL UserId diff --git a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs b/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs new file mode 100644 index 0000000000..531c776a33 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs @@ -0,0 +1,17 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230111_users_agent_user_id where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230111_users_agent_user_id :: Query +m20230111_users_agent_user_id = + [sql| +PRAGMA ignore_check_constraints=ON; + +ALTER TABLE users ADD COLUMN agent_user_id INTEGER CHECK (agent_user_id NOT NULL); +UPDATE users SET agent_user_id = 1; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 84059e9ebf..dec6982769 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -29,7 +29,8 @@ CREATE TABLE users( local_display_name TEXT NOT NULL UNIQUE, active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- 1 for active user + updated_at TEXT CHECK(updated_at NOT NULL), + agent_user_id INTEGER CHECK(agent_user_id NOT NULL), -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index c415f3b949..f690de6fbd 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -25,7 +25,7 @@ module Simplex.Chat.Store createChatStore, chatStoreFile, agentStoreFile, - createUser, + createUserRecord, getUsers, setActiveUser, getSetActiveUser, @@ -330,6 +330,7 @@ import Simplex.Chat.Migrations.M20221222_chat_ts import Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status import Simplex.Chat.Migrations.M20221230_idxs import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter +import Simplex.Chat.Migrations.M20230111_users_agent_user_id import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -390,7 +391,8 @@ schemaMigrations = ("20221222_chat_ts", m20221222_chat_ts), ("20221223_idx_chat_items_item_status", m20221223_idx_chat_items_item_status), ("20221230_idxs", m20221230_idxs), - ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter) + ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter), + ("20230111_users_agent_user_id", m20230111_users_agent_user_id) ] -- | The list of migrations in ascending order by date @@ -419,15 +421,15 @@ handleSQLError err e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" -createUser :: DB.Connection -> Profile -> Bool -> ExceptT StoreError IO User -createUser db Profile {displayName, fullName, image, preferences = userPreferences} activeUser = +createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User +createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, preferences = userPreferences} activeUser = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db - "INSERT INTO users (local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,0,?,?)" - (displayName, activeUser, currentTs, currentTs) + "INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)" + (auId, displayName, activeUser, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -444,7 +446,7 @@ createUser db Profile {displayName, fullName, image, preferences = userPreferenc (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser (userId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) + pure $ toUser (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) getUsers :: DB.Connection -> IO [User] getUsers db = @@ -453,16 +455,16 @@ getUsers db = userQuery :: Query userQuery = [sql| - SELECT u.user_id, u.contact_id, cp.contact_profile_id, u.active_user, u.local_display_name, cp.full_name, cp.image, cp.preferences + SELECT u.user_id, u.agent_user_id, u.contact_id, cp.contact_profile_id, u.active_user, u.local_display_name, cp.full_name, cp.image, cp.preferences FROM users u JOIN contacts ct ON ct.contact_id = u.contact_id JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id |] -toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User -toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = +toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User +toUser (userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""} - in User {userId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences} + in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences} setActiveUser :: DB.Connection -> UserId -> IO () setActiveUser db userId = do diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index dd777ff793..f7cac37534 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -17,7 +17,6 @@ import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output -import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..)) import Simplex.Messaging.Client (defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) import System.Exit (exitFailure) @@ -26,7 +25,7 @@ terminalChatConfig :: ChatConfig terminalChatConfig = defaultChatConfig { defaultServers = - InitialAgentServers + DefaultAgentServers { smp = L.fromList [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index da9e0411f5..55d26561fa 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -80,8 +80,31 @@ instance IsContact Contact where preferences' Contact {profile = LocalProfile {preferences}} = preferences {-# INLINE preferences' #-} +newtype AgentUserId = AgentUserId UserId + deriving (Eq, Show) + +instance StrEncoding AgentUserId where + strEncode (AgentUserId uId) = strEncode uId + strDecode s = AgentUserId <$> strDecode s + strP = AgentUserId <$> strP + +instance FromJSON AgentUserId where + parseJSON = strParseJSON "AgentUserId" + +instance ToJSON AgentUserId where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f + +instance ToField AgentUserId where toField (AgentUserId uId) = toField uId + +aUserId :: User -> UserId +aUserId User {agentUserId = AgentUserId uId} = uId + data User = User { userId :: UserId, + agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, profile :: LocalProfile, @@ -92,7 +115,7 @@ data User = User instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions -type UserId = ContactId +type UserId = Int64 type ContactId = Int64 diff --git a/stack.yaml b/stack.yaml index 87b0fe5172..1558136b14 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 058e3ac55e8577280267f9341ccd7d3e971bc51a + commit: 8e024590bc2b4428e64e625a9c2392908fc5912e # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 15674bfef8..75b0ddf0dd 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (Profile, User (..)) +import Simplex.Chat.Types (AgentUserId (..), Profile, User (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) @@ -108,7 +108,7 @@ testCfgV1 = testCfg {agentConfig = testAgentCfgV1} createTestChat :: ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat cfg opts@ChatOpts {dbKey} dbPrefix profile = do db@ChatDatabase {chatStore} <- createChatDatabase (testDBPrefix <> dbPrefix) dbKey False - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUser db' profile True + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: ChatConfig -> ChatOpts -> String -> IO TestCC diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 580b206967..b0b42864f3 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -3800,14 +3800,14 @@ testTestSMPServerConnection :: IO () testTestSMPServerConnection = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001" + alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001" alice <## "SMP server test passed" -- to test with password: -- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH" -- alice <## "Server requires authorization to create queues, check password" - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" + alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" alice <## "SMP server test passed" - alice ##> "/smp test smp://LcJU@localhost:5001" + alice ##> "/smp test 1 smp://LcJU@localhost:5001" alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:5001 NETWORK" alice <## "Possibly, certificate fingerprint in server address is incorrect" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index ada96f42b6..dc41be9041 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -7,7 +7,7 @@ import ChatTests import Control.Monad.Except import Simplex.Chat.Mobile import Simplex.Chat.Store -import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Test.Hspec mobileTests :: Spec @@ -32,9 +32,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\" activeUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}" +activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}" #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}" +activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}" #endif chatStarted :: String @@ -93,7 +93,7 @@ testChatApi = withTmpFiles $ do let dbPrefix = testDBPrefix <> "1" f = chatStoreFile dbPrefix st <- createChatStore f "myKey" True - Right _ <- withTransaction st $ \db -> runExceptT $ createUser db aliceProfile {preferences = Nothing} True + Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" From 892b91e958d32bef8409bec4680adaf6e0636b58 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 13 Jan 2023 15:14:46 +0400 Subject: [PATCH 05/59] core: update simplexmq (subscribe users in different sessions) (#1734) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 35fa723daa..67663f5921 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8e024590bc2b4428e64e625a9c2392908fc5912e + tag: 9c9ba8c25c9a6e6042cff184c0fd72524134755e source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b47943f56e..6fa250a343 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8e024590bc2b4428e64e625a9c2392908fc5912e" = "0rgsf1jz2dpqbdpdfpajsi8gry47jl8jqgw13dfxr3ll9v7pr4sf"; + "https://github.com/simplex-chat/simplexmq.git"."9c9ba8c25c9a6e6042cff184c0fd72524134755e" = "1aabrljnwy86ld5nvv3842w76z7fpp2zac2a5655mj16r4hja1vx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/stack.yaml b/stack.yaml index 1558136b14..0e2c105a79 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 8e024590bc2b4428e64e625a9c2392908fc5912e + commit: 9c9ba8c25c9a6e6042cff184c0fd72524134755e # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 From e63e158b2d8d4ff575d29efedc343ae5e0d00b31 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 13 Jan 2023 12:24:54 +0000 Subject: [PATCH 06/59] core: refactor withUserId (#1735) * refactor withUserId * update * more --- src/Simplex/Chat.hs | 85 +++++++-------- src/Simplex/Chat/Store.hs | 18 ++-- src/Simplex/Chat/Types.hs | 2 +- src/Simplex/Chat/View.hs | 210 +++++++++++++++++++------------------- 4 files changed, 151 insertions(+), 164 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 388add5475..9ba419de1b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -175,7 +175,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen pure InitialAgentServers {smp = smp', ntf, netCfg} where initialServers :: [User] -> IO [(UserId, NonEmpty SMPServerWithAuth)] - initialServers = mapM (\u -> (aUserId u,) <$> userServers u) + initialServers = mapM $ \u -> (aUserId u,) <$> userServers u userServers :: User -> IO (NonEmpty SMPServerWithAuth) userServers user' = activeAgentServers config <$> withTransaction chatStore (`getSMPServers` user') @@ -328,8 +328,7 @@ processChatCommand = \case APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) - APIGetChats cmdUserId withPCC -> withUser' $ \user -> do - checkCorrectCmdUser cmdUserId user + APIGetChats userId withPCC -> withUserId userId $ \user -> do chats <- withStore' $ \db -> getChatPreviews db user withPCC pure $ CRApiChats user chats APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of @@ -739,8 +738,7 @@ processChatCommand = \case (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) updateCallItemStatus userId ct call WCSDisconnected $ Just msgId pure Nothing - APIGetCallInvitations cmdUserId -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + APIGetCallInvitations userId -> withUserId userId $ \user -> do calls <- asks currentCalls >>= readTVarIO let invs = mapMaybe callInvitation $ M.elems calls rcvCallInvitations <- mapM (rcvCallInvitation user) invs @@ -755,9 +753,7 @@ processChatCommand = \case APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \userId ct call -> updateCallItemStatus userId ct call receivedStatus Nothing $> Just call - APIUpdateProfile cmdUserId profile -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user - updateProfile user profile + APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId updateContactPrefs user ct prefs' @@ -778,15 +774,13 @@ processChatCommand = \case pure $ CRNtfTokenStatus tokenStatus APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk Nothing APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk Nothing - APIGetNtfMessage cmdUserId nonce encNtfInfo -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + APIGetNtfMessage userId nonce encNtfInfo -> withUserId userId $ \user -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing pure CRNtfMessages {user, connEntity, msgTs = msgTs', ntfMessages} - APIGetUserSMPServers cmdUserId -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + APIGetUserSMPServers userId -> withUserId userId $ \user -> do ChatConfig {defaultServers = DefaultAgentServers {smp = defaultSMPServers}} <- asks config smpServers <- withStore' (`getSMPServers` user) let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers @@ -795,19 +789,17 @@ processChatCommand = \case toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} GetUserSMPServers -> withUser $ \User {userId} -> processChatCommand $ APIGetUserSMPServers userId - APISetUserSMPServers cmdUserId (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do - checkCorrectCmdUser cmdUserId user + APISetUserSMPServers userId (SMPServersConfig smpServers) -> withUserId userId $ \user -> withChatLock "setUserSMPServers" $ do withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config withAgent $ \a -> setSMPServers a (aUserId user) $ activeAgentServers cfg smpServers pure $ CRCmdOk (Just user) SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig - TestSMPServer cmdUserId smpServer -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + TestSMPServer userId smpServer -> withUserId userId $ \user -> CRSmpTestResult <$> (withAgent $ \a -> testSMPServerConnection a (aUserId user) smpServer) - APISetChatItemTTL cmdUserId newTTL_ -> withUser' $ \user -> do - checkCorrectCmdUser cmdUserId user + APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do + checkSameUser userId user checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do case newTTL_ of @@ -824,8 +816,7 @@ processChatCommand = \case pure $ CRCmdOk (Just user) SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ - APIGetChatItemTTL cmdUserId -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + APIGetChatItemTTL userId -> withUserId userId $ \user -> do ttl <- withStore' (`getChatItemTTL` user) pure $ CRChatItemTTL user ttl GetChatItemTTL -> withUser' $ \User {userId} -> do @@ -932,31 +923,26 @@ processChatCommand = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact cmdUserId -> withUser $ \user@User {userId} -> withChatLock "addContact" . procCmd $ do - checkCorrectCmdUser cmdUserId user + APIAddContact userId -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing - conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile toView $ CRNewContactConnection user conn pure $ CRInvitation user cReq AddContact -> withUser $ \User {userId} -> processChatCommand $ APIAddContact userId - APIConnect cmdUserId (Just (ACR SCMInvitation cReq)) -> withUser $ \user@User {userId} -> withChatLock "connect" . procCmd $ do - checkCorrectCmdUser cmdUserId user + APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do -- [incognito] generate profile to send incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend - conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnJoined $ incognitoProfile $> profileToSend + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user - APIConnect cmdUserId (Just (ACR SCMContact cReq)) -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user - -- [incognito] generate profile to send - connectViaContact user cReq + APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq) APIConnect _ Nothing -> throwChatError CEInvalidConnReq Connect cReqUri -> withUser $ \User {userId} -> processChatCommand $ APIConnect userId cReqUri @@ -965,21 +951,17 @@ processChatCommand = \case connectViaContact user adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect - APIListContacts cmdUserId -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user - contacts <- withStore' (`getUserContacts` user) - pure $ CRContactsList user contacts + APIListContacts userId -> withUserId userId $ \user -> + CRContactsList user <$> withStore' (`getUserContacts` user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId - APICreateMyAddress cmdUserId -> withUser $ \user@User {userId} -> withChatLock "createMyAddress" . procCmd $ do - checkCorrectCmdUser cmdUserId user + APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing - withStore $ \db -> createUserContactLink db userId connId cReq + withStore $ \db -> createUserContactLink db user connId cReq pure $ CRUserContactLinkCreated user cReq CreateMyAddress -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId - APIDeleteMyAddress cmdUserId -> withUser $ \user -> withChatLock "deleteMyAddress" $ do - checkCorrectCmdUser cmdUserId user + APIDeleteMyAddress userId -> withUserId userId $ \user -> withChatLock "deleteMyAddress" $ do conns <- withStore (`getUserAddressConnections` user) procCmd $ do forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () @@ -987,15 +969,13 @@ processChatCommand = \case pure $ CRUserContactLinkDeleted user DeleteMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIDeleteMyAddress userId - APIShowMyAddress cmdUserId -> withUser $ \user@User {userId} -> do - checkCorrectCmdUser cmdUserId user - contactLink <- withStore (`getUserAddress` userId) + APIShowMyAddress userId -> withUserId userId $ \user -> do + contactLink <- withStore (`getUserAddress` user) pure $ CRUserContactLink user contactLink ShowMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIShowMyAddress userId - APIAddressAutoAccept cmdUserId autoAccept_ -> withUser $ \user@User {userId} -> do - checkCorrectCmdUser cmdUserId user - contactLink <- withStore (\db -> updateUserAddressAutoAccept db userId autoAccept_) + APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do + contactLink <- withStore (\db -> updateUserAddressAutoAccept db user autoAccept_) pure $ CRUserContactLinkUpdated user contactLink AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> processChatCommand $ APIAddressAutoAccept userId autoAccept_ @@ -1038,10 +1018,9 @@ processChatCommand = \case chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc - APINewGroup cmdUserId gProfile -> withUser $ \user -> do - checkCorrectCmdUser cmdUserId user + APINewGroup userId gProfile -> withUserId userId $ \user -> do gVar <- asks idsDrg - groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) + groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile pure $ CRGroupCreated user groupInfo NewGroup gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId gProfile @@ -1361,8 +1340,6 @@ processChatCommand = \case withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk Nothing checkStoreNotChanged :: m ChatResponse -> m ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) - checkCorrectCmdUser :: UserId -> User -> m () - checkCorrectCmdUser cmdUserId User {userId = activeUserId} = when (cmdUserId /= activeUserId) $ throwChatError (CEDifferentActiveUser cmdUserId activeUserId) withUserName :: UserName -> (UserId -> ChatCommand) -> m ChatResponse withUserName uName cmd = withStore (`getUserIdByName` uName) >>= processChatCommand . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> m ChatResponse @@ -3773,6 +3750,14 @@ withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) +withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse +withUserId userId action = withUser $ \user -> do + checkSameUser userId user + action user + +checkSameUser :: ChatMonad m => UserId -> User -> m () +checkSameUser userId User {userId = activeUserId} = when (userId /= activeUserId) $ throwChatError (CEDifferentActiveUser userId activeUserId) + chatStarted :: ChatMonad m => m Bool chatStarted = fmap isJust . readTVarIO =<< asks agentAsync diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f690de6fbd..bcbfc47216 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -543,8 +543,8 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: DB.Connection -> UserId -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection -createDirectConnection db userId acId cReq pccConnStatus incognitoProfile = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile DB.execute @@ -882,8 +882,8 @@ getUserContactProfiles db User {userId} = toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe Preferences) -> (Profile) toContactProfile (displayName, fullName, image, preferences) = Profile {displayName, fullName, image, preferences} -createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO () -createUserContactLink db userId agentConnId cReq = +createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId cReq = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -991,8 +991,8 @@ toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing -getUserAddress :: DB.Connection -> UserId -> ExceptT StoreError IO UserContactLink -getUserAddress db userId = +getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink +getUserAddress db User {userId} = ExceptT . firstRow toUserContactLink SEUserContactLinkNotFound $ DB.query db @@ -1016,9 +1016,9 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -updateUserAddressAutoAccept :: DB.Connection -> UserId -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink -updateUserAddressAutoAccept db userId autoAccept = do - link <- getUserAddress db userId +updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink +updateUserAddressAutoAccept db user@User {userId} autoAccept = do + link <- getUserAddress db user liftIO updateUserAddressAutoAccept_ $> link {autoAccept} where updateUserAddressAutoAccept_ = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 55d26561fa..ef4bda562d 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -111,7 +111,7 @@ data User = User fullPreferences :: FullPreferences, activeUser :: Bool } - deriving (Eq, Show, Generic, FromJSON) + deriving (Show, Generic, FromJSON) instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 98979dd620..ebcfd49c91 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -64,31 +64,31 @@ responseToView user_ testView liveItems ts = \case CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] CRChatSuspended -> ["chat suspended"] - CRApiChats u chats -> withOtherUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] - CRApiChat u chat -> withOtherUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] + CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] - CRUserSMPServers u smpServers _ -> withOtherUser u $ viewSMPServers (L.toList smpServers) testView + CRUserSMPServers u smpServers _ -> ttyUser u $ viewSMPServers (L.toList smpServers) testView CRSmpTestResult testFailure -> viewSMPTestResult testFailure - CRChatItemTTL u ttl -> withOtherUser u $ viewChatItemTTL ttl + CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg - CRContactInfo u ct cStats customUserProfile -> withOtherUser u $ viewContactInfo ct cStats customUserProfile - CRGroupMemberInfo u g m cStats -> withOtherUser u $ viewGroupMemberInfo g m cStats - CRContactSwitch u ct progress -> withOtherUser u $ viewContactSwitch ct progress - CRGroupMemberSwitch u g m progress -> withOtherUser u $ viewGroupMemberSwitch g m progress - CRConnectionVerified u verified code -> withOtherUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] - CRContactCode u ct code -> withOtherUser u $ viewContactCode ct code testView - CRGroupMemberCode u g m code -> withOtherUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> withOtherUser u $ unmuted chat item $ viewChatItem chat item False ts - CRChatItems u chatItems -> withOtherUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems - CRChatItemId u itemId -> withOtherUser u [plain $ maybe "no item" show itemId] - CRChatItemStatusUpdated u _ -> withOtherUser u [] - CRChatItemUpdated u (AChatItem _ _ chat item) -> withOtherUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts - CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> withOtherUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts - CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> withOtherUser u [ttyFrom $ c <> "> [deleted - original message not found]"] - CRBroadcastSent u mc n t -> withOtherUser u $ viewSentBroadcast mc n ts t - CRMsgIntegrityError u mErr -> withOtherUser u $ viewMsgIntegrityError mErr + CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile + CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats + CRContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress + CRGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress + CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] + CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView + CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts + CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems + CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] + CRChatItemStatusUpdated u _ -> ttyUser u [] + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts + CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] + CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t + CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr CRCmdAccepted _ -> [] - CRCmdOk u_ -> withOtherUser' u_ ["ok"] + CRCmdOk u_ -> ttyUser' u_ ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo HSFiles -> filesHelpInfo @@ -98,61 +98,61 @@ responseToView user_ testView liveItems ts = \case HSMarkdown -> markdownInfo HSSettings -> settingsInfo CRWelcome user -> chatWelcome user - CRContactsList u cs -> withOtherUser u $ viewContactsList cs - CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> withOtherUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept - CRUserContactLinkUpdated u UserContactLink {autoAccept} -> withOtherUser u $ autoAcceptStatus_ autoAccept - CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> withOtherUser u [ttyContact c <> ": contact request rejected"] - CRGroupCreated u g -> withOtherUser u $ viewGroupCreated g - CRGroupMembers u g -> withOtherUser u $ viewGroupMembers g - CRGroupsList u gs -> withOtherUser u $ viewGroupsList gs + CRContactsList u cs -> ttyUser u $ viewContactsList cs + CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept + CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] + CRGroupCreated u g -> ttyUser u $ viewGroupCreated g + CRGroupMembers u g -> ttyUser u $ viewGroupMembers g + CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> - withOtherUser u $ + ttyUser u $ if viaGroupLink . contactConn $ c then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - CRFileTransferStatus u ftStatus -> withOtherUser u $ viewFileTransferStatus ftStatus - CRUserProfile u p -> withOtherUser u $ viewUserProfile p - CRUserProfileNoChange u -> withOtherUser u ["user profile did not change"] + CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus + CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRVersionInfo _ -> [plain versionStr, plain updateStr] - CRInvitation u cReq -> withOtherUser u $ viewConnReqInvitation cReq - CRSentConfirmation u -> withOtherUser u ["confirmation sent!"] - CRSentInvitation u customUserProfile -> withOtherUser u $ viewSentInvitation customUserProfile testView - CRContactDeleted u c -> withOtherUser u [ttyContact' c <> ": contact is deleted"] - CRChatCleared u chatInfo -> withOtherUser u $ viewChatCleared chatInfo - CRAcceptingContactRequest u c -> withOtherUser u [ttyFullContact c <> ": accepting contact request..."] - CRContactAlreadyExists u c -> withOtherUser u [ttyFullContact c <> ": contact already exists"] - CRContactRequestAlreadyAccepted u c -> withOtherUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] - CRUserContactLinkCreated u cReq -> withOtherUser u $ connReqContact_ "Your new chat address is created!" cReq - CRUserContactLinkDeleted u -> withOtherUser u viewUserContactLinkDeleted - CRUserAcceptedGroupSent u _g _ -> withOtherUser u [] -- [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember u g m -> withOtherUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] - CRLeftMemberUser u g -> withOtherUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser u g -> withOtherUser u [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted u ci -> withOtherUser u $ savingFile' ci - CRRcvFileAcceptedSndCancelled u ft -> withOtherUser u $ viewRcvFileSndCancelled ft - CRSndGroupFileCancelled u _ ftm fts -> withOtherUser u $ viewSndGroupFileCancelled ftm fts - CRRcvFileCancelled u ft -> withOtherUser u $ receivingFile_ "cancelled" ft - CRUserProfileUpdated u p p' -> withOtherUser u $ viewUserProfileUpdated p p' - CRContactPrefsUpdated {user = u, fromContact, toContact} -> withOtherUser u $ viewUserContactPrefsUpdated u fromContact toContact - CRContactAliasUpdated u c -> withOtherUser u $ viewContactAliasUpdated c - CRConnectionAliasUpdated u c -> withOtherUser u $ viewConnectionAliasUpdated c - CRContactUpdated {user = u, fromContact = c, toContact = c'} -> withOtherUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRContactsMerged u intoCt mergedCt -> withOtherUser u $ viewContactsMerged intoCt mergedCt - CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> withOtherUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> withOtherUser u $ receivingFile_' "started" ci - CRRcvFileComplete u ci -> withOtherUser u $ receivingFile_' "completed" ci - CRRcvFileSndCancelled u ft -> withOtherUser u $ viewRcvFileSndCancelled ft - CRSndFileStart u _ ft -> withOtherUser u $ sendingFile_ "started" ft - CRSndFileComplete u _ ft -> withOtherUser u $ sendingFile_ "completed" ft + CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq + CRSentConfirmation u -> ttyUser u ["confirmation sent!"] + CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] + CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo + CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] + CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] + CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted + CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRSndGroupFileCancelled u _ ftm fts -> ttyUser u $ viewSndGroupFileCancelled ftm fts + CRRcvFileCancelled u ft -> ttyUser u $ receivingFile_ "cancelled" ft + CRUserProfileUpdated u p p' -> ttyUser u $ viewUserProfileUpdated p p' + CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact + CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c + CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c + CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CRContactsMerged u intoCt mergedCt -> ttyUser u $ viewContactsMerged intoCt mergedCt + CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci + CRRcvFileSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft + CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> - withOtherUser u $ [ttyContact c <> " cancelled receiving " <> sndFile ft] - CRContactConnecting u _ -> withOtherUser u [] - CRContactConnected u ct userCustomProfile -> withOtherUser u $ viewContactConnected ct userCustomProfile testView - CRContactAnotherClient u c -> withOtherUser u [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd u acEntity -> withOtherUser u [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] - CRContactsDisconnected u srv cs -> withOtherUser u [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed u srv cs -> withOtherUser u [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + ttyUser u $ [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRContactConnecting u _ -> ttyUser u [] + CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView + CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CRSubscriptionEnd u acEntity -> ttyUser u [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] + CRContactsDisconnected u srv cs -> ttyUser u [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed u srv cs -> ttyUser u [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" @@ -166,26 +166,26 @@ responseToView user_ testView liveItems ts = \case addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRGroupInvitation g -> [groupInvitation' g] - CRReceivedGroupInvitation u g c role -> withOtherUser u $ viewReceivedGroupInvitation g c role - CRUserJoinedGroup u g _ -> withOtherUser u $ viewUserJoinedGroup g - CRJoinedGroupMember u g m -> withOtherUser u $ viewJoinedGroupMember g m + CRReceivedGroupInvitation u g c role -> ttyUser u $ viewReceivedGroupInvitation g c role + CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting u g host m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember u g m -> withOtherUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] - CRMemberRole u g by m r r' -> withOtherUser u $ viewMemberRoleChanged g by m r r' - CRMemberRoleUser u g m r r' -> withOtherUser u $ viewMemberRoleUserChanged g m r r' - CRDeletedMemberUser u g by -> withOtherUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember u g by m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] - CRLeftMember u g m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + CRConnectedToGroupMember u g m -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' + CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r' + CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g + CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted u g m -> withOtherUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] - CRGroupUpdated u g g' m -> withOtherUser u $ viewGroupUpdated g g' m - CRGroupProfile u g -> withOtherUser u $ viewGroupProfile g - CRGroupLinkCreated u g cReq -> withOtherUser u $ groupLink_ "Group link is created!" g cReq - CRGroupLink u g cReq -> withOtherUser u $ groupLink_ "Group link:" g cReq - CRGroupLinkDeleted u g -> withOtherUser u $ viewGroupLinkDeleted g + CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CRGroupProfile u g -> ttyUser u $ viewGroupProfile g + CRGroupLinkCreated u g cReq -> ttyUser u $ groupLink_ "Group link is created!" g cReq + CRGroupLink u g cReq -> ttyUser u $ groupLink_ "Group link:" g cReq + CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" @@ -195,16 +195,16 @@ responseToView user_ testView liveItems ts = \case ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation u RcvCallInvitation {contact, callType, sharedKey} -> withOtherUser u $ viewCallInvitation contact callType sharedKey - CRCallOffer {user = u, contact, callType, offer, sharedKey} -> withOtherUser u $ viewCallOffer contact callType offer sharedKey - CRCallAnswer {user = u, contact, answer} -> withOtherUser u $ viewCallAnswer contact answer - CRCallExtraInfo {user = u, contact} -> withOtherUser u ["call extra info from " <> ttyContact' contact] - CRCallEnded {user = u, contact} -> withOtherUser u ["call with " <> ttyContact' contact <> " ended"] - CRCallInvitations u _ -> withOtherUser u [] + CRCallInvitation u RcvCallInvitation {contact, callType, sharedKey} -> ttyUser u $ viewCallInvitation contact callType sharedKey + CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey + CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer + CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] + CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] + CRCallInvitations u _ -> ttyUser u [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection u _ -> withOtherUser u [] - CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> withOtherUser u ["connection :" <> sShow pccConnId <> " deleted"] + CRNewContactConnection u _ -> ttyUser u [] + CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] @@ -215,18 +215,20 @@ responseToView user_ testView liveItems ts = \case ] CRAgentStats stats -> map (plain . intercalate ",") stats CRConnectionDisabled entity -> viewConnectionEntityDisabled entity - CRMessageError u prefix err -> withOtherUser u [plain prefix <> ": " <> plain err] - CRChatCmdError u e -> withOtherUser' u $ viewChatError e - CRChatError u e -> withOtherUser' u $ viewChatError e + CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err] + CRChatCmdError u e -> ttyUser' u $ viewChatError e + CRChatError u e -> ttyUser' u $ viewChatError e where - withOtherUser :: User -> [StyledString] -> [StyledString] - withOtherUser = withOtherUser' . Just - withOtherUser' :: Maybe User -> [StyledString] -> [StyledString] - withOtherUser' cmdUser@(Just User {localDisplayName = u}) ss@(s : ss') - | cmdUser /= user_ = "[user: " <> highlight u <> "] " <> s : ss' - | otherwise = ss - withOtherUser' (Just _) [] = [] - withOtherUser' Nothing ss = ss + ttyUser :: User -> [StyledString] -> [StyledString] + ttyUser _ [] = [] + ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + where + userPrefix = case user_ of + Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" + _ -> prefix + prefix = "[user: " <> highlight u <> "] " + ttyUser' :: Maybe User -> [StyledString] -> [StyledString] + ttyUser' = maybe id ttyUser testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where From cccdcef91456d969e05ceafe7365a81df76a03fa Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 13 Jan 2023 16:26:55 +0400 Subject: [PATCH 07/59] core: add delays to tests to prevent output races (#1736) --- tests/ChatTests.hs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index b0b42864f3..134a9e5723 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -256,6 +256,7 @@ testAddContact = versionTestMatrix2 runTestAddContact concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 chatsEmpty alice bob alice #> "@bob hello there 🙂" bob <# "alice> hello there 🙂" @@ -2579,6 +2580,7 @@ testUserContactLink = versionTestMatrix3 $ \alice bob cath -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 alice @@@ [("@bob", "Voice messages: enabled")] alice <##> bob @@ -2590,6 +2592,7 @@ testUserContactLink = versionTestMatrix3 $ \alice bob cath -> do concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@bob", "hey")] alice <##> cath @@ -2692,6 +2695,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@bob", "hey")] alice <##> cath @@ -2764,6 +2768,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@robert", "hey")] alice <##> cath @@ -3410,6 +3415,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ concurrently_ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") + threadDelay 100000 alice @@@ [("@bob", "Voice messages: enabled")] alice ##> "/cs" alice <## "bob (Bob) (alias: friend)" @@ -3815,6 +3821,7 @@ testAsyncInitiatingOffline :: IO () testAsyncInitiatingOffline = withTmpFiles $ do putStrLn "testAsyncInitiatingOffline" inv <- withNewTestChat "alice" aliceProfile $ \alice -> do + threadDelay 250000 putStrLn "1" alice ##> "/c" putStrLn "2" @@ -4446,6 +4453,7 @@ testMultipleUserAddresses = concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 alice @@@ [("@bob", "Voice messages: enabled")] alice <##> bob @@ -4463,6 +4471,7 @@ testMultipleUserAddresses = concurrently_ (bob <## "alisa: contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Voice messages: enabled")]) alice <##> bob @@ -4493,6 +4502,7 @@ testMultipleUserAddresses = concurrently_ (cath <## "alisa: contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", "Voice messages: enabled"), ("@bob", "hey")]) alice <##> cath @@ -4567,6 +4577,7 @@ testGroupLink = bob <## "alice (Alice): contact is connected" bob <## "#team: you joined the group" ] + threadDelay 100000 alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) -- contacts connected via group link are not in chat previews alice @@@ [("#team", "connected")] @@ -4713,6 +4724,7 @@ testGroupLinkContactUsed = bob <## "#team: you joined the group" ] -- sending/receiving a message marks contact as used + threadDelay 100000 alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] alice #> "@bob hello" From 0c3d643408c6dbea79e09dce1f791471b5289774 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 13 Jan 2023 21:01:26 +0400 Subject: [PATCH 08/59] core: expire chat items for all users (#1737) --- src/Simplex/Chat.hs | 111 +++++++++++++++++++-------------- src/Simplex/Chat/Controller.hs | 4 +- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9ba419de1b..0b21795a1f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -155,12 +155,12 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen filesFolder <- newTVarIO optFilesFolder incognitoMode <- newTVarIO False chatStoreChanged <- newTVarIO False - expireCIsAsync <- newTVarIO Nothing - expireCIs <- newTVarIO False + expireCIThreads <- newTVarIO M.empty + expireCIFlags <- newTVarIO M.empty cleanupManagerAsync <- newTVarIO Nothing timedItemThreads <- atomically TM.empty showLiveItems <- newTVarIO False - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs, cleanupManagerAsync, timedItemThreads, showLiveItems} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems} where configServers :: DefaultAgentServers configServers = @@ -187,45 +187,47 @@ activeAgentServers ChatConfig {defaultServers = DefaultAgentServers {smp}} = . filter (\ServerCfg {enabled} -> enabled) startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) -startChatController user subConns enableExpireCIs = do +startChatController currentUser subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient - restoreCalls user + users <- fromRight [] <$> runExceptT (withStore' getUsers) + restoreCalls currentUser s <- asks agentAsync - readTVarIO s >>= maybe (start s) (pure . fst) + readTVarIO s >>= maybe (start s users) (pure . fst) where - start s = do + start s users = do a1 <- async $ race_ notificationSubscriber agentSubscriber a2 <- if subConns - then Just <$> async (void . runExceptT $ subscribeUserConnections Agent.subscribeConnections user) + then Just <$> async (void . runExceptT $ subscribeUserConnections Agent.subscribeConnections currentUser) else pure Nothing atomically . writeTVar s $ Just (a1, a2) startCleanupManager - when enableExpireCIs startExpireCIs + when enableExpireCIs $ startExpireCIs users pure a1 startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case Nothing -> do - a <- Just <$> async (void . runExceptT $ cleanupManager user) + a <- Just <$> async (void . runExceptT $ cleanupManager currentUser) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs = do - expireAsync <- asks expireCIsAsync - readTVarIO expireAsync >>= \case - Nothing -> do - a <- Just <$> async (void $ runExceptT runExpireCIs) - atomically $ writeTVar expireAsync a - setExpireCIs True - _ -> setExpireCIs True - runExpireCIs = forever $ do - -- TODO per user - flip catchError (toView . CRChatError (Just user)) $ do - expire <- asks expireCIs - atomically $ readTVar expire >>= \b -> unless b retry - ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False - threadDelay $ 1800 * 1000000 -- 30 minutes + startExpireCIs users = do + expireThreads <- asks expireCIThreads + forM_ users $ \u@User {userId} -> + atomically (TM.lookup userId expireThreads) >>= \case + Nothing -> do + a <- Just <$> async (void . runExceptT $ runExpireCIs u) + atomically $ TM.insert userId a expireThreads + setExpireCIFlag u True + _ -> setExpireCIFlag u True + where + runExpireCIs u@User {userId} = forever $ do + flip catchError (toView . CRChatError (Just u)) $ do + expireFlags <- asks expireCIFlags + atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry + ttl <- withStore' (`getChatItemTTL` u) + forM_ ttl $ \t -> expireChatItems u t False + threadDelay $ 1800 * 1000000 -- 30 minutes restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () restoreCalls user = do @@ -235,13 +237,14 @@ restoreCalls user = do atomically $ writeTVar calls callsMap stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIs} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles closeFiles rcvFiles atomically $ do - writeTVar expireCIs False + keys <- M.keys <$> readTVar expireCIFlags + forM_ keys $ \k -> TM.insert k False expireCIFlags writeTVar s Nothing where closeFiles :: TVar (Map Int64 Handle) -> m () @@ -304,10 +307,10 @@ processChatCommand = \case APIActivateChat -> withUser $ \user -> do restoreCalls user withAgent activateAgent - setExpireCIs True + setAllExpireCIFlags True pure $ CRCmdOk Nothing APISuspendChat t -> do - setExpireCIs False + setAllExpireCIFlags False withAgent (`suspendAgent` t) pure $ CRCmdOk Nothing ResubscribeAllConnections -> withUser $ \user -> do @@ -797,7 +800,7 @@ processChatCommand = \case SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig TestSMPServer userId smpServer -> withUserId userId $ \user -> - CRSmpTestResult <$> (withAgent $ \a -> testSMPServerConnection a (aUserId user) smpServer) + CRSmpTestResult <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do checkSameUser userId user checkStoreNotChanged $ @@ -805,14 +808,14 @@ processChatCommand = \case case newTTL_ of Nothing -> do withStore' $ \db -> setChatItemTTL db user newTTL_ - setExpireCIs False + setExpireCIFlag user False Just newTTL -> do oldTTL <- withStore' (`getChatItemTTL` user) when (maybe True (newTTL <) oldTTL) $ do - setExpireCIs False + setExpireCIFlag user False expireChatItems user newTTL True withStore' $ \db -> setChatItemTTL db user newTTL_ - whenM chatStarted $ setExpireCIs True + whenM chatStarted $ setExpireCIFlag user True pure $ CRCmdOk (Just user) SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ @@ -1535,10 +1538,17 @@ assertDirectAllowed user dir ct event = XCallInv_ -> False _ -> True -setExpireCIs :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m () -setExpireCIs b = do - expire <- asks expireCIs - atomically $ writeTVar expire b +setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m () +setExpireCIFlag User {userId} b = do + expireFlags <- asks expireCIFlags + atomically $ TM.insert userId b expireFlags + +setAllExpireCIFlags :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m () +setAllExpireCIFlags b = do + expireFlags <- asks expireCIFlags + atomically $ do + keys <- M.keys <$> readTVar expireFlags + forM_ keys $ \k -> TM.insert k b expireFlags deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m () deleteFile user CIFileInfo {filePath, fileId, fileStatus} = @@ -1909,24 +1919,29 @@ startUpdatedTimedItemThread user chatRef ci ci' = _ -> pure () expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () -expireChatItems user ttl sync = do +expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs - expire <- asks expireCIs contacts <- withStore' (`getUserContacts` user) - loop expire contacts $ processContact expirationDate + loop contacts $ processContact expirationDate groups <- withStore' (`getUserGroupDetails` user) - loop expire groups $ processGroup expirationDate createdAtCutoff + loop groups $ processGroup expirationDate createdAtCutoff where - loop :: TVar Bool -> [a] -> (a -> m ()) -> m () - loop _ [] _ = pure () - loop expire (a : as) process = continue expire $ do + loop :: [a] -> (a -> m ()) -> m () + loop [] _ = pure () + loop (a : as) process = continue $ do process a `catchError` (toView . CRChatError (Just user)) - loop expire as process - continue :: TVar Bool -> m () -> m () - continue expire = if sync then id else \a -> whenM (readTVarIO expire) $ threadDelay 100000 >> a + loop as process + continue :: m () -> m () + continue a = + if sync + then a + else do + expireFlags <- asks expireCIFlags + expire <- atomically $ TM.lookup userId expireFlags + when (expire == Just True) $ threadDelay 100000 >> a processContact :: UTCTime -> Contact -> m () processContact expirationDate ct = do filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fae79d8ca9..1b2fccdbf4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -129,8 +129,8 @@ data ChatController = ChatController config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, incognitoMode :: TVar Bool, - expireCIsAsync :: TVar (Maybe (Async ())), - expireCIs :: TVar Bool, + expireCIThreads :: TMap UserId (Maybe (Async ())), + expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool From 9290fcc6b2ec40856626e1ced3c7bc6cbe1a55e6 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 13 Jan 2023 21:01:36 +0400 Subject: [PATCH 09/59] core: set active prompt to none when changing current user (#1738) --- src/Simplex/Chat.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0b21795a1f..b61f2f90eb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -280,6 +280,7 @@ processChatCommand = \case [] -> pure 1 _ -> withAgent (`createUser` smp) user <- withStore $ \db -> createUserRecord db (AgentUserId auId) p True + setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user ListUsers -> do @@ -288,6 +289,7 @@ processChatCommand = \case APISetActiveUser userId -> do u <- asks currentUser user <- withStore $ \db -> getSetActiveUser db userId + setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user SetActiveUser uName -> withUserName uName APISetActiveUser @@ -295,6 +297,7 @@ processChatCommand = \case -- prohibit to delete active user -- withStore' $ \db -> deleteUser db userId -- ? other cleanup + setActive ActiveNone pure $ CRCmdOk Nothing DeleteUser uName -> withUserName uName APIDeleteUser StartChat subConns enableExpireCIs -> withUser' $ \user -> From e452edb781f1469e3dac5426f6acce3b19ca9895 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 14 Jan 2023 15:45:13 +0400 Subject: [PATCH 10/59] core: subscribe all users (#1743) --- src/Simplex/Chat.hs | 40 +++++++++++++++++++++------------- src/Simplex/Chat/Controller.hs | 28 ++++++++++++------------ src/Simplex/Chat/View.hs | 35 ++++++++++++++--------------- tests/MobileTests.hs | 19 +++++++++------- 4 files changed, 68 insertions(+), 54 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b61f2f90eb..2408d41f7a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -32,7 +32,7 @@ import Data.Either (fromRight) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, isSuffixOf, sortOn) +import Data.List (find, isSuffixOf, partition, sortOn) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -198,7 +198,7 @@ startChatController currentUser subConns enableExpireCIs = do a1 <- async $ race_ notificationSubscriber agentSubscriber a2 <- if subConns - then Just <$> async (void . runExceptT $ subscribeUserConnections Agent.subscribeConnections currentUser) + then Just <$> async (subscribeUsers users) else pure Nothing atomically . writeTVar s $ Just (a1, a2) startCleanupManager @@ -229,6 +229,15 @@ startChatController currentUser subConns enableExpireCIs = do forM_ ttl $ \t -> expireChatItems u t False threadDelay $ 1800 * 1000000 -- 30 minutes +subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m () +subscribeUsers users = do + let (us, us') = partition activeUser users + subscribe us + subscribe us' + where + subscribe :: [User] -> m () + subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections + restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () restoreCalls user = do savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) @@ -316,8 +325,9 @@ processChatCommand = \case setAllExpireCIFlags False withAgent (`suspendAgent` t) pure $ CRCmdOk Nothing - ResubscribeAllConnections -> withUser $ \user -> do - subscribeUserConnections Agent.resubscribeConnections user + ResubscribeAllConnections -> do + users <- withStore' getUsers + subscribeUsers users pure $ CRCmdOk Nothing SetFilesFolder filesFolder' -> do createDirectoryIfMissing True filesFolder' @@ -1805,19 +1815,19 @@ subscribeUserConnections agentBatchSubscribe user = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> m () - contactSubsToView rs = toView . CRContactSubSummary . map (uncurry ContactSubStatus) . resultsFor rs + contactSubsToView rs = toView . CRContactSubSummary user . map (uncurry ContactSubStatus) . resultsFor rs contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () - contactLinkSubsToView rs = toView . CRUserContactSubSummary . map (uncurry UserContactSubStatus) . resultsFor rs + contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () groupSubsToView rs gs ms ce = do mapM_ groupSub $ sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs - toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs + toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms groupSub :: Group -> m () groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do - when ce $ mapM_ (toView . uncurry (CRMemberSubError g)) mErrors + when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where mErrors :: [(GroupMember, ChatError)] @@ -1827,26 +1837,26 @@ subscribeUserConnections agentBatchSubscribe user = do $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation g + | memberStatus membership == GSMemInvited = CRGroupInvitation user g | all (\GroupMember {activeConn} -> isNothing activeConn) members = if memberActive membership - then CRGroupEmpty g - else CRGroupRemoved g - | otherwise = CRGroupSubscribed g + then CRGroupEmpty user g + else CRGroupRemoved user g + | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> m () sndFileSubsToView rs sfts = do let sftRs = resultsFor rs sfts forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toView . CRSndFileSubError ft + forM_ err_ $ toView . CRSndFileSubError user ft void . forkIO $ do threadDelay 1000000 l <- asks chatLock when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withLock l "subscribe sendFileChunk" $ sendFileChunk user ft rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> m () - rcvFileSubsToView rs = mapM_ (toView . uncurry CRRcvFileSubError) . filterErrors . resultsFor rs + rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () - pendingConnSubsToView rs = toView . CRPendingSubSummary . map (uncurry PendingSubStatus) . resultsFor rs + pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a] withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError (Just user) e) >> pure [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1b2fccdbf4..08c5c9bb55 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -395,12 +395,12 @@ data ChatResponse | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} | CRContactsDisconnected {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} | CRContactsSubscribed {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactSubError {contact :: Contact, chatError :: ChatError} - | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} - | CRUserContactSubSummary {userContactSubscriptions :: [UserContactSubStatus]} + | CRContactSubError {contact :: Contact, chatError :: ChatError} -- TODO delete + | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} + | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {groupInfo :: GroupInfo} + | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -411,8 +411,8 @@ data ChatResponse | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupEmpty {groupInfo :: GroupInfo} - | CRGroupRemoved {groupInfo :: GroupInfo} + | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} + | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} @@ -420,20 +420,20 @@ data ChatResponse | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} - | CRMemberSubError {groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} - | CRMemberSubSummary {memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {groupInfo :: GroupInfo} - | CRPendingSubSummary {pendingSubscriptions :: [PendingSubStatus]} - | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} + | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} + | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} + | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} | CRCallInvitation {user :: User, callInvitation :: RcvCallInvitation} | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} | CRCallEnded {user :: User, contact :: Contact} | CRCallInvitations {user :: User, callInvitations :: [RcvCallInvitation]} - | CRUserContactLinkSubscribed - | CRUserContactLinkSubError {chatError :: ChatError} + | CRUserContactLinkSubscribed -- TODO delete + | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} | CRNtfMessages {user :: User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ebcfd49c91..530f447cf4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -146,7 +146,7 @@ responseToView user_ testView liveItems ts = \case CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> - ttyUser u $ [ttyContact c <> " cancelled receiving " <> sndFile ft] + ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRContactConnecting u _ -> ttyUser u [] CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] @@ -154,18 +154,19 @@ responseToView user_ testView liveItems ts = \case CRContactsDisconnected u srv cs -> ttyUser u [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactsSubscribed u srv cs -> ttyUser u [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] - CRContactSubSummary summary -> - [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" + CRContactSubSummary u summary -> + ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" where (errors, subscribed) = partition (isJust . contactError) summary - CRUserContactSubSummary summary -> - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") + CRUserContactSubSummary u summary -> + ttyUser u $ + map addressSS addresses + <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") where (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CRGroupInvitation g -> [groupInvitation' g] + CRGroupInvitation u g -> ttyUser u [groupInvitation' g] CRReceivedGroupInvitation u g c role -> ttyUser u $ viewReceivedGroupInvitation g c role CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -178,8 +179,8 @@ responseToView user_ testView liveItems ts = \case CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] + CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g @@ -187,14 +188,14 @@ responseToView user_ testView liveItems ts = \case CRGroupLink u g cReq -> ttyUser u $ groupLink_ "Group link:" g cReq CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] - CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] - CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed g -> viewGroupSubscribed g - CRPendingSubSummary _ -> [] - CRSndFileSubError SndFileTransfer {fileId, fileName} e -> - ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" + CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRPendingSubSummary u _ -> ttyUser u [] + CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> + ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> + ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRCallInvitation u RcvCallInvitation {contact, callType, sharedKey} -> ttyUser u $ viewCallInvitation contact callType sharedKey CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index dc41be9041..a02c9ccbba 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -46,32 +46,35 @@ chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" contactSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -contactSubSummary = "{\"resp\":{\"contactSubSummary\":{\"contactSubscriptions\":[]}}}" +contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" #else -contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\",\"contactSubscriptions\":[]}}" +contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" #endif memberSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -memberSubSummary = "{\"resp\":{\"memberSubSummary\":{\"memberSubscriptions\":[]}}}" +memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" #else -memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\",\"memberSubscriptions\":[]}}" +memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" #endif userContactSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{\"userContactSubscriptions\":[]}}}" +userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" #else -userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\",\"userContactSubscriptions\":[]}}" +userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" #endif pendingSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{\"pendingSubscriptions\":[]}}}" +pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" #else -pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\",\"pendingSubscriptions\":[]}}" +pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" #endif +userJSON :: String +userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":false}" + parsedMarkdown :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" From 6e0addbea3f223349ffb097f1e17f24c34ba1147 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 14 Jan 2023 15:45:42 +0400 Subject: [PATCH 11/59] core: add user to CRSmpTestResult response (#1744) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2408d41f7a..3036349e9f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -813,7 +813,7 @@ processChatCommand = \case SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig TestSMPServer userId smpServer -> withUserId userId $ \user -> - CRSmpTestResult <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) + CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do checkSameUser userId user checkStoreNotChanged $ diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 08c5c9bb55..0450e4cf47 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -324,7 +324,7 @@ data ChatResponse | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {user :: User, smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} - | CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure} + | CRSmpTestResult {user :: User, smpTestFailure :: Maybe SMPTestFailure} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 530f447cf4..da8f2b1ab0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -68,7 +68,7 @@ responseToView user_ testView liveItems ts = \case CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers u smpServers _ -> ttyUser u $ viewSMPServers (L.toList smpServers) testView - CRSmpTestResult testFailure -> viewSMPTestResult testFailure + CRSmpTestResult u testFailure -> ttyUser u $ viewSMPTestResult testFailure CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile From 9fc26ca7999daaa27a439b24f8d8b6e610a35644 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 14 Jan 2023 17:52:40 +0400 Subject: [PATCH 12/59] core: start chat item expiration thread for new users (#1745) --- src/Simplex/Chat.hs | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3036349e9f..b673c3349d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -186,7 +186,7 @@ activeAgentServers ChatConfig {defaultServers = DefaultAgentServers {smp}} = . map (\ServerCfg {server} -> server) . filter (\ServerCfg {enabled} -> enabled) -startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) +startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) startChatController currentUser subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient users <- fromRight [] <$> runExceptT (withStore' getUsers) @@ -211,23 +211,12 @@ startChatController currentUser subConns enableExpireCIs = do a <- Just <$> async (void . runExceptT $ cleanupManager currentUser) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs users = do - expireThreads <- asks expireCIThreads - forM_ users $ \u@User {userId} -> - atomically (TM.lookup userId expireThreads) >>= \case - Nothing -> do - a <- Just <$> async (void . runExceptT $ runExpireCIs u) - atomically $ TM.insert userId a expireThreads - setExpireCIFlag u True - _ -> setExpireCIFlag u True - where - runExpireCIs u@User {userId} = forever $ do - flip catchError (toView . CRChatError (Just u)) $ do - expireFlags <- asks expireCIFlags - atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry - ttl <- withStore' (`getChatItemTTL` u) - forM_ ttl $ \t -> expireChatItems u t False - threadDelay $ 1800 * 1000000 -- 30 minutes + startExpireCIs users = + forM_ users $ \user -> do + ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) + forM_ ttl $ \_ -> do + startExpireCIThread user + setExpireCIFlag user True subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m () subscribeUsers users = do @@ -828,6 +817,7 @@ processChatCommand = \case setExpireCIFlag user False expireChatItems user newTTL True withStore' $ \db -> setChatItemTTL db user newTTL_ + startExpireCIThread user whenM chatStarted $ setExpireCIFlag user True pure $ CRCmdOk (Just user) SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do @@ -1551,6 +1541,23 @@ assertDirectAllowed user dir ct event = XCallInv_ -> False _ -> True +startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +startExpireCIThread user@User {userId} = do + expireThreads <- asks expireCIThreads + atomically (TM.lookup userId expireThreads) >>= \case + Nothing -> do + a <- Just <$> async (void $ runExceptT runExpireCIs) + atomically $ TM.insert userId a expireThreads + _ -> pure () + where + runExpireCIs = forever $ do + flip catchError (toView . CRChatError (Just user)) $ do + expireFlags <- asks expireCIFlags + atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry + ttl <- withStore' (`getChatItemTTL` user) + forM_ ttl $ \t -> expireChatItems user t False + threadDelay $ 1800 * 1000000 -- 30 minutes + setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m () setExpireCIFlag User {userId} b = do expireFlags <- asks expireCIFlags From a040fa65bbdb10a99d7756bbbd00432016235526 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 14 Jan 2023 19:21:10 +0400 Subject: [PATCH 13/59] core: run cleanup for all users (#1746) --- src/Simplex/Chat.hs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b673c3349d..c05534f7f7 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -208,7 +208,7 @@ startChatController currentUser subConns enableExpireCIs = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case Nothing -> do - a <- Just <$> async (void . runExceptT $ cleanupManager currentUser) + a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () startExpireCIs users = @@ -1882,15 +1882,20 @@ subscribeUserConnections agentBatchSubscribe user = do cleanupManagerInterval :: Int cleanupManagerInterval = 1800 -- 30 minutes -cleanupManager :: forall m. ChatMonad m => User -> m () -cleanupManager user = do +cleanupManager :: forall m. ChatMonad m => m () +cleanupManager = do forever $ do - flip catchError (toView . CRChatError (Just user)) $ do + flip catchError (toView . CRChatError Nothing) $ do waitChatStarted - cleanupTimedItems + users <- withStore' getUsers + let (us, us') = partition activeUser users + forM_ us cleanupUser + forM_ us' cleanupUser threadDelay $ cleanupManagerInterval * 1000000 where - cleanupTimedItems = do + cleanupUser user = + cleanupTimedItems user `catchError` (toView . CRChatError (Just user)) + cleanupTimedItems user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime (realToFrac cleanupManagerInterval) ts timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff From 9dc6c1327f5ad4a8a4aaff45de8b0cbcede583c9 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 16 Jan 2023 15:06:03 +0400 Subject: [PATCH 14/59] core: manage calls for all users (#1748) --- apps/ios/Shared/Model/SimpleXAPI.swift | 3 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- src/Simplex/Chat.hs | 59 ++++++++++++++------------ src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Store.hs | 21 +++++---- src/Simplex/Chat/View.hs | 2 +- tests/ChatTests.hs | 2 +- 8 files changed, 52 insertions(+), 45 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a7f41b570e..6c05c3386b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -711,8 +711,7 @@ func apiEndCall(_ contact: Contact) async throws { } func apiGetCallInvitations() throws -> [RcvCallInvitation] { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetCallInvitations: no current user") } - let r = chatSendCmdSync(.apiGetCallInvitations(userId: userId)) + let r = chatSendCmdSync(.apiGetCallInvitations) if case let .callInvitations(invs) = r { return invs } throw r } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 789aaf2d94..973f635ced 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -84,7 +84,7 @@ public enum ChatCommand { case apiSendCallAnswer(contact: Contact, answer: WebRTCSession) case apiSendCallExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case apiEndCall(contact: Contact) - case apiGetCallInvitations(userId: Int64) + case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -169,7 +169,7 @@ public enum ChatCommand { case let .apiSendCallAnswer(contact, answer): return "/_call answer @\(contact.apiId) \(encodeJSON(answer))" case let .apiSendCallExtraInfo(contact, extraInfo): return "/_call extra @\(contact.apiId) \(encodeJSON(extraInfo))" case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" - case let .apiGetCallInvitations(userId): return "/_call get \(userId)" + case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c05534f7f7..fa626b5c56 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -28,7 +28,7 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) -import Data.Either (fromRight) +import Data.Either (fromRight, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) @@ -186,11 +186,11 @@ activeAgentServers ChatConfig {defaultServers = DefaultAgentServers {smp}} = . map (\ServerCfg {server} -> server) . filter (\ServerCfg {enabled} -> enabled) -startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) -startChatController currentUser subConns enableExpireCIs = do +startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => Bool -> Bool -> m (Async ()) +startChatController subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient users <- fromRight [] <$> runExceptT (withStore' getUsers) - restoreCalls currentUser + restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) where @@ -227,9 +227,9 @@ subscribeUsers users = do subscribe :: [User] -> m () subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections -restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -restoreCalls user = do - savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) +restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m () +restoreCalls = do + savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db) let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls calls <- asks currentCalls atomically $ writeTVar calls callsMap @@ -298,15 +298,15 @@ processChatCommand = \case setActive ActiveNone pure $ CRCmdOk Nothing DeleteUser uName -> withUserName uName APIDeleteUser - StartChat subConns enableExpireCIs -> withUser' $ \user -> + StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController user subConns enableExpireCIs $> CRChatStarted + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs $> CRChatStarted APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> withUser $ \user -> do - restoreCalls user + APIActivateChat -> withUser $ \_ -> do + restoreCalls withAgent activateAgent setAllExpireCIFlags True pure $ CRCmdOk Nothing @@ -702,25 +702,25 @@ processChatCommand = \case _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> -- party accepting call - withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing offer = CallOffer {callType, rtcSession, callDhPubKey} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallOffer callId offer) - withStore' $ \db -> updateDirectChatItemsRead db userId contactId $ Just (chatItemId, chatItemId) - updateDirectChatItemView userId ct chatItemId aciContent False $ Just msgId + withStore' $ \db -> updateDirectChatItemsRead db user contactId $ Just (chatItemId, chatItemId) + updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallAnswer contactId rtcSession -> -- party initiating call - withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) - updateDirectChatItemView userId ct chatItemId aciContent False $ Just msgId + updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallExtraInfo contactId rtcExtraInfo -> @@ -739,25 +739,26 @@ processChatCommand = \case _ -> throwChatError . CECallState $ callStateTag callState APIEndCall contactId -> -- any call party - withCurrentCall contactId $ \userId ct call@Call {callId} -> do + withCurrentCall contactId $ \user ct call@Call {callId} -> do (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) - updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + updateCallItemStatus user ct call WCSDisconnected $ Just msgId pure Nothing - APIGetCallInvitations userId -> withUserId userId $ \user -> do + APIGetCallInvitations -> withUser $ \_ -> do calls <- asks currentCalls >>= readTVarIO let invs = mapMaybe callInvitation $ M.elems calls - rcvCallInvitations <- mapM (rcvCallInvitation user) invs - pure $ CRCallInvitations user rcvCallInvitations + rcvCallInvitations <- rights <$> mapM rcvCallInvitation invs + pure $ CRCallInvitations rcvCallInvitations where callInvitation Call {contactId, callState, callTs} = case callState of CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey) _ -> Nothing - rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do - contact <- withStore (\db -> getContact db user contactId) + rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do + user <- getUserByContactId db contactId + contact <- getContact db user contactId pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs} APICallStatus contactId receivedStatus -> - withCurrentCall contactId $ \userId ct call -> - updateCallItemStatus userId ct call receivedStatus Nothing $> Just call + withCurrentCall contactId $ \user ct call -> + updateCallItemStatus user ct call receivedStatus Nothing $> Just call APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId @@ -1470,8 +1471,10 @@ processChatCommand = \case let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse - withCurrentCall ctId action = withUser $ \user -> do - ct <- withStore $ \db -> getContact db user ctId + withCurrentCall ctId action = do + (user, ct) <- withStore $ \db -> do + user <- getUserByContactId db ctId + (user,) <$> getContact db user ctId calls <- asks currentCalls withChatLock "currentCall" $ atomically (TM.lookup ctId calls) >>= \case @@ -3878,7 +3881,7 @@ chatCommandP = "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP), "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), - "/_call get " *> (APIGetCallInvitations <$> A.decimal), + "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0450e4cf47..9a12b6e7d1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -183,7 +183,7 @@ data ChatCommand | APISendCallAnswer ContactId WebRTCSession | APISendCallExtraInfo ContactId WebRTCExtraInfo | APIEndCall ContactId - | APIGetCallInvitations UserId + | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences @@ -431,7 +431,7 @@ data ChatResponse | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} | CRCallEnded {user :: User, contact :: Contact} - | CRCallInvitations {user :: User, callInvitations :: [RcvCallInvitation]} + | CRCallInvitations {callInvitations :: [RcvCallInvitation]} | CRUserContactLinkSubscribed -- TODO delete | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index b1bf9b1f55..a458c0f76f 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -30,7 +30,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController u True True) cc + a1 <- runReaderT (startChatController True True) cc a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index bcbfc47216..470532c1f6 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -31,6 +31,7 @@ module Simplex.Chat.Store getSetActiveUser, getUserIdByName, getUserByAConnId, + getUserByContactId, createDirectConnection, createConnReqConnection, getProfileById, @@ -455,10 +456,10 @@ getUsers db = userQuery :: Query userQuery = [sql| - SELECT u.user_id, u.agent_user_id, u.contact_id, cp.contact_profile_id, u.active_user, u.local_display_name, cp.full_name, cp.image, cp.preferences + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.preferences FROM users u - JOIN contacts ct ON ct.contact_id = u.contact_id - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + JOIN contacts uct ON uct.contact_id = u.contact_id + JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User @@ -491,6 +492,11 @@ getUserByAConnId db agentConnId = maybeFirstRow toUser $ DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) +getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User +getUserByContactId db contactId = + ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ + DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ?") (Only contactId) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime @@ -4552,19 +4558,17 @@ deleteCalls :: DB.Connection -> User -> ContactId -> IO () deleteCalls db User {userId} contactId = do DB.execute db "DELETE FROM calls WHERE user_id = ? AND contact_id = ?" (userId, contactId) -getCalls :: DB.Connection -> User -> IO [Call] -getCalls db User {userId} = do +getCalls :: DB.Connection -> IO [Call] +getCalls db = map toCall - <$> DB.query + <$> DB.query_ db [sql| SELECT contact_id, shared_call_id, chat_item_id, call_state, call_ts FROM calls - WHERE user_id = ? ORDER BY call_ts ASC |] - (Only userId) where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} @@ -4849,6 +4853,7 @@ data StoreError = SEDuplicateName | SEUserNotFound {userId :: UserId} | SEUserNotFoundByName {contactName :: ContactName} + | SEUserNotFoundByContactId {contactId :: ContactId} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index da8f2b1ab0..df43857c0b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -201,7 +201,7 @@ responseToView user_ testView liveItems ts = \case CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] - CRCallInvitations u _ -> ttyUser u [] + CRCallInvitations _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] CRNewContactConnection u _ -> ttyUser u [] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 134a9e5723..23fe332830 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4186,7 +4186,7 @@ testNegotiateCall = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob -- just for testing db query - alice ##> "/_call get 1" + alice ##> "/_call get" -- alice invite bob to call alice ##> ("/_call invite @2 " <> serialize testCallType) alice <## "ok" From df6cec6a32185fe2f2cd73ef4d41ee0a288a6daf Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 16 Jan 2023 17:00:24 +0400 Subject: [PATCH 15/59] core: update simplexmq (session mode, users commands) (#1757) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 67663f5921..a24fb85c3f 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9c9ba8c25c9a6e6042cff184c0fd72524134755e + tag: 324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 6fa250a343..51e0916efe 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9c9ba8c25c9a6e6042cff184c0fd72524134755e" = "1aabrljnwy86ld5nvv3842w76z7fpp2zac2a5655mj16r4hja1vx"; + "https://github.com/simplex-chat/simplexmq.git"."324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8" = "1b0wf3ay0zs8m05ba475wk5z8kam0vcqrlyrnv1a0qc6i06l7f76"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/stack.yaml b/stack.yaml index 0e2c105a79..dfe6f11728 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 9c9ba8c25c9a6e6042cff184c0fd72524134755e + commit: 324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 From 3ed5e6e50b2cb875ffeb8199bec6dc25aefe2772 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 16 Jan 2023 17:51:25 +0400 Subject: [PATCH 16/59] core: support receiving file by id for any user (not only current) (#1759) --- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Store.hs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2f7e6c2185..134100fd81 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1247,9 +1247,9 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage - ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \user -> + ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do - ft <- withStore $ \db -> getRcvFileTransfer db user fileId + (user, ft) <- withStore $ \db -> getRcvFileTransferById db fileId (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError user ft where processError user ft = \case diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 470532c1f6..056a2071f6 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -158,6 +158,7 @@ module Simplex.Chat.Store deleteSndFileChunks, createRcvFileTransfer, createRcvGroupFileTransfer, + getRcvFileTransferById, getRcvFileTransfer, acceptRcvFileTransfer, getContactByFileId, @@ -497,6 +498,11 @@ getUserByContactId db contactId = ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ?") (Only contactId) +getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User +getUserByFileId db fileId = + ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ + DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime @@ -2775,7 +2781,12 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} -getRcvFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransferById :: DB.Connection -> FileTransferId -> ExceptT StoreError IO (User, RcvFileTransfer) +getRcvFileTransferById db fileId = do + user <- getUserByFileId db fileId + (user,) <$> getRcvFileTransfer db user fileId + +getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer getRcvFileTransfer db user@User {userId} fileId = do rftRow <- ExceptT . firstRow id (SERcvFileNotFound fileId) $ @@ -4854,6 +4865,7 @@ data StoreError | SEUserNotFound {userId :: UserId} | SEUserNotFoundByName {contactName :: ContactName} | SEUserNotFoundByContactId {contactId :: ContactId} + | SEUserNotFoundByFileId {fileId :: FileTransferId} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} From 882966d5d37e964caa8d974bdae64ef1e804aacf Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 16 Jan 2023 15:10:16 +0000 Subject: [PATCH 17/59] ios: update network config (#1760) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/ios/SimpleXChat/APITypes.swift | 6 ++++ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 937782465d..2fa90fbc68 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -76,6 +76,11 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; + 5CA85D1D29759E4A0095AF72 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1829759E4A0095AF72 /* libgmp.a */; }; + 5CA85D1E29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */; }; + 5CA85D1F29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */; }; + 5CA85D2029759E4A0095AF72 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1B29759E4A0095AF72 /* libffi.a */; }; + 5CA85D2129759E4A0095AF72 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -134,11 +139,6 @@ 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; - 6407BA8929704D910082BA18 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6407BA8429704D910082BA18 /* libgmpxx.a */; }; - 6407BA8A29704D910082BA18 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6407BA8529704D910082BA18 /* libgmp.a */; }; - 6407BA8B29704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6407BA8629704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a */; }; - 6407BA8C29704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6407BA8729704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a */; }; - 6407BA8D29704D910082BA18 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6407BA8829704D910082BA18 /* libffi.a */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; 6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; }; 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; }; @@ -300,6 +300,11 @@ 5CA85D0B297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 5CA85D1829759E4A0095AF72 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a"; sourceTree = ""; }; + 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a"; sourceTree = ""; }; + 5CA85D1B29759E4A0095AF72 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = ""; }; 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -362,11 +367,6 @@ 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; - 6407BA8429704D910082BA18 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 6407BA8529704D910082BA18 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 6407BA8629704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a"; sourceTree = ""; }; - 6407BA8729704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a"; sourceTree = ""; }; - 6407BA8829704D910082BA18 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.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 = ""; }; @@ -424,13 +424,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6407BA8929704D910082BA18 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 6407BA8B29704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a in Frameworks */, - 6407BA8A29704D910082BA18 /* libgmp.a in Frameworks */, - 6407BA8D29704D910082BA18 /* libffi.a in Frameworks */, + 5CA85D2029759E4A0095AF72 /* libffi.a in Frameworks */, + 5CA85D1D29759E4A0095AF72 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 6407BA8C29704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a in Frameworks */, + 5CA85D1F29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a in Frameworks */, + 5CA85D1E29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a in Frameworks */, + 5CA85D2129759E4A0095AF72 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -488,11 +488,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 6407BA8829704D910082BA18 /* libffi.a */, - 6407BA8529704D910082BA18 /* libgmp.a */, - 6407BA8429704D910082BA18 /* libgmpxx.a */, - 6407BA8729704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x-ghc8.10.7.a */, - 6407BA8629704D910082BA18 /* libHSsimplex-chat-4.4.2-4Gu4VKBxHYB3XfShhkyX2x.a */, + 5CA85D1B29759E4A0095AF72 /* libffi.a */, + 5CA85D1829759E4A0095AF72 /* libgmp.a */, + 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */, + 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */, + 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 973f635ced..45adff73dd 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -805,6 +805,7 @@ public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil public var hostMode: HostMode = .publicHost public var requiredHostMode = true + public var sessionMode = TransportSessionMode.user public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpKeepAlive: KeepAliveOpts? @@ -872,6 +873,11 @@ public enum OnionHosts: String, Identifiable { public static let values: [OnionHosts] = [.no, .prefer, .require] } +public enum TransportSessionMode: String, Codable { + case user + case entity +} + public struct KeepAliveOpts: Codable, Equatable { public var keepIdle: Int // seconds public var keepIntvl: Int // seconds From 91a39cae23ca883766e00abbb08562330fcf5462 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 16 Jan 2023 17:25:06 +0000 Subject: [PATCH 18/59] core: fix error handling (#1761) * core: fix error handling * fix tests --- src/Simplex/Chat.hs | 14 ++++++++------ tests/MobileTests.hs | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 134100fd81..00d4766a23 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -254,11 +254,11 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, atomically $ writeTVar files M.empty execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse -execChatCommand s = case parseChatCommand s of - Left e -> do - u <- readTVarIO =<< asks currentUser - pure $ chatCmdError u e - Right cmd -> either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) +execChatCommand s = do + u <- readTVarIO =<< asks currentUser + case parseChatCommand s of + Left e -> pure $ chatCmdError u e + Right cmd -> either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -3792,7 +3792,9 @@ withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser >>= readTVarIO - >>= maybe (throwChatError CENoActiveUser) (\u -> action u `catchError` (pure . CRChatError (Just u))) + >>= maybe (throwChatError CENoActiveUser) run + where + run u = action u `catchError` (pure . CRChatCmdError (Just u)) withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 2bc28dc63e..c19e6069e4 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,9 +25,9 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e activeUserExists :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"errorStore\":{\"storeError\":{\"duplicateName\":{}}}}}}}" +activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"errorStore\":{\"storeError\":{\"duplicateName\":{}}}}}}}" #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"errorStore\",\"storeError\":{\"type\":\"duplicateName\"}}}}" +activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"type\":\"errorStore\",\"storeError\":{\"type\":\"duplicateName\"}}}}" #endif activeUser :: String From 2fdc23274dcfaf0bd2aa68d083de21f40c3711bc Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Mon, 16 Jan 2023 22:57:31 +0400 Subject: [PATCH 19/59] core: return user unread counts on ListUsers command (#1763) * core: return user unread counts on ListUsers command * split * tests * refactor * viewUserInfo * refactor * remove omit nothing * corrections * fix Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 4 +--- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Store.hs | 14 ++++++++++++++ src/Simplex/Chat/Types.hs | 10 ++++++++++ src/Simplex/Chat/View.hs | 13 ++++++++----- tests/ChatTests.hs | 2 +- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 00d4766a23..73cbacc30b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -283,9 +283,7 @@ processChatCommand = \case setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user - ListUsers -> do - users <- withStore' getUsers - pure $ CRUsersList users + ListUsers -> CRUsersList <$> withStore' getUsersInfo APISetActiveUser userId -> do u <- asks currentUser user <- withStore $ \db -> getSetActiveUser db userId diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 76fe7310e5..65931173da 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -315,7 +315,7 @@ data ChatCommand data ChatResponse = CRActiveUser {user :: User} - | CRUsersList {users :: [User]} + | CRUsersList {users :: [UserInfo]} | CRChatStarted | CRChatRunning | CRChatStopped diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 056a2071f6..78de28f5cf 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -26,6 +26,7 @@ module Simplex.Chat.Store chatStoreFile, agentStoreFile, createUserRecord, + getUsersInfo, getUsers, setActiveUser, getSetActiveUser, @@ -450,6 +451,19 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) pure $ toUser (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) +getUsersInfo :: DB.Connection -> IO [UserInfo] +getUsersInfo db = getUsers db >>= mapM getUserInfo + where + getUserInfo :: User -> IO UserInfo + getUserInfo user@User {userId} = do + count_ <- + maybeFirstRow fromOnly $ + DB.query + db + "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND item_status = ? GROUP BY user_id" + (userId, CISRcvNew) + pure UserInfo {user, unreadCount = fromMaybe 0 count_} + getUsers :: DB.Connection -> IO [User] getUsers db = map toUser <$> DB.query_ db userQuery diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index ef4bda562d..2776ae7f3b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -115,6 +115,16 @@ data User = User instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions +data UserInfo = UserInfo + { user :: User, + unreadCount :: Int + } + deriving (Show, Generic, FromJSON) + +instance ToJSON UserInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + type UserId = Int64 type ContactId = Int64 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 53f5c94d0b..2d1fc9443f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -267,12 +267,15 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case | muted chat chatItem = [] | otherwise = s -viewUsersList :: [User] -> [StyledString] -viewUsersList = - let ldn = T.toLower . (localDisplayName :: User -> ContactName) - in map (\user@User {profile = LocalProfile {displayName, fullName}} -> ttyFullName displayName fullName <> active user) . sortOn ldn +viewUsersList :: [UserInfo] -> [StyledString] +viewUsersList = map userInfo . sortOn ldn where - active User {activeUser} = if activeUser then highlight' " (active)" else "" + ldn (UserInfo User {localDisplayName = n} _) = T.toLower n + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser} count) = + ttyFullName n fullName <> active <> unread + where + active = if activeUser then highlight' " (active)" else "" + unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else "" muted :: ChatInfo c -> ChatItem c d -> Bool muted chat ChatItem {chatDir} = case (chat, chatDir) of diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index aba627a458..2716bd90fb 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4425,7 +4425,7 @@ testCreateSecondUser = showActiveUser alice "alice (Alice)" alice ##> "/users" - alice <## "alice (Alice) (active)" + alice <## "alice (Alice) (active) (unread: 1)" alice <## "alisa" alice <##> bob From 2f39cfd86f9486e391ffd2e189fa4ffc9e1fd25a Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 17 Jan 2023 13:08:51 +0400 Subject: [PATCH 20/59] core: support marking chat items read for any user (#1784) --- src/Simplex/Chat.hs | 8 +++++--- src/Simplex/Chat/Store.hs | 7 +++++++ tests/ChatTests.hs | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 73cbacc30b..8a1cec3958 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -544,8 +544,9 @@ processChatCommand = \case (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \user@User {userId} -> case cType of + APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do + user <- withStore $ \db -> getUserByContactId db chatId timedItems <- withStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime forM_ timedItems $ \(itemId, ttl) -> do @@ -555,6 +556,7 @@ processChatCommand = \case withStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds pure $ CRCmdOk (Just user) CTGroup -> do + user@User {userId} <- withStore $ \db -> getUserByGroupId db chatId timedItems <- withStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime forM_ timedItems $ \(itemId, ttl) -> do @@ -563,8 +565,8 @@ processChatCommand = \case startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt withStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds pure $ CRCmdOk (Just user) - CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> pure $ chatCmdError Nothing "not supported" + CTContactConnection -> pure $ chatCmdError Nothing "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withStore $ \db -> do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 78de28f5cf..4af9febbf5 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -33,6 +33,7 @@ module Simplex.Chat.Store getUserIdByName, getUserByAConnId, getUserByContactId, + getUserByGroupId, createDirectConnection, createConnReqConnection, getProfileById, @@ -512,6 +513,11 @@ getUserByContactId db contactId = ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ?") (Only contactId) +getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User +getUserByGroupId db groupId = + ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ + DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) + getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User getUserByFileId db fileId = ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ @@ -4879,6 +4885,7 @@ data StoreError | SEUserNotFound {userId :: UserId} | SEUserNotFoundByName {contactName :: ContactName} | SEUserNotFoundByContactId {contactId :: ContactId} + | SEUserNotFoundByGroupId {groupId :: GroupId} | SEUserNotFoundByFileId {fileId :: FileTransferId} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 2716bd90fb..aba627a458 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4425,7 +4425,7 @@ testCreateSecondUser = showActiveUser alice "alice (Alice)" alice ##> "/users" - alice <## "alice (Alice) (active) (unread: 1)" + alice <## "alice (Alice) (active)" alice <## "alisa" alice <##> bob From 5c7ad0926c3fee7b6d4d8448cc8d64730a2304d2 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 17 Jan 2023 15:45:37 +0400 Subject: [PATCH 21/59] core: add missing fkey indexes (#1785) --- simplex-chat.cabal | 1 + .../Chat/Migrations/M20230117_fkey_indexes.hs | 59 ++++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 77 +++++++++++++++++++ src/Simplex/Chat/Store.hs | 4 +- 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8c9cbe05c2..f31cf9ff96 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -80,6 +80,7 @@ library Simplex.Chat.Migrations.M20221230_idxs Simplex.Chat.Migrations.M20230107_connections_auth_err_counter Simplex.Chat.Migrations.M20230111_users_agent_user_id + Simplex.Chat.Migrations.M20230117_fkey_indexes Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs b/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs new file mode 100644 index 0000000000..5986863093 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs @@ -0,0 +1,59 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230117_fkey_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- .lint fkey-indexes +m20230117_fkey_indexes :: Query +m20230117_fkey_indexes = + [sql| +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections(custom_user_profile_id); +CREATE INDEX idx_connections_via_user_contact_link ON connections(via_user_contact_link); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections(user_contact_link_id); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests(contact_profile_id); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests(user_contact_link_id); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros(to_group_member_id); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members(user_id, local_display_name); +CREATE INDEX idx_group_members_member_profile_id ON group_members(member_profile_id); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members(contact_profile_id); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups(host_conn_custom_user_profile_id); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages(group_member_intro_id); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages(message_id); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages(group_member_id); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index dec6982769..6525800eb4 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -470,3 +470,80 @@ CREATE INDEX idx_connections_group_member ON connections( group_member_id ); CREATE INDEX idx_commands_connection_id ON commands(connection_id); +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections( + custom_user_profile_id +); +CREATE INDEX idx_connections_via_user_contact_link ON connections( + via_user_contact_link +); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections( + user_contact_link_id +); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( + contact_profile_id +); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( + user_contact_link_id +); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros( + to_group_member_id +); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members( + user_id, + local_display_name +); +CREATE INDEX idx_group_members_member_profile_id ON group_members( + member_profile_id +); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members( + contact_profile_id +); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( + host_conn_custom_user_profile_id +); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages( + group_member_intro_id +); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages( + message_id +); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages( + group_member_id +); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( + file_id, + connection_id +); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 4af9febbf5..42f0f03f3a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -335,6 +335,7 @@ import Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status import Simplex.Chat.Migrations.M20221230_idxs import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter import Simplex.Chat.Migrations.M20230111_users_agent_user_id +import Simplex.Chat.Migrations.M20230117_fkey_indexes import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -396,7 +397,8 @@ schemaMigrations = ("20221223_idx_chat_items_item_status", m20221223_idx_chat_items_item_status), ("20221230_idxs", m20221230_idxs), ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter), - ("20230111_users_agent_user_id", m20230111_users_agent_user_id) + ("20230111_users_agent_user_id", m20230111_users_agent_user_id), + ("20230117_fkey_indexes", m20230117_fkey_indexes) ] -- | The list of migrations in ascending order by date From e8cab01c038a6e1e9e7499cc1135572aa6d4ad95 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:41:19 +0400 Subject: [PATCH 22/59] core: update simplexmq (fkey indexes) (#1786) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index a24fb85c3f..2c2e3e1eeb 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8 + tag: 42df6a421d3e0865b96f849423f5eddf0f692653 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 51e0916efe..0632974b7a 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8" = "1b0wf3ay0zs8m05ba475wk5z8kam0vcqrlyrnv1a0qc6i06l7f76"; + "https://github.com/simplex-chat/simplexmq.git"."42df6a421d3e0865b96f849423f5eddf0f692653" = "01lzpkwn7ylgmp0n3dyi27hiwlgmr2swjad9x9ka36x3dp86asan"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/stack.yaml b/stack.yaml index dfe6f11728..a458976e57 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 324e01300dc9e5f3d1372c10f8d6b6df0cfe0bf8 + commit: 42df6a421d3e0865b96f849423f5eddf0f692653 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 From a668bd57361f86c7cb419c3f7ce62bdb5ece61b0 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:58:36 +0400 Subject: [PATCH 23/59] core: cleanup obsolete chat item deletion code (see #1625) (#1787) --- src/Simplex/Chat.hs | 36 ++---------------------------- src/Simplex/Chat/Store.hs | 46 --------------------------------------- 2 files changed, 2 insertions(+), 80 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8a1cec3958..b75939b6ed 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -631,33 +631,17 @@ processChatCommand = \case CTDirect -> do ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withStore' $ \db -> deleteContactCIs db user ct - -- TODO delete - ct' <- case maxItemTs_ of - Just ts -> do - withStore' $ \db -> updateContactTs db user ct ts - pure (ct :: Contact) {updatedAt = ts} - _ -> pure ct - pure $ CRChatCleared user (AChatInfo SCTDirect (DirectChat ct')) + pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getGroupMaxItemTs db user gInfo forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withStore' $ \db -> deleteGroupCIs db user gInfo membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m - -- TODO delete - gInfo' <- case maxItemTs_ of - Just ts -> do - withStore' $ \db -> updateGroupTs db user gInfo ts - pure (gInfo :: GroupInfo) {updatedAt = ts} - _ -> pure gInfo - pure $ CRChatCleared user (AChatInfo SCTGroup (GroupChat gInfo')) + pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock "acceptContact" $ do @@ -1978,31 +1962,15 @@ expireChatItems user@User {userId} ttl sync = do processContact :: UTCTime -> Contact -> m () processContact expirationDate ct = do filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate - -- TODO delete - withStore' $ \db -> do - ciCount_ <- getContactCICount db user ct - case (maxItemTs_, ciCount_) of - (Just ts, Just count) -> when (count == 0) $ updateContactTs db user ct ts - _ -> pure () processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () processGroup expirationDate createdAtCutoff gInfo = do filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getGroupMaxItemTs db user gInfo forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m - -- TODO delete - withStore' $ \db -> do - ciCount_ <- getGroupCICount db user gInfo - case (maxItemTs_, ciCount_) of - (Just ts, Just count) -> when (count == 0) $ updateGroupTs db user gInfo ts - _ -> pure () processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent -> m () processAgentMessage _ "" msg = diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 42f0f03f3a..8f9a281aa2 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -176,13 +176,9 @@ module Simplex.Chat.Store getFileTransferMeta, getSndFileTransfer, getContactFileInfo, - getContactMaxItemTs, -- TODO delete deleteContactCIs, - updateContactTs, -- TODO delete getGroupFileInfo, - getGroupMaxItemTs, -- TODO delete deleteGroupCIs, - updateGroupTs, -- TODO delete createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, @@ -243,10 +239,8 @@ module Simplex.Chat.Store setChatItemTTL, getContactExpiredFileInfo, deleteContactExpiredCIs, - getContactCICount, -- TODO delete getGroupExpiredFileInfo, deleteGroupExpiredCIs, - getGroupCICount, -- TODO delete getPendingContactConnection, deletePendingContactConnection, updateContactSettings, @@ -3050,12 +3044,6 @@ getContactFileInfo db User {userId} Contact {contactId} = toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} --- TODO delete -getContactMaxItemTs :: DB.Connection -> User -> Contact -> IO (Maybe UTCTime) -getContactMaxItemTs db User {userId} Contact {contactId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT MAX(item_ts) FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - deleteContactCIs :: DB.Connection -> User -> Contact -> IO () deleteContactCIs db user@User {userId} ct@Contact {contactId} = do connIds <- getContactConnIds_ db user ct @@ -3068,14 +3056,6 @@ getContactConnIds_ db User {userId} Contact {contactId} = map fromOnly <$> DB.query db "SELECT connection_id FROM connections WHERE user_id = ? AND contact_id = ?" (userId, contactId) --- TODO delete -updateContactTs :: DB.Connection -> User -> Contact -> UTCTime -> IO () -updateContactTs db User {userId} Contact {contactId} updatedAt = - DB.execute - db - "UPDATE contacts SET chat_ts = ? WHERE user_id = ? AND contact_id = ?" - (updatedAt, userId, contactId) - getGroupFileInfo :: DB.Connection -> User -> GroupInfo -> IO [CIFileInfo] getGroupFileInfo db User {userId} GroupInfo {groupId} = map toFileInfo @@ -3089,25 +3069,11 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} = |] (userId, groupId) --- TODO delete -getGroupMaxItemTs :: DB.Connection -> User -> GroupInfo -> IO (Maybe UTCTime) -getGroupMaxItemTs db User {userId} GroupInfo {groupId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT MAX(item_ts) FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - deleteGroupCIs :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupCIs db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) --- TODO delete -updateGroupTs :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () -updateGroupTs db User {userId} GroupInfo {groupId} updatedAt = - DB.execute - db - "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" - (updatedAt, userId, groupId) - createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage e) -> ExceptT StoreError IO SndMessage createNewSndMessage db gVar connOrGroupId mkMessage = createWithRandomId gVar $ \sharedMsgId -> do @@ -4791,12 +4757,6 @@ deleteContactExpiredCIs db user@User {userId} ct@Contact {contactId} expirationD DB.execute db "DELETE FROM messages WHERE connection_id = ? AND created_at <= ?" (connId, expirationDate) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ?" (userId, contactId, expirationDate) --- TODO delete -getContactCICount :: DB.Connection -> User -> Contact -> IO (Maybe Int64) -getContactCICount db User {userId} Contact {contactId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - getGroupExpiredFileInfo :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO [CIFileInfo] getGroupExpiredFileInfo db User {userId} GroupInfo {groupId} expirationDate createdAtCutoff = map toFileInfo @@ -4815,12 +4775,6 @@ deleteGroupExpiredCIs db User {userId} GroupInfo {groupId} expirationDate create DB.execute db "DELETE FROM messages WHERE group_id = ? AND created_at <= ?" (groupId, min expirationDate createdAtCutoff) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_ts <= ? AND created_at <= ?" (userId, groupId, expirationDate, createdAtCutoff) --- TODO delete -getGroupCICount :: DB.Connection -> User -> GroupInfo -> IO (Maybe Int64) -getGroupCICount db User {userId} GroupInfo {groupId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO (Either StoreError a)) -> IO (Either StoreError a) From 153f80fe6435587908e7196b47d716634f063cb6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 17 Jan 2023 17:47:37 +0000 Subject: [PATCH 24/59] ios: menu to switch active user profile (#1758) * ios: User chooser UI * Change * Changes * update view * fix layout/refactor * fix preview * wider menu, update label * hide Your profiles button * Clickable background that hides userChooser * No click listener * Better animation * Disabled scrolling for small number of items * Separated scrollview and buttons * No transition * Re-indent * Limiting width by the longest label * UserManagerView * Adapted API * Hide user chooser after selection * Top counter, users refactor * Padding * use VStack to fix layout bug * eol * rename: rename to getUserChatData * update layout * s/semibold/medium * remove SettingsButton view * rename Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 1 + apps/ios/Shared/Model/SimpleXAPI.swift | 33 +++- .../Shared/Views/ChatList/ChatListView.swift | 70 +++++-- .../Shared/Views/ChatList/UserPicker.swift | 181 ++++++++++++++++++ .../Views/Helpers/PressedButtonStyle.swift | 15 ++ .../Views/UserSettings/SettingsButton.swift | 29 --- .../Views/UserSettings/UserProfilesView.swift | 56 ++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +- .../xcschemes/SimpleX (iOS).xcscheme | 1 - apps/ios/SimpleXChat/APITypes.swift | 12 ++ apps/ios/SimpleXChat/ChatTypes.swift | 21 +- 11 files changed, 382 insertions(+), 53 deletions(-) create mode 100644 apps/ios/Shared/Views/ChatList/UserPicker.swift create mode 100644 apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift delete mode 100644 apps/ios/Shared/Views/UserSettings/SettingsButton.swift create mode 100644 apps/ios/Shared/Views/UserSettings/UserProfilesView.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 5a15c4946a..d8a1efddf6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,6 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? + @Published var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6c05c3386b..f80605239a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -131,6 +131,24 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } +func listUsers() -> [UserInfo] { + let r = chatSendCmdSync(.listUsers) + if case let .usersList(users) = r { return users } + return [] +} + +func apiSetActiveUser(_ userId: Int64) throws -> User { + let r = chatSendCmdSync(.apiSetActiveUser(userId: userId)) + if case let .activeUser(user) = r { return user } + throw r +} + +func apiDeleteUser(_ userId: Int64) throws { + let r = chatSendCmdSync(.apiDeleteUser(userId: userId)) + if case .cmdOk = r { return } + throw r +} + func apiStartChat() throws -> Bool { let r = chatSendCmdSync(.startChat(subscribe: true, expire: true)) switch r { @@ -900,11 +918,8 @@ func startChat() throws { try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() if justStarted { - m.userAddress = try apiGetUserAddress() - (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() - m.chatItemTTL = try getChatItemTTL() - let chats = try apiGetChats() - m.chats = chats.map { Chat.init($0) } + try getUserChatData(m) + m.users = listUsers() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() @@ -922,6 +937,14 @@ func startChat() throws { chatLastStartGroupDefault.set(Date.now) } +func getUserChatData(_ m: ChatModel) throws { + m.userAddress = try apiGetUserAddress() + (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() + m.chatItemTTL = try getChatItemTTL() + let chats = try apiGetChats() + m.chats = chats.map { Chat.init($0) } +} + class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index f35bb32aac..6df579d964 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -11,25 +11,46 @@ import SimpleXChat struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - // not really used in this view @State private var showSettings = false @State private var searchText = "" @State private var showAddChat = false + @State var userPickerVisible = false + @State var selectedView: Int? = nil var body: some View { - NavigationView { - VStack { - if chatModel.chats.isEmpty { - onboardingButtons() - } - if chatModel.chats.count > 8 { - chatList.searchable(text: $searchText) - } else { - chatList + ZStack(alignment: .topLeading) { + NavigationView { + VStack { + if chatModel.chats.isEmpty { + onboardingButtons() + } + if chatModel.chats.count > 8 { + chatList.searchable(text: $searchText) + } else { + chatList + } + NavigationLink(destination: UserProfilesView().navigationTitle("Profiles"), tag: 0, selection: $selectedView) { + EmptyView() + } } } + .navigationViewStyle(.stack) + if userPickerVisible { + Rectangle().fill(.white.opacity(0.001)).onTapGesture { + withAnimation { + userPickerVisible.toggle() + } + } + } + UserPicker( + showSettings: $showSettings, + userPickerVisible: $userPickerVisible, + manageUsers: { + selectedView = 0 + userPickerVisible = false + } + ) } - .navigationViewStyle(.stack) } var chatList: some View { @@ -48,13 +69,35 @@ struct ChatListView: View { } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onAppear() { connectViaUrl() } + .onDisappear() { withAnimation { userPickerVisible = false } } .offset(x: -8) .listStyle(.plain) .navigationTitle("Your chats") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - SettingsButton() + Button { + withAnimation { + userPickerVisible.toggle() + } + } label: { + let user = chatModel.currentUser ?? User.sampleData + let color = Color(uiColor: .tertiarySystemGroupedBackground) + HStack(spacing: 0) { + ProfileImage(imageStr: user.image, color: color) + .frame(width: 32, height: 32) + .padding(.trailing, 6) + .padding(.vertical, 6) + let unread = chatModel.users + .filter { !$0.user.activeUser } + .reduce(0, {cnt, u in cnt + u.unreadCount}) + if unread > 0 { + unreadCounter(unread) + .padding(.leading, -12) + .padding(.top, -16) + } + } + } } ToolbarItem(placement: .principal) { if (chatModel.incognito) { @@ -75,6 +118,9 @@ struct ChatListView: View { } } } + .sheet(isPresented: $showSettings) { + SettingsView(showSettings: $showSettings) + } .background( NavigationLink( destination: chatView(), diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift new file mode 100644 index 0000000000..75d96e2fc6 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -0,0 +1,181 @@ +// +// Created by Avently on 16.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: 0.11, alpha: 255)) +private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255)) + +struct UserPicker: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.colorScheme) var colorScheme + @Binding var showSettings: Bool + @Binding var userPickerVisible: Bool + var manageUsers: () -> Void = {} + @State var scrollViewContentSize: CGSize = .zero + @State var disableScrolling: Bool = true + private let menuButtonHeight: CGFloat = 68 + @State var chatViewNameWidth: CGFloat = 0 + + var fillColor: Color { + colorScheme == .dark ? fillColorDark : fillColorLight + } + + var body: some View { + VStack { + Spacer().frame(height: 1) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in + Button(action: { + if !userInfo.user.activeUser { + changeActiveUser(toUser: userInfo) + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: userInfo.user.image) + .frame(width: 44, height: 44) + .padding(.trailing, 12) + Text(userInfo.user.chatViewName) + .fontWeight(i == 0 ? .medium : .regular) + .foregroundColor(.primary) + .overlay(DetermineWidth()) + Spacer() + if i == 0 { + Image(systemName: "checkmark") + } else if userInfo.unreadCount > 0 { + unreadCounter(userInfo.unreadCount) + } + } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + if i < chatModel.users.count - 1 { + Divider() + } + } + } + .overlay { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + scrollViewContentSize = geo.size + let layoutFrame = UIApplication.shared.windows[0].safeAreaLayoutGuide.layoutFrame + disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + } + return Color.clear + } + } + } + .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) + .frame(maxHeight: scrollViewContentSize.height) + + Divider() + menuButton("Your user profiles", icon: "pencil") { + manageUsers() + } + Divider() + menuButton("Settings", icon: "gearshape") { + showSettings = true + withAnimation { + userPickerVisible.toggle() + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + .background( + Rectangle() + .fill(fillColor) + .cornerRadius(16) + .shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0) + ) + .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 } + .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300) + .padding(8) + .onChange(of: [chatModel.currentUser?.chatViewName, chatModel.currentUser?.image] ) { _ in + reloadCurrentUser() + } + .opacity(userPickerVisible ? 1.0 : 0.0) + .onAppear { + reloadUsers() + } + } + + private func reloadCurrentUser() { + if let updatedUser = chatModel.currentUser, let index = chatModel.users.firstIndex(where: { $0.user.userId == updatedUser.userId }) { + let removed = chatModel.users.remove(at: index) + chatModel.users.insert(UserInfo(user: updatedUser, unreadCount: removed.unreadCount), at: 0) + } + } + + private func changeActiveUser(toUser: UserInfo) { + Task { + do { + let activeUser = try apiSetActiveUser(toUser.user.userId) + let oldActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == chatModel.currentUser?.userId })! + var oldActive = chatModel.users[oldActiveIndex] + oldActive.user.activeUser = false + chatModel.users[oldActiveIndex] = oldActive + + chatModel.currentUser = activeUser + let currentActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == activeUser.userId })! + let removed = chatModel.users.remove(at: currentActiveIndex) + chatModel.users.insert(UserInfo(user: activeUser, unreadCount: removed.unreadCount), at: 0) + chatModel.users = chatModel.users.map { $0 } + try getUserChatData(chatModel) + userPickerVisible = false + } catch { + logger.error("Unable to set active user: \(error.localizedDescription)") + } + } + } + + private func reloadUsers() { + Task { + chatModel.users = listUsers().sorted { one, two -> Bool in one.user.activeUser } + } + } + + private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 0) { + Text(title) + .overlay(DetermineWidth()) + Spacer() + Image(systemName: icon) +// .frame(width: 24, alignment: .center) + } + .padding(.horizontal) + .padding(.vertical, 22) + .frame(height: menuButtonHeight) + } + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + } +} + +func unreadCounter(_ unread: Int64) -> some View { + unreadCountText(Int(truncatingIfNeeded: unread)) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(Color.accentColor) + .cornerRadius(10) +} + +struct UserPicker_Previews: PreviewProvider { + static var previews: some View { + let m = ChatModel() + m.users = [UserInfo.sampleData, UserInfo.sampleData] + return UserPicker( + showSettings: Binding.constant(false), + userPickerVisible: Binding.constant(true) + ) + .environmentObject(m) + } +} diff --git a/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift b/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift new file mode 100644 index 0000000000..d8c7cd4ce6 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift @@ -0,0 +1,15 @@ +// +// Created by Avently on 16.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PressedButtonStyle: ButtonStyle { + var defaultColor: Color + var pressedColor: Color + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? pressedColor : defaultColor) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift deleted file mode 100644 index 7292fd4373..0000000000 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// SettingsButton.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 31/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct SettingsButton: View { - @EnvironmentObject var chatModel: ChatModel - @State private var showSettings = false - - var body: some View { - Button { showSettings = true } label: { - Image(systemName: "gearshape") - } - .sheet(isPresented: $showSettings, content: { - SettingsView(showSettings: $showSettings) - }) - } -} - -struct SettingsButton_Previews: PreviewProvider { - static var previews: some View { - SettingsButton() - } -} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift new file mode 100644 index 0000000000..a6ee3302c2 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -0,0 +1,56 @@ +// +// Created by Avently on 17.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct UserProfilesView: View { + @EnvironmentObject private var m: ChatModel + @Environment(\.editMode) private var editMode + @State private var selectedUser: Int? = nil + @State private var showAddUser: Bool? = false + @State private var showScanSMPServer = false + @State private var testing = false + + var body: some View { + List { + Section("Your profiles") { + ForEach(Array($m.users.enumerated()), id: \.0) { i, userInfo in + userProfileView(userInfo, index: i) + .deleteDisabled(userInfo.wrappedValue.user.activeUser) + } + .onDelete { indexSet in + do { + try apiDeleteUser(m.users[indexSet.first!].user.userId) + m.users.remove(atOffsets: indexSet) + } catch { + fatalError("Failed to delete user: \(responseError(error))") + } + } + NavigationLink(destination: CreateProfile(), tag: true, selection: $showAddUser) { + Text("Add profile…") + } + } + } + .toolbar { EditButton() } + } + + private func userProfileView(_ userBinding: Binding, index: Int) -> some View { + let user = userBinding.wrappedValue.user + return NavigationLink(tag: index, selection: $selectedUser) { +// UserPrefs(user: userBinding, index: index) +// .navigationBarTitle(user.chatViewName) +// .navigationBarTitleDisplayMode(.large) + } label: { + Text(user.chatViewName) + } + } +} + +struct UserProfilesView_Previews: PreviewProvider { + static var previews: some View { + UserProfilesView() + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2fa90fbc68..3f37d768f6 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,7 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 1841538E296606C74533367C /* UserPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415835CBD939A9ABDC108A /* UserPicker.swift */; }; + 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */; }; 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841511920742C6E152E469F /* AnimatedImageView.swift */; }; + 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */; }; 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* WebRTCView.swift */; }; 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; @@ -95,7 +98,6 @@ 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */; }; - 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; @@ -222,6 +224,9 @@ /* Begin PBXFileReference section */ 1841511920742C6E152E469F /* AnimatedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; + 18415835CBD939A9ABDC108A /* UserPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserPicker.swift; sourceTree = ""; }; + 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserProfilesView.swift; sourceTree = ""; }; + 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PressedButtonStyle.swift; sourceTree = ""; }; 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; @@ -321,7 +326,6 @@ 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; 5CB346E62868D76D001FD2EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnvironment.swift; sourceTree = ""; }; - 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = ""; }; @@ -536,6 +540,7 @@ 5C6BA666289BD954009B8ECC /* DismissSheets.swift */, 5C00164328A26FBC0094D739 /* ContextMenu.swift */, 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */, + 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */, ); path = Helpers; sourceTree = ""; @@ -623,7 +628,6 @@ 5CB924DF27A8678B00ACCCDD /* UserSettings */ = { isa = PBXGroup; children = ( - 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, @@ -642,6 +646,7 @@ 5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, + 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */, ); path = UserSettings; sourceTree = ""; @@ -656,6 +661,7 @@ 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */, + 18415835CBD939A9ABDC108A /* UserPicker.swift */, ); path = ChatList; sourceTree = ""; @@ -1053,7 +1059,6 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, - 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, 6440CA00288857A10062C672 /* CIEventView.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, @@ -1073,6 +1078,9 @@ 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, + 1841538E296606C74533367C /* UserPicker.swift in Sources */, + 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, + 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index 8b60dc4fae..6a1d4192e6 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -44,7 +44,6 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - enableAddressSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 45adff73dd..8725deede2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -15,6 +15,9 @@ let jsonEncoder = getJSONEncoder() public enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) + case listUsers + case apiSetActiveUser(userId: Int64) + case apiDeleteUser(userId: Int64) case startChat(subscribe: Bool, expire: Bool) case apiStopChat case apiActivateChat @@ -96,6 +99,9 @@ public enum ChatCommand { switch self { case .showActiveUser: return "/u" case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)" + case .listUsers: return "/users" + case let .apiSetActiveUser(userId): return "/_user \(userId)" + case let .apiDeleteUser(userId): return "/_delete user \(userId)" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" @@ -184,6 +190,9 @@ public enum ChatCommand { switch self { case .showActiveUser: return "showActiveUser" case .createActiveUser: return "createActiveUser" + case .listUsers: return "listUsers" + case .apiSetActiveUser: return "apiSetActiveUser" + case .apiDeleteUser: return "apiDeleteUser" case .startChat: return "startChat" case .apiStopChat: return "apiStopChat" case .apiActivateChat: return "apiActivateChat" @@ -302,6 +311,7 @@ struct APIResponse: Decodable { public enum ChatResponse: Decodable, Error { case response(type: String, json: String) case activeUser(user: User) + case usersList(users: [UserInfo]) case chatStarted case chatRunning case chatStopped @@ -408,6 +418,7 @@ public enum ChatResponse: Decodable, Error { switch self { case let .response(type, _): return "* \(type)" case .activeUser: return "activeUser" + case .usersList: return "usersList" case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" case .chatStopped: return "chatStopped" @@ -514,6 +525,7 @@ public enum ChatResponse: Decodable, Error { switch self { case let .response(_, json): return json case let .activeUser(user): return String(describing: user) + case let .usersList(users): return String(describing: users) case .chatStarted: return noDetails case .chatRunning: return noDetails case .chatStopped: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 2902de89d5..22007d7b2c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,19 +9,21 @@ import Foundation import SwiftUI -public struct User: Decodable, NamedChat { +public struct User: Decodable, NamedChat, Identifiable { public var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile public var fullPreferences: FullPreferences - var activeUser: Bool + public var activeUser: Bool public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } public var localAlias: String { get { "" } } + public var id: Int64 { userId } + public static let sampleData = User( userId: 1, userContactId: 1, @@ -32,6 +34,21 @@ public struct User: Decodable, NamedChat { ) } +public struct UserInfo: Decodable { + public var user: User + public var unreadCount: Int64 + + public init(user: User, unreadCount: Int64) { + self.user = user + self.unreadCount = unreadCount + } + + public static let sampleData = UserInfo( + user: User.sampleData, + unreadCount: 1 + ) +} + public typealias ContactName = String public typealias GroupName = String From 84237f79fcab2a466a0dd7c555ad7b38eb71de5c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 18 Jan 2023 10:20:55 +0000 Subject: [PATCH 25/59] core: refactor (#1764) --- src/Simplex/Chat.hs | 109 ++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b75939b6ed..8f404d3bc1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -296,7 +296,7 @@ processChatCommand = \case -- withStore' $ \db -> deleteUser db userId -- ? other cleanup setActive ActiveNone - pure $ CRCmdOk Nothing + ok_ DeleteUser uName -> withUserName uName APIDeleteUser StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case @@ -309,33 +309,27 @@ processChatCommand = \case restoreCalls withAgent activateAgent setAllExpireCIFlags True - pure $ CRCmdOk Nothing + ok_ APISuspendChat t -> do setAllExpireCIFlags False withAgent (`suspendAgent` t) - pure $ CRCmdOk Nothing - ResubscribeAllConnections -> do - users <- withStore' getUsers - subscribeUsers users - pure $ CRCmdOk Nothing - SetFilesFolder filesFolder' -> do - createDirectoryIfMissing True filesFolder' - ff <- asks filesFolder - atomically . writeTVar ff $ Just filesFolder' - pure $ CRCmdOk Nothing + ok_ + ResubscribeAllConnections -> withStore' getUsers >>= subscribeUsers >> ok_ + SetFilesFolder ff -> do + createDirectoryIfMissing True ff + asks filesFolder >>= atomically . (`writeTVar` Just ff) + ok_ SetIncognito onOff -> do - incognito <- asks incognitoMode - atomically . writeTVar incognito $ onOff - pure $ CRCmdOk Nothing - APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk Nothing + asks incognitoMode >>= atomically . (`writeTVar` onOff) + ok_ + APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ APIImportArchive cfg -> withStoreChanged $ importArchive cfg APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) - APIGetChats userId withPCC -> withUserId userId $ \user -> do - chats <- withStore' $ \db -> getChatPreviews db user withPCC - pure $ CRApiChats user chats + APIGetChats userId withPCC -> withUserId userId $ \user -> + CRApiChats user <$> withStore' (\db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do @@ -554,7 +548,7 @@ processChatCommand = \case withStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt withStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds - pure $ CRCmdOk (Just user) + ok user CTGroup -> do user@User {userId} <- withStore $ \db -> getUserByGroupId db chatId timedItems <- withStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds @@ -564,7 +558,7 @@ processChatCommand = \case withStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt withStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds - pure $ CRCmdOk (Just user) + ok user CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of @@ -572,12 +566,12 @@ processChatCommand = \case withStore $ \db -> do ct <- getContact db user chatId liftIO $ updateContactUnreadChat db user ct unreadChat - pure $ CRCmdOk (Just user) + ok user CTGroup -> do withStore $ \db -> do Group {groupInfo} <- getGroup db user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat - pure $ CRCmdOk (Just user) + ok user _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do @@ -674,7 +668,7 @@ processChatCommand = \case call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - pure $ CRCmdOk (Just user) + ok user SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType @@ -761,11 +755,10 @@ processChatCommand = \case pure $ CRConnectionAliasUpdated user conn' APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken - APIRegisterToken token mode -> withUser $ \_ -> do - tokenStatus <- withAgent $ \a -> registerNtfToken a token mode - pure $ CRNtfTokenStatus tokenStatus - APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk Nothing - APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk Nothing + APIRegisterToken token mode -> withUser $ \_ -> + CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) + APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ + APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ APIGetNtfMessage userId nonce encNtfInfo -> withUserId userId $ \user -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs @@ -785,7 +778,7 @@ processChatCommand = \case withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config withAgent $ \a -> setSMPServers a (aUserId user) $ activeAgentServers cfg smpServers - pure $ CRCmdOk (Just user) + ok user SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserSMPServers userId smpServersConfig TestSMPServer userId smpServer -> withUserId userId $ \user -> @@ -806,7 +799,7 @@ processChatCommand = \case withStore' $ \db -> setChatItemTTL db user newTTL_ startExpireCIThread user whenM chatStarted $ setExpireCIFlag user True - pure $ CRCmdOk (Just user) + ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId userId $ \user -> do @@ -814,10 +807,9 @@ processChatCommand = \case pure $ CRChatItemTTL user ttl GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand $ APIGetChatItemTTL userId - APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk Nothing - APIGetNetworkConfig -> withUser' $ \_ -> do - networkConfig <- withAgent getNetworkConfig - pure $ CRNetworkConfig networkConfig + APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) >> ok_ + APIGetNetworkConfig -> withUser' $ \_ -> + CRNetworkConfig <$> withAgent getNetworkConfig APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> do @@ -825,7 +817,7 @@ processChatCommand = \case liftIO $ updateContactSettings db user chatId chatSettings pure ct withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) - pure $ CRCmdOk (Just user) + ok user CTGroup -> do ms <- withStore $ \db -> do Group _ ms <- getGroup db user chatId @@ -833,7 +825,7 @@ processChatCommand = \case pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError (Just user)) - pure $ CRCmdOk (Just user) + ok user _ -> pure $ chatCmdError (Just user) "not supported" APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact @@ -848,11 +840,11 @@ processChatCommand = \case APISwitchContact contactId -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct - pure $ CRCmdOk (Just user) + ok user APISwitchGroupMember gId gMemberId -> withUser $ \user -> do m <- withStore $ \db -> getGroupMember db user gId gMemberId case memberConnId m of - Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) $> CRCmdOk (Just user) + Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) >> ok user _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId @@ -890,13 +882,13 @@ processChatCommand = \case APIEnableContact contactId -> withUser $ \user -> do Contact {activeConn} <- withStore $ \db -> getContact db user contactId withStore' $ \db -> setConnectionAuthErrCounter db user activeConn 0 - pure $ CRCmdOk (Just user) + ok user APIEnableGroupMember gId gMemberId -> withUser $ \user -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 - pure $ CRCmdOk (Just user) + ok user _ -> throwChatError CEGroupMemberNotActive ShowMessages (ChatName cType name) ntfOn -> withUser $ \user -> do chatId <- case cType of @@ -962,9 +954,8 @@ processChatCommand = \case pure $ CRUserContactLinkDeleted user DeleteMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIDeleteMyAddress userId - APIShowMyAddress userId -> withUserId userId $ \user -> do - contactLink <- withStore (`getUserAddress` user) - pure $ CRUserContactLink user contactLink + APIShowMyAddress userId -> withUserId userId $ \user -> + CRUserContactLink user <$> withStore (`getUserAddress` user) ShowMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIShowMyAddress userId APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do @@ -1125,9 +1116,8 @@ processChatCommand = \case forM_ members $ deleteMemberConnection user withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} - APIListMembers groupId -> withUser $ \user -> do - group <- withStore $ \db -> getGroup db user groupId - pure $ CRGroupMembers user group + APIListMembers groupId -> withUser $ \user -> + CRGroupMembers user <$> withStore (\db -> getGroup db user groupId) AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole @@ -1148,17 +1138,15 @@ processChatCommand = \case ListMembers gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId - ListGroups -> withUser $ \user -> do - groups <- withStore' (`getUserGroupDetails` user) - pure $ CRGroupsList user groups + ListGroups -> withUser $ \user -> + CRGroupsList user <$> withStore' (`getUserGroupDetails` user) APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db user groupId runUpdateGroupProfile user g p' UpdateGroupNames gName GroupProfile {displayName, fullName} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName} - ShowGroupProfile gName -> withUser $ \user -> do - groupProfile <- withStore $ \db -> getGroupInfoByName db user gName - pure $ CRGroupProfile user groupProfile + ShowGroupProfile gName -> withUser $ \user -> + CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do @@ -1177,7 +1165,7 @@ processChatCommand = \case pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId - groupLink <- withStore (\db -> getGroupLink db user gInfo) + groupLink <- withStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink CreateGroupLink gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -1216,9 +1204,8 @@ processChatCommand = \case ShowChatItem Nothing -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing pure $ CRChatItems user chatItems - ShowLiveItems on -> withUser $ \_ -> do - asks showLiveItems >>= atomically . (`writeTVar` on) - pure $ CRCmdOk Nothing + ShowLiveItems on -> withUser $ \_ -> + asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCFile "") @@ -1306,7 +1293,7 @@ processChatCommand = \case where stat (AgentStatsKey {host, clientTs, cmd, res}, count) = map B.unpack [host, clientTs, cmd, res, bshow count] - ResetAgentStats -> withAgent resetAgentStats $> CRCmdOk Nothing + ResetAgentStats -> withAgent resetAgentStats >> ok_ where withChatLock name action = asks chatLock >>= \l -> withLock l name action -- below code would make command responses asynchronous where they can be slow @@ -1322,6 +1309,8 @@ processChatCommand = \case -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id + ok_ = pure $ CRCmdOk Nothing + ok = pure . CRCmdOk . Just getChatRef :: User -> ChatName -> m ChatRef getChatRef user (ChatName cType name) = ChatRef cType <$> case cType of @@ -1333,7 +1322,7 @@ processChatCommand = \case setStoreChanged :: m () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) withStoreChanged :: m () -> m ChatResponse - withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk Nothing + withStoreChanged a = checkChatStopped $ a >> setStoreChanged >> ok_ checkStoreNotChanged :: m ChatResponse -> m ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) withUserName :: UserName -> (UserId -> ChatCommand) -> m ChatResponse @@ -1478,7 +1467,7 @@ processChatCommand = \case _ -> do withStore' $ \db -> deleteCalls db user ctId atomically $ TM.delete ctId calls - pure $ CRCmdOk (Just user) + ok user | otherwise -> throwChatError $ CECallContact contactId forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do From a227e21fcf67a4b9b4d4f9c67919008fd5319a61 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 18 Jan 2023 17:08:48 +0400 Subject: [PATCH 26/59] core: support user deletion (#1788) * core: support user deletion * doSendCancel * Apply suggestions from code review Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * sendCancel * refactor * error to view * refactor * refactor Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 46 +++++++++++++++--------- src/Simplex/Chat/Controller.hs | 2 ++ src/Simplex/Chat/Store.hs | 66 ++++++++++++++++------------------ src/Simplex/Chat/View.hs | 2 ++ tests/ChatTests.hs | 40 +++++++++++++++++++-- 5 files changed, 101 insertions(+), 55 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8f404d3bc1..4c43a099e2 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -291,12 +291,19 @@ processChatCommand = \case atomically . writeTVar u $ Just user pure $ CRActiveUser user SetActiveUser uName -> withUserName uName APISetActiveUser - APIDeleteUser _userId -> do - -- prohibit to delete active user - -- withStore' $ \db -> deleteUser db userId - -- ? other cleanup - setActive ActiveNone - ok_ + APIDeleteUser userId -> do + user <- withStore (`getUser` userId) + when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) + users <- withStore' getUsers + -- shouldn't happen - last user should be active + when (length users == 1) $ throwChatError (CECantDeleteLastUser userId) + filesInfo <- withStore' (`getUserFileInfo` user) + withChatLock "deleteUser" . procCmd $ do + forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + withAgent (`deleteUser` aUserId user) + withStore' (`deleteUserRecord` user) + setActive ActiveNone + ok_ DeleteUser uName -> withUserName uName APIDeleteUser StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case @@ -1233,7 +1240,7 @@ processChatCommand = \case withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {cancelled} fts -> do unless cancelled $ do - cancelSndFile user ftm fts + cancelSndFile user ftm fts True sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId withStore (\db -> getChatRefByFileId db user fileId) >>= \case ChatRef CTDirect contactId -> do @@ -1552,7 +1559,10 @@ setAllExpireCIFlags b = do forM_ keys $ \k -> TM.insert k b expireFlags deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m () -deleteFile user CIFileInfo {filePath, fileId, fileStatus} = +deleteFile user fileInfo = deleteFile' user fileInfo False + +deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m () +deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = (cancel' >> delete) `catchError` (toView . CRChatError (Just user)) where cancel' = forM_ fileStatus $ \(AFS dir status) -> @@ -1560,7 +1570,7 @@ deleteFile user CIFileInfo {filePath, fileId, fileStatus} = case dir of SMDSnd -> do (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) - unless cancelled $ cancelSndFile user ftm fts + unless cancelled $ cancelSndFile user ftm fts sendCancel SMDRcv -> do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ cancelRcvFileTransfer user ft @@ -2353,7 +2363,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do - cancelSndFileTransfer user ft + cancelSndFileTransfer user ft True case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> getChatItemByFileId db user fileId @@ -3392,18 +3402,20 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus, rcvFileInline deleteAgentConnectionAsync' user connId agentConnId _ -> pure () -cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> m () -cancelSndFile user FileTransferMeta {fileId} fts = do +cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> m () +cancelSndFile user FileTransferMeta {fileId} fts sendCancel = do withStore' $ \db -> updateFileCancelled db user fileId CIFSSndCancelled - forM_ fts $ \ft' -> cancelSndFileTransfer user ft' + forM_ fts $ \ft' -> cancelSndFileTransfer user ft' sendCancel -cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> m () -cancelSndFileTransfer user ft@SndFileTransfer {connId, agentConnId = agentConnId@(AgentConnId acId), fileStatus} = +cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m () +cancelSndFileTransfer user ft@SndFileTransfer {connId, agentConnId = agentConnId@(AgentConnId acId), fileStatus} sendCancel = unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do withStore' $ \db -> do updateSndFileStatus db ft FSCancelled deleteSndFileChunks db ft - withAgent $ \a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel) `catchError` \_ -> pure () + when sendCancel $ + withAgent (\a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel)) + `catchError` (toView . CRChatError (Just user)) deleteAgentConnectionAsync' user connId agentConnId closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () @@ -3557,7 +3569,7 @@ deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - deleteFile user fileInfo + deleteFile' user fileInfo True markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> m ChatResponse markDirectCIDeleted user ct ci@(CChatItem msgDir deletedItem) msgId byUser = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 65931173da..f58e7cca52 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -589,6 +589,8 @@ data ChatErrorType | CENoConnectionUser {agentConnId :: AgentConnId} | CEActiveUserExists -- TODO delete | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} + | CECantDeleteActiveUser {userId :: UserId} + | CECantDeleteLastUser {userId :: UserId} | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 8f9a281aa2..c99085f3bb 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -30,10 +30,13 @@ module Simplex.Chat.Store getUsers, setActiveUser, getSetActiveUser, + getUser, getUserIdByName, getUserByAConnId, getUserByContactId, getUserByGroupId, + getUserFileInfo, + deleteUserRecord, createDirectConnection, createConnReqConnection, getProfileById, @@ -487,10 +490,10 @@ setActiveUser db userId = do getSetActiveUser :: DB.Connection -> UserId -> ExceptT StoreError IO User getSetActiveUser db userId = do liftIO $ setActiveUser db userId - getUser_ db userId + getUser db userId -getUser_ :: DB.Connection -> UserId -> ExceptT StoreError IO User -getUser_ db userId = +getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User +getUser db userId = ExceptT . firstRow toUser (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) @@ -519,6 +522,26 @@ getUserByFileId db fileId = ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) +getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] +getUserFileInfo db User {userId} = + map toFileInfo + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ?") (Only userId) + +fileInfoQuery :: Query +fileInfoQuery = + [sql| + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + |] + +toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo +toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} + +deleteUserRecord :: DB.Connection -> User -> IO () +deleteUserRecord db User {userId} = + DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime @@ -3031,18 +3054,7 @@ getFileTransferMeta db User {userId} fileId = getContactFileInfo :: DB.Connection -> User -> Contact -> IO [CIFileInfo] getContactFileInfo db User {userId} Contact {contactId} = map toFileInfo - <$> DB.query - db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? - |] - (userId, contactId) - -toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo -toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId) deleteContactCIs :: DB.Connection -> User -> Contact -> IO () deleteContactCIs db user@User {userId} ct@Contact {contactId} = do @@ -3059,15 +3071,7 @@ getContactConnIds_ db User {userId} Contact {contactId} = getGroupFileInfo :: DB.Connection -> User -> GroupInfo -> IO [CIFileInfo] getGroupFileInfo db User {userId} GroupInfo {groupId} = map toFileInfo - <$> DB.query - db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? - |] - (userId, groupId) + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ?") (userId, groupId) deleteGroupCIs :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupCIs db User {userId} GroupInfo {groupId} = do @@ -4742,12 +4746,7 @@ getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate = map toFileInfo <$> DB.query db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ? - |] + (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ?") (userId, contactId, expirationDate) deleteContactExpiredCIs :: DB.Connection -> User -> Contact -> UTCTime -> IO () @@ -4762,12 +4761,7 @@ getGroupExpiredFileInfo db User {userId} GroupInfo {groupId} expirationDate crea map toFileInfo <$> DB.query db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? AND i.item_ts <= ? AND i.created_at <= ? - |] + (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.item_ts <= ? AND i.created_at <= ?") (userId, groupId, expirationDate, createdAtCutoff) deleteGroupExpiredCIs :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2d1fc9443f..0070e5b907 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1168,6 +1168,8 @@ viewChatError logLevel = \case CENoConnectionUser _agentConnId -> [] -- ["error: connection has no user, conn id: " <> sShow agentConnId] CEActiveUserExists -> ["error: active user already exists"] CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] + CECantDeleteActiveUser _ -> ["cannot delete active user"] + CECantDeleteLastUser _ -> ["cannot delete last user"] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index aba627a458..0b7a6d846b 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -177,6 +177,7 @@ chatTests = do describe "multiple users" $ do it "create second user" testCreateSecondUser it "both users have contact link" testMultipleUserAddresses + it "delete user" testDeleteUser describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL describe "queue rotation" $ do @@ -2357,9 +2358,8 @@ testFilesFoldersImageSndDelete = checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" - bob <## "alice cancelled sending file 1 (test_1MB.pdf)" bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: test_1MB.pdf" + bob <##. "receiving file 1 (test_1MB.pdf) progress" -- deleting contact should remove cancelled file checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" @@ -4511,6 +4511,42 @@ testMultipleUserAddresses = showActiveUser alice "alice (Alice)" alice @@@ [("@bob", "hey alice")] +testDeleteUser :: IO () +testDeleteUser = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + + alice ##> "/_delete user 1" + alice <## "cannot delete active user" + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + connectUsers alice cath + + alice ##> "/users" + alice <## "alice (Alice)" + alice <## "alisa (active)" + + alice ##> "/delete user alice" + alice <## "ok" + + alice ##> "/users" + alice <## "alisa (active)" + + bob #> "@alice hey" + -- bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + (alice "/delete user alisa" + alice <## "cannot delete active user" + + alice ##> "/users" + alice <## "alisa (active)" + + alice <##> cath + testSetChatItemTTL :: IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ From ca64ed97847ebd84dfc88211e4533879187fd3fc Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 18 Jan 2023 18:49:56 +0400 Subject: [PATCH 27/59] core: option to reuse servers for new user; support for users to configure same smp servers (add user_id to smp_servers UNIQUE constraint) (#1792) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 27 +++++++++++-- src/Simplex/Chat/Controller.hs | 2 +- .../M20230118_recreate_smp_servers.hs | 39 +++++++++++++++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 30 +++++++------- src/Simplex/Chat/Store.hs | 4 +- tests/ChatTests.hs | 36 +++++++++++++++++ 7 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f31cf9ff96..0fb35f94c7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -81,6 +81,7 @@ library Simplex.Chat.Migrations.M20230107_connections_auth_err_counter Simplex.Chat.Migrations.M20230111_users_agent_user_id Simplex.Chat.Migrations.M20230117_fkey_indexes + Simplex.Chat.Migrations.M20230118_recreate_smp_servers Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4c43a099e2..bb23f7b920 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -271,18 +271,32 @@ toView event = do processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser - CreateActiveUser p -> do + CreateActiveUser p sameServers -> do u <- asks currentUser - -- TODO option to choose current user servers - DefaultAgentServers {smp} <- asks $ defaultServers . config + (smp, smpServers) <- chooseServers auId <- withStore' getUsers >>= \case [] -> pure 1 _ -> withAgent (`createUser` smp) user <- withStore $ \db -> createUserRecord db (AgentUserId auId) p True + unless (null smpServers) $ + withStore $ \db -> overwriteSMPServers db user smpServers setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user + where + chooseServers :: m (NonEmpty SMPServerWithAuth, [ServerCfg]) + chooseServers + | sameServers = + asks currentUser >>= readTVarIO >>= \case + Nothing -> throwChatError CENoActiveUser + Just user -> do + smpServers <- withStore' (`getSMPServers` user) + cfg <- asks config + pure (activeAgentServers cfg smpServers, smpServers) + | otherwise = do + DefaultAgentServers {smp} <- asks $ defaultServers . config + pure (smp, []) ListUsers -> CRUsersList <$> withStore' getUsersInfo APISetActiveUser userId -> do u <- asks currentUser @@ -3814,7 +3828,12 @@ chatCommandP = choice [ "/mute " *> ((`ShowMessages` False) <$> chatNameP'), "/unmute " *> ((`ShowMessages` True) <$> chatNameP'), - "/create user " *> (CreateActiveUser <$> userProfile), + "/create user" + *> ( do + sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False + uProfile <- A.space *> userProfile + pure $ CreateActiveUser uProfile sameSmp + ), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal), ("/user " <|> "/u ") *> (SetActiveUser <$> displayName), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f58e7cca52..da128a5f74 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -146,7 +146,7 @@ instance ToJSON HelpSection where data ChatCommand = ShowActiveUser - | CreateActiveUser Profile + | CreateActiveUser Profile Bool | ListUsers | APISetActiveUser UserId | SetActiveUser UserName diff --git a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs b/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs new file mode 100644 index 0000000000..3098c31d7a --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230118_recreate_smp_servers where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- UNIQUE constraint includes user_id +m20230118_recreate_smp_servers :: Query +m20230118_recreate_smp_servers = + [sql| +DROP INDEX idx_smp_servers_user_id; + +CREATE TABLE new_smp_servers ( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + basic_auth TEXT, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, host, port) +); + +INSERT INTO new_smp_servers + (smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) +SELECT + smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at + FROM smp_servers; + +DROP TABLE smp_servers; +ALTER TABLE new_smp_servers RENAME TO smp_servers; + +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 6525800eb4..adbb190cab 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -386,20 +386,6 @@ CREATE INDEX idx_connections_via_contact_uri_hash ON connections( ); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); -CREATE TABLE smp_servers( - smp_server_id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port TEXT NOT NULL, - key_hash BLOB NOT NULL, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT(datetime('now')), - updated_at TEXT NOT NULL DEFAULT(datetime('now')), - basic_auth TEXT, - preset INTEGER DEFAULT 0 CHECK(preset NOT NULL), - tested INTEGER, - enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL), - UNIQUE(host, port) -); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE TABLE calls( @@ -539,7 +525,6 @@ CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); CREATE INDEX idx_settings_user_id ON settings(user_id); -CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( file_id, connection_id @@ -547,3 +532,18 @@ CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +CREATE TABLE IF NOT EXISTS "smp_servers"( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + basic_auth TEXT, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(user_id, host, port) +); +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index c99085f3bb..0ee1372b6d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -333,6 +333,7 @@ import Simplex.Chat.Migrations.M20221230_idxs import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter import Simplex.Chat.Migrations.M20230111_users_agent_user_id import Simplex.Chat.Migrations.M20230117_fkey_indexes +import Simplex.Chat.Migrations.M20230118_recreate_smp_servers import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -395,7 +396,8 @@ schemaMigrations = ("20221230_idxs", m20221230_idxs), ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter), ("20230111_users_agent_user_id", m20230111_users_agent_user_id), - ("20230117_fkey_indexes", m20230117_fkey_indexes) + ("20230117_fkey_indexes", m20230117_fkey_indexes), + ("20230118_recreate_smp_servers", m20230118_recreate_smp_servers) ] -- | The list of migrations in ascending order by date diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 0b7a6d846b..653647955d 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -177,6 +177,8 @@ chatTests = do describe "multiple users" $ do it "create second user" testCreateSecondUser it "both users have contact link" testMultipleUserAddresses + it "create user with default servers" testCreateUserDefaultServers + it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL @@ -4511,6 +4513,40 @@ testMultipleUserAddresses = showActiveUser alice "alice (Alice)" alice @@@ [("@bob", "hey alice")] +testCreateUserDefaultServers :: IO () +testCreateUserDefaultServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + + -- with same_smp=off + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user same_smp=off alisa2" + showActiveUser alice "alisa2" + + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + +testCreateUserSameServers :: IO () +testCreateUserSameServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user same_smp=on alisa" + showActiveUser alice "alisa" + + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + testDeleteUser :: IO () testDeleteUser = testChat3 aliceProfile bobProfile cathProfile $ From ba29d0242ef39a121b94947132942aed94b3bdf3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 19 Jan 2023 16:00:41 +0000 Subject: [PATCH 28/59] core: add user to RcvCallInvitation (#1797) * core: Include user into RcvCallInvitation * update build * parens Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- src/Simplex/Chat.hs | 4 ++-- src/Simplex/Chat/Call.hs | 5 +++-- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 600678b0e6..3c2d3ef6fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: branches: - master - stable - - sqlcipher + - users tags: - "v*" pull_request: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bb23f7b920..3c37d9c0b9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -756,7 +756,7 @@ processChatCommand = \case rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do user <- getUserByContactId db contactId contact <- getContact db user contactId - pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs} + pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -3049,7 +3049,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView $ CRCallInvitation user (RcvCallInvitation {contact = ct, callType, sharedKey, callTs = chatItemTs' ci}) + toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 8c4379d0ee..c56ec68cb2 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -21,7 +21,7 @@ import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Chat.Types (Contact, ContactId, decodeJSON, encodeJSON) +import Simplex.Chat.Types (Contact, ContactId, decodeJSON, encodeJSON, User) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) @@ -125,7 +125,8 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { contact :: Contact, + { user :: User, + contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, callTs :: UTCTime diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index da128a5f74..7d16c6a701 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -429,7 +429,7 @@ data ChatResponse | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} - | CRCallInvitation {user :: User, callInvitation :: RcvCallInvitation} + | CRCallInvitation {callInvitation :: RcvCallInvitation} | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0070e5b907..f7b3b0c106 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -197,7 +197,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation u RcvCallInvitation {contact, callType, sharedKey} -> ttyUser u $ viewCallInvitation contact callType sharedKey + CRCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] From ad6aa10cd26f02f9681c6befceec3d36234e56ef Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 19 Jan 2023 16:22:56 +0000 Subject: [PATCH 29/59] ios: Multiusers feature continue (#1793) * ios: Multiusers feature continue * Logging of user in responses * UserId Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * Undo ugly user inclusion into functions. Now it's in backend * Do not set active user if it's unchanged * Blank line * if * Change active user function * refactor * refactor Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * Alert Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/Model/ChatModel.swift | 44 ++- apps/ios/Shared/Model/NtfManager.swift | 17 +- apps/ios/Shared/Model/SimpleXAPI.swift | 305 +++++++++------- apps/ios/Shared/Views/Call/CallManager.swift | 1 + .../Views/ChatList/ChatListNavLink.swift | 4 +- .../Shared/Views/ChatList/UserPicker.swift | 100 +++--- .../Database/DatabaseEncryptionView.swift | 2 +- .../Views/Onboarding/CreateProfile.swift | 9 +- apps/ios/Shared/Views/TerminalView.swift | 2 +- .../Views/UserSettings/UserProfilesView.swift | 38 +- .../ios/SimpleX NSE/NotificationService.swift | 74 ++-- apps/ios/SimpleXChat/API.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 337 +++++++++--------- apps/ios/SimpleXChat/CallTypes.swift | 2 + apps/ios/SimpleXChat/Notifications.swift | 21 +- 16 files changed, 557 insertions(+), 407 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 268ebb1a75..6b70f0f054 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -50,7 +50,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active } catch { - if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { + if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { m.tokenStatus = .expired } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d8a1efddf6..32c3ad19ad 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,7 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? - @Published var users: [UserInfo] = [] + @Published private(set) var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false @@ -177,6 +177,7 @@ final class ChatModel: ObservableObject { chats[i].chatItems = [cItem] if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 + increaseUnreadCounter(user: currentUser!) NtfManager.shared.incNtfBadgeCount() } if i > 0 { @@ -344,6 +345,7 @@ final class ChatModel: ObservableObject { if markedCount > 0 { NtfManager.shared.decNtfBadgeCount(by: markedCount) chat.chatStats.unreadCount -= markedCount + self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) } } } @@ -395,6 +397,21 @@ final class ChatModel: ObservableObject { func decreaseUnreadCounter(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 + decreaseUnreadCounter(user: currentUser!) + } + } + + func increaseUnreadCounter(user: User) { + changeUnreadCounter(user: user, by: 1) + } + + func decreaseUnreadCounter(user: User, by: Int = 1) { + changeUnreadCounter(user: user, by: -by) + } + + private func changeUnreadCounter(user: User, by: Int) { + if let i = users.firstIndex(where: { $0.user.id == user.id }) { + users[i].unreadCount += Int64(by) } } @@ -480,6 +497,31 @@ final class ChatModel: ObservableObject { while i < maxIx && inView(i) { i += 1 } return reversedChatItems[min(i - 1, maxIx)] } + + func updateUsers(_ new: [UserInfo]) { + users = new + .sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } + .sorted { first, _ in first.user.activeUser } + } + + func changeActiveUser(_ toUserId: Int64) { + do { + let activeUser = try apiSetActiveUser(toUserId) + var users = users + let oldActiveIndex = users.firstIndex(where: { $0.user.userId == currentUser?.userId })! + var oldActive = users[oldActiveIndex] + oldActive.user.activeUser = false + users[oldActiveIndex] = oldActive + + currentUser = activeUser + let currentActiveIndex = users.firstIndex(where: { $0.user.userId == activeUser.userId })! + users[currentActiveIndex] = UserInfo(user: activeUser, unreadCount: users[currentActiveIndex].unreadCount) + updateUsers(users) + try getUserChatData(self) + } catch { + logger.error("Unable to set active user: \(error.localizedDescription)") + } + } } struct UnreadChatItemCounts { diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 398e9eb378..b817b44857 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -28,7 +28,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { private var granted = false private var prevNtfTime: Dictionary = [:] - // Handle notification when app is in background func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -38,6 +37,10 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { let chatModel = ChatModel.shared let action = response.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") + if let userId = content.userInfo["userId"] as? Int64, + userId != chatModel.currentUser?.userId { + chatModel.changeActiveUser(userId) + } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { @@ -189,20 +192,20 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { center.delegate = self } - func notifyContactRequest(_ contactRequest: UserContactRequest) { + func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification(createContactRequestNtf(contactRequest)) + addNotification(createContactRequestNtf(user, contactRequest)) } - func notifyContactConnected(_ contact: Contact) { + func notifyContactConnected(_ user: User, _ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification(createContactConnectedNtf(contact)) + addNotification(createContactConnectedNtf(user, contact)) } - func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled { - addNotification(createMessageReceivedNtf(cInfo, cItem)) + addNotification(createMessageReceivedNtf(user, cInfo, cItem)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f80605239a..51c46520aa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -120,7 +120,7 @@ func apiGetActiveUser() throws -> User? { let r = chatSendCmdSync(.showActiveUser) switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: throw r } } @@ -209,19 +209,19 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th func apiGetChats() throws -> [ChatData] { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") } let r = chatSendCmdSync(.apiGetChats(userId: userId)) - if case let .apiChats(chats) = r { return chats } + if case let .apiChats(_, chats) = r { return chats } throw r } func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat { let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search)) - if case let .apiChat(chat) = r { return Chat.init(chat) } + if case let .apiChat(_, chat) = r { return Chat.init(chat) } throw r } func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) - if case let .apiChat(chat) = r { return chat.chatItems } + if case let .apiChat(_, chat) = r { return chat.chatItems } throw r } @@ -246,7 +246,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 var cItem: ChatItem! let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } }) r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { cItem = aChatItem.chatItem chatModel.messageDelivery[cItem.id] = endTask return cItem @@ -258,7 +258,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 return nil } else { r = await chatSendCmd(cmd, bgDelay: msgDelay) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem } sendMessageErrorAlert(r) @@ -276,13 +276,13 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) - if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } + if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) { let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) - if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } + if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } throw r } @@ -290,7 +290,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) - case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) + case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) default: logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)") return (nil, nil, .off) @@ -331,7 +331,7 @@ func apiDeleteToken(token: DeviceToken) async throws { func getUserSMPServers() throws -> ([ServerCfg], [String]) { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") } let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId)) - if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) } + if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) } throw r } @@ -343,7 +343,7 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws { func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) - if case let .smpTestResult(testFailure) = r { + if case let .smpTestResult(_, testFailure) = r { if let t = testFailure { return .failure(t) } @@ -355,7 +355,7 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> func getChatItemTTL() throws -> ChatItemTTL { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") } let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId)) - if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } throw r } @@ -382,13 +382,13 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) - if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) } + if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } throw r } func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) { let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) } + if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) } throw r } @@ -402,26 +402,26 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) - if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) } + if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } throw r } func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) { let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) } + if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } throw r } func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyContact error: \(String(describing: r))") return nil } func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyGroupMember error: \(String(describing: r))") return nil } @@ -432,7 +432,7 @@ func apiAddContact() async -> String? { return nil } let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) - if case let .invitation(connReqInvitation) = r { return connReqInvitation } + if case let .invitation(_, connReqInvitation) = r { return connReqInvitation } connectionErrorAlert(r) return nil } @@ -447,7 +447,7 @@ func apiConnect(connReq: String) async -> ConnReqType? { switch r { case .sentConfirmation: return .invitation case .sentInvitation: return .contact - case let .contactAlreadyExists(contact): + case let .contactAlreadyExists(_, contact): let m = ChatModel.shared if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } @@ -457,19 +457,19 @@ func apiConnect(connReq: String) async -> ConnReqType? { message: "You are already connected to \(contact.displayName)." ) return nil - case .chatCmdError(.error(.invalidConnReq)): + case .chatCmdError(_, .error(.invalidConnReq)): am.showAlertMsg( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) return nil - case .chatCmdError(.errorAgent(.SMP(.AUTH))): + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): am.showAlertMsg( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return nil - case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))): + case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): if internalErr == "SEUniqueID" { am.showAlertMsg( title: "Already connected?", @@ -516,7 +516,7 @@ func deleteChat(_ chat: Chat) async { func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) - if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo } + if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } throw r } @@ -533,7 +533,7 @@ func clearChat(_ chat: Chat) async { func apiListContacts() throws -> [Contact] { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") } let r = chatSendCmdSync(.apiListContacts(userId: userId)) - if case let .contactsList(contacts) = r { return contacts } + if case let .contactsList(_, contacts) = r { return contacts } throw r } @@ -542,33 +542,33 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { case .userProfileNoChange: return nil - case let .userProfileUpdated(_, toProfile): return toProfile + case let .userProfileUpdated(_, _, toProfile): return toProfile default: throw r } } func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) - if case let .contactPrefsUpdated(_, toContact) = r { return toContact } + if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } throw r } func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) - if case let .contactAliasUpdated(toContact) = r { return toContact } + if case let .contactAliasUpdated(_, toContact) = r { return toContact } throw r } func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) - if case let .connectionAliasUpdated(toConnection) = r { return toConnection } + if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } throw r } func apiCreateUserAddress() async throws -> String { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") } let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) - if case let .userContactLinkCreated(connReq) = r { return connReq } + if case let .userContactLinkCreated(_, connReq) = r { return connReq } throw r } @@ -583,8 +583,8 @@ func apiGetUserAddress() throws -> UserContactLink? { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") } let r = chatSendCmdSync(.apiShowMyAddress(userId: userId)) switch r { - case let .userContactLink(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLink(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } @@ -593,8 +593,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") } let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { - case let .userContactLinkUpdated(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLinkUpdated(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } @@ -603,8 +603,8 @@ func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? { let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) let am = AlertManager.shared - if case let .acceptingContactRequest(contact) = r { return contact } - if case .chatCmdError(.errorAgent(.SMP(.AUTH))) = r { + if case let .acceptingContactRequest(_, contact) = r { return contact } + if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r { am.showAlertMsg( title: "Connection error (AUTH)", message: "Sender may have deleted the connection request." @@ -643,7 +643,7 @@ func receiveFile(fileId: Int64) async { func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline)) let am = AlertManager.shared - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case .rcvFileAcceptedSndCancelled = r { am.showAlertMsg( title: "Cannot receive file", @@ -652,7 +652,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { } else if !networkErrorAlert(r) { logger.error("apiReceiveFile error: \(String(describing: r))") switch r { - case .chatCmdError(.error(.fileAlreadyReceiving)): + case .chatCmdError(_, .error(.fileAlreadyReceiving)): logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error") default: am.showAlertMsg( @@ -667,13 +667,13 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { func networkErrorAlert(_ r: ChatResponse) -> Bool { let am = AlertManager.shared switch r { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): am.showAlertMsg( title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again." ) return true - case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): am.showAlertMsg( title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again." @@ -788,13 +788,13 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") } let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) - if case let .groupCreated(groupInfo) = r { return groupInfo } + if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) - if case let .sentGroupInvitation(_, _, member) = r { return member } + if case let .sentGroupInvitation(_, _, _, member) = r { return member } throw r } @@ -807,22 +807,22 @@ enum JoinGroupResult { func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) switch r { - case let .userAcceptedGroupSent(groupInfo, _): return .joined(groupInfo: groupInfo) - case .chatCmdError(.errorAgent(.SMP(.AUTH))): return .invitationRemoved - case .chatCmdError(.errorStore(.groupNotFound)): return .groupNotFound + case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved + case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound default: throw r } } func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, member) = r { return member } + if case let .userDeletedMember(_, _, member) = r { return member } throw r } func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, member, _, _) = r { return member } + if case let .memberRoleUser(_, _, member, _, _) = r { return member } throw r } @@ -837,19 +837,19 @@ func leaveGroup(_ groupId: Int64) async { func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) - if case let .leftMemberUser(groupInfo) = r { return groupInfo } + if case let .leftMemberUser(_, groupInfo) = r { return groupInfo } throw r } func apiListMembers(_ groupId: Int64) async -> [GroupMember] { let r = await chatSendCmd(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } func apiListMembersSync(_ groupId: Int64) -> [GroupMember] { let r = chatSendCmdSync(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } @@ -863,13 +863,13 @@ func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo { let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) - if case let .groupUpdated(toGroup) = r { return toGroup } + if case let .groupUpdated(_, toGroup) = r { return toGroup } throw r } func apiCreateGroupLink(_ groupId: Int64) async throws -> String { let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) - if case let .groupLinkCreated(_, connReq) = r { return connReq } + if case let .groupLinkCreated(_, _, connReq) = r { return connReq } throw r } @@ -882,9 +882,9 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { func apiGetGroupLink(_ groupId: Int64) throws -> String? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, connReq): + case let .groupLink(_, _, connReq): return connReq - case .chatCmdError(chatError: .errorStore(storeError: .groupLinkNotFound)): + case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r } @@ -917,9 +917,9 @@ func startChat() throws { let m = ChatModel.shared try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() + m.updateUsers(listUsers()) if justStarted { try getUserChatData(m) - m.users = listUsers() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() @@ -927,7 +927,7 @@ func startChat() throws { registerToken(token: token) } withAnimation { - m.onboardingStage = m.onboardingStage == .step2_CreateProfile + m.onboardingStage = m.onboardingStage == .step2_CreateProfile && m.users.count == 1 ? .step3_SetNotificationsMode : .onboardingComplete } @@ -988,25 +988,31 @@ func processReceivedMsg(_ res: ChatResponse) async { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(connection): - m.updateContactConnection(connection) - case let .contactConnectionDeleted(connection): - m.removeChat(connection.id) - case let .contactConnected(contact, _): - if contact.directOrUsed { + case let .newContactConnection(user, connection): + if active(user) { + m.updateContactConnection(connection) + } + case let .contactConnectionDeleted(user, connection): + if active(user) { + m.removeChat(connection.id) + } + case let .contactConnected(user, contact, _): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) m.updateNetworkStatus(contact.id, .connected) - NtfManager.shared.notifyContactConnected(contact) + NtfManager.shared.notifyContactConnected(user, contact) } - case let .contactConnecting(contact): - if contact.directOrUsed { + case let .contactConnecting(user, contact): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) } - case let .receivedContactRequest(contactRequest): + case let .receivedContactRequest(user, contactRequest): + if !active(user) { return } + let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) if m.hasChat(contactRequest.id) { m.updateChatInfo(cInfo) @@ -1015,27 +1021,34 @@ func processReceivedMsg(_ res: ChatResponse) async { chatInfo: cInfo, chatItems: [] )) - NtfManager.shared.notifyContactRequest(contactRequest) + NtfManager.shared.notifyContactRequest(user, contactRequest) } - case let .contactUpdated(toContact): - let cInfo = ChatInfo.direct(contact: toContact) - if m.hasChat(toContact.id) { + case let .contactUpdated(user, toContact): + if active(user) && m.hasChat(toContact.id) { + let cInfo = ChatInfo.direct(contact: toContact) m.updateChatInfo(cInfo) } - case let .contactsMerged(intoContact, mergedContact): - if m.hasChat(mergedContact.id) { + case let .contactsMerged(user, intoContact, mergedContact): + if active(user) && m.hasChat(mergedContact.id) { if m.chatId == mergedContact.id { m.chatId = intoContact.id } m.removeChat(mergedContact.id) } - case let .contactsSubscribed(_, contactRefs): - updateContactsStatus(contactRefs, status: .connected) - case let .contactsDisconnected(_, contactRefs): - updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(contact, chatError): - processContactSubError(contact, chatError) - case let .contactSubSummary(contactSubscriptions): + case let .contactsSubscribed(user, _, contactRefs): + if active(user) { + updateContactsStatus(contactRefs, status: .connected) + } + case let .contactsDisconnected(user, _, contactRefs): + if active(user) { + updateContactsStatus(contactRefs, status: .disconnected) + } + case let .contactSubError(user, contact, chatError): + if active(user) { + processContactSubError(contact, chatError) + } + case let .contactSubSummary(user, contactSubscriptions): + if !active(user) { return } for sub in contactSubscriptions { if let err = sub.contactError { processContactSubError(sub.contact, err) @@ -1044,7 +1057,14 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateNetworkStatus(sub.contact.id, .connected) } } - case let .newChatItem(aChatItem): + case let .newChatItem(user, aChatItem): + if !active(user) { + if case .rcvNew = aChatItem.chatItem.meta.itemStatus { + m.increaseUnreadCounter(user: user) + } + return + } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem m.addChatItem(cInfo, cItem) @@ -1060,9 +1080,11 @@ func processReceivedMsg(_ res: ChatResponse) async { } } if cItem.showNotification { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } - case let .chatItemStatusUpdated(aChatItem): + case let .chatItemStatusUpdated(user, aChatItem): + if !active(user) { return } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem var res = false @@ -1070,7 +1092,7 @@ func processReceivedMsg(_ res: ChatResponse) async { res = m.upsertChatItem(cInfo, cItem) } if res { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() @@ -1079,48 +1101,87 @@ func processReceivedMsg(_ res: ChatResponse) async { default: break } } - case let .chatItemUpdated(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, _): + case let .chatItemUpdated(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .chatItemDeleted(user, deletedChatItem, toChatItem, _): + if !active(user) { + if toChatItem == nil && deletedChatItem.chatItem.isRcvNew { + m.decreaseUnreadCounter(user: user) + } + return + } + if let toChatItem = toChatItem { _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem) } else { m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem) } - case let .receivedGroupInvitation(groupInfo, _, _): - m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated - // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? - case let .userAcceptedGroupSent(groupInfo, hostContact): + case let .receivedGroupInvitation(user, groupInfo, _, _): + if active(user) { + m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated + // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? + } + case let .userAcceptedGroupSent(user, groupInfo, hostContact): + if !active(user) { return } + m.updateGroup(groupInfo) if let hostContact = hostContact { m.dismissConnReqView(hostContact.activeConn.id) m.removeChat(hostContact.activeConn.id) } - case let .joinedGroupMemberConnecting(groupInfo, _, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .deletedMemberUser(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .deletedMember(groupInfo, _, deletedMember): - _ = m.upsertGroupMember(groupInfo, deletedMember) - case let .leftMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupDeleted(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .userJoinedGroup(groupInfo): - m.updateGroup(groupInfo) - case let .joinedGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .connectedToGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupUpdated(toGroup): - m.updateGroup(toGroup) - case let .rcvFileStart(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .rcvFileComplete(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .sndFileStart(aChatItem, _): - chatItemSimpleUpdate(aChatItem) - case let .sndFileComplete(aChatItem, _): + case let .joinedGroupMemberConnecting(user, groupInfo, _, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .deletedMemberUser(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .deletedMember(user, groupInfo, _, deletedMember): + if active(user) { + _ = m.upsertGroupMember(groupInfo, deletedMember) + } + case let .leftMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupDeleted(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .userJoinedGroup(user, groupInfo): + if active(user) { + m.updateGroup(groupInfo) + } + case let .joinedGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .connectedToGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupUpdated(user, toGroup): + if active(user) { + m.updateGroup(toGroup) + } + case let .rcvFileStart(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .rcvFileComplete(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileStart(user, aChatItem, _): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileComplete(user, aChatItem, _): + if !active(user) { return } + chatItemSimpleUpdate(aChatItem) let cItem = aChatItem.chatItem let mc = cItem.content.msgContent @@ -1145,7 +1206,7 @@ func processReceivedMsg(_ res: ChatResponse) async { // logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") // } // } - case let .callOffer(contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, _): withCall(contact) { call in call.callState = .offerReceived call.peerMedia = callType.media @@ -1163,16 +1224,16 @@ func processReceivedMsg(_ res: ChatResponse) async { relay: useRelay ) } - case let .callAnswer(contact, answer): + case let .callAnswer(_, contact, answer): withCall(contact) { call in call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) } - case let .callExtraInfo(contact, extraInfo): + case let .callExtraInfo(_, contact, extraInfo): withCall(contact) { _ in m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) } - case let .callEnded(contact): + case let .callEnded(_, contact): if let invitation = m.callInvitations.removeValue(forKey: contact.id) { CallController.shared.reportCallRemoteEnded(invitation: invitation) } @@ -1186,6 +1247,10 @@ func processReceivedMsg(_ res: ChatResponse) async { logger.debug("unsupported event: \(res.responseType)") } + func active(_ user: User) -> Bool { + user.id == m.currentUser?.id + } + func withCall(_ contact: Contact, _ perform: (Call) -> Void) { if let call = m.activeCall, call.contact.apiId == contact.apiId { perform(call) @@ -1201,7 +1266,7 @@ func chatItemSimpleUpdate(_ aChatItem: AChatItem) { let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem if m.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(m.currentUser!, cInfo, cItem) } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 962d783bcb..7da22919e9 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -36,6 +36,7 @@ class CallManager { func answerIncomingCall(invitation: RcvCallInvitation) { let m = ChatModel.shared + // TODO: change active user m.callInvitations.removeValue(forKey: invitation.contact.id) m.activeCall = Call( direction: .incoming, diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 81c642625b..59ecad36e7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -416,9 +416,9 @@ struct ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { switch error as? ChatResponse { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") default: return ErrorAlert(title: title, message: "Error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 75d96e2fc6..4f817c8753 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -29,45 +29,56 @@ struct UserPicker: View { Spacer().frame(height: 1) VStack(spacing: 0) { ScrollView { - VStack(spacing: 0) { - ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in - Button(action: { - if !userInfo.user.activeUser { - changeActiveUser(toUser: userInfo) - } - }, label: { - HStack(spacing: 0) { - ProfileImage(imageStr: userInfo.user.image) + ScrollViewReader { sp in + VStack(spacing: 0) { + ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in + Button(action: { + if !userInfo.user.activeUser { + chatModel.changeActiveUser(userInfo.user.userId) + userPickerVisible = false + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: userInfo.user.image) .frame(width: 44, height: 44) .padding(.trailing, 12) - Text(userInfo.user.chatViewName) + Text(userInfo.user.chatViewName) .fontWeight(i == 0 ? .medium : .regular) .foregroundColor(.primary) .overlay(DetermineWidth()) - Spacer() - if i == 0 { - Image(systemName: "checkmark") - } else if userInfo.unreadCount > 0 { - unreadCounter(userInfo.unreadCount) + Spacer() + if i == 0 { + Image(systemName: "checkmark") + } else if userInfo.unreadCount > 0 { + unreadCounter(userInfo.unreadCount) + } } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + if i < chatModel.users.count - 1 { + Divider() } - .padding(.trailing) - .padding([.leading, .vertical], 12) - }) - .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) - if i < chatModel.users.count - 1 { - Divider() } } - } - .overlay { - GeometryReader { geo -> Color in - DispatchQueue.main.async { - scrollViewContentSize = geo.size - let layoutFrame = UIApplication.shared.windows[0].safeAreaLayoutGuide.layoutFrame - disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + .overlay { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + scrollViewContentSize = geo.size + let scenes = UIApplication.shared.connectedScenes + if let windowScene = scenes.first as? UIWindowScene { + let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame + disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + } + } + return Color.clear + } + } + .onChange(of: userPickerVisible) { visible in + if visible { + sp.scrollTo(0) } - return Color.clear } } } @@ -108,36 +119,15 @@ struct UserPicker: View { private func reloadCurrentUser() { if let updatedUser = chatModel.currentUser, let index = chatModel.users.firstIndex(where: { $0.user.userId == updatedUser.userId }) { - let removed = chatModel.users.remove(at: index) - chatModel.users.insert(UserInfo(user: updatedUser, unreadCount: removed.unreadCount), at: 0) - } - } - - private func changeActiveUser(toUser: UserInfo) { - Task { - do { - let activeUser = try apiSetActiveUser(toUser.user.userId) - let oldActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == chatModel.currentUser?.userId })! - var oldActive = chatModel.users[oldActiveIndex] - oldActive.user.activeUser = false - chatModel.users[oldActiveIndex] = oldActive - - chatModel.currentUser = activeUser - let currentActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == activeUser.userId })! - let removed = chatModel.users.remove(at: currentActiveIndex) - chatModel.users.insert(UserInfo(user: activeUser, unreadCount: removed.unreadCount), at: 0) - chatModel.users = chatModel.users.map { $0 } - try getUserChatData(chatModel) - userPickerVisible = false - } catch { - logger.error("Unable to set active user: \(error.localizedDescription)") - } + var users = chatModel.users + users[index] = UserInfo(user: updatedUser, unreadCount: users[index].unreadCount) + chatModel.updateUsers(users) } } private func reloadUsers() { Task { - chatModel.users = listUsers().sorted { one, two -> Bool in one.user.activeUser } + chatModel.updateUsers(listUsers()) } } @@ -171,7 +161,7 @@ func unreadCounter(_ unread: Int64) -> some View { struct UserPicker_Previews: PreviewProvider { static var previews: some View { let m = ChatModel() - m.users = [UserInfo.sampleData, UserInfo.sampleData] + m.updateUsers([UserInfo.sampleData, UserInfo.sampleData]) return UserPicker( showSettings: Binding.constant(false), userPickerVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 52cf600767..22ab2a4edf 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -152,7 +152,7 @@ struct DatabaseEncryptionView: View { await operationEnded(.databaseEncrypted) } } catch let error { - if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index c6ecb21eb9..512cbeda35 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel + @Environment(\.presentationMode) var presentationMode: Binding @State private var displayName: String = "" @State private var fullName: String = "" @FocusState private var focusDisplayName @@ -97,8 +98,12 @@ struct CreateProfile: View { do { m.currentUser = try apiCreateActiveUser(profile) try startChat() - withAnimation { m.onboardingStage = .step3_SetNotificationsMode } - + if m.users.count == 1 { + withAnimation { m.onboardingStage = .step3_SetNotificationsMode } + } else { + presentationMode.wrappedValue.dismiss() + try getUserChatData(m) + } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index f50b87a695..0274e6fca8 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -108,7 +108,7 @@ struct TerminalView: View { func sendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) + let resp = ChatResponse.chatCmdError(user: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) DispatchQueue.main.async { ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) ChatModel.shared.terminalItems.append(.resp(.now, resp)) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index a6ee3302c2..fd1f920002 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -11,23 +11,24 @@ struct UserProfilesView: View { @Environment(\.editMode) private var editMode @State private var selectedUser: Int? = nil @State private var showAddUser: Bool? = false - @State private var showScanSMPServer = false - @State private var testing = false var body: some View { List { Section("Your profiles") { - ForEach(Array($m.users.enumerated()), id: \.0) { i, userInfo in + ForEach(Array(m.users.enumerated()), id: \.0) { i, userInfo in userProfileView(userInfo, index: i) - .deleteDisabled(userInfo.wrappedValue.user.activeUser) + .deleteDisabled(userInfo.user.activeUser) } .onDelete { indexSet in - do { - try apiDeleteUser(m.users[indexSet.first!].user.userId) - m.users.remove(atOffsets: indexSet) - } catch { - fatalError("Failed to delete user: \(responseError(error))") - } + AlertManager.shared.showAlert( + Alert( + title: Text("Delete profile?"), + message: Text("All chats and messages will be deleted - this cannot be undone!"), + primaryButton: .destructive(Text("Delete")) { + removeUser(index: indexSet.first!) + }, + secondaryButton: .cancel() + )) } NavigationLink(destination: CreateProfile(), tag: true, selection: $showAddUser) { Text("Add profile…") @@ -37,8 +38,21 @@ struct UserProfilesView: View { .toolbar { EditButton() } } - private func userProfileView(_ userBinding: Binding, index: Int) -> some View { - let user = userBinding.wrappedValue.user + private func removeUser(index: Int) { + do { + try apiDeleteUser(m.users[index].user.userId) + var users = m.users + users.remove(at: index) + m.updateUsers(users) + } catch { + AlertManager.shared.showAlertMsg( + title: "Failed to delete the user", + message: "Error: \(responseError(error))" + ) + } + } + private func userProfileView(_ userBinding: UserInfo, index: Int) -> some View { + let user = userBinding.user return NavigationLink(tag: index, selection: $selectedUser) { // UserPrefs(user: userBinding, index: index) // .navigationBarTitle(user.chatViewName) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index bebc06c17e..6971c3f447 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -107,20 +107,22 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let dbStatus = startChat() { if case .ok = dbStatus, - let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { - setBestAttemptNtf(createConnectionEventNtf(connEntity)) - if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) - deliverBestAttemptNtf() + let ntfMsgInfos = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + for ntfMsgInfo in ntfMsgInfos { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") + if let connEntity = ntfMsgInfo.connEntity { + setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + if let id = connEntity.id { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) + deliverBestAttemptNtf() + } } - return } } + return } else { setBestAttemptNtf(createErrorNtf(dbStatus)) } @@ -209,13 +211,13 @@ func chatRecvMsg() async -> ChatResponse? { func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? { logger.debug("NotificationService processReceivedMsg: \(res.responseType)") switch res { - case let .contactConnected(contact, _): - return (contact.id, createContactConnectedNtf(contact)) + case let .contactConnected(user, contact, _): + return (contact.id, createContactConnectedNtf(user, contact)) // case let .contactConnecting(contact): // TODO profile update - case let .receivedContactRequest(contactRequest): - return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(contactRequest)) - case let .newChatItem(aChatItem): + case let .receivedContactRequest(user, contactRequest): + return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest)) + case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem if case .image = cItem.content.msgContent { @@ -234,7 +236,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem } } - return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) : nil + return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil case let .callInvitation(invitation): return (invitation.contact.id, createCallInvitationNtf(invitation)) default: @@ -256,12 +258,21 @@ func updateNetCfg() { } } +func listUsers() -> [UserInfo] { + let r = sendSimpleXCmd(.listUsers) + logger.debug("listUsers sendSimpleXCmd response: \(String(describing: r))") + switch r { + case let .usersList(users): return users + default: return [] + } +} + func apiGetActiveUser() -> User? { let r = sendSimpleXCmd(.showActiveUser) - logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + logger.debug("apiGetActiveUser sendSimpleXCmd response: \(String(describing: r))") switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") return nil @@ -289,22 +300,26 @@ func apiSetIncognito(incognito: Bool) throws { throw r } -func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { - guard let user = apiGetActiveUser() else { - logger.debug("no active user") - return nil +func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> [NtfMessages]? { + let users = listUsers() + if users.isEmpty { + logger.debug("no users") + return [] } - let r = sendSimpleXCmd(.apiGetNtfMessage(userId: user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { - return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + var result: [NtfMessages] = [] + users.forEach { + let r = sendSimpleXCmd(.apiGetNtfMessage(userId: $0.user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r { + result.append(NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages)) + } + logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") } - logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") - return nil + return result } func apiReceiveFile(fileId: Int64, inline: Bool) -> AChatItem? { let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline)) - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil } @@ -316,6 +331,7 @@ func setNetworkConfig(_ cfg: NetCfg) throws { } struct NtfMessages { + var user: User var connEntity: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index cbb296e829..b6b7d55f3a 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -134,6 +134,7 @@ public func chatResponse(_ s: String) -> ChatResponse { type = jResp.allKeys[0] as? String if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: User = try? decodeObject(jApiChats["user"] as Any), let jChats = jApiChats["chats"] as? NSArray { let chats = jChats.map { jChat in if let chatData = try? parseChatData(jChat) { @@ -141,13 +142,14 @@ public func chatResponse(_ s: String) -> ChatResponse { } return ChatData.invalidJSON(prettyJSON(jChat) ?? "") } - return .apiChats(chats: chats) + return .apiChats(user: user, chats: chats) } } else if type == "apiChat" { if let jApiChat = jResp["apiChat"] as? NSDictionary, + let user: User = try? decodeObject(jApiChat["user"] as Any), let jChat = jApiChat["chat"] as? NSDictionary, let chat = try? parseChatData(jChat) { - return .apiChat(chat: chat) + return .apiChat(user: user, chat: chat) } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 8725deede2..737b51e607 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -316,102 +316,102 @@ public enum ChatResponse: Decodable, Error { case chatRunning case chatStopped case chatSuspended - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String]) - case smpTestResult(smpTestFailure: SMPTestFailure?) - case chatItemTTL(chatItemTTL: Int64?) + case apiChats(user: User, chats: [ChatData]) + case apiChat(user: User, chat: ChatData) + case userSMPServers(user: User, smpServers: [ServerCfg], presetSMPServers: [String]) + case smpTestResult(user: User, smpTestFailure: SMPTestFailure?) + case chatItemTTL(user: User, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) - case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) - case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) - case contactCode(contact: Contact, connectionCode: String) - case groupMemberCode(groupInfo: GroupInfo, member: GroupMember, connectionCode: String) - case connectionVerified(verified: Bool, expectedCode: String) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case chatCleared(chatInfo: ChatInfo) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case contactAliasUpdated(toContact: Contact) - case connectionAliasUpdated(toConnection: PendingContactConnection) - case contactPrefsUpdated(fromContact: Contact, toContact: Contact) - case userContactLink(contactLink: UserContactLink) - case userContactLinkUpdated(contactLink: UserContactLink) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact, userCustomProfile: Profile?) - case contactConnecting(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) - case contactsSubscribed(server: String, contactRefs: [ContactRef]) - case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) + case contactInfo(user: User, contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) + case groupMemberInfo(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case contactCode(user: User, contact: Contact, connectionCode: String) + case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) + case connectionVerified(user: User, verified: Bool, expectedCode: String) + case invitation(user: User, connReqInvitation: String) + case sentConfirmation(user: User) + case sentInvitation(user: User) + case contactAlreadyExists(user: User, contact: Contact) + case contactDeleted(user: User, contact: Contact) + case chatCleared(user: User, chatInfo: ChatInfo) + case userProfileNoChange(user: User) + case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile) + case contactAliasUpdated(user: User, toContact: Contact) + case connectionAliasUpdated(user: User, toConnection: PendingContactConnection) + case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) + case userContactLink(user: User, contactLink: UserContactLink) + case userContactLinkUpdated(user: User, contactLink: UserContactLink) + case userContactLinkCreated(user: User, connReqContact: String) + case userContactLinkDeleted(user: User) + case contactConnected(user: User, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: User, contact: Contact) + case receivedContactRequest(user: User, contactRequest: UserContactRequest) + case acceptingContactRequest(user: User, contact: Contact) + case contactRequestRejected(user: User) + case contactUpdated(user: User, toContact: Contact) + case contactsSubscribed(user: User, server: String, contactRefs: [ContactRef]) + case contactsDisconnected(user: User, server: String, contactRefs: [ContactRef]) + case contactSubError(user: User, contact: Contact, chatError: ChatError) + case contactSubSummary(user: User, contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(user: User, groupInfo: GroupInfo) + case memberSubErrors(user: User, memberSubErrors: [MemberSubError]) + case groupEmpty(user: User, groupInfo: GroupInfo) case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) - case contactsList(contacts: [Contact]) + case newChatItem(user: User, chatItem: AChatItem) + case chatItemStatusUpdated(user: User, chatItem: AChatItem) + case chatItemUpdated(user: User, chatItem: AChatItem) + case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) + case contactsList(user: User, contacts: [Contact]) // group events - case groupCreated(groupInfo: GroupInfo) - case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember) - case userAcceptedGroupSent(groupInfo: GroupInfo, hostContact: Contact?) - case userDeletedMember(groupInfo: GroupInfo, member: GroupMember) - case leftMemberUser(groupInfo: GroupInfo) - case groupMembers(group: Group) - case receivedGroupInvitation(groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) - case groupDeletedUser(groupInfo: GroupInfo) - case joinedGroupMemberConnecting(groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) - case memberRole(groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case deletedMemberUser(groupInfo: GroupInfo, member: GroupMember) - case deletedMember(groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) - case leftMember(groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(intoContact: Contact, mergedContact: Contact) - case groupInvitation(groupInfo: GroupInfo) // unused - case userJoinedGroup(groupInfo: GroupInfo) - case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember) - case groupRemoved(groupInfo: GroupInfo) // unused - case groupUpdated(toGroup: GroupInfo) - case groupLinkCreated(groupInfo: GroupInfo, connReqContact: String) - case groupLink(groupInfo: GroupInfo, connReqContact: String) - case groupLinkDeleted(groupInfo: GroupInfo) + case groupCreated(user: User, groupInfo: GroupInfo) + case sentGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, member: GroupMember) + case userAcceptedGroupSent(user: User, groupInfo: GroupInfo, hostContact: Contact?) + case userDeletedMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case leftMemberUser(user: User, groupInfo: GroupInfo) + case groupMembers(user: User, group: Group) + case receivedGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case groupDeletedUser(user: User, groupInfo: GroupInfo) + case joinedGroupMemberConnecting(user: User, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberRole(user: User, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case memberRoleUser(user: User, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case deletedMemberUser(user: User, groupInfo: GroupInfo, member: GroupMember) + case deletedMember(user: User, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) + case leftMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: User, groupInfo: GroupInfo, member: GroupMember) + case contactsMerged(user: User, intoContact: Contact, mergedContact: Contact) + case groupInvitation(user: User, groupInfo: GroupInfo) // unused + case userJoinedGroup(user: User, groupInfo: GroupInfo) + case joinedGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupRemoved(user: User, groupInfo: GroupInfo) // unused + case groupUpdated(user: User, toGroup: GroupInfo) + case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLinkDeleted(user: User, groupInfo: GroupInfo) // receiving file events - case rcvFileAccepted(chatItem: AChatItem) - case rcvFileAcceptedSndCancelled(rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(chatItem: AChatItem) - case rcvFileComplete(chatItem: AChatItem) + case rcvFileAccepted(user: User, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: User, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: User, chatItem: AChatItem) + case rcvFileComplete(user: User, chatItem: AChatItem) // sending file events - case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileStart(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndFileRcvCancelled(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndGroupFileCancelled(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(contact: Contact, answer: WebRTCSession) - case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(contact: Contact) + case callOffer(user: User, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: User, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: User, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: User, contact: Contact) case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) - case newContactConnection(connection: PendingContactConnection) - case contactConnectionDeleted(connection: PendingContactConnection) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) + case ntfMessages(user: User, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case newContactConnection(user: User, connection: PendingContactConnection) + case contactConnectionDeleted(user: User, connection: PendingContactConnection) + case cmdOk(user: User?) + case chatCmdError(user: User?, chatError: ChatError) + case chatError(user: User?, chatError: ChatError) public var responseType: String { get { @@ -530,104 +530,111 @@ public enum ChatResponse: Decodable, Error { case .chatRunning: return noDetails case .chatStopped: return noDetails case .chatSuspended: return noDetails - case let .apiChats(chats): return String(describing: chats) - case let .apiChat(chat): return String(describing: chat) - case let .userSMPServers(smpServers, presetServers): return "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))" - case let .smpTestResult(smpTestFailure): return String(describing: smpTestFailure) - case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL) + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .userSMPServers(u, smpServers, presetServers): return withUser(u, "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))") + case let .smpTestResult(u, smpTestFailure): return withUser(u, String(describing: smpTestFailure)) + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" - case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))" - case let .contactCode(contact, connectionCode): return "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)" - case let .groupMemberCode(groupInfo, member, connectionCode): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)" - case let .connectionVerified(verified, expectedCode): return "verified: \(verified)\nconnectionCode: \(expectedCode)" - case let .invitation(connReqInvitation): return connReqInvitation + case let .contactInfo(u, contact, connectionStats, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") + case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case let .chatCleared(chatInfo): return String(describing: chatInfo) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .contactAliasUpdated(toContact): return String(describing: toContact) - case let .connectionAliasUpdated(toConnection): return String(describing: toConnection) - case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))" - case let .userContactLink(contactLink): return contactLink.responseDetails - case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails - case let .userContactLinkCreated(connReq): return connReq + case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile)) + case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") + case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkCreated(u, connReq): return withUser(u, connReq) case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact, _): return String(describing: contact) - case let .contactConnecting(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return String(describing: toContact) - case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .contactsSubscribed(u, server, contactRefs): return withUser(u, "server: \(server)\ncontacts:\n\(String(describing: contactRefs))") + case let .contactsDisconnected(u, server, contactRefs): return withUser(u, "server: \(server)\ncontacts:\n\(String(describing: contactRefs))") + case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") + case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) + case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) - case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, byUser): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)" - case let .contactsList(contacts): return String(describing: contacts) - case let .groupCreated(groupInfo): return String(describing: groupInfo) - case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)" - case let .userAcceptedGroupSent(groupInfo, hostContact): return "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))" - case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .leftMemberUser(groupInfo): return String(describing: groupInfo) - case let .groupMembers(group): return String(describing: group) - case let .receivedGroupInvitation(groupInfo, contact, memberRole): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)" - case let .groupDeletedUser(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMemberConnecting(groupInfo, hostMember, member): return "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)" - case let .memberRole(groupInfo, byMember, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .memberRoleUser(groupInfo, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .deletedMemberUser(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .deletedMember(groupInfo, byMember, deletedMember): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)" - case let .leftMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupDeleted(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .contactsMerged(intoContact, mergedContact): return "intoContact: \(intoContact)\nmergedContact: \(mergedContact)" - case let .groupInvitation(groupInfo): return String(describing: groupInfo) - case let .userJoinedGroup(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .connectedToGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupRemoved(groupInfo): return String(describing: groupInfo) - case let .groupUpdated(toGroup): return String(describing: toGroup) - case let .groupLinkCreated(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLink(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLinkDeleted(groupInfo): return String(describing: groupInfo) - case let .rcvFileAccepted(chatItem): return String(describing: chatItem) + case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails - case let .rcvFileStart(chatItem): return String(describing: chatItem) - case let .rcvFileComplete(chatItem): return String(describing: chatItem) - case let .sndFileStart(chatItem, _): return String(describing: chatItem) - case let .sndFileComplete(chatItem, _): return String(describing: chatItem) + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) - case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) - case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndGroupFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" - case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" - case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" - case let .callEnded(contact): return "contact: \(contact.id)" + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" - case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" - case let .newContactConnection(connection): return String(describing: connection) - case let .contactConnectionDeleted(connection): return String(describing: connection) + case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) + case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } } } private var noDetails: String { get { "\(responseType): no details" } } + + private func withUser(_ u: User?, _ s: String) -> String { + if let id = u?.userId { + return "userId: \(id)\n\(s)" + } + return s + } } public enum ChatPagination { diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index 1d5fa360ca..4a041f784e 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -38,6 +38,7 @@ public struct WebRTCExtraInfo: Codable { } public struct RcvCallInvitation: Decodable { + public var user: User public var contact: Contact public var callkitUUID: UUID? = UUID() public var callType: CallType @@ -53,6 +54,7 @@ public struct RcvCallInvitation: Decodable { } public static let sampleData = RcvCallInvitation( + user: User.sampleData, contact: Contact.sampleData, callType: CallType(media: .audio, capabilities: CallCapabilities(encryption: false)), callTs: .now diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index e859d880a8..035e849ff5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -21,7 +21,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") -public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { +public func createContactRequestNtf(_ user: User, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactRequest, @@ -34,11 +34,11 @@ public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNM hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName ), targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId] ) } -public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { +public func createContactConnectedNtf(_ user: User, _ contact: Contact) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactConnected, @@ -50,12 +50,13 @@ public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificati NSLocalizedString("You can now send messages to %@", comment: "notification body"), hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName ), - targetContentIdentifier: contact.id + targetContentIdentifier: contact.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": contact.id, "contactId": contact.apiId] ) } -public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { +public func createMessageReceivedNtf(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { @@ -67,7 +68,8 @@ public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UN categoryIdentifier: ntfCategoryMessageReceived, title: title, body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), - targetContentIdentifier: cInfo.id + targetContentIdentifier: cInfo.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] ) } @@ -82,11 +84,11 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutabl title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):", body: text, targetContentIdentifier: nil, - userInfo: ["chatId": invitation.contact.id] + userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId] ) } -public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutableNotificationContent { +public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String var body: String? = nil @@ -115,7 +117,8 @@ public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutabl categoryIdentifier: ntfCategoryConnectionEvent, title: title, body: body, - targetContentIdentifier: targetContentIdentifier + targetContentIdentifier: targetContentIdentifier, + userInfo: ["userId": user.userId] ) } From cf4105e256bbdbc2e8570368fa77df6208532dc9 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 19 Jan 2023 20:54:00 +0400 Subject: [PATCH 30/59] core: add connection id to ContactRef (#1798) --- src/Simplex/Chat/Store.hs | 7 +++++-- src/Simplex/Chat/Types.hs | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 0ee1372b6d..587b87b186 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1721,11 +1721,11 @@ getConnectionsContacts db userId agentConnIds = do DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds conns <- - map (uncurry ContactRef) + map toContactRef <$> DB.query db [sql| - SELECT ct.contact_id, ct.local_display_name + SELECT ct.contact_id, c.connection_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? @@ -1735,6 +1735,9 @@ getConnectionsContacts db userId agentConnIds = do (userId, ConnContact) DB.execute_ db "DROP TABLE temp.conn_ids" pure conns + where + toContactRef :: (ContactId, Int64, ContactName) -> ContactRef + toContactRef (contactId, connId, localDisplayName) = ContactRef {contactId, connId, localDisplayName} getGroupAndMember :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2776ae7f3b..7999f8f341 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -172,6 +172,7 @@ contactSecurityCode Contact {activeConn} = connectionCode activeConn data ContactRef = ContactRef { contactId :: ContactId, + connId :: Int64, localDisplayName :: ContactName } deriving (Eq, Show, Generic) From 006a30e65c1be095ae96adeb1095413a066e21a7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 19 Jan 2023 19:41:19 +0000 Subject: [PATCH 31/59] update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 2c2e3e1eeb..431f2b8afa 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 42df6a421d3e0865b96f849423f5eddf0f692653 + tag: f66e8239f4dcaea37c760c82fecd7395de718294 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0632974b7a..240c1b96f4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."42df6a421d3e0865b96f849423f5eddf0f692653" = "01lzpkwn7ylgmp0n3dyi27hiwlgmr2swjad9x9ka36x3dp86asan"; + "https://github.com/simplex-chat/simplexmq.git"."f66e8239f4dcaea37c760c82fecd7395de718294" = "00wycsq18z7mxmv85yhpvjvdj58msi8rnn0lafjr15pf2v0dalwf"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/stack.yaml b/stack.yaml index a458976e57..b92f4da791 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 42df6a421d3e0865b96f849423f5eddf0f692653 + commit: f66e8239f4dcaea37c760c82fecd7395de718294 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 From 69ca731641a19c1f01b17ff309258151cb0302ce Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 20 Jan 2023 10:48:25 +0000 Subject: [PATCH 32/59] core: process push notifications for any user (#1806) * core: process push notifications for any user * return regardless * refactor * more refactor --- src/Simplex/Chat.hs | 12 ++++++++---- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/View.hs | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3c37d9c0b9..275e3b4966 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -780,12 +780,16 @@ processChatCommand = \case CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ - APIGetNtfMessage userId nonce encNtfInfo -> withUserId userId $ \user -> do + APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta - connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing - pure CRNtfMessages {user, connEntity, msgTs = msgTs', ntfMessages} + agentConnId = AgentConnId ntfConnId + user_ <- withStore' (`getUserByAConnId` agentConnId) + connEntity <- + pure user_ $>>= \user -> + withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchError` \_ -> pure Nothing + pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} APIGetUserSMPServers userId -> withUserId userId $ \user -> do ChatConfig {defaultServers = DefaultAgentServers {smp = defaultSMPServers}} <- asks config smpServers <- withStore' (`getSMPServers` user) @@ -3886,7 +3890,7 @@ chatCommandP = "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), "/_ntf verify " *> (APIVerifyToken <$> strP <* A.space <*> strP <* A.space <*> strP), "/_ntf delete " *> (APIDeleteToken <$> strP), - "/_ntf message " *> (APIGetNtfMessage <$> A.decimal <* A.space <*> strP <* A.space <*> strP), + "/_ntf message " *> (APIGetNtfMessage <$> strP <* A.space <*> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal), "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7d16c6a701..146bd8bd35 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -195,7 +195,7 @@ data ChatCommand | APIRegisterToken DeviceToken NotificationsMode | APIVerifyToken DeviceToken C.CbNonce ByteString | APIDeleteToken DeviceToken - | APIGetNtfMessage {userId :: UserId, nonce :: C.CbNonce, encNtfInfo :: ByteString} + | APIGetNtfMessage {nonce :: C.CbNonce, encNtfInfo :: ByteString} | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup GroupId | APIMemberRole GroupId GroupMemberId GroupMemberRole @@ -439,7 +439,7 @@ data ChatResponse | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {user :: User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} | CRNewContactConnection {user :: User, connection :: PendingContactConnection} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRSQLResult {rows :: [Text]} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f7b3b0c106..753670f00e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1165,7 +1165,7 @@ viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case ChatError err -> case err of CENoActiveUser -> ["error: active user is required"] - CENoConnectionUser _agentConnId -> [] -- ["error: connection has no user, conn id: " <> sShow agentConnId] + CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] CEActiveUserExists -> ["error: active user already exists"] CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] CECantDeleteActiveUser _ -> ["cannot delete active user"] From 396b3ae639de50b4a77747e39d241db2e91c1252 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 20 Jan 2023 14:56:05 +0400 Subject: [PATCH 33/59] ios: maintain connections network statuses map separately from chats (allows to keep track of network statuses for all users) (#1803) --- apps/ios/Shared/Model/ChatModel.swift | 102 +++++++++--------- apps/ios/Shared/Model/SimpleXAPI.swift | 31 +++--- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 6 +- .../Views/Chat/Group/GroupChatInfoView.swift | 6 -- .../Chat/Group/GroupMemberInfoView.swift | 2 - .../Views/ChatList/ChatPreviewView.swift | 26 +++-- apps/ios/SimpleXChat/ChatTypes.swift | 3 +- 7 files changed, 83 insertions(+), 93 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 32c3ad19ad..0752279494 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -24,6 +24,8 @@ final class ChatModel: ObservableObject { @Published var chatDbStatus: DBMigrationResult? // list of chat "previews" @Published var chats: [Chat] = [] + // map of connections network statuses, key is connection id + @Published var networkStatuses: Dictionary = [:] // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] @@ -128,17 +130,9 @@ final class ChatModel: ObservableObject { } } - func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) { - if let i = getChatIndex(id) { - chats[i].serverInfo.networkStatus = status - } - } - func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { - let serverInfo = chats[i].serverInfo chats[i] = chat - chats[i].serverInfo = serverInfo } else { // invalid state, correcting chats.insert(chat, at: 0) @@ -522,6 +516,14 @@ final class ChatModel: ObservableObject { logger.error("Unable to set active user: \(error.localizedDescription)") } } + + func updateContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { + networkStatuses[contact.activeConn.connId] = status + } + + func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { + networkStatuses[contact.activeConn.connId] ?? .unknown + } } struct UnreadChatItemCounts { @@ -533,62 +535,18 @@ final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @Published var chatStats: ChatStats - @Published var serverInfo = ServerInfo(networkStatus: .unknown) var created = Date.now - struct ServerInfo: Decodable { - var networkStatus: NetworkStatus - } - - enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) - - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } - } - init(_ cData: ChatData) { self.chatInfo = cData.chatInfo self.chatItems = cData.chatItems self.chatStats = cData.chatStats } - init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats(), serverInfo: ServerInfo = ServerInfo(networkStatus: .unknown)) { + init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) { self.chatInfo = chatInfo self.chatItems = chatItems self.chatStats = chatStats - self.serverInfo = serverInfo } var id: ChatId { get { chatInfo.id } } @@ -597,3 +555,41 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } + +enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(String) + + var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51c46520aa..d0169fbff2 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1001,9 +1001,9 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) - m.updateNetworkStatus(contact.id, .connected) NtfManager.shared.notifyContactConnected(user, contact) } + m.updateContactNetworkStatus(contact, .connected) case let .contactConnecting(user, contact): if active(user) && contact.directOrUsed { m.updateContact(contact) @@ -1035,26 +1035,24 @@ func processReceivedMsg(_ res: ChatResponse) async { } m.removeChat(mergedContact.id) } - case let .contactsSubscribed(user, _, contactRefs): - if active(user) { - updateContactsStatus(contactRefs, status: .connected) - } - case let .contactsDisconnected(user, _, contactRefs): - if active(user) { - updateContactsStatus(contactRefs, status: .disconnected) - } + case let .contactsSubscribed(_, _, contactRefs): + updateContactsStatus(contactRefs, status: .connected) + case let .contactsDisconnected(_, _, contactRefs): + updateContactsStatus(contactRefs, status: .disconnected) case let .contactSubError(user, contact, chatError): if active(user) { - processContactSubError(contact, chatError) + m.updateContact(contact) } + processContactSubError(contact, chatError) case let .contactSubSummary(user, contactSubscriptions): - if !active(user) { return } for sub in contactSubscriptions { + if active(user) { + m.updateContact(sub.contact) + } if let err = sub.contactError { processContactSubError(sub.contact, err) } else { - m.updateContact(sub.contact) - m.updateNetworkStatus(sub.contact.id, .connected) + m.updateContactNetworkStatus(sub.contact, .connected) } } case let .newChatItem(user, aChatItem): @@ -1270,23 +1268,22 @@ func chatItemSimpleUpdate(_ aChatItem: AChatItem) { } } -func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) { +func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) { let m = ChatModel.shared for c in contactRefs { - m.updateNetworkStatus(c.id, status) + m.networkStatuses[c.connId] = status } } func processContactSubError(_ contact: Contact, _ chatError: ChatError) { let m = ChatModel.shared - m.updateContact(contact) var err: String switch chatError { case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network" case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.updateNetworkStatus(contact.id, .error(err)) + m.updateContactNetworkStatus(contact, .error(err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ed33256f06..5cab211e89 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -263,14 +263,14 @@ struct ChatInfoView: View { .foregroundColor(.accentColor) .font(.system(size: 14)) Spacer() - Text(chat.serverInfo.networkStatus.statusString) + Text(chatModel.contactNetworkStatus(contact).statusString) .foregroundColor(.secondary) serverImage() } } private func serverImage() -> some View { - let status = chat.serverInfo.networkStatus + let status = chatModel.contactNetworkStatus(contact) return Image(systemName: status.imageName) .foregroundColor(status == .connected ? .green : .secondary) .font(.system(size: 12)) @@ -337,7 +337,7 @@ struct ChatInfoView: View { private func networkStatusAlert() -> Alert { Alert( title: Text("Network status"), - message: Text(chat.serverInfo.networkStatus.statusExplanation) + message: Text(chatModel.contactNetworkStatus(contact).statusExplanation) ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 20b70fb0dc..bea381bdc5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -145,12 +145,6 @@ struct GroupChatInfoView: View { } } - private func serverImage() -> some View { - let status = chat.serverInfo.networkStatus - return Image(systemName: status.imageName) - .foregroundColor(status == .connected ? .green : .secondary) - } - private func memberView(_ member: GroupMember, user: Bool = false) -> some View { HStack{ ProfileImage(imageStr: member.image) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5bcfa72ea2..9f367fb472 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -155,8 +155,6 @@ struct GroupMemberInfoView: View { Button { do { let chat = try apiGetChat(type: .direct, id: contactId) - // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend - chat.serverInfo = Chat.ServerInfo(networkStatus: .connected) chatModel.addChat(chat) dismissAllSheets(animated: true) DispatchQueue.main.async { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 00afb65aad..e85eeaa345 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -179,16 +179,21 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatStatusImage() -> some View { - switch chat.serverInfo.networkStatus { - case .connected: EmptyView() - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) + switch chat.chatInfo { + case let .direct(contact): + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: EmptyView() + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } default: - ProgressView() + EmptyView() } } } @@ -220,8 +225,7 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], - chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0), - serverInfo: Chat.ServerInfo(networkStatus: .error("status")) + chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 22007d7b2c..64dbeec2a8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1154,6 +1154,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public struct ContactRef: Decodable, Equatable { var contactId: Int64 + public var connId: Int64 var localDisplayName: ContactName public var id: ChatId { get { "@\(contactId)" } } @@ -1165,7 +1166,7 @@ public struct ContactSubStatus: Decodable { } public struct Connection: Decodable { - var connId: Int64 + public var connId: Int64 var connStatus: ConnStatus public var connLevel: Int public var viaGroupLink: Bool From ef15dca0b4404e75b56ab78c4d911a71b523ea19 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 20 Jan 2023 15:02:27 +0400 Subject: [PATCH 34/59] core: don't filter out non active user connections on UP & DOWN agent events; use agent connection id instead of db connection id for ContactRef (#1807) --- src/Simplex/Chat.hs | 12 +++++------- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Store.hs | 15 +++++++-------- src/Simplex/Chat/Types.hs | 1 + src/Simplex/Chat/View.hs | 4 ++-- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 275e3b4966..42e57e19df 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1991,16 +1991,14 @@ expireChatItems user@User {userId} ttl sync = do processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent -> m () processAgentMessage _ "" msg = - asks currentUser >>= readTVarIO >>= \case - Just user -> processAgentMessageNoConn user msg `catchError` (toView . CRChatError (Just user)) - _ -> throwChatError CENoActiveUser + processAgentMessageNoConn msg `catchError` (toView . CRChatError Nothing) processAgentMessage corrId connId msg = withStore' (`getUserByAConnId` AgentConnId connId) >>= \case Just user -> processAgentMessageConn user corrId connId msg `catchError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -processAgentMessageNoConn :: forall m. ChatMonad m => User -> ACommand 'Agent -> m () -processAgentMessageNoConn user@User {userId} = \case +processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent -> m () +processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" @@ -2010,8 +2008,8 @@ processAgentMessageNoConn user@User {userId} = \case where hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent srv@(SMPServer host _ _) conns event str = do - cs <- withStore' $ \db -> getConnectionsContacts db userId conns - toView $ event user srv cs + cs <- withStore' $ \db -> getConnectionsContacts db conns + toView $ event srv cs showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent -> m () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 146bd8bd35..934addfb5c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -396,8 +396,8 @@ data ChatResponse | CRContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} | CRContactAnotherClient {user :: User, contact :: Contact} | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} - | CRContactsDisconnected {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactsSubscribed {user :: User, server :: SMPServer, contactRefs :: [ContactRef]} + | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} + | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactSubError {contact :: Contact, chatError :: ChatError} -- TODO delete | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 587b87b186..f1d6546433 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1715,8 +1715,8 @@ getConnectionById db User {userId} connId = ExceptT $ do |] (userId, connId) -getConnectionsContacts :: DB.Connection -> UserId -> [ConnId] -> IO [ContactRef] -getConnectionsContacts db userId agentConnIds = do +getConnectionsContacts :: DB.Connection -> [ConnId] -> IO [ContactRef] +getConnectionsContacts db agentConnIds = do DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds @@ -1725,19 +1725,18 @@ getConnectionsContacts db userId agentConnIds = do <$> DB.query db [sql| - SELECT ct.contact_id, c.connection_id, ct.local_display_name + SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? - AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) AND c.conn_type = ? |] - (userId, ConnContact) + (Only ConnContact) DB.execute_ db "DROP TABLE temp.conn_ids" pure conns where - toContactRef :: (ContactId, Int64, ContactName) -> ContactRef - toContactRef (contactId, connId, localDisplayName) = ContactRef {contactId, connId, localDisplayName} + toContactRef :: (ContactId, Int64, ConnId, ContactName) -> ContactRef + toContactRef (contactId, connId, acId, localDisplayName) = ContactRef {contactId, connId, agentConnId = AgentConnId acId, localDisplayName} getGroupAndMember :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId = diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7999f8f341..f79d37211f 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -173,6 +173,7 @@ contactSecurityCode Contact {activeConn} = connectionCode activeConn data ContactRef = ContactRef { contactId :: ContactId, connId :: Int64, + agentConnId :: AgentConnId, localDisplayName :: ContactName } deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 753670f00e..8d74601d35 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -152,8 +152,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] CRSubscriptionEnd u acEntity -> ttyUser u [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] - CRContactsDisconnected u srv cs -> ttyUser u [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed u srv cs -> ttyUser u [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary u summary -> ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" From ed12ccaac26eba0584bf1989ed8cb4c3995c7c06 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 20 Jan 2023 12:28:45 +0000 Subject: [PATCH 35/59] ios: update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 50 +++++++++------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c8288e85ea..40ac25f10c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -79,11 +79,6 @@ 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */; }; - 5CA85D1D29759E4A0095AF72 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1829759E4A0095AF72 /* libgmp.a */; }; - 5CA85D1E29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */; }; - 5CA85D1F29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */; }; - 5CA85D2029759E4A0095AF72 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1B29759E4A0095AF72 /* libffi.a */; }; - 5CA85D2129759E4A0095AF72 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */; }; 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79929211BB900072E13 /* PreferencesView.swift */; }; 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */; }; 5CB0BA882826CB3A00B3292C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */; }; @@ -104,11 +99,6 @@ 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */; }; 5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; }; - 5CBDFB57297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CBDFB52297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca.a */; }; - 5CBDFB58297A883C00E723C8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CBDFB53297A883C00E723C8 /* libgmpxx.a */; }; - 5CBDFB59297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CBDFB54297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca-ghc8.10.7.a */; }; - 5CBDFB5A297A883C00E723C8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CBDFB55297A883C00E723C8 /* libffi.a */; }; - 5CBDFB5B297A883C00E723C8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CBDFB56297A883C00E723C8 /* libgmp.a */; }; 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; }; 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; }; 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; @@ -141,6 +131,11 @@ 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CF283BA297ABFB000A8CCB5 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF283B5297ABFB000A8CCB5 /* libgmp.a */; }; + 5CF283BB297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF283B6297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a */; }; + 5CF283BC297ABFB000A8CCB5 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF283B7297ABFB000A8CCB5 /* libgmpxx.a */; }; + 5CF283BD297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF283B8297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a */; }; + 5CF283BE297ABFB000A8CCB5 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CF283B9297ABFB000A8CCB5 /* libffi.a */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -310,11 +305,6 @@ 5CA85D0B297218AA0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 5CA85D0C297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = "it.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CA85D0D297219EF0095AF72 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - 5CA85D1829759E4A0095AF72 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a"; sourceTree = ""; }; - 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a"; sourceTree = ""; }; - 5CA85D1B29759E4A0095AF72 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CADE79929211BB900072E13 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPreferencesView.swift; sourceTree = ""; }; 5CB0BA872826CB3A00B3292C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -341,11 +331,6 @@ 5CBD285829565D2600EC2CF4 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUtils.swift; sourceTree = ""; }; 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = ""; }; - 5CBDFB52297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca.a"; sourceTree = ""; }; - 5CBDFB53297A883C00E723C8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CBDFB54297A883C00E723C8 /* libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-GH5mduSQmlJ14EbWq6Ljca-ghc8.10.7.a"; sourceTree = ""; }; - 5CBDFB55297A883C00E723C8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CBDFB56297A883C00E723C8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = ""; }; 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = ""; }; 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; @@ -377,6 +362,11 @@ 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; + 5CF283B5297ABFB000A8CCB5 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CF283B6297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a"; sourceTree = ""; }; + 5CF283B7297ABFB000A8CCB5 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CF283B8297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a"; sourceTree = ""; }; + 5CF283B9297ABFB000A8CCB5 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; @@ -439,12 +429,12 @@ buildActionMask = 2147483647; files = ( 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CA85D2029759E4A0095AF72 /* libffi.a in Frameworks */, - 5CA85D1D29759E4A0095AF72 /* libgmp.a in Frameworks */, + 5CF283BC297ABFB000A8CCB5 /* libgmpxx.a in Frameworks */, + 5CF283BA297ABFB000A8CCB5 /* libgmp.a in Frameworks */, + 5CF283BB297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a in Frameworks */, + 5CF283BE297ABFB000A8CCB5 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CA85D1F29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a in Frameworks */, - 5CA85D1E29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a in Frameworks */, - 5CA85D2129759E4A0095AF72 /* libgmpxx.a in Frameworks */, + 5CF283BD297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -502,11 +492,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA85D1B29759E4A0095AF72 /* libffi.a */, - 5CA85D1829759E4A0095AF72 /* libgmp.a */, - 5CA85D1C29759E4A0095AF72 /* libgmpxx.a */, - 5CA85D1929759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU-ghc8.10.7.a */, - 5CA85D1A29759E4A0095AF72 /* libHSsimplex-chat-4.4.2-3kn1cNN6GZzBjvOakT9XbU.a */, + 5CF283B9297ABFB000A8CCB5 /* libffi.a */, + 5CF283B5297ABFB000A8CCB5 /* libgmp.a */, + 5CF283B7297ABFB000A8CCB5 /* libgmpxx.a */, + 5CF283B6297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG-ghc8.10.7.a */, + 5CF283B8297ABFB000A8CCB5 /* libHSsimplex-chat-4.4.2-BwepkrkiRWZ1ABwc5I53xG.a */, ); path = Libraries; sourceTree = ""; From 04d886e546a3af82940dadfefa5ea30f0c73e542 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 20 Jan 2023 12:38:38 +0000 Subject: [PATCH 36/59] ios: user profiles view, per-user settings (#1801) * ios: user profiles view, per-user settings * remove comment * bold profile name --- apps/ios/Shared/Model/ChatModel.swift | 27 +----- apps/ios/Shared/Model/NtfManager.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 26 +++-- .../Shared/Views/ChatList/ChatListView.swift | 13 +-- .../Shared/Views/ChatList/UserPicker.swift | 92 ++++++++---------- .../Shared/Views/Database/DatabaseView.swift | 31 +++--- .../Views/Onboarding/CreateProfile.swift | 11 ++- .../Views/UserSettings/SMPServersView.swift | 7 +- .../Views/UserSettings/SettingsView.swift | 9 +- .../Views/UserSettings/UserProfilesView.swift | 97 +++++++++++++------ .../xcshareddata/swiftpm/Package.resolved | 23 +++++ apps/ios/SimpleXChat/ChatTypes.swift | 4 +- 12 files changed, 191 insertions(+), 151 deletions(-) create mode 100644 apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0752279494..cc71c537ae 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,7 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? - @Published private(set) var users: [UserInfo] = [] + @Published var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false @@ -492,31 +492,6 @@ final class ChatModel: ObservableObject { return reversedChatItems[min(i - 1, maxIx)] } - func updateUsers(_ new: [UserInfo]) { - users = new - .sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } - .sorted { first, _ in first.user.activeUser } - } - - func changeActiveUser(_ toUserId: Int64) { - do { - let activeUser = try apiSetActiveUser(toUserId) - var users = users - let oldActiveIndex = users.firstIndex(where: { $0.user.userId == currentUser?.userId })! - var oldActive = users[oldActiveIndex] - oldActive.user.activeUser = false - users[oldActiveIndex] = oldActive - - currentUser = activeUser - let currentActiveIndex = users.firstIndex(where: { $0.user.userId == activeUser.userId })! - users[currentActiveIndex] = UserInfo(user: activeUser, unreadCount: users[currentActiveIndex].unreadCount) - updateUsers(users) - try getUserChatData(self) - } catch { - logger.error("Unable to set active user: \(error.localizedDescription)") - } - } - func updateContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { networkStatuses[contact.activeConn.connId] = status } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index b817b44857..90e092701d 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -39,7 +39,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") if let userId = content.userInfo["userId"] as? Int64, userId != chatModel.currentUser?.userId { - chatModel.changeActiveUser(userId) + changeActiveUser(userId) } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d0169fbff2..b76a3a3b2b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -131,10 +131,12 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } -func listUsers() -> [UserInfo] { +func listUsers() throws -> [UserInfo] { let r = chatSendCmdSync(.listUsers) - if case let .usersList(users) = r { return users } - return [] + if case let .usersList(users) = r { + return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } + } + throw r } func apiSetActiveUser(_ userId: Int64) throws -> User { @@ -917,9 +919,9 @@ func startChat() throws { let m = ChatModel.shared try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() - m.updateUsers(listUsers()) + m.users = try listUsers() if justStarted { - try getUserChatData(m) + try getUserChatData() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() @@ -937,7 +939,19 @@ func startChat() throws { chatLastStartGroupDefault.set(Date.now) } -func getUserChatData(_ m: ChatModel) throws { +func changeActiveUser(_ toUserId: Int64) { + let m = ChatModel.shared + do { + m.currentUser = try apiSetActiveUser(toUserId) + m.users = try listUsers() + try getUserChatData() + } catch let error { + logger.error("Unable to set active user: \(responseError(error))") + } +} + +func getUserChatData() throws { + let m = ChatModel.shared m.userAddress = try apiGetUserAddress() (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() m.chatItemTTL = try getChatItemTTL() diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6df579d964..bd50a5d707 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -15,7 +15,6 @@ struct ChatListView: View { @State private var searchText = "" @State private var showAddChat = false @State var userPickerVisible = false - @State var selectedView: Int? = nil var body: some View { ZStack(alignment: .topLeading) { @@ -29,9 +28,6 @@ struct ChatListView: View { } else { chatList } - NavigationLink(destination: UserProfilesView().navigationTitle("Profiles"), tag: 0, selection: $selectedView) { - EmptyView() - } } } .navigationViewStyle(.stack) @@ -42,14 +38,7 @@ struct ChatListView: View { } } } - UserPicker( - showSettings: $showSettings, - userPickerVisible: $userPickerVisible, - manageUsers: { - selectedView = 0 - userPickerVisible = false - } - ) + UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible) } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 4f817c8753..7078cdbfd1 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -10,11 +10,10 @@ private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255)) struct UserPicker: View { - @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @Binding var showSettings: Bool @Binding var userPickerVisible: Bool - var manageUsers: () -> Void = {} @State var scrollViewContentSize: CGSize = .zero @State var disableScrolling: Bool = true private let menuButtonHeight: CGFloat = 68 @@ -31,35 +30,9 @@ struct UserPicker: View { ScrollView { ScrollViewReader { sp in VStack(spacing: 0) { - ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in - Button(action: { - if !userInfo.user.activeUser { - chatModel.changeActiveUser(userInfo.user.userId) - userPickerVisible = false - } - }, label: { - HStack(spacing: 0) { - ProfileImage(imageStr: userInfo.user.image) - .frame(width: 44, height: 44) - .padding(.trailing, 12) - Text(userInfo.user.chatViewName) - .fontWeight(i == 0 ? .medium : .regular) - .foregroundColor(.primary) - .overlay(DetermineWidth()) - Spacer() - if i == 0 { - Image(systemName: "checkmark") - } else if userInfo.unreadCount > 0 { - unreadCounter(userInfo.unreadCount) - } - } - .padding(.trailing) - .padding([.leading, .vertical], 12) - }) - .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) - if i < chatModel.users.count - 1 { - Divider() - } + ForEach(Array(m.users.sorted(by: { u, _ in u.user.activeUser }))) { u in + userView(u) + Divider() } } .overlay { @@ -69,7 +42,7 @@ struct UserPicker: View { let scenes = UIApplication.shared.connectedScenes if let windowScene = scenes.first as? UIWindowScene { let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame - disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height } } return Color.clear @@ -85,11 +58,6 @@ struct UserPicker: View { .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) .frame(maxHeight: scrollViewContentSize.height) - Divider() - menuButton("Your user profiles", icon: "pencil") { - manageUsers() - } - Divider() menuButton("Settings", icon: "gearshape") { showSettings = true withAnimation { @@ -108,27 +76,43 @@ struct UserPicker: View { .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 } .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300) .padding(8) - .onChange(of: [chatModel.currentUser?.chatViewName, chatModel.currentUser?.image] ) { _ in - reloadCurrentUser() - } .opacity(userPickerVisible ? 1.0 : 0.0) .onAppear { - reloadUsers() + do { + m.users = try listUsers() + } catch let error { + logger.error("Error updating users \(responseError(error))") + } } } - private func reloadCurrentUser() { - if let updatedUser = chatModel.currentUser, let index = chatModel.users.firstIndex(where: { $0.user.userId == updatedUser.userId }) { - var users = chatModel.users - users[index] = UserInfo(user: updatedUser, unreadCount: users[index].unreadCount) - chatModel.updateUsers(users) - } - } - - private func reloadUsers() { - Task { - chatModel.updateUsers(listUsers()) - } + private func userView(_ u: UserInfo) -> some View { + let user = u.user + return Button(action: { + if !user.activeUser { + changeActiveUser(user.userId) + userPickerVisible = false + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: user.image) + .frame(width: 44, height: 44) + .padding(.trailing, 12) + Text(user.chatViewName) + .fontWeight(user.activeUser ? .medium : .regular) + .foregroundColor(.primary) + .overlay(DetermineWidth()) + Spacer() + if user.activeUser { + Image(systemName: "checkmark") + } else if u.unreadCount > 0 { + unreadCounter(u.unreadCount) + } + } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) } private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { @@ -161,7 +145,7 @@ func unreadCounter(_ unread: Int64) -> some View { struct UserPicker_Previews: PreviewProvider { static var previews: some View { let m = ChatModel() - m.updateUsers([UserInfo.sampleData, UserInfo.sampleData]) + m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( showSettings: Binding.constant(false), userPickerVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 701374f662..11b5ef25db 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -67,6 +67,23 @@ struct DatabaseView: View { private func chatDatabaseView() -> some View { List { let stopped = m.chatRunning == false + Section { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ttl) + } + if case .seconds = chatItemTTL { + Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) + } + } + .frame(height: 36) + .disabled(m.chatDbChanged || progressIndicator) + } header: { + Text("Messages") + } footer: { + Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.") + } + Section { settingsRow( stopped ? "exclamationmark.octagon.fill" : "play.fill", @@ -157,22 +174,12 @@ struct DatabaseView: View { } Section { - Picker("Delete messages after", selection: $chatItemTTL) { - ForEach(ChatItemTTL.values) { ttl in - Text(ttl.deleteAfterText).tag(ttl) - } - if case .seconds = chatItemTTL { - Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) - } - } - .frame(height: 36) - .disabled(m.chatDbChanged || progressIndicator) - Button("Delete files & media", role: .destructive) { + Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { alert = .deleteFilesAndMedia } .disabled(!stopped || appFilesCountAndSize?.0 == 0) } header: { - Text("Data") + Text("Files & media") } footer: { if let (fileCount, size) = appFilesCountAndSize { if fileCount == 0 { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 512cbeda35..211905255c 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -11,7 +11,7 @@ import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel - @Environment(\.presentationMode) var presentationMode: Binding + @Environment(\.dismiss) var dismiss @State private var displayName: String = "" @State private var fullName: String = "" @FocusState private var focusDisplayName @@ -97,12 +97,13 @@ struct CreateProfile: View { ) do { m.currentUser = try apiCreateActiveUser(profile) - try startChat() - if m.users.count == 1 { + if m.users.isEmpty { + try startChat() withAnimation { m.onboardingStage = .step3_SetNotificationsMode } } else { - presentationMode.wrappedValue.dismiss() - try getUserChatData(m) + dismiss() + m.users = try listUsers() + try getUserChatData() } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") diff --git a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift index 372c5bb34a..7cea87cb6a 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift @@ -44,7 +44,7 @@ struct SMPServersView: View { private func smpServersView() -> some View { List { - Section("SMP servers") { + Section { ForEach($servers) { srv in smpServerView(srv) } @@ -57,6 +57,11 @@ struct SMPServersView: View { Button("Add server…") { showAddServer = true } + } header: { + Text("SMP servers") + } footer: { + Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .lineLimit(10) } Section { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f41c022dc3..9a57df8d1f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -113,12 +113,19 @@ struct SettingsView: View { Section("You") { NavigationLink { UserProfile() - .navigationTitle("Your chat profile") + .navigationTitle("Your current profile") } label: { ProfilePreview(profileOf: user) .padding(.leading, -8) } + NavigationLink { + UserProfilesView() + .navigationTitle("Your chat profiles") + } label: { + settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } + } + incognitoRow() NavigationLink { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index fd1f920002..adb75d6a10 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -9,57 +9,90 @@ import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @Environment(\.editMode) private var editMode - @State private var selectedUser: Int? = nil - @State private var showAddUser: Bool? = false + @State private var alert: UserProfilesAlert? + + private enum UserProfilesAlert: Identifiable { + case deleteUser(index: Int) + case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + + var id: String { + switch self { + case let .deleteUser(index): return "deleteUser \(index)" + case let .error(title, _): return "error \(title)" + } + } + } var body: some View { List { - Section("Your profiles") { - ForEach(Array(m.users.enumerated()), id: \.0) { i, userInfo in - userProfileView(userInfo, index: i) - .deleteDisabled(userInfo.user.activeUser) + Section { + ForEach(m.users) { u in + userView(u.user) } .onDelete { indexSet in - AlertManager.shared.showAlert( - Alert( - title: Text("Delete profile?"), - message: Text("All chats and messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - removeUser(index: indexSet.first!) - }, - secondaryButton: .cancel() - )) + if let i = indexSet.first { + alert = .deleteUser(index: i) + } } - NavigationLink(destination: CreateProfile(), tag: true, selection: $showAddUser) { - Text("Add profile…") + + NavigationLink { + CreateProfile() + } label: { + Label("Add profile", systemImage: "plus") } + .frame(height: 44) + .padding(.vertical, 4) + } footer: { + Text("Your chat profiles are stored locally, only on your device.") } } .toolbar { EditButton() } + .alert(item: $alert) { alert in + switch alert { + case let .deleteUser(index): + return Alert( + title: Text("Delete user profile?"), + message: Text("All chats and messages will be deleted - this cannot be undone!"), + primaryButton: .destructive(Text("Delete")) { + removeUser(index: index) + }, + secondaryButton: .cancel() + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } } private func removeUser(index: Int) { do { try apiDeleteUser(m.users[index].user.userId) - var users = m.users - users.remove(at: index) - m.updateUsers(users) - } catch { - AlertManager.shared.showAlertMsg( - title: "Failed to delete the user", - message: "Error: \(responseError(error))" - ) + m.users.remove(at: index) + } catch let error { + let a = getErrorAlert(error, "Error deleting user profile") + alert = .error(title: a.title, error: a.message) } } - private func userProfileView(_ userBinding: UserInfo, index: Int) -> some View { - let user = userBinding.user - return NavigationLink(tag: index, selection: $selectedUser) { -// UserPrefs(user: userBinding, index: index) -// .navigationBarTitle(user.chatViewName) -// .navigationBarTitleDisplayMode(.large) + + @ViewBuilder private func userView(_ user: User) -> some View { + Button { + if !user.activeUser { + changeActiveUser(user.userId) + } } label: { - Text(user.chatViewName) + HStack { + ProfileImage(imageStr: user.image) + .frame(width: 44, height: 44) + .padding(.vertical, 4) + .padding(.trailing, 12) + Text(user.chatViewName) + Spacer() + Image(systemName: "checkmark") + .foregroundColor(user.activeUser ? .primary : .clear) + } } + .foregroundColor(.primary) + .deleteDisabled(user.activeUser) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..146d9f8052 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner", + "state" : { + "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", + "version" : "2.1.1" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif", + "state" : { + "branch" : "master", + "revision" : "5e8619335d394901379c9add5c4c1c2f420b3800" + } + } + ], + "version" : 2 +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 64dbeec2a8..9f059fa339 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -34,7 +34,7 @@ public struct User: Decodable, NamedChat, Identifiable { ) } -public struct UserInfo: Decodable { +public struct UserInfo: Decodable, Identifiable { public var user: User public var unreadCount: Int64 @@ -43,6 +43,8 @@ public struct UserInfo: Decodable { self.unreadCount = unreadCount } + public var id: Int64 { user.userId } + public static let sampleData = UserInfo( user: User.sampleData, unreadCount: 1 From cb5f26d354b3c16c5cfd93419bd5c9366eb18dfa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 20 Jan 2023 13:17:41 +0000 Subject: [PATCH 37/59] ios: only show menu if there is more than 1 user, do not show unread count (only badge) --- .../Shared/Views/ChatList/ChatListView.swift | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index bd50a5d707..ef485f3085 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -66,24 +66,25 @@ struct ChatListView: View { .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { - withAnimation { - userPickerVisible.toggle() + if chatModel.users.count > 1 { + withAnimation { + userPickerVisible.toggle() + } + } else { + showSettings = true } } label: { let user = chatModel.currentUser ?? User.sampleData let color = Color(uiColor: .tertiarySystemGroupedBackground) - HStack(spacing: 0) { + ZStack(alignment: .topTrailing) { ProfileImage(imageStr: user.image, color: color) .frame(width: 32, height: 32) - .padding(.trailing, 6) - .padding(.vertical, 6) - let unread = chatModel.users + .padding(.trailing, 4) + let allRead = chatModel.users .filter { !$0.user.activeUser } - .reduce(0, {cnt, u in cnt + u.unreadCount}) - if unread > 0 { - unreadCounter(unread) - .padding(.leading, -12) - .padding(.top, -16) + .allSatisfy { u in u.unreadCount == 0 } + if !allRead { + unreadBadge(size: 12) } } } @@ -121,6 +122,12 @@ struct ChatListView: View { ) } + private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { + Circle() + .frame(width: size, height: size) + .foregroundColor(.accentColor) + } + private func onboardingButtons() -> some View { VStack(alignment: .trailing, spacing: 0) { Path { p in From 980c7a9ddd7e6b7cfb75443ca85c58d87dea74b6 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 20 Jan 2023 17:35:39 +0400 Subject: [PATCH 38/59] ios: use agent connection id as key for network statuses map (#1808) --- apps/ios/Shared/Model/ChatModel.swift | 10 +++++----- apps/ios/Shared/Model/SimpleXAPI.swift | 12 ++++++------ apps/ios/SimpleXChat/APITypes.swift | 8 ++++---- apps/ios/SimpleXChat/ChatTypes.swift | 5 ++++- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index cc71c537ae..fa51fbf404 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -24,8 +24,8 @@ final class ChatModel: ObservableObject { @Published var chatDbStatus: DBMigrationResult? // list of chat "previews" @Published var chats: [Chat] = [] - // map of connections network statuses, key is connection id - @Published var networkStatuses: Dictionary = [:] + // map of connections network statuses, key is agent connection id + @Published var networkStatuses: Dictionary = [:] // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] @@ -492,12 +492,12 @@ final class ChatModel: ObservableObject { return reversedChatItems[min(i - 1, maxIx)] } - func updateContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - networkStatuses[contact.activeConn.connId] = status + func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { + networkStatuses[contact.activeConn.agentConnId] = status } func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - networkStatuses[contact.activeConn.connId] ?? .unknown + networkStatuses[contact.activeConn.agentConnId] ?? .unknown } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b76a3a3b2b..89dedfa2cb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1017,7 +1017,7 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(contact.activeConn.id) NtfManager.shared.notifyContactConnected(user, contact) } - m.updateContactNetworkStatus(contact, .connected) + m.setContactNetworkStatus(contact, .connected) case let .contactConnecting(user, contact): if active(user) && contact.directOrUsed { m.updateContact(contact) @@ -1049,9 +1049,9 @@ func processReceivedMsg(_ res: ChatResponse) async { } m.removeChat(mergedContact.id) } - case let .contactsSubscribed(_, _, contactRefs): + case let .contactsSubscribed(_, contactRefs): updateContactsStatus(contactRefs, status: .connected) - case let .contactsDisconnected(_, _, contactRefs): + case let .contactsDisconnected(_, contactRefs): updateContactsStatus(contactRefs, status: .disconnected) case let .contactSubError(user, contact, chatError): if active(user) { @@ -1066,7 +1066,7 @@ func processReceivedMsg(_ res: ChatResponse) async { if let err = sub.contactError { processContactSubError(sub.contact, err) } else { - m.updateContactNetworkStatus(sub.contact, .connected) + m.setContactNetworkStatus(sub.contact, .connected) } } case let .newChatItem(user, aChatItem): @@ -1285,7 +1285,7 @@ func chatItemSimpleUpdate(_ aChatItem: AChatItem) { func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) { let m = ChatModel.shared for c in contactRefs { - m.networkStatuses[c.connId] = status + m.networkStatuses[c.agentConnId] = status } } @@ -1297,7 +1297,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.updateContactNetworkStatus(contact, .error(err)) + m.setContactNetworkStatus(contact, .error(err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 2ae5b79f9c..de42584b72 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -348,8 +348,8 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: User, contact: Contact) case contactRequestRejected(user: User) case contactUpdated(user: User, toContact: Contact) - case contactsSubscribed(user: User, server: String, contactRefs: [ContactRef]) - case contactsDisconnected(user: User, server: String, contactRefs: [ContactRef]) + case contactsSubscribed(server: String, contactRefs: [ContactRef]) + case contactsDisconnected(server: String, contactRefs: [ContactRef]) case contactSubError(user: User, contact: Contact, chatError: ChatError) case contactSubSummary(user: User, contactSubscriptions: [ContactSubStatus]) case groupSubscribed(user: User, groupInfo: GroupInfo) @@ -562,8 +562,8 @@ public enum ChatResponse: Decodable, Error { case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) - case let .contactsSubscribed(u, server, contactRefs): return withUser(u, "server: \(server)\ncontacts:\n\(String(describing: contactRefs))") - case let .contactsDisconnected(u, server, contactRefs): return withUser(u, "server: \(server)\ncontacts:\n\(String(describing: contactRefs))") + case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9f059fa339..009462d979 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1156,7 +1156,8 @@ public struct Contact: Identifiable, Decodable, NamedChat { public struct ContactRef: Decodable, Equatable { var contactId: Int64 - public var connId: Int64 + public var agentConnId: String + var connId: Int64 var localDisplayName: ContactName public var id: ChatId { get { "@\(contactId)" } } @@ -1169,6 +1170,7 @@ public struct ContactSubStatus: Decodable { public struct Connection: Decodable { public var connId: Int64 + public var agentConnId: String var connStatus: ConnStatus public var connLevel: Int public var viaGroupLink: Bool @@ -1179,6 +1181,7 @@ public struct Connection: Decodable { static let sampleData = Connection( connId: 1, + agentConnId: "abc", connStatus: .ready, connLevel: 0, viaGroupLink: false From 7dd4dc3b40603ef01ac1436840993512cc632434 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 20 Jan 2023 17:55:57 +0400 Subject: [PATCH 39/59] core: support accepting contact requests for non active users (for accepting via notification) (#1809) * core: support accepting contact requests for non active users (for accepting via notification) * getContactRequest' --- src/Simplex/Chat.hs | 10 +++++----- src/Simplex/Chat/Store.hs | 30 ++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 411a48bea4..2d84c07e88 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -659,18 +659,18 @@ processChatCommand = \case pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock "acceptContact" $ do - cReq <- withStore $ \db -> getContactRequest db userId connReqId + APIAcceptContact connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do + (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId -- [incognito] generate profile to send, create connection with incognito profile incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequest user cReq incognitoProfile pure $ CRAcceptingContactRequest user ct - APIRejectContact connReqId -> withUser $ \user@User {userId} -> withChatLock "rejectContact" $ do + APIRejectContact connReqId -> withUser $ \user -> withChatLock "rejectContact" $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \db -> - getContactRequest db userId connReqId - `E.finally` liftIO (deleteContactRequest db userId connReqId) + getContactRequest db user connReqId + `E.finally` liftIO (deleteContactRequest db user connReqId) withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index f1d6546433..7f40a62ae0 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -35,6 +35,8 @@ module Simplex.Chat.Store getUserByAConnId, getUserByContactId, getUserByGroupId, + getUserByFileId, + getUserByContactRequestId, getUserFileInfo, deleteUserRecord, createDirectConnection, @@ -74,6 +76,7 @@ module Simplex.Chat.Store getGroupLink, getGroupLinkId, createOrUpdateContactRequest, + getContactRequest', getContactRequest, getContactRequestIdByName, deleteContactRequest, @@ -524,6 +527,11 @@ getUserByFileId db fileId = ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) +getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User +getUserByContactRequestId db contactRequestId = + ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ + DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) + getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] getUserFileInfo db User {userId} = map toFileInfo @@ -1173,10 +1181,10 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi createOrUpdate_ = do cReqId <- ExceptT $ - maybeM getContactRequest' xContactId_ >>= \case + maybeM getContactRequestByXContactId xContactId_ >>= \case Nothing -> createContactRequest Just cr -> updateContactRequest cr $> Right (contactRequestId (cr :: UserContactRequest)) - getContactRequest db userId cReqId + getContactRequest db user cReqId createContactRequest :: IO (Either StoreError Int64) createContactRequest = do currentTs <- getCurrentTime @@ -1218,8 +1226,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi LIMIT 1 |] (userId, xContactId) - getContactRequest' :: XContactId -> IO (Maybe UserContactRequest) - getContactRequest' xContactId = + getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) + getContactRequestByXContactId xContactId = maybeFirstRow toContactRequest $ DB.query db @@ -1264,8 +1272,13 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi |] (displayName, fullName, image, currentTs, userId, cReqId) -getContactRequest :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db userId contactRequestId = +getContactRequest' :: DB.Connection -> Int64 -> ExceptT StoreError IO (User, UserContactRequest) +getContactRequest' db contactRequestId = do + user <- getUserByContactRequestId db contactRequestId + (user,) <$> getContactRequest db user contactRequestId + +getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest +getContactRequest db User {userId} contactRequestId = ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ DB.query db @@ -1293,8 +1306,8 @@ getContactRequestIdByName db userId cName = ExceptT . firstRow fromOnly (SEContactRequestNotFoundByName cName) $ DB.query db "SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, cName) -deleteContactRequest :: DB.Connection -> UserId -> Int64 -> IO () -deleteContactRequest db userId contactRequestId = do +deleteContactRequest :: DB.Connection -> User -> Int64 -> IO () +deleteContactRequest db User {userId} contactRequestId = do DB.execute db [sql| @@ -4841,6 +4854,7 @@ data StoreError | SEUserNotFoundByContactId {contactId :: ContactId} | SEUserNotFoundByGroupId {groupId :: GroupId} | SEUserNotFoundByFileId {fileId :: FileTransferId} + | SEUserNotFoundByContactRequestId {contactRequestId :: Int64} | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} From e72d4638d2d9468962ff45015366e4ab5f2a7633 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 20 Jan 2023 20:48:24 +0400 Subject: [PATCH 40/59] core: exlude muted chats from user unread count (#1810) --- src/Simplex/Chat/Store.hs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7f40a62ae0..8d72ab3b8e 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -461,13 +461,29 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo where getUserInfo :: User -> IO UserInfo getUserInfo user@User {userId} = do - count_ <- + ctCount <- maybeFirstRow fromOnly $ DB.query db - "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND item_status = ? GROUP BY user_id" + [sql| + SELECT COUNT(1) + FROM chat_items i + JOIN contacts ct USING (contact_id) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) + |] (userId, CISRcvNew) - pure UserInfo {user, unreadCount = fromMaybe 0 count_} + gCount <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT COUNT(1) + FROM chat_items i + JOIN groups g USING (group_id) + WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + |] + (userId, CISRcvNew) + pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] getUsers db = From c337a6888dccf9e2e277417c96f8516adbdd5612 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 21 Jan 2023 16:40:24 +0400 Subject: [PATCH 41/59] core: delete previous contact calls when receiving a new one (#1812) --- src/Simplex/Chat/Store.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 8d72ab3b8e..be95e5f1c6 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -4579,8 +4579,9 @@ overwriteSMPServers db User {userId} servers = pure $ Right () createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () -createCall db User {userId} Call {contactId, callId, chatItemId, callState} callTs = do +createCall db user@User {userId} Call {contactId, callId, chatItemId, callState} callTs = do currentTs <- getCurrentTime + deleteCalls db user contactId DB.execute db [sql| From 8bec0004ccd9666d77b174266fb668a236132221 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 21 Jan 2023 16:05:09 +0000 Subject: [PATCH 42/59] mobile: UI to choose transport isolation mode (#1813) * mobile: UI to choose transport isolation mode * typo Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * ios: update alerts Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 43 ++++++++-- .../usersettings/AdvancedNetworkSettings.kt | 3 + .../views/usersettings/NetworkAndServers.kt | 83 +++++++++++++++++-- .../app/src/main/res/values/strings.xml | 6 ++ .../UserSettings/NetworkAndServers.swift | 66 +++++++++++---- apps/ios/SimpleXChat/APITypes.swift | 17 +++- apps/ios/SimpleXChat/AppGroup.swift | 11 +++ 7 files changed, 200 insertions(+), 29 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index a802c70a6a..4384c4257b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -109,6 +109,18 @@ class AppPreferences(val context: Context) { val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) + private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) + val networkSessionMode: SharedPreference = SharedPreference( + get = fun(): TransportSessionMode { + val value = _networkSessionMode.get() ?: return TransportSessionMode.default + return try { + TransportSessionMode.valueOf(value) + } catch (e: Error) { + TransportSessionMode.default + } + }, + set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) } + ) val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) @@ -206,6 +218,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" + private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" @@ -254,6 +267,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a apiSetNetworkConfig(getNetCfg()) val justStarted = apiStartChat() if (justStarted) { + chatModel.currentUser.value = user + chatModel.userCreated.value = true apiSetFilesFolder(getAppFilesDirectory(appContext)) apiSetIncognito(chatModel.incognito.value) chatModel.userAddress.value = apiGetUserAddress() @@ -263,8 +278,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatModel.chatItemTTL.value = getChatItemTTL() val chats = apiGetChats() chatModel.updateChats(chats) - chatModel.currentUser.value = user - chatModel.userCreated.value = true chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -1533,6 +1546,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a val socksProxy = if (useSocksProxy) ":9050" else null val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) val requiredHostMode = appPrefs.networkRequiredHostMode.get() + val sessionMode = appPrefs.networkSessionMode.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val smpPingInterval = appPrefs.networkSMPPingInterval.get() @@ -1550,6 +1564,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode, + sessionMode = sessionMode, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpKeepAlive = tcpKeepAlive, @@ -1562,6 +1577,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) appPrefs.networkHostMode.set(cfg.hostMode.name) appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) + appPrefs.networkSessionMode.set(cfg.sessionMode) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval) @@ -2000,9 +2016,10 @@ data class ParsedServerAddress ( @Serializable data class NetCfg( - val socksProxy: String? = null, - val hostMode: HostMode = HostMode.OnionViaSocks, - val requiredHostMode: Boolean = false, + val socksProxy: String?, + val hostMode: HostMode, + val requiredHostMode: Boolean, + val sessionMode: TransportSessionMode, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpKeepAlive: KeepAliveOpts?, @@ -2017,6 +2034,9 @@ data class NetCfg( val defaults: NetCfg = NetCfg( socksProxy = null, + hostMode = HostMode.OnionViaSocks, + requiredHostMode = false, + sessionMode = TransportSessionMode.User, tcpConnectTimeout = 10_000_000, tcpTimeout = 7_000_000, tcpKeepAlive = KeepAliveOpts.defaults, @@ -2027,6 +2047,9 @@ data class NetCfg( val proxyDefaults: NetCfg = NetCfg( socksProxy = ":9050", + hostMode = HostMode.OnionViaSocks, + requiredHostMode = false, + sessionMode = TransportSessionMode.User, tcpConnectTimeout = 20_000_000, tcpTimeout = 15_000_000, tcpKeepAlive = KeepAliveOpts.defaults, @@ -2063,6 +2086,16 @@ enum class HostMode { @SerialName("public") Public; } +@Serializable +enum class TransportSessionMode { + @SerialName("user") User, + @SerialName("entity") Entity; + + companion object { + val default = User + } +} + @Serializable data class KeepAliveOpts( val keepIdle: Int, // seconds diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt index 8f3618e4e3..45b8d3c650 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt @@ -60,6 +60,9 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { } return NetCfg( socksProxy = currentCfg.value.socksProxy, + hostMode = currentCfg.value.hostMode, + requiredHostMode = currentCfg.value.requiredHostMode, + sessionMode = currentCfg.value.sessionMode, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpKeepAlive = tcpKeepAlive, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index cc0a6de7f2..7ab7a9419d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -31,6 +31,7 @@ fun NetworkAndServersView( val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } val developerTools = chatModel.controller.appPrefs.developerTools.get() val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } + val sessionMode = remember { mutableStateOf(netCfg.sessionMode) } LaunchedEffect(Unit) { chatModel.userSMPServersUnsaved.value = null @@ -40,6 +41,7 @@ fun NetworkAndServersView( developerTools = developerTools, networkUseSocksProxy = networkUseSocksProxy, onionHosts = onionHosts, + sessionMode = sessionMode, showModal = showModal, showSettingsModal = showSettingsModal, toggleSocksProxy = { enable -> @@ -82,9 +84,13 @@ fun NetworkAndServersView( OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert) OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert) } - updateOnionHostsDialog(startsWith, onDismiss = { - onionHosts.value = prevValue - }) { + updateNetworkSettingsDialog( + title = generalGetString(R.string.update_onion_hosts_settings_question), + startsWith, + onDismiss = { + onionHosts.value = prevValue + } + ) { withApi { val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it) val res = chatModel.controller.apiSetNetworkConfig(newCfg) @@ -96,6 +102,31 @@ fun NetworkAndServersView( } } } + }, + updateSessionMode = { + if (sessionMode.value == it) return@NetworkAndServersLayout + val prevValue = sessionMode.value + sessionMode.value = it + val startsWith = when (it) { + TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description) + TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description) + } + updateNetworkSettingsDialog( + title = generalGetString(R.string.update_network_session_mode_question), + startsWith, + onDismiss = { sessionMode.value = prevValue } + ) { + withApi { + val newCfg = chatModel.controller.getNetCfg().copy(sessionMode = it) + val res = chatModel.controller.apiSetNetworkConfig(newCfg) + if (res) { + chatModel.controller.setNetCfg(newCfg) + sessionMode.value = it + } else { + sessionMode.value = prevValue + } + } + } } ) } @@ -104,10 +135,12 @@ fun NetworkAndServersView( developerTools: Boolean, networkUseSocksProxy: MutableState, onionHosts: MutableState, + sessionMode: MutableState, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), toggleSocksProxy: (Boolean) -> Unit, useOnion: (OnionHosts) -> Unit, + updateSessionMode: (TransportSessionMode) -> Unit, ) { Column( Modifier.fillMaxWidth(), @@ -124,6 +157,10 @@ fun NetworkAndServersView( SectionDivider() UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) SectionDivider() + if (developerTools) { + SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + SectionDivider() + } SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } Spacer(Modifier.height(8.dp)) @@ -183,7 +220,6 @@ private fun UseOnionHosts( } } val onSelected = showModal { - Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, @@ -203,14 +239,47 @@ private fun UseOnionHosts( ) } -private fun updateOnionHostsDialog( +@Composable +private fun SessionModePicker( + sessionMode: MutableState, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + updateSessionMode: (TransportSessionMode) -> Unit, +) { + val values = remember { + TransportSessionMode.values().map { + when (it) { + TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(R.string.network_session_mode_user), generalGetString(R.string.network_session_mode_user_description)) + TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(R.string.network_session_mode_entity), generalGetString(R.string.network_session_mode_entity_description)) + } + } + } + + SectionItemWithValue( + generalGetString(R.string.network_session_mode_transport_isolation), + sessionMode, + values, + icon = Icons.Outlined.SafetyDivider, + onSelected = showModal { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + AppBarTitle(stringResource(R.string.network_session_mode_transport_isolation)) + SectionViewSelectable(null, sessionMode, values, updateSessionMode) + } + } + ) +} + +private fun updateNetworkSettingsDialog( + title: String, startsWith: String = "", message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), onDismiss: () -> Unit, onConfirm: () -> Unit ) { AlertManager.shared.showAlertDialog( - title = generalGetString(R.string.update_onion_hosts_settings_question), + title = title, text = startsWith + "\n\n" + message, confirmText = generalGetString(R.string.update_network_settings_confirmation), onDismiss = onDismiss, @@ -230,7 +299,9 @@ fun PreviewNetworkAndServersLayout() { showSettingsModal = { {} }, toggleSocksProxy = {}, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, + sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, useOnion = {}, + updateSessionMode = {}, ) } } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index cc79c17e71..46384c14ab 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -478,6 +478,12 @@ Onion hosts will be used when available. Onion hosts will not be used. Onion hosts will be required for connection. + Transport isolation + Chat profile + Connection + A separate TCP connection (and SOCKS credential) will be used for each chat profile you have in the app. + A separate TCP connection (and SOCKS credential) will be used for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + Update transport isolation mode? Appearance diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index b88a18e170..598255a0bd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -9,13 +9,15 @@ import SwiftUI import SimpleXChat -enum OnionHostsAlert: Identifiable { - case update(hosts: OnionHosts) +private enum NetworkAlert: Identifiable { + case updateOnionHosts(hosts: OnionHosts) + case updateSessionMode(mode: TransportSessionMode) case error(err: String) var id: String { switch self { - case let .update(hosts): return "update \(hosts)" + case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)" + case let .updateSessionMode(mode): return "updateSessionMode \(mode)" case let .error(err): return "error \(err)" } } @@ -27,7 +29,8 @@ struct NetworkAndServers: View { @State private var currentNetCfg = NetCfg.defaults @State private var netCfg = NetCfg.defaults @State private var onionHosts: OnionHosts = .no - @State private var showOnionHostsAlert: OnionHostsAlert? + @State private var sessionMode: TransportSessionMode = .user + @State private var alert: NetworkAlert? var body: some View { VStack { @@ -45,6 +48,12 @@ struct NetworkAndServers: View { } .frame(height: 36) + if developerTools { + Picker("Transport isolation", selection: $sessionMode) { + ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) } + } + } + NavigationLink { AdvancedNetworkSettings() .navigationTitle("Network settings") @@ -75,17 +84,37 @@ struct NetworkAndServers: View { } .onChange(of: onionHosts) { _ in if onionHosts != OnionHosts(netCfg: currentNetCfg) { - showOnionHostsAlert = .update(hosts: onionHosts) + alert = .updateOnionHosts(hosts: onionHosts) } } - .alert(item: $showOnionHostsAlert) { a in + .onChange(of: sessionMode) { _ in + if sessionMode != netCfg.sessionMode { + alert = .updateSessionMode(mode: sessionMode) + } + } + .alert(item: $alert) { a in switch a { - case let .update(hosts): + case let .updateOnionHosts(hosts): return Alert( title: Text("Update .onion hosts setting?"), - message: Text(onionHostsInfo()) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + message: Text(onionHostsInfo(hosts)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), primaryButton: .default(Text("Ok")) { - saveNetCfg(hosts) + let (hostMode, requiredHostMode) = hosts.hostMode + netCfg.hostMode = hostMode + netCfg.requiredHostMode = requiredHostMode + saveNetCfg() + }, + secondaryButton: .cancel() { + resetNetCfgView() + } + ) + case let .updateSessionMode(mode): + return Alert( + title: Text("Update transport isolation mode?"), + message: Text(sessionModeInfo(mode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + primaryButton: .default(Text("Ok")) { + netCfg.sessionMode = mode + saveNetCfg() }, secondaryButton: .cancel() { resetNetCfgView() @@ -100,11 +129,8 @@ struct NetworkAndServers: View { } } - private func saveNetCfg(_ hosts: OnionHosts) { + private func saveNetCfg() { do { - let (hostMode, requiredHostMode) = hosts.hostMode - netCfg.hostMode = hostMode - netCfg.requiredHostMode = requiredHostMode let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults netCfg.tcpConnectTimeout = def.tcpConnectTimeout netCfg.tcpTimeout = def.tcpTimeout @@ -114,7 +140,7 @@ struct NetworkAndServers: View { } catch let error { let err = responseError(error) resetNetCfgView() - showOnionHostsAlert = .error(err: err) + alert = .error(err: err) logger.error("\(err)") } } @@ -122,15 +148,23 @@ struct NetworkAndServers: View { private func resetNetCfgView() { netCfg = currentNetCfg onionHosts = OnionHosts(netCfg: netCfg) + sessionMode = netCfg.sessionMode } - private func onionHostsInfo() -> LocalizedStringKey { - switch onionHosts { + private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey { + switch hosts { case .no: return "Onion hosts will not be used." case .prefer: return "Onion hosts will be used when available. Requires enabling VPN." case .require: return "Onion hosts will be required for connection. Requires enabling VPN." } } + + private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey { + switch mode { + case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**." + case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." + } + } } struct NetworkServersView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index de42584b72..f652c1229b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -824,7 +824,7 @@ public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil public var hostMode: HostMode = .publicHost public var requiredHostMode = true - public var sessionMode = TransportSessionMode.user + public var sessionMode: TransportSessionMode public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpKeepAlive: KeepAliveOpts? @@ -834,6 +834,7 @@ public struct NetCfg: Codable, Equatable { public static let defaults: NetCfg = NetCfg( socksProxy: nil, + sessionMode: TransportSessionMode.user, tcpConnectTimeout: 10_000_000, tcpTimeout: 7_000_000, tcpKeepAlive: KeepAliveOpts.defaults, @@ -844,6 +845,7 @@ public struct NetCfg: Codable, Equatable { public static let proxyDefaults: NetCfg = NetCfg( socksProxy: nil, + sessionMode: TransportSessionMode.user, tcpConnectTimeout: 20_000_000, tcpTimeout: 15_000_000, tcpKeepAlive: KeepAliveOpts.defaults, @@ -895,9 +897,20 @@ public enum OnionHosts: String, Identifiable { public static let values: [OnionHosts] = [.no, .prefer, .require] } -public enum TransportSessionMode: String, Codable { +public enum TransportSessionMode: String, Codable, Identifiable { case user case entity + + public var text: LocalizedStringKey { + switch self { + case .user: return "User profile" + case .entity: return "Connection" + } + } + + public var id: TransportSessionMode { self } + + public static let values: [TransportSessionMode] = [.user, .entity] } public struct KeepAliveOpts: Codable, Equatable { diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 0dd43a2fca..d9135a3128 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -17,6 +17,7 @@ let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" +let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL = "networkSMPPingInterval" @@ -36,6 +37,7 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! public func registerGroupDefaults() { groupDefaults.register(defaults: [ GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, + GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.user.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval, @@ -107,6 +109,12 @@ public let networkUseOnionHostsGroupDefault = EnumDefault( withDefault: .no ) +public let networkSessionModeGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SESSION_MODE, + withDefault: .user +) + public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) @@ -186,6 +194,7 @@ public class Default { public func getNetCfg() -> NetCfg { let onionHosts = networkUseOnionHostsGroupDefault.get() let (hostMode, requiredHostMode) = onionHosts.hostMode + let sessionMode = networkSessionModeGroupDefault.get() let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let smpPingInterval = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) @@ -203,6 +212,7 @@ public func getNetCfg() -> NetCfg { return NetCfg( hostMode: hostMode, requiredHostMode: requiredHostMode, + sessionMode: sessionMode, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpKeepAlive: tcpKeepAlive, @@ -214,6 +224,7 @@ public func getNetCfg() -> NetCfg { public func setNetCfg(_ cfg: NetCfg) { networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg)) + networkSessionModeGroupDefault.set(cfg.sessionMode) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.smpPingInterval, forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) From 4cd396a0d22f887f8d544b9d78dbdf89c8459e7f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Jan 2023 13:20:58 +0000 Subject: [PATCH 43/59] ios: Multiuser calls (#1800) * ios: Multiuser calls * counter update on badge * padding before profile info in call view * underline in name * change after merge * do not show Simplex Info button if users already created * unread counter * do not increase badge counter when chat has disabled notifications * update incoming call Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 20 +++---- apps/ios/Shared/Model/NtfManager.swift | 4 +- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +- apps/ios/Shared/SimpleXApp.swift | 2 +- apps/ios/Shared/Views/Call/CallManager.swift | 1 - .../Shared/Views/Call/IncomingCallView.swift | 8 ++- apps/ios/Shared/Views/Chat/ChatView.swift | 1 - .../Shared/Views/ChatList/UserPicker.swift | 4 +- .../Views/Onboarding/CreateProfile.swift | 18 +++--- .../ios/SimpleX NSE/NotificationService.swift | 59 ++++++++----------- apps/ios/SimpleXChat/APITypes.swift | 7 ++- apps/ios/SimpleXChat/ChatTypes.swift | 4 +- 12 files changed, 66 insertions(+), 68 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index fa51fbf404..6f96b8a2c9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -158,7 +158,7 @@ final class ChatModel: ObservableObject { addChat(Chat(c), at: i) } } - NtfManager.shared.setNtfBadgeCount(totalUnreadCount()) + NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) } // func addGroup(_ group: SimpleXChat.Group) { @@ -172,7 +172,6 @@ final class ChatModel: ObservableObject { if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 increaseUnreadCounter(user: currentUser!) - NtfManager.shared.incNtfBadgeCount() } if i > 0 { if chatId == nil { @@ -253,9 +252,6 @@ final class ChatModel: ObservableObject { // remove from current chat if chatId == cInfo.id { if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { - if reversedChatItems[i].isRcvNew { - NtfManager.shared.decNtfBadgeCount() - } _ = withAnimation { self.reversedChatItems.remove(at: i) } @@ -304,7 +300,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in - NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatStats = ChatStats() } // update current chat @@ -337,7 +333,6 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { - NtfManager.shared.decNtfBadgeCount(by: markedCount) chat.chatStats.unreadCount -= markedCount self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) } @@ -357,7 +352,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { - NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatItems = [] chat.chatStats = ChatStats() chat.chatInfo = cInfo @@ -397,20 +392,23 @@ final class ChatModel: ObservableObject { func increaseUnreadCounter(user: User) { changeUnreadCounter(user: user, by: 1) + NtfManager.shared.incNtfBadgeCount() } func decreaseUnreadCounter(user: User, by: Int = 1) { changeUnreadCounter(user: user, by: -by) + NtfManager.shared.decNtfBadgeCount(by: by) } private func changeUnreadCounter(user: User, by: Int) { if let i = users.firstIndex(where: { $0.user.id == user.id }) { - users[i].unreadCount += Int64(by) + users[i].unreadCount += by } } - func totalUnreadCount() -> Int { - chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + func totalUnreadCountForAllUsers() -> Int { + chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + + users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 90e092701d..97252f1ade 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -39,10 +39,10 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") if let userId = content.userInfo["userId"] as? Int64, userId != chatModel.currentUser?.userId { - changeActiveUser(userId) + changeActiveUser(userId) } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, - let chatId = content.userInfo["chatId"] as? String { + let chatId = content.userInfo["chatId"] as? String { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { Task { await acceptContactRequest(contactRequest) } } else { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4026b7c2c1..35dbf0370c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -928,7 +928,7 @@ func startChat() throws { m.users = try listUsers() if justStarted { try getUserChatData() - NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) + NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() if let token = m.deviceToken { @@ -1077,7 +1077,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .newChatItem(user, aChatItem): if !active(user) { - if case .rcvNew = aChatItem.chatItem.meta.itemStatus { + if case .rcvNew = aChatItem.chatItem.meta.itemStatus, aChatItem.chatInfo.ntfsEnabled { m.increaseUnreadCounter(user: user) } return @@ -1125,7 +1125,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .chatItemDeleted(user, deletedChatItem, toChatItem, _): if !active(user) { - if toChatItem == nil && deletedChatItem.chatItem.isRcvNew { + if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled { m.decreaseUnreadCounter(user: user) } return diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 459cab70de..c8b641d20b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -60,7 +60,7 @@ struct SimpleXApp: App { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false - NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCount()) + NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: if chatModel.chatRunning == true { ChatReceiver.shared.start() diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 7da22919e9..962d783bcb 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -36,7 +36,6 @@ class CallManager { func answerIncomingCall(invitation: RcvCallInvitation) { let m = ChatModel.shared - // TODO: change active user m.callInvitations.removeValue(forKey: invitation.contact.id) m.activeCall = Call( direction: .incoming, diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 22a14e4d5b..0044434efd 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -29,6 +29,10 @@ struct IncomingCallView: View { private func incomingCall(_ invitation: RcvCallInvitation) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { + if m.users.count > 1 { + ProfileImage(imageStr: invitation.user.image, color: .white) + .frame(width: 24, height: 24) + } Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green) Text(invitation.callTypeText) } @@ -82,6 +86,8 @@ struct IncomingCallView: View { struct IncomingCallView_Previews: PreviewProvider { static var previews: some View { CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData - return IncomingCallView() + let m = ChatModel() + m.users = [UserInfo.sampleData, UserInfo.sampleData] + return IncomingCallView().environmentObject(m) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index fd94e96a33..62e2822065 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -233,7 +233,6 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) { Task { await apiMarkChatItemRead(cInfo, ci) - NtfManager.shared.decNtfBadgeCount() } } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 7078cdbfd1..9a151c8a90 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -132,8 +132,8 @@ struct UserPicker: View { } } -func unreadCounter(_ unread: Int64) -> some View { - unreadCountText(Int(truncatingIfNeeded: unread)) +func unreadCounter(_ unread: Int) -> some View { + unreadCountText(unread) .font(.caption) .foregroundColor(.white) .padding(.horizontal, 4) diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 211905255c..ce0a6160b8 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -51,13 +51,17 @@ struct CreateProfile: View { Spacer() HStack { - Button { - hideKeyboard() - withAnimation { m.onboardingStage = .step1_SimpleXInfo } - } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") + if m.users.isEmpty { + Button { + hideKeyboard() + withAnimation { + m.onboardingStage = .step1_SimpleXInfo + } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 6971c3f447..9f1ffb731b 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -107,18 +107,16 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let dbStatus = startChat() { if case .ok = dbStatus, - let ntfMsgInfos = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - for ntfMsgInfo in ntfMsgInfos { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { - setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) - if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) - deliverBestAttemptNtf() - } + let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") + if let connEntity = ntfMsgInfo.connEntity { + setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + if let id = connEntity.id { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) + deliverBestAttemptNtf() } } } @@ -220,6 +218,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem + if !cInfo.ntfsEnabled { + ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) + } if case .image = cItem.content.msgContent { if let file = cItem.file, file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV, @@ -258,15 +259,6 @@ func updateNetCfg() { } } -func listUsers() -> [UserInfo] { - let r = sendSimpleXCmd(.listUsers) - logger.debug("listUsers sendSimpleXCmd response: \(String(describing: r))") - switch r { - case let .usersList(users): return users - default: return [] - } -} - func apiGetActiveUser() -> User? { let r = sendSimpleXCmd(.showActiveUser) logger.debug("apiGetActiveUser sendSimpleXCmd response: \(String(describing: r))") @@ -300,21 +292,20 @@ func apiSetIncognito(incognito: Bool) throws { throw r } -func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> [NtfMessages]? { - let users = listUsers() - if users.isEmpty { - logger.debug("no users") - return [] +func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { + guard apiGetActiveUser() != nil else { + logger.debug("no active user") + return nil } - var result: [NtfMessages] = [] - users.forEach { - let r = sendSimpleXCmd(.apiGetNtfMessage(userId: $0.user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r { - result.append(NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages)) - } - logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") + let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { + return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + } else if case let .chatCmdError(_, error) = r { + logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") + } else { + logger.debug("apiGetNtfMessage ignored response: \(r.responseType, privacy: .public) \(String.init(describing: r), privacy: .private)") } - return result + return nil } func apiReceiveFile(fileId: Int64, inline: Bool) -> AChatItem? { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d1b3dbab0d..eb03b9a27d 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -37,7 +37,7 @@ public enum ChatCommand { case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) - case apiGetNtfMessage(userId: Int64, nonce: String, encNtfInfo: String) + case apiGetNtfMessage(nonce: String, encNtfInfo: String) case apiNewGroup(userId: Int64, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) @@ -125,7 +125,7 @@ public enum ChatCommand { case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" - case let .apiGetNtfMessage(userId, nonce, encNtfInfo): return "/_ntf message \(userId) \(nonce) \(encNtfInfo)" + case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" @@ -409,7 +409,7 @@ public enum ChatResponse: Decodable, Error { case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(user: User, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(user: User, connection: PendingContactConnection) case contactConnectionDeleted(user: User, connection: PendingContactConnection) case versionInfo(versionInfo: CoreVersionInfo) @@ -1078,6 +1078,7 @@ public enum ChatError: Decodable { public enum ChatErrorType: Decodable { case noActiveUser case activeUserExists + case differentActiveUser case chatNotStarted case invalidConnReq case invalidChatMessage(message: String) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 009462d979..8ae256cdda 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -36,9 +36,9 @@ public struct User: Decodable, NamedChat, Identifiable { public struct UserInfo: Decodable, Identifiable { public var user: User - public var unreadCount: Int64 + public var unreadCount: Int - public init(user: User, unreadCount: Int64) { + public init(user: User, unreadCount: Int) { self.user = user self.unreadCount = unreadCount } From 1d5c361b9a040b93c22c700058e5ee9f9d11110e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Jan 2023 15:48:29 +0000 Subject: [PATCH 44/59] ios: restore scroll and update user profile in user profile menu (#1811) * ios: Small UserPicker fixes * update scroll * update current user and update users list * refactor Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 12 ++++++++++++ apps/ios/Shared/Views/ChatList/UserPicker.swift | 7 ++++--- .../Shared/Views/UserSettings/PreferencesView.swift | 5 +---- apps/ios/Shared/Views/UserSettings/UserProfile.swift | 4 +--- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6f96b8a2c9..31ee686413 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -275,6 +275,18 @@ final class ChatModel: ObservableObject { return nil } + func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) { + if let current = currentUser { + currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "") + if let preferences = preferences { + currentUser?.fullPreferences = preferences + } + if let current = currentUser, let i = users.firstIndex(where: { $0.user.userId == current.userId }) { + users[i].user = current + } + } + } + func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 9a151c8a90..3503a87615 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -29,8 +29,9 @@ struct UserPicker: View { VStack(spacing: 0) { ScrollView { ScrollViewReader { sp in + let users = m.users.sorted { u, _ in u.user.activeUser } VStack(spacing: 0) { - ForEach(Array(m.users.sorted(by: { u, _ in u.user.activeUser }))) { u in + ForEach(users) { u in userView(u) Divider() } @@ -49,8 +50,8 @@ struct UserPicker: View { } } .onChange(of: userPickerVisible) { visible in - if visible { - sp.scrollTo(0) + if visible, let u = users.first { + sp.scrollTo(u.id) } } } diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index dc6da64ebd..fbce7193fa 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -71,10 +71,7 @@ struct PreferencesView: View { p.preferences = fullPreferencesToPreferences(preferences) if let newProfile = try await apiUpdateProfile(profile: p) { await MainActor.run { - if let profileId = chatModel.currentUser?.profile.profileId { - chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") - chatModel.currentUser?.fullPreferences = preferences - } + chatModel.updateCurrentUser(newProfile, preferences) currentPreferences = preferences } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index de7a19c8bd..5388cc3cfb 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -141,9 +141,7 @@ struct UserProfile: View { do { if let newProfile = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { - if let profileId = chatModel.currentUser?.profile.profileId { - chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") - } + chatModel.updateCurrentUser(newProfile) profile = newProfile } } From 2a20f788772f87576bc4a0d56546b22ca5c98efd Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 24 Jan 2023 16:24:34 +0400 Subject: [PATCH 45/59] core: use batch connection deletion api (#1814) --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Views/UserSettings/UserProfilesView.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 179 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Types.hs | 2 +- stack.yaml | 2 +- tests/ChatTests.hs | 31 ++- 10 files changed, 141 insertions(+), 91 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 35dbf0370c..20d6aaffde 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -145,8 +145,8 @@ func apiSetActiveUser(_ userId: Int64) throws -> User { throw r } -func apiDeleteUser(_ userId: Int64) throws { - let r = chatSendCmdSync(.apiDeleteUser(userId: userId)) +func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool) throws { + let r = chatSendCmdSync(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues)) if case .cmdOk = r { return } throw r } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index adb75d6a10..a96bf501f4 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -66,7 +66,7 @@ struct UserProfilesView: View { private func removeUser(index: Int) { do { - try apiDeleteUser(m.users[index].user.userId) + try apiDeleteUser(m.users[index].user.userId, true) m.users.remove(at: index) } catch let error { let a = getErrorAlert(error, "Error deleting user profile") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index eb03b9a27d..83ec0a766e 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -17,7 +17,7 @@ public enum ChatCommand { case createActiveUser(profile: Profile) case listUsers case apiSetActiveUser(userId: Int64) - case apiDeleteUser(userId: Int64) + case apiDeleteUser(userId: Int64, delSMPQueues: Bool) case startChat(subscribe: Bool, expire: Bool) case apiStopChat case apiActivateChat @@ -102,7 +102,7 @@ public enum ChatCommand { case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)" case .listUsers: return "/users" case let .apiSetActiveUser(userId): return "/_user \(userId)" - case let .apiDeleteUser(userId): return "/_delete user \(userId)" + case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) delSMPQueues=\(onOff(delSMPQueues))" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" diff --git a/cabal.project b/cabal.project index 431f2b8afa..5b06cedbfd 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: f66e8239f4dcaea37c760c82fecd7395de718294 + tag: d4fc638478a9dee69234ea0aaf212fee5cd0e323 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 240c1b96f4..5ecb0b1353 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."f66e8239f4dcaea37c760c82fecd7395de718294" = "00wycsq18z7mxmv85yhpvjvdj58msi8rnn0lafjr15pf2v0dalwf"; + "https://github.com/simplex-chat/simplexmq.git"."d4fc638478a9dee69234ea0aaf212fee5cd0e323" = "011ac45zxg9vwh12x8ykr3f1kyld8lj4lpnc5fs5b3978qcndhv2"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 2f8d5e7506..71fdf7a928 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -38,7 +38,7 @@ import Data.List.NonEmpty (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, listToMaybe, mapMaybe) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T import Data.Time (NominalDiffTime, addUTCTime) @@ -306,7 +306,7 @@ processChatCommand = \case atomically . writeTVar u $ Just user pure $ CRActiveUser user SetActiveUser uName -> withUserName uName APISetActiveUser - APIDeleteUser userId -> do + APIDeleteUser userId delSMPQueues -> do user <- withStore (`getUser` userId) when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) users <- withStore' getUsers @@ -315,11 +315,11 @@ processChatCommand = \case filesInfo <- withStore' (`getUserFileInfo` user) withChatLock "deleteUser" . procCmd $ do forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo - withAgent (`deleteUser` aUserId user) + withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) setActive ActiveNone ok_ - DeleteUser uName -> withUserName uName APIDeleteUser + DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning @@ -599,10 +599,10 @@ processChatCommand = \case CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - conns <- withStore $ \db -> getContactConnections db userId ct + contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) withChatLock "deleteChat direct" . procCmd $ do - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) + deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct @@ -610,8 +610,8 @@ processChatCommand = \case unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do - conn@PendingContactConnection {pccConnId, pccAgentConnId} <- withStore $ \db -> getPendingContactConnection db userId chatId - deleteAgentConnectionAsync' user pccConnId pccAgentConnId + conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId + deleteAgentConnectionAsync user acId withStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup -> do @@ -620,10 +620,10 @@ processChatCommand = \case unless canDelete $ throwChatError CEGroupUserRole filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "deleteChat group" . procCmd $ do - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo when (memberActive membership) . void $ sendGroupMessage user gInfo members XGrpDel deleteGroupLink' user gInfo `catchError` \_ -> pure () - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members @@ -640,20 +640,20 @@ processChatCommand = \case ctGroupId <- withStore' $ \db -> checkContactHasGroups db user ct when (isNothing ctGroupId) $ do conns <- withStore $ \db -> getContactConnections db userId ct - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionsAsync user $ map aConnId conns withStore' $ \db -> deleteContactWithoutGroups db user ct CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m @@ -975,7 +975,7 @@ processChatCommand = \case APIDeleteMyAddress userId -> withUserId userId $ \user -> withChatLock "deleteMyAddress" $ do conns <- withStore (`getUserAddressConnections` user) procCmd $ do - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionsAsync user $ map aConnId conns withStore' (`deleteUserAddress` user) pure $ CRUserContactLinkDeleted user DeleteMyAddress -> withUser $ \User {userId} -> @@ -1139,7 +1139,7 @@ processChatCommand = \case -- TODO delete direct connections that were unused deleteGroupLink' user gInfo `catchError` \_ -> pure () -- member records are not deleted to keep history - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> @@ -1259,7 +1259,8 @@ processChatCommand = \case withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {cancelled} fts -> do unless cancelled $ do - cancelSndFile user ftm fts True + fileAgentConnIds <- cancelSndFile user ftm fts True + deleteAgentConnectionsAsync user fileAgentConnIds sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId withStore (\db -> getChatRefByFileId db user fileId) >>= \case ChatRef CTDirect contactId -> do @@ -1272,7 +1273,8 @@ processChatCommand = \case ci <- withStore $ \db -> getChatItemByFileId db user fileId pure $ CRSndGroupFileCancelled user ci ftm fts FTRcv ftr@RcvFileTransfer {cancelled} -> do - unless cancelled $ cancelRcvFileTransfer user ftr + unless cancelled $ + cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) pure $ CRRcvFileCancelled user ftr FileStatus fileId -> withUser $ \user -> do fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId @@ -1577,22 +1579,34 @@ setAllExpireCIFlags b = do keys <- M.keys <$> readTVar expireFlags forM_ keys $ \k -> TM.insert k b expireFlags -deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m () +deleteFilesAndConns :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () +deleteFilesAndConns user filesInfo = do + connIds <- mapM (deleteFile user) filesInfo + deleteAgentConnectionsAsync user $ concat connIds + +deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m [ConnId] deleteFile user fileInfo = deleteFile' user fileInfo False -deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m () -deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = - (cancel' >> delete) `catchError` (toView . CRChatError (Just user)) +deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] +deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = do + aConnIds <- case fileStatus of + Just fStatus -> cancel' fStatus `catchError` (\e -> toView (CRChatError (Just user) e) >> pure []) + Nothing -> pure [] + delete `catchError` (toView . CRChatError (Just user)) + pure aConnIds where - cancel' = forM_ fileStatus $ \(AFS dir status) -> - unless (ciFileEnded status) $ - case dir of + cancel' :: ACIFileStatus -> m [ConnId] + cancel' (AFS dir status) = + if ciFileEnded status + then pure [] + else case dir of SMDSnd -> do (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) - unless cancelled $ cancelSndFile user ftm fts sendCancel + if cancelled then pure [] else cancelSndFile user ftm fts sendCancel SMDRcv -> do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - unless cancelled $ cancelRcvFileTransfer user ft + if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft + delete :: m () delete = withFilesFolder $ \filesFolder -> forM_ filePath $ \fPath -> do let fsFilePath = filesFolder <> "/" <> fPath @@ -1763,7 +1777,7 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> deleteGroupLink' :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLink' user gInfo = do conn <- withStore $ \db -> getGroupLinkConnection db user gInfo - deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionAsync user $ aConnId conn withStore' $ \db -> deleteGroupLink db user gInfo agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () @@ -1980,12 +1994,12 @@ expireChatItems user@User {userId} ttl sync = do processContact :: UTCTime -> Contact -> m () processContact expirationDate ct = do filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () processGroup expirationDate createdAtCutoff gInfo = do filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m @@ -2380,7 +2394,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do - cancelSndFileTransfer user ft True + cancelSndFileTransfer user ft True >>= mapM_ (deleteAgentConnectionAsync user) case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> getChatItemByFileId db user fileId @@ -2459,7 +2473,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize, cancelled} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless cancelled $ do - cancelRcvFileTransfer user ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) toView $ CRRcvFileSndCancelled user ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of @@ -2485,7 +2499,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do getChatItemByFileId db user fileId toView $ CRRcvFileComplete user ci closeFileHandle fileId rcvFiles - mapM_ (deleteAgentConnectionAsync user) conn_ + forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) RcvChunkDuplicate -> pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo @@ -2592,7 +2606,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do badRcvFileChunk :: RcvFileTransfer -> String -> m () badRcvFileChunk ft@RcvFileTransfer {cancelled} err = unless cancelled $ do - cancelRcvFileTransfer user ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) throwChatError $ CEFileRcvChunk err memberConnectedChatItem :: GroupInfo -> GroupMember -> m () @@ -2821,7 +2835,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do - cancelRcvFileTransfer user ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) toView $ CRRcvFileSndCancelled user ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () @@ -2897,7 +2911,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do - cancelRcvFileTransfer user ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) toView $ CRRcvFileSndCancelled user ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -3250,7 +3264,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do then checkRole membership $ do deleteGroupLink' user gInfo `catchError` \_ -> pure () -- member records are not deleted to keep history - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m @@ -3292,7 +3306,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateGroupMemberStatus db userId membership GSMemGroupDeleted pure members -- member records are not deleted to keep history - forM_ ms $ deleteMemberConnection user + deleteMembersConnections user ms ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo m ci msgMeta toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m @@ -3338,7 +3352,7 @@ parseAChatMessage :: ChatMonad m => ByteString -> m AChatMessage parseAChatMessage = liftEither . first (ChatError . CEInvalidChatMessage) . strDecode sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () -sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, connId, agentConnId} = +sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo @@ -3349,7 +3363,7 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, connId, agentConnId} updateDirectCIFileStatus db user fileId CIFSSndComplete toView $ CRSndFileComplete user ci ft closeFileHandle fileId sndFiles - deleteAgentConnectionAsync' user connId agentConnId + deleteAgentConnectionAsync user acId sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do @@ -3405,35 +3419,39 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m () -cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus, rcvFileInline} = do - closeFileHandle fileId rcvFiles - withStore' $ \db -> do - updateFileCancelled db user fileId CIFSRcvCancelled - updateRcvFileStatus db ft FSCancelled - deleteRcvFileChunks db ft - when (isNothing rcvFileInline) $ case fileStatus of - RFSAccepted RcvFileInfo {connId = Just connId, agentConnId = Just agentConnId} -> - deleteAgentConnectionAsync' user connId agentConnId - RFSConnected RcvFileInfo {connId = Just connId, agentConnId = Just agentConnId} -> - deleteAgentConnectionAsync' user connId agentConnId - _ -> pure () +cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m (Maybe ConnId) +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, rcvFileInline} = + cancel' `catchError` (\e -> toView (CRChatError (Just user) e) >> pure fileConnId) + where + cancel' = do + closeFileHandle fileId rcvFiles + withStore' $ \db -> do + updateFileCancelled db user fileId CIFSRcvCancelled + updateRcvFileStatus db ft FSCancelled + deleteRcvFileChunks db ft + pure fileConnId + fileConnId = if isNothing rcvFileInline then liveRcvFileTransferConnId ft else Nothing -cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> m () +cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> m [ConnId] cancelSndFile user FileTransferMeta {fileId} fts sendCancel = do - withStore' $ \db -> updateFileCancelled db user fileId CIFSSndCancelled - forM_ fts $ \ft' -> cancelSndFileTransfer user ft' sendCancel + withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) + `catchError` (toView . CRChatError (Just user)) + catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) -cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m () -cancelSndFileTransfer user ft@SndFileTransfer {connId, agentConnId = agentConnId@(AgentConnId acId), fileStatus, fileInline} sendCancel = - unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do - withStore' $ \db -> do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft - when sendCancel $ - withAgent (\a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel)) - `catchError` (toView . CRChatError (Just user)) - when (isNothing fileInline) $ deleteAgentConnectionAsync' user connId agentConnId +cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m (Maybe ConnId) +cancelSndFileTransfer user ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = + if fileStatus == FSCancelled || fileStatus == FSComplete + then pure Nothing + else cancel' `catchError` (\e -> toView (CRChatError (Just user) e) >> pure fileConnId) + where + cancel' = do + withStore' $ \db -> do + updateSndFileStatus db ft FSCancelled + deleteSndFileChunks db ft + when sendCancel $ + withAgent (\a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel)) + pure fileConnId + fileConnId = if isNothing fileInline then Just acId else Nothing closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle fileId files = do @@ -3444,10 +3462,16 @@ closeFileHandle fileId files = do throwChatError :: ChatMonad m => ChatErrorType -> m a throwChatError = throwError . ChatError +deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () +deleteMembersConnections user members = do + let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members + deleteAgentConnectionsAsync user $ map aConnId memberConns + forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () deleteMemberConnection user GroupMember {activeConn} = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionAsync user $ aConnId conn withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m () @@ -3586,7 +3610,8 @@ deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - deleteFile' user fileInfo True + fileAgentConnIds <- deleteFile' user fileInfo True + deleteAgentConnectionsAsync user fileAgentConnIds markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> m ChatResponse markDirectCIDeleted user ct ci@(CChatItem msgDir deletedItem) msgId byUser = do @@ -3622,14 +3647,14 @@ agentAcceptContactAsync user enableNtfs invId msg = do connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId $ directMessage msg pure (cmdId, connId) -deleteAgentConnectionAsync :: ChatMonad m => User -> Connection -> m () -deleteAgentConnectionAsync user Connection {agentConnId, connId} = - deleteAgentConnectionAsync' user connId agentConnId +deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () +deleteAgentConnectionAsync user acId = + withAgent (`deleteConnectionAsync` acId) `catchError` (toView . CRChatError (Just user)) -deleteAgentConnectionAsync' :: ChatMonad m => User -> Int64 -> AgentConnId -> m () -deleteAgentConnectionAsync' user connId (AgentConnId acId) = do - cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFDeleteConn - withAgent $ \a -> deleteConnectionAsync a (aCorrId cmdId) acId +deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m () +deleteAgentConnectionsAsync _ [] = pure () +deleteAgentConnectionsAsync user acIds = + withAgent (`deleteConnectionsAsync` acIds) `catchError` (toView . CRChatError (Just user)) userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Profile userProfileToSend user@User {profile = p} incognitoProfile ct = @@ -3840,8 +3865,8 @@ chatCommandP = "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal), ("/user " <|> "/u ") *> (SetActiveUser <$> displayName), - "/_delete user " *> (APIDeleteUser <$> A.decimal), - "/delete user " *> (DeleteUser <$> displayName), + "/_delete user " *> (APIDeleteUser <$> A.decimal <* " delSMPQueues=" <*> onOffP), + "/delete user " *> (DeleteUser <$> displayName <*> pure True), ("/user" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), "/_start" $> StartChat True True, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 85b0b0316d..0372ad86a2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -181,8 +181,8 @@ data ChatCommand | ListUsers | APISetActiveUser UserId | SetActiveUser UserName - | APIDeleteUser UserId - | DeleteUser UserName + | APIDeleteUser UserId Bool + | DeleteUser UserName Bool | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool} | APIStopChat | APIActivateChat diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index fb1eb7f55b..15717672e0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1865,7 +1865,7 @@ data CommandFunction | CFAllowConn | CFAcceptContact | CFAckMessage - | CFDeleteConn + | CFDeleteConn -- not used deriving (Eq, Show, Generic) instance FromField CommandFunction where fromField = fromTextField_ textDecode diff --git a/stack.yaml b/stack.yaml index b92f4da791..d9ef82d1d9 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: f66e8239f4dcaea37c760c82fecd7395de718294 + commit: d4fc638478a9dee69234ea0aaf212fee5cd0e323 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 4321a93d34..55ce82849b 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4585,9 +4585,13 @@ testDeleteUser = \alice bob cath -> do connectUsers alice bob - alice ##> "/_delete user 1" + -- cannot delete active user + + alice ##> "/_delete user 1 delSMPQueues=off" alice <## "cannot delete active user" + -- delete user without deleting SMP queues + alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -4597,16 +4601,18 @@ testDeleteUser = alice <## "alice (Alice)" alice <## "alisa (active)" - alice ##> "/delete user alice" + alice ##> "/_delete user 1 delSMPQueues=off" alice <## "ok" alice ##> "/users" alice <## "alisa (active)" bob #> "@alice hey" - -- bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + -- no connection authorization error - connection wasn't deleted (alice "/delete user alisa" alice <## "cannot delete active user" @@ -4615,6 +4621,25 @@ testDeleteUser = alice <##> cath + -- delete user deleting SMP queues + + alice ##> "/create user alisa2" + showActiveUser alice "alisa2" + + alice ##> "/users" + alice <## "alisa" + alice <## "alisa2 (active)" + + alice ##> "/delete user alisa" + alice <## "ok" + + alice ##> "/users" + alice <## "alisa2 (active)" + + cath #> "@alisa hey" + cath <## "[alisa, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + (alice Date: Tue, 24 Jan 2023 13:39:12 +0000 Subject: [PATCH 46/59] update simplexmq --- cabal.project | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cabal.project b/cabal.project index 5b06cedbfd..bef9e0a9a8 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d4fc638478a9dee69234ea0aaf212fee5cd0e323 + tag: b59669a65eafeb92d4a986a03e481ecb343ee307 source-repository-package type: git From a7a56ea1d9ddc5a9a211e5e44e09267cfd1b72ef Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 24 Jan 2023 17:58:08 +0400 Subject: [PATCH 47/59] core: use batch delete api when deleting unused group contacts (#1830) --- src/Simplex/Chat.hs | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 71fdf7a928..4a6a008b6f 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -630,18 +630,24 @@ processChatCommand = \case withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo let contactIds = mapMaybe memberContactId members - forM_ contactIds $ \ctId -> - deleteUnusedContact ctId `catchError` (toView . CRChatError (Just user)) + deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds pure $ CRGroupDeletedUser user gInfo where - deleteUnusedContact contactId = do - ct <- withStore $ \db -> getContact db user contactId - unless (directOrUsed ct) $ do - ctGroupId <- withStore' $ \db -> checkContactHasGroups db user ct - when (isNothing ctGroupId) $ do - conns <- withStore $ \db -> getContactConnections db userId ct - deleteAgentConnectionsAsync user $ map aConnId conns - withStore' $ \db -> deleteContactWithoutGroups db user ct + deleteUnusedContact :: ContactId -> m [ConnId] + deleteUnusedContact contactId = + (withStore (\db -> getContact db user contactId) >>= delete) + `catchError` (\e -> toView (CRChatError (Just user) e) >> pure []) + where + delete ct + | directOrUsed ct = pure [] + | otherwise = + withStore' (\db -> checkContactHasGroups db user ct) >>= \case + Just _ -> pure [] + Nothing -> do + conns <- withStore $ \db -> getContactConnections db userId ct + withStore' (\db -> deleteContactWithoutGroups db user ct) + `catchError` (toView . CRChatError (Just user)) + pure $ map aConnId conns CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of CTDirect -> do From 6bca013e67e76b6ae7be993727da59f7576b7f17 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 24 Jan 2023 14:29:20 +0000 Subject: [PATCH 48/59] android: multiuser-api (#1829) * android: multiuser-api * add line in try-catch block * changed lines position * when -> if * take condition outside * mutable version of objects and usage of a new function * changed additional places in code * added toMap() so state will be updated Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 137 +++-- .../java/chat/simplex/app/model/NtfManager.kt | 12 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 572 +++++++++++------- .../chat/simplex/app/views/TerminalView.kt | 2 +- .../simplex/app/views/call/CallManager.kt | 2 +- .../app/views/call/IncomingCallActivity.kt | 1 + .../app/views/call/IncomingCallAlertView.kt | 4 +- .../chat/simplex/app/views/call/WebRTC.kt | 3 +- .../simplex/app/views/chat/ChatInfoView.kt | 23 +- .../app/views/chat/group/GroupChatInfoView.kt | 3 +- .../views/chat/group/GroupMemberInfoView.kt | 6 +- .../app/views/chatlist/ChatListNavLinkView.kt | 10 +- .../app/views/chatlist/ChatPreviewView.kt | 24 +- .../app/views/usersettings/Preferences.kt | 6 +- .../app/views/usersettings/UserProfileView.kt | 4 +- 15 files changed, 487 insertions(+), 322 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 438e5ddd50..2778a8012c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -35,6 +35,7 @@ import kotlin.time.* class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) + val users = mutableStateListOf() val userCreated = mutableStateOf(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) @@ -42,6 +43,8 @@ class ChatModel(val controller: ChatController) { val chatDbStatus = mutableStateOf(null) val chatDbDeleted = mutableStateOf(false) val chats = mutableStateListOf() + // map of connections network statuses, key is agent connection id + val networkStatuses = mutableStateMapOf() // current chat val chatId = mutableStateOf(null) @@ -87,13 +90,6 @@ class ChatModel(val controller: ChatController) { val filesToDelete = mutableSetOf() val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get()) - fun updateUserProfile(profile: LocalProfile) { - val user = currentUser.value - if (user != null) { - currentUser.value = user.copy(profile = profile) - } - } - fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } @@ -120,17 +116,8 @@ class ChatModel(val controller: ChatController) { } fun updateChats(newChats: List) { - val mergedChats = arrayListOf() - for (newChat in newChats) { - val i = getChatIndex(newChat.chatInfo.id) - if (i >= 0) { - mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo)) - } else { - mergedChats.add(newChat) - } - } chats.clear() - chats.addAll(mergedChats) + chats.addAll(newChats) val cId = chatId.value // If chat is null, it was deleted in background after apiGetChats call @@ -139,14 +126,6 @@ class ChatModel(val controller: ChatController) { } } - fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) { - val i = getChatIndex(id) - if (i >= 0) { - val chat = chats[i] - chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status)) - } - } - fun replaceChat(id: String, chat: Chat) { val i = getChatIndex(id) if (i >= 0) { @@ -168,6 +147,7 @@ class ChatModel(val controller: ChatController) { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId + increaseUnreadCounter(currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId) } else @@ -256,6 +236,7 @@ class ChatModel(val controller: ChatController) { // clear preview val i = getChatIndex(cInfo.id) if (i >= 0) { + decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) } // clear current chat @@ -264,6 +245,18 @@ class ChatModel(val controller: ChatController) { } } + fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) { + val current = currentUser.value ?: return + val updated = current.copy( + profile = newProfile.toLocalProfile(current.profile.profileId), + fullPreferences = preferences ?: current.fullPreferences + ) + val indexInUsers = users.indexOfFirst { it.user.userId == current.userId } + if (indexInUsers != -1) { + users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) + } + } + suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withContext(Dispatchers.Main) { @@ -286,9 +279,11 @@ class ChatModel(val controller: ChatController) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { + val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 + decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( - unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0, + unreadCount = unreadCount, // Can't use minUnreadItemId currently since chat items can have unread items between read items //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 ) @@ -324,13 +319,30 @@ class ChatModel(val controller: ChatController) { if (chatIndex == -1) return val chat = chats[chatIndex] + val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( - unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0), + unreadCount = unreadCount, ) ) } + fun increaseUnreadCounter(user: User) { + changeUnreadCounter(user, 1) + } + + fun decreaseUnreadCounter(user: User, by: Int = 1) { + changeUnreadCounter(user, -by) + } + + private fun changeUnreadCounter(user: User, by: Int) { + val i = users.indexOfFirst { it.user.userId == user.userId } + if (i != -1) { + users[i] = UserInfo(user, users[i].unreadCount + by) + } + } + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) @@ -375,6 +387,13 @@ class ChatModel(val controller: ChatController) { false } } + + fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { + networkStatuses[contact.activeConn.agentConnId] = status + } + + fun contactNetworkStatus(contact: Contact): NetworkStatus = + networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown() } enum class ChatType(val type: String) { @@ -410,6 +429,19 @@ data class User( } } +@Serializable +data class UserInfo( + val user: User, + val unreadCount: Int +) { + companion object { + val sampleData = UserInfo( + user = User.sampleData, + unreadCount = 1 + ) + } +} + typealias ChatId = String interface NamedChat { @@ -441,37 +473,12 @@ data class Chat ( val chatInfo: ChatInfo, val chatItems: List, val chatStats: ChatStats = ChatStats(), - val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown()) ) { val id: String get() = chatInfo.id @Serializable data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) - @Serializable - data class ServerInfo(val networkStatus: NetworkStatus) - - @Serializable - sealed class NetworkStatus { - val statusString: String get() = - when (this) { - is Connected -> generalGetString(R.string.server_connected) - is Error -> generalGetString(R.string.server_error) - else -> generalGetString(R.string.server_connecting) - } - val statusExplanation: String get() = - when (this) { - is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) - else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) - } - - @Serializable @SerialName("unknown") class Unknown: NetworkStatus() - @Serializable @SerialName("connected") class Connected: NetworkStatus() - @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() - } - companion object { val sampleData = Chat( chatInfo = ChatInfo.Direct.sampleData, @@ -605,6 +612,27 @@ sealed class ChatInfo: SomeChat, NamedChat { } } +@Serializable +sealed class NetworkStatus { + val statusString: String get() = + when (this) { + is Connected -> generalGetString(R.string.server_connected) + is Error -> generalGetString(R.string.server_error) + else -> generalGetString(R.string.server_connecting) + } + val statusExplanation: String get() = + when (this) { + is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) + is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) + else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) + } + + @Serializable @SerialName("unknown") class Unknown: NetworkStatus() + @Serializable @SerialName("connected") class Connected: NetworkStatus() + @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() + @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() +} + @Serializable data class Contact( val contactId: Long, @@ -675,6 +703,8 @@ data class Contact( @Serializable class ContactRef( val contactId: Long, + val agentConnId: String, + val connId: Long, var localDisplayName: String ) { val id: ChatId get() = "@$contactId" @@ -689,6 +719,7 @@ class ContactSubStatus( @Serializable data class Connection( val connId: Long, + val agentConnId: String, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, @@ -697,7 +728,7 @@ data class Connection( ) { val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null) + val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 91d3948a9b..7330db3651 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -77,8 +77,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference } } - fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) { + fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) { notifyMessageReceived( + user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = generalGetString(R.string.notification_new_contact_request), @@ -87,21 +88,22 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference ) } - fun notifyContactConnected(contact: Contact) { + fun notifyContactConnected(user: User, contact: Contact) { notifyMessageReceived( + user = user, chatId = contact.id, displayName = contact.displayName, msgText = generalGetString(R.string.notification_contact_connected) ) } - fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) { + fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) { if (!cInfo.ntfsEnabled) return - notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) } - fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) { + fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) { Log.d(TAG, "notifyMessageReceived $chatId") val now = Clock.System.now().toEpochMilliseconds() val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 311b14536e..a36afc3d65 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -266,18 +266,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (chatModel.chatRunning.value == true) return apiSetNetworkConfig(getNetCfg()) val justStarted = apiStartChat() + chatModel.users.clear() + chatModel.users.addAll(listUsers()) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true apiSetFilesFolder(getAppFilesDirectory(appContext)) apiSetIncognito(chatModel.incognito.value) - chatModel.userAddress.value = apiGetUserAddress() - val smpServers = getUserSMPServers() - chatModel.userSMPServers.value = smpServers?.first - chatModel.presetSMPServers.value = smpServers?.second - chatModel.chatItemTTL.value = getChatItemTTL() - val chats = apiGetChats() - chatModel.updateChats(chats) + getUserChatData() chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -294,6 +290,27 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun changeActiveUser(toUserId: Long) { + try { + chatModel.currentUser.value = apiSetActiveUser(toUserId) + chatModel.users.clear() + chatModel.users.addAll(listUsers()) + getUserChatData() + } catch (e: Exception) { + Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") + } + } + + suspend fun getUserChatData() { + chatModel.userAddress.value = apiGetUserAddress() + val smpServers = getUserSMPServers() + chatModel.userSMPServers.value = smpServers?.first + chatModel.presetSMPServers.value = smpServers?.second + chatModel.chatItemTTL.value = getChatItemTTL() + val chats = apiGetChats() + chatModel.updateChats(chats) + } + private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") if (receiverStarted) return @@ -363,6 +380,27 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a throw Error("user not created ${r.responseType} ${r.details}") } + suspend fun listUsers(): List { + val r = sendCmd(CC.ListUsers()) + if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName } + Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") + throw Exception("failed to list users ${r.responseType} ${r.details}") + } + + suspend fun apiSetActiveUser(userId: Long): User { + val r = sendCmd(CC.ApiSetActiveUser(userId)) + if (r is CR.ActiveUser) return r.user + Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") + throw Exception("failed to set the user as active ${r.responseType} ${r.details}") + } + + suspend fun apiDeleteUser(userId: Long) { + val r = sendCmd(CC.ApiDeleteUser(userId)) + if (r is CR.CmdOk) return + Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") + throw Exception("failed to delete the user ${r.responseType} ${r.details}") + } + suspend fun apiStartChat(): Boolean { val r = sendCmd(CC.StartChat(expire = true)) when (r) { @@ -721,13 +759,6 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } - suspend fun apiParseMarkdown(text: String): List? { - val r = sendCmd(CC.ApiParseMarkdown(text)) - if (r is CR.ParsedMarkdown) return r.formattedText - Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) if (r is CR.ContactPrefsUpdated) return r.toContact @@ -1067,6 +1098,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun apiParseMarkdown(text: String): List? { + val r = sendCmd(CC.ApiParseMarkdown(text)) + if (r is CR.ParsedMarkdown) return r.formattedText + Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") + return null + } + private fun networkErrorAlert(r: CR): Boolean { return when { r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1102,62 +1140,81 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatModel.terminalItems.add(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { - chatModel.updateContactConnection(r.connection) + if (active(r.user)) { + chatModel.updateContactConnection(r.connection) + } } is CR.ContactConnectionDeleted -> { - chatModel.removeChat(r.connection.id) + if (active(r.user)) { + chatModel.removeChat(r.connection.id) + } } is CR.ContactConnected -> { - if (r.contact.directOrUsed) { + if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) chatModel.dismissConnReqView(r.contact.activeConn.id) chatModel.removeChat(r.contact.activeConn.id) - chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected()) - ntfManager.notifyContactConnected(r.contact) + ntfManager.notifyContactConnected(r.user, r.contact) } + chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ContactConnecting -> { - if (r.contact.directOrUsed) { + if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) chatModel.dismissConnReqView(r.contact.activeConn.id) chatModel.removeChat(r.contact.activeConn.id) } } is CR.ReceivedContactRequest -> { + if (!active(r.user)) return + val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf())) - ntfManager.notifyContactRequestReceived(cInfo) + ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - val cInfo = ChatInfo.Direct(r.toContact) - if (chatModel.hasChat(r.toContact.id)) { + if (active(r.user) && chatModel.hasChat(r.toContact.id)) { + val cInfo = ChatInfo.Direct(r.toContact) chatModel.updateChatInfo(cInfo) } } is CR.ContactsMerged -> { - if (chatModel.hasChat(r.mergedContact.id)) { + if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } chatModel.removeChat(r.mergedContact.id) } } - is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected()) - is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected()) - is CR.ContactSubError -> processContactSubError(r.contact, r.chatError) + is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) + is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) + is CR.ContactSubError -> { + if (active(r.user)) { + chatModel.updateContact(r.contact) + } + processContactSubError(r.contact, r.chatError) + } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { + if (active(r.user)) { + chatModel.updateContact(sub.contact) + } val err = sub.contactError if (err == null) { - chatModel.updateContact(sub.contact) - chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected()) + chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected()) } else { processContactSubError(sub.contact, sub.contactError) } } } is CR.NewChatItem -> { + if (!active(r.user)) { + if (r.chatItem.chatItem.isRcvNew && r.chatItem.chatInfo.ntfsEnabled) { + chatModel.increaseUnreadCounter(r.user) + } + return + } val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) @@ -1171,10 +1228,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } is CR.ChatItemStatusUpdated -> { + if (!active(r.user)) return + val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem var res = false @@ -1182,12 +1241,21 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a res = chatModel.upsertChatItem(cInfo, cItem) } if (res) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } is CR.ChatItemUpdated -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.ChatItemDeleted -> { + if (!active(r.user)) { + if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { + chatModel.decreaseUnreadCounter(r.user) + } + return + } + val cInfo = r.deletedChatItem.chatInfo val cItem = r.deletedChatItem.chatItem AudioPlayer.stop(cItem) @@ -1195,6 +1263,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { ntfManager.cancelNotificationsForChat(cInfo.id) ntfManager.notifyMessageReceived( + r.user, cInfo.id, cInfo.displayName, generalGetString(if (r.toChatItem != null) R.string.marked_deleted_description else R.string.deleted_description) @@ -1207,10 +1276,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } is CR.ReceivedGroupInvitation -> { - chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated - // TODO NtfManager.shared.notifyGroupInvitation + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated + // TODO NtfManager.shared.notifyGroupInvitation + } } is CR.UserAcceptedGroupSent -> { + if (!active(r.user)) return + chatModel.updateGroup(r.groupInfo) if (r.hostContact != null) { chatModel.dismissConnReqView(r.hostContact.activeConn.id) @@ -1218,34 +1291,64 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } is CR.JoinedGroupMemberConnecting -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.DeletedMemberUser -> // TODO update user member - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.DeletedMember -> - chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + } is CR.LeftMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.MemberRole -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.MemberRoleUser -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.GroupDeleted -> // TODO update user member - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.UserJoinedGroup -> - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.JoinedGroupMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.ConnectedToGroupMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.GroupUpdated -> - chatModel.updateGroup(r.toGroup) + if (active(r.user)) { + chatModel.updateGroup(r.toGroup) + } is CR.RcvFileStart -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.RcvFileComplete -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.SndFileStart -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.SndFileComplete -> { + if (!active(r.user)) return + chatItemSimpleUpdate(r.chatItem) val cItem = r.chatItem.chatItem val mc = cItem.content.msgContent @@ -1307,6 +1410,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + private fun active(user: User): Boolean = user.userId == chatModel.currentUser.value?.userId + private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value if (call != null && call.contact.apiId == contact.apiId) { @@ -1335,18 +1440,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem if (chatModel.upsertChatItem(cInfo, cItem)) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(chatModel.currentUser.value!!, cInfo, cItem) } } - fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) { + private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) { for (c in contactRefs) { - chatModel.updateNetworkStatus(c.id, status) + chatModel.networkStatuses[c.agentConnId] = status } } - fun processContactSubError(contact: Contact, chatError: ChatError) { - chatModel.updateContact(contact) + private fun processContactSubError(contact: Contact, chatError: ChatError) { val e = chatError val err: String = if (e is ChatError.ChatErrorAgent) { @@ -1358,7 +1462,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } else e.string - chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err)) + chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } fun showBackgroundServiceNoticeIfNeeded() { @@ -1621,6 +1725,9 @@ sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() + class ListUsers: CC() + class ApiSetActiveUser(val userId: Long): CC() + class ApiDeleteUser(val userId: Long): CC() class StartChat(val expire: Boolean): CC() class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() @@ -1693,6 +1800,9 @@ sealed class CC { is Console -> cmd is ShowActiveUser -> "/u" is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}" + is ListUsers -> "/users" + is ApiSetActiveUser -> "/_user $userId" + is ApiDeleteUser -> "/_delete user $userId" is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" @@ -1701,7 +1811,7 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" - is ApiGetChats -> "/_get $userId chats pcc=on" + is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" @@ -1766,6 +1876,9 @@ sealed class CC { is Console -> "console command" is ShowActiveUser -> "showActiveUser" is CreateActiveUser -> "createActiveUser" + is ListUsers -> "listUsers" + is ApiSetActiveUser -> "apiSetActiveUser" + is ApiDeleteUser -> "apiDeleteUser" is StartChat -> "startChat" is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" @@ -2689,17 +2802,19 @@ class APIResponse(val resp: CR, val corr: String? = null) { val type = resp["type"]?.jsonPrimitive?.content ?: "invalid" try { if (type == "apiChats") { + val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chats: List = resp["chats"]!!.jsonArray.map { parseChatData(it) } return APIResponse( - resp = CR.ApiChats(chats), + resp = CR.ApiChats(user, chats), corr = data["corr"]?.toString() ) } else if (type == "apiChat") { + val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chat = parseChatData(resp["chat"]!!) return APIResponse( - resp = CR.ApiChat(chat), + resp = CR.ApiChat(user, chat), corr = data["corr"]?.toString() ) } @@ -2735,108 +2850,110 @@ private fun decodeObject(deserializer: DeserializationStrategy, obj: Json @Serializable sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() + @Serializable @SerialName("usersList") class UsersList(val users: List): CR() @Serializable @SerialName("chatStarted") class ChatStarted: CR() @Serializable @SerialName("chatRunning") class ChatRunning: CR() @Serializable @SerialName("chatStopped") class ChatStopped: CR() - @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() - @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() - @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List, val presetSMPServers: List): CR() - @Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): CR() - @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR() + @Serializable @SerialName("apiChats") class ApiChats(val user: User, val chats: List): CR() + @Serializable @SerialName("apiChat") class ApiChat(val user: User, val chat: Chat): CR() + @Serializable @SerialName("userSMPServers") class UserSMPServers(val user: User, val smpServers: List, val presetSMPServers: List): CR() + @Serializable @SerialName("smpTestResult") class SmpTestResult(val user: User, val smpTestFailure: SMPTestFailure? = null): CR() + @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() - @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() - @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() - @Serializable @SerialName("contactCode") class ContactCode(val contact: Contact, val connectionCode: String): CR() - @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() - @Serializable @SerialName("connectionVerified") class ConnectionVerified(val verified: Boolean, val expectedCode: String): CR() - @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() - @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() - @Serializable @SerialName("sentInvitation") class SentInvitation: CR() - @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR() - @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR() - @Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR() - @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() - @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() - @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR() - @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR() - @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR() - @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() - @Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() - @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() - @Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact, val userCustomProfile: Profile? = null): CR() - @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR() - @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR() - @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR() - @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR() - @Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR() + @Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() + @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() + @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR() + @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() + @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR() + @Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR() + @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR() + @Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR() + @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR() + @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: User, val contact: Contact): CR() + @Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR() + @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() + @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR() + @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR() + @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() + @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR() + @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR() + @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR() + @Serializable @SerialName("contactConnected") class ContactConnected(val user: User, val contact: Contact, val userCustomProfile: Profile? = null): CR() + @Serializable @SerialName("contactConnecting") class ContactConnecting(val user: User, val contact: Contact): CR() + @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val user: User, val contactRequest: UserContactRequest): CR() + @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: User, val contact: Contact): CR() + @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: User): CR() + @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: User, val toContact: Contact): CR() @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR() - @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR() - @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List): CR() - @Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR() + @Serializable @SerialName("contactSubError") class ContactSubError(val user: User, val contact: Contact, val chatError: ChatError): CR() + @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: User, val contactSubscriptions: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: User, val group: GroupInfo): CR() + @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: User, val memberSubErrors: List): CR() + @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: User, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() - @Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() - @Serializable @SerialName("contactsList") class ContactsList(val contacts: List): CR() + @Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() + @Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List): CR() // group events - @Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() - @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() - @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("groupMembers") class GroupMembers(val group: Group): CR() - @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() - @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() - @Serializable @SerialName("memberRole") class MemberRole(val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() - @Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused - @Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR() - @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val groupInfo: GroupInfo, val connReqContact: String): CR() - @Serializable @SerialName("groupLink") class GroupLink(val groupInfo: GroupInfo, val connReqContact: String): CR() - @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupCreated") class GroupCreated(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() + @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: User, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() + @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupMembers") class GroupMembers(val user: User, val group: Group): CR() + @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: User, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberRole") class MemberRole(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("deletedMember") class DeletedMember(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() + @Serializable @SerialName("leftMember") class LeftMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: User, val intoContact: Contact, val mergedContact: Contact): CR() + @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused + @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() + @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() + @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR() // receiving file events - @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR() // sending file events - @Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() - @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() - @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR() - @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() - @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR() - @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() - @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() + @Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() + @Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR() + @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: User, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() + @Serializable @SerialName("callEnded") class CallEnded(val user: User, val contact: Contact): CR() + @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR() + @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR() - @Serializable @SerialName("cmdOk") class CmdOk: CR() - @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() - @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() + @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() + @Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR() + @Serializable @SerialName("chatCmdError") class ChatCmdError(val user: User?, val chatError: ChatError): CR() + @Serializable @SerialName("chatError") class ChatRespError(val user: User?, val chatError: ChatError): CR() @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() val responseType: String get() = when(this) { is ActiveUser -> "activeUser" + is UsersList -> "usersList" is ChatStarted -> "chatStarted" is ChatRunning -> "chatRunning" is ChatStopped -> "chatStopped" @@ -2862,7 +2979,6 @@ sealed class CR { is ContactAliasUpdated -> "contactAliasUpdated" is ConnectionAliasUpdated -> "connectionAliasUpdated" is ContactPrefsUpdated -> "contactPrefsUpdated" - is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkUpdated -> "userContactLinkUpdated" is UserContactLinkCreated -> "userContactLinkCreated" @@ -2928,6 +3044,7 @@ sealed class CR { is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is VersionInfo -> "versionInfo" + is ParsedMarkdown -> "apiParsedMarkdown" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -2936,106 +3053,109 @@ sealed class CR { } val details: String get() = when(this) { - is ActiveUser -> json.encodeToString(user) + is ActiveUser -> withUser(user, json.encodeToString(user)) + is UsersList -> json.encodeToString(users) is ChatStarted -> noDetails() is ChatRunning -> noDetails() is ChatStopped -> noDetails() - is ApiChats -> json.encodeToString(chats) - is ApiChat -> json.encodeToString(chat) - is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}" - is SmpTestResult -> json.encodeToString(smpTestFailure) - is ChatItemTTL -> json.encodeToString(chatItemTTL) + is ApiChats -> withUser(user, json.encodeToString(chats)) + is ApiChat -> withUser(user, json.encodeToString(chat)) + is UserSMPServers -> withUser(user, "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}") + is SmpTestResult -> withUser(user, json.encodeToString(smpTestFailure)) + is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) - is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" - is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}" - is ContactCode -> "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode" - is GroupMemberCode -> "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode" - is ConnectionVerified -> "verified: $verified\nconnectionCode: $expectedCode" - is Invitation -> connReqInvitation - is SentConfirmation -> noDetails() - is SentInvitation -> noDetails() - is ContactAlreadyExists -> json.encodeToString(contact) - is ContactDeleted -> json.encodeToString(contact) - is ChatCleared -> json.encodeToString(chatInfo) - is UserProfileNoChange -> noDetails() - is UserProfileUpdated -> json.encodeToString(toProfile) - is ContactAliasUpdated -> json.encodeToString(toContact) - is ConnectionAliasUpdated -> json.encodeToString(toConnection) - is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}" + is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") + is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") + is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") + is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") + is Invitation -> withUser(user, connReqInvitation) + is SentConfirmation -> withUser(user, noDetails()) + is SentInvitation -> withUser(user, noDetails()) + is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) + is ContactDeleted -> withUser(user, json.encodeToString(contact)) + is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) + is UserProfileNoChange -> withUser(user, noDetails()) + is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) + is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) + is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) + is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is ParsedMarkdown -> json.encodeToString(formattedText) - is UserContactLink -> contactLink.responseDetails - is UserContactLinkUpdated -> contactLink.responseDetails - is UserContactLinkCreated -> connReqContact - is UserContactLinkDeleted -> noDetails() - is ContactConnected -> json.encodeToString(contact) - is ContactConnecting -> json.encodeToString(contact) - is ReceivedContactRequest -> json.encodeToString(contactRequest) - is AcceptingContactRequest -> json.encodeToString(contact) - is ContactRequestRejected -> noDetails() - is ContactUpdated -> json.encodeToString(toContact) + is UserContactLink -> withUser(user, contactLink.responseDetails) + is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails) + is UserContactLinkCreated -> withUser(user, connReqContact) + is UserContactLinkDeleted -> withUser(user, noDetails()) + is ContactConnected -> withUser(user, json.encodeToString(contact)) + is ContactConnecting -> withUser(user, json.encodeToString(contact)) + is ReceivedContactRequest -> withUser(user, json.encodeToString(contactRequest)) + is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) + is ContactRequestRejected -> withUser(user, noDetails()) + is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}" - is ContactSubSummary -> json.encodeToString(contactSubscriptions) - is GroupSubscribed -> json.encodeToString(group) - is MemberSubErrors -> json.encodeToString(memberSubErrors) - is GroupEmpty -> json.encodeToString(group) + is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") + is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is GroupSubscribed -> withUser(user, json.encodeToString(group)) + is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) + is GroupEmpty -> withUser(user, json.encodeToString(group)) is UserContactLinkSubscribed -> noDetails() - is NewChatItem -> json.encodeToString(chatItem) - is ChatItemStatusUpdated -> json.encodeToString(chatItem) - is ChatItemUpdated -> json.encodeToString(chatItem) - is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser" - is ContactsList -> json.encodeToString(contacts) - is GroupCreated -> json.encodeToString(groupInfo) - is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member" + is NewChatItem -> withUser(user, json.encodeToString(chatItem)) + is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem)) + is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) + is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") + is ContactsList -> withUser(user, json.encodeToString(contacts)) + is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) - is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member" - is LeftMemberUser -> json.encodeToString(groupInfo) - is GroupMembers -> json.encodeToString(group) - is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole" - is GroupDeletedUser -> json.encodeToString(groupInfo) - is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member" - is MemberRole -> "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" - is MemberRoleUser -> "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" - is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member" - is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember" - is LeftMember -> "groupInfo: $groupInfo\nmember: $member" - is GroupDeleted -> "groupInfo: $groupInfo\nmember: $member" - is ContactsMerged -> "intoContact: $intoContact\nmergedContact: $mergedContact" - is GroupInvitation -> json.encodeToString(groupInfo) - is UserJoinedGroup -> json.encodeToString(groupInfo) - is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member" - is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member" - is GroupRemoved -> json.encodeToString(groupInfo) - is GroupUpdated -> json.encodeToString(toGroup) - is GroupLinkCreated -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact" - is GroupLink -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact" - is GroupLinkDeleted -> json.encodeToString(groupInfo) - is RcvFileAcceptedSndCancelled -> noDetails() - is RcvFileAccepted -> json.encodeToString(chatItem) - is RcvFileStart -> json.encodeToString(chatItem) - is RcvFileComplete -> json.encodeToString(chatItem) + is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) + is GroupMembers -> withUser(user, json.encodeToString(group)) + is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) + is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") + is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") + is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") + is GroupInvitation -> withUser(user, json.encodeToString(groupInfo)) + is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) + is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) + is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") + is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") + is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) + is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) + is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) + is RcvFileStart -> withUser(user, json.encodeToString(chatItem)) + is RcvFileComplete -> withUser(user, json.encodeToString(chatItem)) is SndFileCancelled -> json.encodeToString(chatItem) - is SndFileComplete -> json.encodeToString(chatItem) - is SndFileRcvCancelled -> json.encodeToString(chatItem) - is SndFileStart -> json.encodeToString(chatItem) - is SndGroupFileCancelled -> json.encodeToString(chatItem) + is SndFileComplete -> withUser(user, json.encodeToString(chatItem)) + is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem)) + is SndFileStart -> withUser(user, json.encodeToString(chatItem)) + is SndGroupFileCancelled -> withUser(user, json.encodeToString(chatItem)) is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" - is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}" - is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}" - is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}" - is CallEnded -> "contact: ${contact.id}" - is NewContactConnection -> json.encodeToString(connection) - is ContactConnectionDeleted -> json.encodeToString(connection) + is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") + is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") + is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") + is CallEnded -> withUser(user, "contact: ${contact.id}") + is NewContactConnection -> withUser(user, json.encodeToString(connection)) + is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) is VersionInfo -> json.encodeToString(versionInfo) - is CmdOk -> noDetails() - is ChatCmdError -> chatError.string - is ChatRespError -> chatError.string + is CmdOk -> withUser(user, noDetails()) + is ChatCmdError -> withUser(user, chatError.string) + is ChatRespError -> withUser(user, chatError.string) is Response -> json is Invalid -> str } fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details) + + private fun withUser(u: User?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s } abstract class TerminalItem { @@ -3111,11 +3231,13 @@ sealed class ChatError { sealed class ChatErrorType { val string: String get() = when (this) { is NoActiveUser -> "noActiveUser" + is DifferentActiveUser -> "differentActiveUser" is InvalidConnReq -> "invalidConnReq" is FileAlreadyReceiving -> "fileAlreadyReceiving" is СommandError -> "commandError $message" } @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType() + @Serializable @SerialName("differentActiveUser") class DifferentActiveUser: ChatErrorType() @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType() @Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType() @Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index b827e92b89..18ec98fc7b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -102,7 +102,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index bfb3ef089f..dc5286fd6e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -54,10 +54,14 @@ fun ChatInfoView( val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { + val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) { + mutableStateOf(chatModel.contactNetworkStatus(contact)) + } ChatInfoLayout( chat, contact, connStats, + contactNetworkStatus.value, customUserProfile, localAlias, connectionCode, @@ -149,6 +153,7 @@ fun ChatInfoLayout( chat: Chat, contact: Contact, connStats: ConnectionStats?, + contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -200,9 +205,9 @@ fun ChatInfoLayout( SectionItemView({ AlertManager.shared.showAlertMsg( generalGetString(R.string.network_status), - chat.serverInfo.networkStatus.statusExplanation + contactNetworkStatus.statusExplanation )}) { - NetworkStatusRow(chat.serverInfo.networkStatus) + NetworkStatusRow(contactNetworkStatus) } val rcvServers = connStats.rcvServers if (rcvServers != null && rcvServers.isNotEmpty()) { @@ -314,7 +319,7 @@ fun LocalAliasEditor( } @Composable -private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { +private fun NetworkStatusRow(networkStatus: NetworkStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -346,14 +351,14 @@ private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { } @Composable -private fun ServerImage(networkStatus: Chat.NetworkStatus) { +private fun ServerImage(networkStatus: NetworkStatus) { Box(Modifier.size(18.dp)) { when (networkStatus) { - is Chat.NetworkStatus.Connected -> + is NetworkStatus.Connected -> Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) - is Chat.NetworkStatus.Disconnected -> + is NetworkStatus.Disconnected -> Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) - is Chat.NetworkStatus.Error -> + is NetworkStatus.Error -> Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } @@ -446,14 +451,14 @@ fun PreviewChatInfoLayout() { ChatInfoLayout( chat = Chat( chatInfo = ChatInfo.Direct.sampleData, - chatItems = arrayListOf(), - serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + chatItems = arrayListOf() ), Contact.sampleData, localAlias = "", connectionCode = "123", developerTools = false, connStats = null, + contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, openPreferences = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index 874c0db684..24b2b9ab3f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -427,8 +427,7 @@ fun PreviewGroupChatInfoLayout() { GroupChatInfoLayout( chat = Chat( chatInfo = ChatInfo.Direct.sampleData, - chatItems = arrayListOf(), - serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + chatItems = arrayListOf() ), groupInfo = GroupInfo.sampleData, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index c32b52914e..3ec6d1ed38 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -66,11 +66,9 @@ fun GroupMemberInfoView( withApi { val c = chatModel.controller.apiGetChat(ChatType.Direct, it) if (c != null) { - // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend - val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected())) - chatModel.addChat(newChat) + chatModel.addChat(c) chatModel.chatItems.clear() - chatModel.chatId.value = newChat.id + chatModel.chatId.value = c.id closeAll() } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index ff88b3762b..0a6fd54463 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -44,17 +44,19 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { delay(500L) } when (chat.chatInfo) { - is ChatInfo.Direct -> + is ChatInfo.Direct -> { + val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, stopped ) + } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, @@ -627,6 +629,7 @@ fun PreviewChatListNavLinkDirect() { ), false, null, + null, stopped = false, linkMode = SimplexLinkMode.DESCRIPTION ) @@ -665,6 +668,7 @@ fun PreviewChatListNavLinkGroup() { ), false, null, + null, stopped = false, linkMode = SimplexLinkMode.DESCRIPTION ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 7bdc675a6d..a385efda46 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -25,7 +25,14 @@ import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* @Composable -fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) { +fun ChatPreviewView( + chat: Chat, + chatModelIncognito: Boolean, + currentUserProfileDisplayName: String?, + contactNetworkStatus: NetworkStatus?, + stopped: Boolean, + linkMode: SimplexLinkMode +) { val cInfo = chat.chatInfo @Composable @@ -187,7 +194,9 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD Modifier.padding(top = 52.dp), contentAlignment = Alignment.Center ) { - ChatStatusImage(chat) + if (chat.chatInfo is ChatInfo.Direct) { + ChatStatusImage(chat, contactNetworkStatus) + } } } } @@ -210,10 +219,9 @@ fun unreadCountStr(n: Int): String { } @Composable -fun ChatStatusImage(chat: Chat) { - val s = chat.serverInfo.networkStatus - val descr = s.statusString - if (s is Chat.NetworkStatus.Error) { +fun ChatStatusImage(chat: Chat, s: NetworkStatus?) { + val descr = s?.statusString + if (s is NetworkStatus.Error) { Icon( Icons.Outlined.ErrorOutline, contentDescription = descr, @@ -221,7 +229,7 @@ fun ChatStatusImage(chat: Chat) { modifier = Modifier .size(19.dp) ) - } else if (s !is Chat.NetworkStatus.Connected) { + } else if (s !is NetworkStatus.Connected) { CircularProgressIndicator( Modifier .padding(horizontal = 2.dp) @@ -241,6 +249,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt index b1de4d8d5f..113e454b1f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -29,12 +29,8 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) val updatedProfile = m.controller.apiUpdateProfile(newProfile) if (updatedProfile != null) { - val updatedUser = user.copy( - profile = updatedProfile.toLocalProfile(user.profile.profileId), - fullPreferences = preferences - ) + m.updateCurrentUser(updatedProfile, preferences) currentPreferences = preferences - m.currentUser.value = updatedUser } afterSave() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index b33a9548c5..3baa54ea34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -46,9 +46,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { withApi { val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image)) if (newProfile != null) { - chatModel.currentUser.value?.profile?.profileId?.let { - chatModel.updateUserProfile(newProfile.toLocalProfile(it)) - } + chatModel.updateCurrentUser(newProfile) profile = newProfile } editProfile.value = false From b6db41dd50dc2b0f52d97f73e1836ec2a768a540 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 24 Jan 2023 18:46:02 +0400 Subject: [PATCH 49/59] update simplexmq (complete) --- scripts/nix/sha256map.nix | 2 +- stack.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5ecb0b1353..958d856221 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d4fc638478a9dee69234ea0aaf212fee5cd0e323" = "011ac45zxg9vwh12x8ykr3f1kyld8lj4lpnc5fs5b3978qcndhv2"; + "https://github.com/simplex-chat/simplexmq.git"."b59669a65eafeb92d4a986a03e481ecb343ee307" = "0h4kgnlg0hy5dy5svshj6fhz277rqls5w1h95b1vg719xxcc2n8m"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; diff --git a/stack.yaml b/stack.yaml index d9ef82d1d9..20253b58b8 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: d4fc638478a9dee69234ea0aaf212fee5cd0e323 + commit: b59669a65eafeb92d4a986a03e481ecb343ee307 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 From b386346cf14bede89c59a915a5226c523841838e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 24 Jan 2023 16:00:32 +0000 Subject: [PATCH 50/59] core: update syntact for /_delete (#1831) --- src/Simplex/Chat.hs | 2 +- tests/ChatTests.hs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 4a6a008b6f..497b3b2549 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3871,7 +3871,7 @@ chatCommandP = "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal), ("/user " <|> "/u ") *> (SetActiveUser <$> displayName), - "/_delete user " *> (APIDeleteUser <$> A.decimal <* " delSMPQueues=" <*> onOffP), + "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP), "/delete user " *> (DeleteUser <$> displayName <*> pure True), ("/user" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 55ce82849b..bb13882130 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4587,7 +4587,7 @@ testDeleteUser = -- cannot delete active user - alice ##> "/_delete user 1 delSMPQueues=off" + alice ##> "/_delete user 1 del_smp=off" alice <## "cannot delete active user" -- delete user without deleting SMP queues @@ -4601,7 +4601,7 @@ testDeleteUser = alice <## "alice (Alice)" alice <## "alisa (active)" - alice ##> "/_delete user 1 delSMPQueues=off" + alice ##> "/_delete user 1 del_smp=off" alice <## "ok" alice ##> "/users" From bc1d86e303283e58ddb60edffedf1541c50c66fd Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 24 Jan 2023 20:07:35 +0400 Subject: [PATCH 51/59] core: send agent DEL events to view (#1832) --- src/Simplex/Chat.hs | 5 +++++ src/Simplex/Chat/Controller.hs | 17 ++++++++++++++++- src/Simplex/Chat/View.hs | 9 +++++++++ tests/ChatTests.hs | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 497b3b2549..f338fb26ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -2013,6 +2013,10 @@ expireChatItems user@User {userId} ttl sync = do processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent -> m () processAgentMessage _ "" msg = processAgentMessageNoConn msg `catchError` (toView . CRChatError Nothing) +processAgentMessage _ connId (DEL_RCVQ srv qId err_) = + toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ +processAgentMessage _ connId DEL_CONN = + toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = withStore' (`getUserByAConnId` AgentConnId connId) >>= \case Just user -> processAgentMessageConn user corrId connId msg `catchError` (toView . CRChatError (Just user)) @@ -2025,6 +2029,7 @@ processAgentMessageNoConn = \case DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" SUSPENDED -> toView CRChatSuspended + DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId _ -> pure () where hostEvent = whenM (asks $ hostEvents . config) . toView diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0372ad86a2..c1afd0e781 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -53,7 +53,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags, NtfServer) +import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags, NtfServer, QueueId) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -477,6 +477,9 @@ data ChatResponse | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} + | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} + | CRAgentConnDeleted {agentConnId :: AgentConnId} + | CRAgentUserDeleted {agentUserId :: Int64} | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} @@ -486,6 +489,18 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +newtype AgentQueueId = AgentQueueId QueueId + deriving (Eq, Show) + +instance StrEncoding AgentQueueId where + strEncode (AgentQueueId qId) = strEncode qId + strDecode s = AgentQueueId <$> strDecode s + strP = AgentQueueId <$> strP + +instance ToJSON AgentQueueId where + toJSON = strToJSON + toEncoding = strToJEncoding + data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]} deriving (Show, Generic, FromJSON) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 654b81d09f..802a4cebb4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -217,6 +217,15 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case ] CRAgentStats stats -> map (plain . intercalate ",") stats CRConnectionDisabled entity -> viewConnectionEntityDisabled entity + CRAgentRcvQueueDeleted acId srv aqId err_ -> + [ "completed deleting rcv queue, agent connection id: " <> sShow acId + <> (", server: " <> sShow srv) + <> (", agent queue id: " <> sShow aqId) + <> maybe "" (\e -> ", error: " <> sShow e) err_ + | logLevel <= CLLInfo + ] + CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] + CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] CRChatCmdError u e -> ttyUser' u $ viewChatError logLevel e CRChatError u e -> ttyUser' u $ viewChatError logLevel e diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index bb13882130..04b76e7440 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -4631,7 +4631,7 @@ testDeleteUser = alice <## "alisa2 (active)" alice ##> "/delete user alisa" - alice <## "ok" + alice <### ["ok", "completed deleting user"] alice ##> "/users" alice <## "alisa2 (active)" From 93ab7137486a346c9d96d8126ad0823dc918854a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 24 Jan 2023 19:00:30 +0000 Subject: [PATCH 52/59] ios: choose user deletion mode (#1833) * ios: choose user deletion mode * update text * refactor, disable button * darker profile icon colors * do not delete active user if changing user failed --- apps/ios/Shared/Model/SimpleXAPI.swift | 13 +++-- .../Shared/Views/ChatList/ChatListView.swift | 3 +- .../Shared/Views/ChatList/UserPicker.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/UserProfilesView.swift | 53 ++++++++++++++----- apps/ios/SimpleXChat/APITypes.swift | 2 +- 6 files changed, 53 insertions(+), 22 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 20d6aaffde..30249c7390 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -945,17 +945,22 @@ func startChat() throws { chatLastStartGroupDefault.set(Date.now) } -func changeActiveUser(_ toUserId: Int64) { +func changeActiveUser(_ userId: Int64) { let m = ChatModel.shared do { - m.currentUser = try apiSetActiveUser(toUserId) - m.users = try listUsers() - try getUserChatData() + try changeActiveUser_(userId) } catch let error { logger.error("Unable to set active user: \(responseError(error))") } } +func changeActiveUser_(_ userId: Int64) throws { + let m = ChatModel.shared + m.currentUser = try apiSetActiveUser(userId) + m.users = try listUsers() + try getUserChatData() +} + func getUserChatData() throws { let m = ChatModel.shared m.userAddress = try apiGetUserAddress() diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 638ce711bb..eaefaa0475 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -80,9 +80,8 @@ struct ChatListView: View { } } label: { let user = chatModel.currentUser ?? User.sampleData - let color = Color(uiColor: .tertiarySystemGroupedBackground) ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: user.image, color: color) + ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel)) .frame(width: 32, height: 32) .padding(.trailing, 4) let allRead = chatModel.users diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 3503a87615..8d50bff6b0 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -96,7 +96,7 @@ struct UserPicker: View { } }, label: { HStack(spacing: 0) { - ProfileImage(imageStr: user.image) + ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill)) .frame(width: 44, height: 44) .padding(.trailing, 12) Text(user.chatViewName) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d133edad3f..a05a43ad21 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -394,7 +394,7 @@ func settingsRow(_ icon: String, color: Color = .secondary, cont struct ProfilePreview: View { var profileOf: NamedChat - var color = Color(uiColor: .tertiarySystemGroupedBackground) + var color = Color(uiColor: .tertiarySystemFill) var body: some View { HStack { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index a96bf501f4..6752dab748 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -9,15 +9,17 @@ import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @Environment(\.editMode) private var editMode + @State private var showDeleteConfirmation = false + @State private var userToDelete: Int? @State private var alert: UserProfilesAlert? private enum UserProfilesAlert: Identifiable { - case deleteUser(index: Int) + case deleteUser(index: Int, delSMPQueues: Bool) case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { switch self { - case let .deleteUser(index): return "deleteUser \(index)" + case let .deleteUser(index, delSMPQueues): return "deleteUser \(index) \(delSMPQueues)" case let .error(title, _): return "error \(title)" } } @@ -31,7 +33,8 @@ struct UserProfilesView: View { } .onDelete { indexSet in if let i = indexSet.first { - alert = .deleteUser(index: i) + showDeleteConfirmation = true + userToDelete = i } } @@ -47,14 +50,18 @@ struct UserProfilesView: View { } } .toolbar { EditButton() } + .confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + deleteModeButton("Profile and server connections", true) + deleteModeButton("Local profile data only", false) + } .alert(item: $alert) { alert in switch alert { - case let .deleteUser(index): + case let .deleteUser(index, delSMPQueues): return Alert( title: Text("Delete user profile?"), message: Text("All chats and messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { - removeUser(index: index) + removeUser(index, delSMPQueues) }, secondaryButton: .cancel() ) @@ -64,24 +71,43 @@ struct UserProfilesView: View { } } - private func removeUser(index: Int) { + private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { + Button(title, role: .destructive) { + if let i = userToDelete { + alert = .deleteUser(index: i, delSMPQueues: delSMPQueues) + } + } + } + + private func removeUser(_ index: Int, _ delSMPQueues: Bool) { + if index >= m.users.count { return } do { - try apiDeleteUser(m.users[index].user.userId, true) - m.users.remove(at: index) + let u = m.users[index].user + if u.activeUser { + if let newActive = m.users.first(where: { !$0.user.activeUser }) { + try changeActiveUser_(newActive.user.userId) + try deleteUser(u.userId) + } + } else { + try deleteUser(u.userId) + } } catch let error { let a = getErrorAlert(error, "Error deleting user profile") alert = .error(title: a.title, error: a.message) } + + func deleteUser(_ userId: Int64) throws { + try apiDeleteUser(userId, delSMPQueues) + m.users.remove(at: index) + } } @ViewBuilder private func userView(_ user: User) -> some View { Button { - if !user.activeUser { - changeActiveUser(user.userId) - } + changeActiveUser(user.userId) } label: { HStack { - ProfileImage(imageStr: user.image) + ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill)) .frame(width: 44, height: 44) .padding(.vertical, 4) .padding(.trailing, 12) @@ -91,8 +117,9 @@ struct UserProfilesView: View { .foregroundColor(user.activeUser ? .primary : .clear) } } + .disabled(user.activeUser) .foregroundColor(.primary) - .deleteDisabled(user.activeUser) + .deleteDisabled(m.users.count <= 1) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 83ec0a766e..23c2958c17 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -102,7 +102,7 @@ public enum ChatCommand { case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)" case .listUsers: return "/users" case let .apiSetActiveUser(userId): return "/_user \(userId)" - case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) delSMPQueues=\(onOff(delSMPQueues))" + case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" From 2679bc2e944c2ae9ac5df2501c9d9c06fc597d98 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 24 Jan 2023 19:24:46 +0000 Subject: [PATCH 53/59] ios: enable swipe to go back from chat to list (#1824) * ios: Testing workaround of a crash * another try * complete * added file * enable swipe to go back from ChatView * Revert "enable swipe to go back from ChatView" This reverts commit 22de79505c19fd89c593610d04067ff0c8e1e622. * ios: enable swipe to go back from ChatView * remove title change * remove unused * remove unused variable Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 1 - apps/ios/Shared/Views/Chat/ChatView.swift | 28 ++++++++----------- .../Shared/Views/ChatList/ChatListView.swift | 2 ++ 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 30249c7390..b171ddf25b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -946,7 +946,6 @@ func startChat() throws { } func changeActiveUser(_ userId: Int64) { - let m = ChatModel.shared do { try changeActiveUser_(userId) } catch let error { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b656950eb8..9da2c6a7d6 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -63,7 +63,6 @@ struct ChatView: View { .padding(.top, 1) .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) .onAppear { if chat.chatStats.unreadChat { Task { @@ -71,25 +70,20 @@ struct ChatView: View { } } } - .onChange(of: chatModel.chatId) { _ in - if chatModel.chatId == nil { dismiss() } - } - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - chatModel.chatId = nil - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - if chatModel.chatId == nil { - chatModel.reversedChatItems = [] - } - } - } label: { - HStack(spacing: 0) { - Image(systemName: "chevron.backward") - Text("Chats") + .onChange(of: chatModel.chatId) { _ in + if chatModel.chatId == nil { dismiss() } + } + .onDisappear { + if chatModel.chatId == cInfo.id { + chatModel.chatId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + if chatModel.chatId == nil { + chatModel.reversedChatItems = [] } } } + } + .toolbar { ToolbarItem(placement: .principal) { if case let .direct(contact) = cInfo { Button { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index eaefaa0475..d23418101f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -102,6 +102,8 @@ struct ChatListView: View { } Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) } + } else { + Text("Your chats").font(.headline) } } ToolbarItem(placement: .navigationBarTrailing) { From e27013071b9d1a2c7486fd3d8302ddc1f650869d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 Jan 2023 08:35:25 +0000 Subject: [PATCH 54/59] ios: preserve message draft in the latest chat only (#1834) * ios: preserve message draft in the latest chat only * show attachment icon and formatting in draft * button to remove message text * show icon for active draft, refactor * add voice message duration to draft --- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/Views/Chat/ChatView.swift | 17 +++- .../Chat/ComposeMessage/ComposeView.swift | 4 + .../Chat/ComposeMessage/SendMessageView.swift | 85 ++++++++++++------- .../Views/ChatList/ChatPreviewView.swift | 70 ++++++++++----- 5 files changed, 123 insertions(+), 55 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 31ee686413..1ca049a84a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -57,6 +57,8 @@ final class ChatModel: ObservableObject { @Published var connReqInv: String? // audio recording and playback @Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches + @Published var draft: ComposeState? + @Published var draftChatId: String? var callWebView: WKWebView? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 9da2c6a7d6..96d0874f0e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -64,6 +64,9 @@ struct ChatView: View { .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) .onAppear { + if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { + composeState = draft + } if chat.chatStats.unreadChat { Task { await markChatUnread(chat, unreadChat: false) @@ -73,9 +76,21 @@ struct ChatView: View { .onChange(of: chatModel.chatId) { _ in if chatModel.chatId == nil { dismiss() } } + .onChange(of: "\(composeState.empty) \(composeState.noPreview) \(composeState.message)") { _ in + if !composeState.empty { + chatModel.draft = composeState + chatModel.draftChatId = chat.id + } else if chatModel.draftChatId == chat.id { + chatModel.draft = nil + chatModel.draftChatId = nil + } + } .onDisappear { if chatModel.chatId == cInfo.id { chatModel.chatId = nil + if chatModel.draftChatId == cInfo.id { + chatModel.draft = composeState + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { chatModel.reversedChatItems = [] @@ -175,7 +190,7 @@ struct ChatView: View { } } } - + private func searchToolbar() -> some View { HStack { HStack { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5ca4627917..9d4b714164 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -161,6 +161,10 @@ struct ComposeState { default: return true } } + + var empty: Bool { + message == "" && liveMessage == nil && noPreview + } } func chatItemPreview(chatItem: ChatItem) -> ComposePreview { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index b5f8e9b5fd..8088f82fa3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -76,45 +76,20 @@ struct SendMessageView: View { } } - if (composeState.inProgress) { + if composeState.inProgress { ProgressView() .scaleEffect(1.4) .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - let vmrs = composeState.voiceMessageRecordingState - if showVoiceMessageButton - && composeState.message.isEmpty - && !composeState.editing - && composeState.liveMessage == nil - && ((composeState.noPreview && vmrs == .noRecording) - || (vmrs == .recording && holdingVMR)) { - HStack { - if voiceMessageAllowed { - RecordVoiceMessageButton( - startVoiceMessageRecording: startVoiceMessageRecording, - finishVoiceMessageRecording: finishVoiceMessageRecording, - holdingVMR: $holdingVMR, - disabled: composeState.disabled - ) - } else { - voiceMessageNotAllowedButton() - } - if let send = sendLiveMessage, - let update = updateLiveMessage, - case .noContextItem = composeState.contextItem { - startLiveMessageButton(send: send, update: update) - } + VStack(alignment: .trailing) { + if teHeight > 100 { + deleteTextButton() + Spacer() } - } else if vmrs == .recording && !holdingVMR { - finishVoiceMessageRecordingButton() - } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { - cancelLiveMessageButton { - cancelLiveMessage?() - } - } else { - sendMessageButton() + composeActionButtons() } + .frame(height: teHeight, alignment: .bottom) } } @@ -125,6 +100,52 @@ struct SendMessageView: View { .padding(.vertical, 8) } + @ViewBuilder private func composeActionButtons() -> some View { + let vmrs = composeState.voiceMessageRecordingState + if showVoiceMessageButton + && composeState.message.isEmpty + && !composeState.editing + && composeState.liveMessage == nil + && ((composeState.noPreview && vmrs == .noRecording) + || (vmrs == .recording && holdingVMR)) { + HStack { + if voiceMessageAllowed { + RecordVoiceMessageButton( + startVoiceMessageRecording: startVoiceMessageRecording, + finishVoiceMessageRecording: finishVoiceMessageRecording, + holdingVMR: $holdingVMR, + disabled: composeState.disabled + ) + } else { + voiceMessageNotAllowedButton() + } + if let send = sendLiveMessage, + let update = updateLiveMessage, + case .noContextItem = composeState.contextItem { + startLiveMessageButton(send: send, update: update) + } + } + } else if vmrs == .recording && !holdingVMR { + finishVoiceMessageRecordingButton() + } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { + cancelLiveMessageButton { + cancelLiveMessage?() + } + } else { + sendMessageButton() + } + } + + private func deleteTextButton() -> some View { + Button { + composeState.message = "" + } label: { + Image(systemName: "multiply.circle.fill") + } + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding([.top, .trailing], 4) + } + @ViewBuilder private func sendMessageButton() -> some View { let v = Button(action: sendMessage) { Image(systemName: composeState.editing || composeState.liveMessage != nil diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index e85eeaa345..f006ebf13c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -106,31 +106,57 @@ struct ChatPreviewView: View { .kerning(-2) } + private func chatPreviewLayout(_ text: Text) -> some View { + ZStack(alignment: .topTrailing) { + text + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, 8) + .padding(.trailing, 36) + let s = chat.chatStats + if s.unreadCount > 0 || s.unreadChat { + unreadCountText(s.unreadCount) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary) + .cornerRadius(10) + } else if !chat.chatInfo.ntfsEnabled { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.secondary) + } + } + } + + private func messageDraft(_ draft: ComposeState) -> Text { + let msg = draft.message + return image("rectangle.and.pencil.and.ellipsis", color: .accentColor) + + attachment() + + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true) + + func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { + Text(Image(systemName: s)).foregroundColor(color) + Text(" ") + } + + func attachment() -> Text { + switch draft.preview { + case .filePreview: return image("doc.fill") + case .imagePreviews: return image("photo") + case let .voicePreview(_, duration): return image("play.fill") + Text(voiceMessageTime(TimeInterval(duration))) + default: return Text("") + } + } + } + @ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View { - if let cItem = cItem { + if chatModel.draftChatId == chat.id, let draft = chatModel.draft { + chatPreviewLayout(messageDraft(draft)) + } else if let cItem = cItem { let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") let itemFormattedText = !cItem.meta.itemDeleted ? cItem.formattedText : nil - ZStack(alignment: .topTrailing) { - (itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.leading, 8) - .padding(.trailing, 36) - let s = chat.chatStats - if s.unreadCount > 0 || s.unreadChat { - unreadCountText(s.unreadCount) - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 4) - .frame(minWidth: 18, minHeight: 18) - .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary) - .cornerRadius(10) - } else if !chat.chatInfo.ntfsEnabled { - Image(systemName: "speaker.slash.fill") - .foregroundColor(.secondary) - } - } + chatPreviewLayout(itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true)) } else { switch (chat.chatInfo) { case let .direct(contact): From 25e4a1e86df170f4e020aa368f2e3b537e56d647 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 Jan 2023 10:18:02 +0000 Subject: [PATCH 55/59] ios: fix layout of voice message (#1836) * ios: fix layout of voice message * fix layout * prevent translations --- .../Views/Chat/ChatItem/CIVoiceView.swift | 56 +++++++++++-------- .../ComposeMessage/ComposeVoiceView.swift | 6 +- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index fef3c59375..18e0f3ff75 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -17,25 +17,28 @@ struct CIVoiceView: View { @State var playbackTime: TimeInterval? var body: some View { - VStack ( - alignment: chatItem.chatDir.sent ? .trailing : .leading, - spacing: 6 - ) { - HStack { - if chatItem.chatDir.sent { - playerTime() - .frame(width: 50, alignment: .leading) - player() - } else { - player() - playerTime() - .frame(width: 50, alignment: .leading) + Group { + if chatItem.chatDir.sent { + VStack (alignment: .trailing, spacing: 6) { + HStack { + playerTime() + player() + } + .frame(alignment: .trailing) + metaView().padding(.trailing, 10) + } + } else { + VStack (alignment: .leading, spacing: 6) { + HStack { + player() + playerTime() + } + .frame(alignment: .leading) + metaView().padding(.leading, -6) } } - CIMetaView(chatItem: chatItem) - .padding(.leading, chatItem.chatDir.sent ? 0 : 12) - .padding(.trailing, chatItem.chatDir.sent ? 12 : 0) } + .padding([.top, .horizontal], 4) .padding(.bottom, 8) } @@ -58,6 +61,10 @@ struct CIVoiceView: View { ) .foregroundColor(.secondary) } + + private func metaView() -> some View { + CIMetaView(chatItem: chatItem) + } } struct VoiceMessagePlayerTime: View { @@ -66,13 +73,16 @@ struct VoiceMessagePlayerTime: View { @Binding var playbackTime: TimeInterval? var body: some View { - switch playbackState { - case .noPlayback: - Text(voiceMessageTime(recordingTime)) - case .playing: - Text(voiceMessageTime_(playbackTime)) - case .paused: - Text(voiceMessageTime_(playbackTime)) + ZStack(alignment: .leading) { + Text(String("66:66")).foregroundColor(.clear) + switch playbackState { + case .noPlayback: + Text(voiceMessageTime(recordingTime)) + case .playing: + Text(voiceMessageTime_(playbackTime)) + case .paused: + Text(voiceMessageTime_(playbackTime)) + } } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift index 77310240b4..ee2fa29fd3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift @@ -16,13 +16,11 @@ enum VoiceMessagePlaybackState { } func voiceMessageTime(_ time: TimeInterval) -> String { - let min = Int(time / 60) - let sec = Int(time.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", min, sec) + durationText(Int(time)) } func voiceMessageTime_(_ time: TimeInterval?) -> String { - return voiceMessageTime(time ?? TimeInterval(0)) + durationText(Int(time ?? 0)) } struct ComposeVoiceView: View { From c01c629f73def5f58660161671b57d1920f2821d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 25 Jan 2023 11:48:54 +0000 Subject: [PATCH 56/59] mobile: use GMT timezone in filenames to prevent leaking user location (#1837) * ios: use GMT timezone in filenames to prevent leaking user location * android: use GMT timestamp in generated file names --- .../simplex/app/views/helpers/RecAndPlay.kt | 4 ++-- .../chat/simplex/app/views/helpers/Util.kt | 24 ++++++++++++------- apps/ios/Shared/Model/ImageUtils.swift | 4 ++-- .../Chat/ComposeMessage/ComposeView.swift | 16 +++++++------ 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 5b8a198983..f0d17d5803 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -50,8 +50,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { rec.setAudioEncodingBitRate(16000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) rec.setMaxFileSize(recordedBytesLimit) - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a"))) + val fileToSave = generateNewFileName(SimplexApp.context, "voice", "m4a") + val path = getAppFilePath(SimplexApp.context, fileToSave) filePath = path rec.setOutputFile(path) rec.prepare() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index a620b6725e..27e2f5220f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -331,8 +331,7 @@ fun saveImage(context: Context, image: Bitmap): String? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") + val fileToSave = generateNewFileName(context, "IMG", ext) val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) dataResized.writeTo(output) @@ -355,8 +354,7 @@ fun saveAnimImage(context: Context, uri: Uri): String? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") + val fileToSave = generateNewFileName(context, "IMG", ext) val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) context.contentResolver.openInputStream(uri)!!.use { input -> @@ -390,15 +388,23 @@ fun saveFileFromUri(context: Context, uri: Uri): String? { } } +fun generateNewFileName(context: Context, prefix: String, ext: String): String { + val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("GMT") + val timestamp = sdf.format(Date()) + return uniqueCombine(context, "${prefix}_$timestamp.$ext") +} + fun uniqueCombine(context: Context, fileName: String): String { - fun tryCombine(fileName: String, n: Int): String { - val name = File(fileName).nameWithoutExtension - val ext = File(fileName).extension + val orig = File(fileName) + val name = orig.nameWithoutExtension + val ext = orig.extension + fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f + return if (File(getAppFilePath(context, f)).exists()) tryCombine(n + 1) else f } - return tryCombine(fileName, 0) + return tryCombine(0) } fun formatBytes(bytes: Long): String { diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 79382e4d6d..92be827dfc 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -165,8 +165,7 @@ func saveFileFromURL(_ url: URL) -> String? { } func generateNewFileName(_ prefix: String, _ ext: String) -> String { - let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") - return fileName + uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") } private func uniqueCombine(_ fileName: String) -> String { @@ -191,6 +190,7 @@ private func getTimestamp() -> String { df = DateFormatter() df.dateFormat = "yyyyMMdd_HHmmss" df.locale = Locale(identifier: "US") + df.timeZone = TimeZone(secondsFromGMT: 0) tsFormatter = df } return df.string(from: Date()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 9d4b714164..0136159ed4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -553,14 +553,16 @@ struct ComposeView: View { sent = await send(checkLinkPreview(), quoted: quoted, live: live) case let .imagePreviews(imagePreviews: images): let last = min(chosenImages.count, images.count) - 1 - for i in 0..= 0 { + for i in 0.. Date: Wed, 25 Jan 2023 19:29:09 +0400 Subject: [PATCH 57/59] core: add multiple users tests for subscription, chat item expiration, timed messages (#1840) --- src/Simplex/Chat.hs | 19 +- src/Simplex/Chat/Controller.hs | 3 +- tests/ChatTests.hs | 446 ++++++++++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f338fb26ff..605306663a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -108,7 +108,8 @@ defaultChatConfig = subscriptionConcurrency = 16, subscriptionEvents = False, hostEvents = False, - testView = False + testView = False, + ciExpirationInterval = 1800 * 1000000 -- 30 minutes } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -1565,13 +1566,15 @@ startExpireCIThread user@User {userId} = do atomically $ TM.insert userId a expireThreads _ -> pure () where - runExpireCIs = forever $ do - flip catchError (toView . CRChatError (Just user)) $ do - expireFlags <- asks expireCIFlags - atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry - ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False - threadDelay $ 1800 * 1000000 -- 30 minutes + runExpireCIs = do + interval <- asks $ ciExpirationInterval . config + forever $ do + flip catchError (toView . CRChatError (Just user)) $ do + expireFlags <- asks expireCIFlags + atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry + ttl <- withStore' (`getChatItemTTL` user) + forM_ ttl $ \t -> expireChatItems user t False + threadDelay interval setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m () setExpireCIFlag User {userId} b = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c1afd0e781..c48de9332b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -109,7 +109,8 @@ data ChatConfig = ChatConfig subscriptionEvents :: Bool, hostEvents :: Bool, logLevel :: ChatLogLevel, - testView :: Bool + testView :: Bool, + ciExpirationInterval :: Int -- microseconds } data DefaultAgentServers = DefaultAgentServers diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 04b76e7440..5fc0ed9596 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -177,10 +177,16 @@ chatTests = do it "mute/unmute group" testMuteGroup describe "multiple users" $ do it "create second user" testCreateSecondUser + it "multiple users subscribe and receive messages after restart" testUsersSubscribeAfterRestart it "both users have contact link" testMultipleUserAddresses it "create user with default servers" testCreateUserDefaultServers it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser + it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL + it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration + it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser + it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser + it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL describe "queue rotation" $ do @@ -4473,6 +4479,30 @@ testCreateSecondUser = alice ##> "/_user 2" showActiveUser alice "alisa" +testUsersSubscribeAfterRestart :: IO () +testUsersSubscribeAfterRestart = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + alice <##> bob + + withTestChat "alice" $ \alice -> do + -- second user is active + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- second user receives message + alice <##> bob + + -- first user receives message + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + testMultipleUserAddresses :: IO () testMultipleUserAddresses = testChat3 aliceProfile bobProfile cathProfile $ @@ -4581,8 +4611,8 @@ testCreateUserSameServers = testDeleteUser :: IO () testDeleteUser = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do connectUsers alice bob -- cannot delete active user @@ -4596,6 +4626,7 @@ testDeleteUser = showActiveUser alice "alisa" connectUsers alice cath + alice <##> cath alice ##> "/users" alice <## "alice (Alice)" @@ -4626,6 +4657,9 @@ testDeleteUser = alice ##> "/create user alisa2" showActiveUser alice "alisa2" + connectUsers alice dan + alice <##> dan + alice ##> "/users" alice <## "alisa" alice <## "alisa2 (active)" @@ -4640,6 +4674,414 @@ testDeleteUser = cath <## "[alisa, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" (alice dan + +testUsersDifferentCIExpirationTTL :: IO () +testUsersDifferentCIExpirationTTL = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- first user messages + connectUsers alice bob + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- set ttl for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_ttl 1 1", id, "ok") + + -- set ttl for second user + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 3", id, "ok") + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2"), (1, "alice 3"), (0, "alice 4")]) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 3 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages both before and after setting chat item ttl are deleted + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testUsersRestartCIExpiration :: IO () +testUsersRestartCIExpiration = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- set ttl for first user + alice #$> ("/_ttl 1 1", id, "ok") + connectUsers alice bob + + -- create second user and set ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 3", id, "ok") + connectUsers alice bob + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- first user will be active on restart + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2"), (1, "alice 3"), (0, "alice 4")]) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 3 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages both before and after restart are deleted + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testEnableCIExpirationOnlyForOneUser :: IO () +testEnableCIExpirationOnlyForOneUser = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- first user messages + connectUsers alice bob + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2")]) + + -- second user messages before first user sets ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- set ttl for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_ttl 1 1", id, "ok") + + -- second user messages after first user sets ttl + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages are deleted for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- messages are not deleted for second user + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- messages are not deleted for second user after restart + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + alice #> "@bob alisa 5" + bob <# "alisa> alisa 5" + bob #> "@alisa alisa 6" + alice <# "bob> alisa 6" + + threadDelay 2000000 + + -- new messages are not deleted for second user + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4"), (1, "alisa 5"), (0, "alisa 6")]) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testDisableCIExpirationOnlyForOneUser :: IO () +testDisableCIExpirationOnlyForOneUser = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- set ttl for first user + alice #$> ("/_ttl 1 1", id, "ok") + connectUsers alice bob + + -- create second user and set ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 1", id, "ok") + connectUsers alice bob + + -- first user disables expiration + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl none", id, "ok") + alice #$> ("/ttl", id, "old messages are not being deleted") + + -- second user still has ttl configured + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 2000000 + + -- second user messages are deleted + alice #$> ("/_get chat @4 count=100", chat, []) + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- second user still has ttl configured after restart + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- second user messages are deleted + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testUsersTimedMessages :: IO () +testUsersTimedMessages = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + configureTimedMessages alice bob "2" "1" + + -- create second user and configure timed messages for contact + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + configureTimedMessages alice bob "4" "2" + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- messages are deleted after ttl + threadDelay 500000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, [(1, "alice 1"), (0, "alice 2")]) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 1000000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 1000000 + + alice ##> "/user" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, []) + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + withTestChat "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, [(1, "alice 3"), (0, "alice 4")]) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + -- messages are deleted after restart + threadDelay 1500000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 1000000 + + alice ##> "/user" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, []) + where + configureTimedMessages alice bob bobId ttl = do + aliceName <- userName alice + alice ##> ("/_set prefs @" <> bobId <> " {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": " <> ttl <> "}}") + alice <## "you updated preferences for bob:" + alice <## ("Disappearing messages: off (you allow: yes (" <> ttl <> " sec), contact allows: no)") + bob <## (aliceName <> " updated preferences for you:") + bob <## ("Disappearing messages: off (you allow: no, contact allows: yes (" <> ttl <> " sec))") + bob ##> ("/set disappear @" <> aliceName <> " yes") + bob <## ("you updated preferences for " <> aliceName <> ":") + bob <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") + alice <## "bob updated preferences for you:" + alice <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") + alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items + testSetChatItemTTL :: IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ From db3fc4ee7b7fa6bd12c79e8d9efc494d5103365d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Jan 2023 15:30:30 +0000 Subject: [PATCH 58/59] android: multiuser-userpicker (#1839) * android: multiuser-userpicker * sizes of buttons * update paddings * change names Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 6 +- .../app/views/chatlist/ChatListView.kt | 72 ++++++-- .../simplex/app/views/chatlist/UserPicker.kt | 170 ++++++++++++++++++ .../chat/simplex/app/views/helpers/Enums.kt | 5 +- .../simplex/app/views/newchat/NewChatSheet.kt | 6 +- 5 files changed, 239 insertions(+), 20 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index a36afc3d65..0518ac819e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -266,8 +266,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (chatModel.chatRunning.value == true) return apiSetNetworkConfig(getNetCfg()) val justStarted = apiStartChat() + val users = listUsers() chatModel.users.clear() - chatModel.users.addAll(listUsers()) + chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true @@ -293,8 +294,9 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun changeActiveUser(toUserId: Long) { try { chatModel.currentUser.value = apiSetActiveUser(toUserId) + val users = listUsers() chatModel.users.clear() - chatModel.users.addAll(listUsers()) + chatModel.users.addAll(users) getUserChatData() } catch (e: Exception) { Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 2131c63f93..b2bb7733e3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -13,16 +14,15 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* +import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.connectIfOpenedViaUri import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -37,13 +37,14 @@ import kotlinx.coroutines.launch @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) } + val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val showNewChatSheet = { - newChatSheetState.value = NewChatSheetState.VISIBLE + newChatSheetState.value = AnimatedViewState.VISIBLE } val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> - if (animated) newChatSheetState.value = NewChatSheetState.HIDING - else newChatSheetState.value = NewChatSheetState.GONE + if (animated) newChatSheetState.value = AnimatedViewState.HIDING + else newChatSheetState.value = AnimatedViewState.GONE } LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { @@ -63,8 +64,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } var searchInList by rememberSaveable { mutableStateOf("") } val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() Scaffold( - topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } }, + topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA) }, floatingActionButton = { @@ -111,6 +113,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (searchInList.isEmpty()) { NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + } } @Composable @@ -156,7 +161,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { +private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { var showSearch by rememberSaveable { mutableStateOf(false) } val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } if (showSearch) { @@ -189,10 +194,23 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop val scope = rememberCoroutineScope() DefaultTopAppBar( navigationButton = { - if (showSearch) + if (showSearch) { NavigationButtonBack(hideSearchOnBack) - else + } else if (chatModel.users.isEmpty()) { NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + } else { + val users by remember { derivedStateOf { chatModel.users.toList() } } + val allRead = users + .filter { !it.user.activeUser } + .all { u -> u.unreadCount == 0 } + UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { + if (users.size == 1) { + scope.launch { drawerState.open() } + } else { + userPickerState.value = AnimatedViewState.VISIBLE + } + } + } }, title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -219,6 +237,36 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop Divider(Modifier.padding(top = AppBarHeight)) } +@Composable +private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 36.dp + ) + if (!allRead) { + unreadBadge() + } + } + } +} + +@Composable +private fun BoxScope.unreadBadge(text: String? = "") { + Text( + text ?: "", + color = MaterialTheme.colors.onPrimary, + fontSize = 6.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp) + .align(Alignment.TopEnd) + ) +} + private var lazyListState = 0 to 0 @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt new file mode 100644 index 0000000000..f90a47bc21 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt @@ -0,0 +1,170 @@ +package chat.simplex.app.views.chatlist + +import SectionItemViewSpaceBetween +import android.util.Log +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.UserInfo +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow, openSettings: () -> Unit) { + val scope = rememberCoroutineScope() + var newChat by remember { mutableStateOf(userPickerState.value) } + val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } } + val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } + LaunchedEffect(Unit) { + launch { + userPickerState.collect { + newChat = it + launch { + animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) + if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE + } + } + } + } + LaunchedEffect(Unit) { + snapshotFlow { newChat.isVisible() } + .distinctUntilChanged() + .filter { it } + .collect { + try { + val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + var same = users.size == updatedUsers.size + if (same) { + for (i in 0 until minOf(users.size, updatedUsers.size)) { + val prev = updatedUsers[i].user + val next = users[i].user + if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) { + same = false + break + } + } + } + if (!same) { + chatModel.users.clear() + chatModel.users.addAll(updatedUsers) + } + } catch (e: Exception) { + Log.e(TAG, "Error updating users ${e.stackTraceToString()}") + } + } + } + val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } + val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + Box(Modifier + .fillMaxSize() + .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING }) + .padding(bottom = 10.dp, top = 10.dp) + .graphicsLayer { + alpha = animatedFloat.value + translationY = (animatedFloat.value - 1) * xOffset + } + ) { + Column( + Modifier + .widthIn(min = 220.dp) + .width(IntrinsicSize.Min) + .height(IntrinsicSize.Min) + .shadow(8.dp, MaterialTheme.shapes.medium, clip = false) + .background(MaterialTheme.colors.background, MaterialTheme.shapes.medium) + ) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + users.forEachIndexed { i, u -> + UserProfilePickerItem(u) { + userPickerState.value = AnimatedViewState.HIDING + scope.launch { + if (!u.user.activeUser) { + chatModel.controller.changeActiveUser(u.user.userId) + } + } + } + if (i != users.lastIndex) { + Divider(Modifier.requiredHeight(1.dp)) + } + } + } + Divider() + SettingsPickerItem { + openSettings() + userPickerState.value = AnimatedViewState.GONE + } + } + } +} + +@Composable +private fun UserProfilePickerItem(u: UserInfo, onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = 8.dp, end = 8.dp)) { + Row( + Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f) + .padding(top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage( + image = u.user.image, + size = 60.dp + ) + Text( + u.user.chatViewName, + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + ) + } + if (u.user.activeUser) { + Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.primary) + } else if (u.unreadCount > 0) { + Text( + unreadCountStr(u.unreadCount), + color = MaterialTheme.colors.onPrimary, + fontSize = 11.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .sizeIn(minWidth = 20.dp, minHeight = 20.dp) + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } +} + +@Composable +private fun SettingsPickerItem(onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, minHeight = 60.dp) { + val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current) + Text( + text, + color = MaterialTheme.colors.onBackground, + ) + Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index a7ed87d761..d6729ac68d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.helpers -import android.graphics.Bitmap import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow @@ -11,7 +10,7 @@ sealed class SharedContent { data class File(val text: String, val uri: Uri): SharedContent() } -enum class NewChatSheetState { +enum class AnimatedViewState { VISIBLE, HIDING, GONE; fun isVisible(): Boolean { return this == VISIBLE @@ -23,7 +22,7 @@ enum class NewChatSheetState { return this == GONE } companion object { - fun saver(): Saver, *> = Saver( + fun saver(): Saver, *> = Saver( save = { it.value.toString() }, restore = { MutableStateFlow(valueOf(it)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index a1a262a933..02ebdf6b89 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -63,7 +63,7 @@ private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons. @Composable private fun NewChatSheetLayout( - newChatSheetState: StateFlow, + newChatSheetState: StateFlow, stopped: Boolean, addContact: () -> Unit, connectViaLink: () -> Unit, @@ -216,7 +216,7 @@ fun ActionButton( private fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( - MutableStateFlow(NewChatSheetState.VISIBLE), + MutableStateFlow(AnimatedViewState.VISIBLE), stopped = false, addContact = {}, connectViaLink = {}, From 1c47bfbf4426a5f72eb05e804c54dd1191ac60f0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Jan 2023 20:43:02 +0000 Subject: [PATCH 59/59] android: better user picker layout (#1842) * android: multiuser-fixes * update paddings * progressIndicator Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 1 + .../app/views/chatlist/ChatListView.kt | 2 +- .../simplex/app/views/chatlist/UserPicker.kt | 46 +++++++++++++++---- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 2778a8012c..d8411ecc3d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -255,6 +255,7 @@ class ChatModel(val controller: ChatController) { if (indexInUsers != -1) { users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) } + currentUser.value = updated } suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index b2bb7733e3..d3d3d23534 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -243,7 +243,7 @@ private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: Box { ProfileImage( image = image, - size = 36.dp + size = 37.dp ) if (!allRead) { unreadBadge() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt index f90a47bc21..605dc117d7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt @@ -25,8 +25,9 @@ import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.ChatModel import chat.simplex.app.model.UserInfo -import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -37,6 +38,15 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow UserProfilePickerItem(u) { userPickerState.value = AnimatedViewState.HIDING - scope.launch { - if (!u.user.activeUser) { + if (!u.user.activeUser) { + chatModel.chats.clear() + scope.launch { + val job = launch { + delay(500) + progressIndicator = true + } chatModel.controller.changeActiveUser(u.user.userId) + job.cancel() + progressIndicator = false } } } @@ -121,16 +138,16 @@ fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow Unit) { - SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = 8.dp, end = 8.dp)) { + SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) { Row( Modifier .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f) - .padding(top = 8.dp, bottom = 8.dp), + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { ProfileImage( image = u.user.image, - size = 60.dp + size = 54.dp ) Text( u.user.chatViewName, @@ -153,13 +170,15 @@ private fun UserProfilePickerItem(u: UserInfo, onClick: () -> Unit) { textAlign = TextAlign.Center, maxLines = 1 ) + } else { + Box(Modifier.size(20.dp)) } } } @Composable private fun SettingsPickerItem(onClick: () -> Unit) { - SectionItemViewSpaceBetween(onClick, minHeight = 60.dp) { + SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) { val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current) Text( text, @@ -167,4 +186,15 @@ private fun SettingsPickerItem(onClick: () -> Unit) { ) Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) } -} \ No newline at end of file +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) +}