core, iOS: hidden and muted user profiles (#2025)

* core, ios: profile privacy design

* migration

* core: user profile privacy

* update nix dependencies

* update simplexmq

* import stateTVar

* update core library

* update UI

* update hide/show user profile

* update API, UI, fix test

* update api, UI, test

* update api call

* fix api

* update UI for hidden profiles

* filter notifications on hidden/muted profiles when inactive, alerts

* updates

* update schema, test, icon
This commit is contained in:
Evgeny Poberezkin
2023-03-22 15:58:01 +00:00
committed by GitHub
parent bcdf502ce6
commit 06a0dbd0f2
29 changed files with 1067 additions and 228 deletions

View File

@@ -41,6 +41,7 @@ import qualified Data.Map.Strict as M
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
import Data.Time.Clock.System (SystemTime, systemToUTCTime)
@@ -195,7 +196,7 @@ activeAgentServers ChatConfig {defaultServers} srvSel =
. map (\ServerCfg {server} -> server)
. filter (\ServerCfg {enabled} -> enabled)
startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => Bool -> Bool -> m (Async ())
startChatController :: forall m. ChatMonad' m => Bool -> Bool -> m (Async ())
startChatController subConns enableExpireCIs = do
asks smpAgent >>= resumeAgentClient
users <- fromRight [] <$> runExceptT (withStore' getUsers)
@@ -227,7 +228,7 @@ startChatController subConns enableExpireCIs = do
startExpireCIThread user
setExpireCIFlag user True
subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m ()
subscribeUsers :: forall m. ChatMonad' m => [User] -> m ()
subscribeUsers users = do
let (us, us') = partition activeUser users
subscribe us
@@ -236,7 +237,7 @@ subscribeUsers users = do
subscribe :: [User] -> m ()
subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections
restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
restoreCalls :: ChatMonad' m => m ()
restoreCalls = do
savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db)
let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls
@@ -260,7 +261,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
mapM_ hClose fs
atomically $ writeTVar files M.empty
execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse
execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse
execChatCommand s = do
u <- readTVarIO =<< asks currentUser
case parseChatCommand s of
@@ -308,27 +309,61 @@ processChatCommand = \case
DefaultAgentServers {smp} <- asks $ defaultServers . config
pure (smp, [])
ListUsers -> CRUsersList <$> withStore' getUsersInfo
APISetActiveUser userId -> do
u <- asks currentUser
user <- withStore $ \db -> getSetActiveUser db userId
APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
validateUserPassword user user' viewPwd_
withStore' $ \db -> setActiveUser db userId'
setActive ActiveNone
atomically . writeTVar u $ Just user
pure $ CRActiveUser user
SetActiveUser uName -> withUserName uName APISetActiveUser
APIDeleteUser userId delSMPQueues -> 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 $ \a -> deleteUser a (aUserId user) delSMPQueues
withStore' (`deleteUserRecord` user)
setActive ActiveNone
ok_
DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues
let user'' = user' {activeUser = True}
asks currentUser >>= atomically . (`writeTVar` Just user'')
pure $ CRActiveUser user''
SetActiveUser uName viewPwd_ -> do
tryError (withStore (`getUserIdByName` uName)) >>= \case
Left _ -> throwChatError CEUserUnknown
Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_
APIHideUser userId' (UserPwd viewPwd) -> withUser $ \_ -> do
user' <- privateGetUser userId'
case viewPwdHash user' of
Just _ -> throwChatError $ CEUserAlreadyHidden userId'
_ -> do
when (T.null viewPwd) $ throwChatError $ CEEmptyUserPassword userId'
users <- withStore' getUsers
unless (length (filter (isNothing . viewPwdHash) users) > 1) $ throwChatError $ CECantHideLastUser userId'
viewPwdHash' <- hashPassword
setUserPrivacy user' {viewPwdHash = viewPwdHash', showNtfs = False}
where
hashPassword = do
salt <- drgRandomBytes 16
let hash = B64UrlByteString $ C.sha512Hash $ encodeUtf8 viewPwd <> salt
pure $ Just UserPwdHash {hash, salt = B64UrlByteString salt}
APIUnhideUser userId' viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
case viewPwdHash user' of
Nothing -> throwChatError $ CEUserNotHidden userId'
_ -> do
validateUserPassword user user' viewPwd_
setUserPrivacy user' {viewPwdHash = Nothing, showNtfs = True}
APIMuteUser userId' viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
validateUserPassword user user' viewPwd_
setUserPrivacy user' {showNtfs = False}
APIUnmuteUser userId' viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
case viewPwdHash user' of
Just _ -> throwChatError $ CECantUnmuteHiddenUser userId'
_ -> do
validateUserPassword user user' viewPwd_
setUserPrivacy user' {showNtfs = True}
HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand $ APIHideUser userId viewPwd
UnhideUser -> withUser $ \User {userId} -> processChatCommand $ APIUnhideUser userId Nothing
MuteUser -> withUser $ \User {userId} -> processChatCommand $ APIMuteUser userId Nothing
UnmuteUser -> withUser $ \User {userId} -> processChatCommand $ APIUnmuteUser userId Nothing
APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do
user' <- privateGetUser userId'
validateUserPassword user user' viewPwd_
checkDeleteChatUser user'
withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues
DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_
StartChat subConns enableExpireCIs -> withUser' $ \_ ->
asks agentAsync >>= readTVarIO >>= \case
Just _ -> pure CRChatRunning
@@ -708,7 +743,7 @@ processChatCommand = \case
assertDirectAllowed user MDSnd ct XCallInv_
calls <- asks currentCalls
withChatLock "sendCallInvitation" $ do
callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
callId <- CallId <$> drgRandomBytes 16
dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
@@ -1210,7 +1245,7 @@ processChatCommand = \case
gInfo <- withStore $ \db -> getGroupInfo db user groupId
assertUserGroupRole gInfo GRAdmin
when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole
groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
let crClientData = encodeJSON $ CRDataGroup groupLinkId
(connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData
withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole
@@ -1426,7 +1461,7 @@ processChatCommand = \case
withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case
(Just contact, _) -> pure $ CRContactAlreadyExists user contact
(_, xContactId_) -> procCmd $ do
let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16))
let randomXContactId = XContactId <$> drgRandomBytes 16
xContactId <- maybe randomXContactId pure xContactId_
-- [incognito] generate profile to send
-- if user makes a contact request using main profile, then turns on incognito mode and repeats the request,
@@ -1584,6 +1619,42 @@ processChatCommand = \case
<$> if live
then pure Nothing
else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime
drgRandomBytes :: Int -> m ByteString
drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n)
privateGetUser :: UserId -> m User
privateGetUser userId =
tryError (withStore (`getUser` userId)) >>= \case
Left _ -> throwChatError CEUserUnknown
Right user -> pure user
validateUserPassword :: User -> User -> Maybe UserPwd -> m ()
validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ =
forM_ viewPwdHash $ \pwdHash ->
let pwdOk = case viewPwd_ of
Nothing -> userId == userId'
Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash
in unless pwdOk $ throwChatError CEUserUnknown
validPassword :: Text -> UserPwdHash -> Bool
validPassword pwd UserPwdHash {hash = B64UrlByteString hash, salt = B64UrlByteString salt} =
hash == C.sha512Hash (encodeUtf8 pwd <> salt)
setUserPrivacy :: User -> m ChatResponse
setUserPrivacy user = do
asks currentUser >>= atomically . (`writeTVar` Just user)
withStore' (`updateUserPrivacy` user)
pure $ CRUserPrivacy user
checkDeleteChatUser :: User -> m ()
checkDeleteChatUser user@User {userId} = do
when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId)
users <- withStore' getUsers
unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $
throwChatError (CECantDeleteLastUser userId)
setActive ActiveNone
deleteChatUser :: User -> Bool -> m ChatResponse
deleteChatUser user delSMPQueues = do
filesInfo <- withStore' (`getUserFileInfo` user)
forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
withStore' (`deleteUserRecord` user)
ok_
assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m ()
assertDirectAllowed user dir ct event =
@@ -1600,7 +1671,7 @@ assertDirectAllowed user dir ct event =
XCallInv_ -> False
_ -> True
startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
startExpireCIThread :: forall m. ChatMonad' m => User -> m ()
startExpireCIThread user@User {userId} = do
expireThreads <- asks expireCIThreads
atomically (TM.lookup userId expireThreads) >>= \case
@@ -1619,12 +1690,12 @@ startExpireCIThread user@User {userId} = do
forM_ ttl $ \t -> expireChatItems user t False
threadDelay interval
setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m ()
setExpireCIFlag :: ChatMonad' 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 :: ChatMonad' m => Bool -> m ()
setAllExpireCIFlags b = do
expireFlags <- asks expireCIFlags
atomically $ do
@@ -1841,7 +1912,7 @@ deleteGroupLink_ user gInfo conn = do
deleteAgentConnectionAsync user $ aConnId conn
withStore' $ \db -> deleteGroupLink db user gInfo
agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
agentSubscriber :: ChatMonad' m => m ()
agentSubscriber = do
q <- asks $ subQ . smpAgent
l <- asks chatLock
@@ -2104,7 +2175,7 @@ processAgentMessageConn user _ agentConnId END =
withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case
RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do
toView $ CRContactAnotherClient user ct
showToast (c <> "> ") "connected to another client"
whenUserNtfs user $ showToast (c <> "> ") "connected to another client"
unsetActive $ ActiveC c
entity -> toView $ CRSubscriptionEnd user entity
processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
@@ -2237,8 +2308,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile)
when (directOrUsed ct) $ createFeatureEnabledItems ct
setActive $ ActiveC c
showToast (c <> "> ") "connected"
whenUserNtfs user $ do
setActive $ ActiveC c
showToast (c <> "> ") "connected"
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
forM_ viaUserContactLink $ \userContactLinkId ->
withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case
@@ -2368,13 +2440,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
memberConnectedChatItem gInfo m
forM_ description $ groupDescriptionChatItem gInfo m
setActive $ ActiveG gName
showToast ("#" <> gName) "you are connected to group"
whenUserNtfs user $ do
setActive $ ActiveG gName
showToast ("#" <> gName) "you are connected to group"
GCInviteeMember -> do
memberConnectedChatItem gInfo m
toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected}
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
whenGroupNtfs user gInfo $ do
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore' $ \db -> createIntroductions db members m
void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m
forM_ intros $ \intro ->
@@ -2622,7 +2696,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
toView $ CRAcceptingGroupJoinRequest user gInfo ct
_ -> do
toView $ CRReceivedContactRequest user cReq
showToast (localDisplayName <> "> ") "wants to connect to you"
whenUserNtfs user $
showToast (localDisplayName <> "> ") "wants to connect to you"
_ -> pure ()
incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m ()
@@ -2703,8 +2778,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
memberConnectedChatItem gInfo m
toView $ CRConnectedToGroupMember user gInfo m
let g = groupName' gInfo
setActive $ ActiveG g
showToast ("#" <> g) $ "member " <> c <> " is connected"
whenGroupNtfs user gInfo $ do
setActive $ ActiveG g
showToast ("#" <> g) $ "member " <> c <> " is connected"
probeMatchingContacts :: Contact -> Bool -> m ()
probeMatchingContacts ct connectedIncognito = do
@@ -2730,7 +2806,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
messageError = toView . CRMessageError user "error"
newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
let ExtMsgContent content fileInvitation_ _ _ = mcExtMsgContent mc
@@ -2744,7 +2820,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
live = fromMaybe False live_
ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
when (enableNtfs chatSettings) $ do
whenContactNtfs user ct $ do
showMsgToast (c <> "> ") content formattedText
setActive $ ActiveC c
where
@@ -2811,7 +2887,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo@GroupInfo {chatSettings} m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do
let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc
if isVoice content && not (groupFeatureAllowed SGFVoice gInfo)
then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False
@@ -2822,7 +2898,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
ciFile_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m
ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live
let g = groupName' gInfo
when (enableNtfs chatSettings) $ do
whenGroupNtfs user gInfo $ do
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
setActive $ ActiveG g
where
@@ -2896,8 +2972,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
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 user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
showToast (c <> "> ") "wants to send a file"
setActive $ ActiveC c
whenContactNtfs user ct $ do
showToast (c <> "> ") "wants to send a file"
setActive $ ActiveC c
-- TODO remove once XFile is discontinued
processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
@@ -2909,8 +2986,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False
groupMsgToView gInfo m ci msgMeta
let g = groupName' gInfo
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
setActive $ ActiveG g
whenGroupNtfs user gInfo $ do
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
setActive $ ActiveG g
receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode)
receiveInlineMode FileInvitation {fileSize, fileInline} mc_ chSize = case fileInline of
@@ -3041,7 +3119,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
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
processGroupInvitation ct inv msg msgMeta = do
let Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} = ct
GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
@@ -3061,7 +3141,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
toView $ CRReceivedGroupInvitation user gInfo ct memRole
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
whenContactNtfs user ct $
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
where
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
sameGroupLinkId (Just gli) (Just gli') = gli == gli'
@@ -3888,17 +3969,26 @@ getCreateActiveUser st = do
getWithPrompt :: String -> IO String
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
showMsgToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> MsgContent -> Maybe MarkdownList -> m ()
whenUserNtfs :: ChatMonad' m => User -> m () -> m ()
whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser
whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m ()
whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings)
whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m ()
whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings)
showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m ()
showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_
where
hideSecret :: FormattedText -> Text
hideSecret FormattedText {format = Just Secret} = "..."
hideSecret FormattedText {text} = text
showToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> Text -> m ()
showToast :: ChatMonad' m => Text -> Text -> m ()
showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ
notificationSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
notificationSubscriber :: ChatMonad' m => m ()
notificationSubscriber = do
ChatController {notifyQ, sendNotification} <- ask
forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification
@@ -3958,8 +4048,8 @@ withStoreCtx ctx_ action = do
chatCommandP :: Parser ChatCommand
chatCommandP =
choice
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP'),
"/unmute " *> ((`ShowMessages` True) <$> chatNameP'),
[ "/mute " *> ((`ShowMessages` False) <$> chatNameP),
"/unmute " *> ((`ShowMessages` True) <$> chatNameP),
"/create user"
*> ( do
sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False
@@ -3967,10 +4057,18 @@ chatCommandP =
pure $ CreateActiveUser uProfile sameSmp
),
"/users" $> ListUsers,
"/_user " *> (APISetActiveUser <$> A.decimal),
("/user " <|> "/u ") *> (SetActiveUser <$> displayName),
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP),
"/delete user " *> (DeleteUser <$> displayName <*> pure True),
"/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)),
("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)),
"/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP),
"/_unhide user " *> (APIUnhideUser <$> A.decimal <*> optional (A.space *> jsonP)),
"/_mute user " *> (APIMuteUser <$> A.decimal <*> optional (A.space *> jsonP)),
"/_unmute user " *> (APIUnmuteUser <$> A.decimal <*> optional (A.space *> jsonP)),
"/hide user " *> (HideUser <$> pwdP),
"/unhide user" $> UnhideUser,
"/mute user" $> MuteUser,
"/unmute user" $> UnmuteUser,
"/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)),
"/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)),
("/user" <|> "/u") $> ShowActiveUser,
"/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP),
"/_start" $> StartChat True True,
@@ -4199,6 +4297,7 @@ chatCommandP =
n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n
textP = safeDecodeUtf8 <$> A.takeByteString
pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' '))
msgTextP = jsonP <|> textP
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
filePath = stringP

View File

@@ -19,7 +19,7 @@ import Control.Monad.Except
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Crypto.Random (ChaChaDRG)
import Data.Aeson (FromJSON, ToJSON)
import Data.Aeson (FromJSON (..), ToJSON (..))
import qualified Data.Aeson as J
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
@@ -182,10 +182,18 @@ data ChatCommand
= ShowActiveUser
| CreateActiveUser Profile Bool
| ListUsers
| APISetActiveUser UserId
| SetActiveUser UserName
| APIDeleteUser UserId Bool
| DeleteUser UserName Bool
| APISetActiveUser UserId (Maybe UserPwd)
| SetActiveUser UserName (Maybe UserPwd)
| APIHideUser UserId UserPwd
| APIUnhideUser UserId (Maybe UserPwd)
| APIMuteUser UserId (Maybe UserPwd)
| APIUnmuteUser UserId (Maybe UserPwd)
| HideUser UserPwd
| UnhideUser
| MuteUser
| UnmuteUser
| APIDeleteUser UserId Bool (Maybe UserPwd)
| DeleteUser UserName Bool (Maybe UserPwd)
| StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool}
| APIStopChat
| APIActivateChat
@@ -406,6 +414,7 @@ data ChatResponse
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
| CRUserProfile {user :: User, profile :: Profile}
| CRUserProfileNoChange {user :: User}
| CRUserPrivacy {user :: User}
| CRVersionInfo {versionInfo :: CoreVersionInfo}
| CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation}
| CRSentConfirmation {user :: User}
@@ -522,6 +531,16 @@ instance ToJSON ChatResponse where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR"
newtype UserPwd = UserPwd {unUserPwd :: Text}
deriving (Eq, Show)
instance FromJSON UserPwd where
parseJSON v = UserPwd <$> parseJSON v
instance ToJSON UserPwd where
toJSON (UserPwd p) = toJSON p
toEncoding (UserPwd p) = toEncoding p
newtype AgentQueueId = AgentQueueId QueueId
deriving (Eq, Show)
@@ -683,11 +702,17 @@ instance ToJSON ChatError where
data ChatErrorType
= CENoActiveUser
| CENoConnectionUser {agentConnId :: AgentConnId}
| CEUserUnknown
| CEActiveUserExists -- TODO delete
| CEUserExists {contactName :: ContactName}
| CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId}
| CECantDeleteActiveUser {userId :: UserId}
| CECantDeleteLastUser {userId :: UserId}
| CECantHideLastUser {userId :: UserId}
| CECantUnmuteHiddenUser {userId :: UserId}
| CEEmptyUserPassword {userId :: UserId}
| CEUserAlreadyHidden {userId :: UserId}
| CEUserNotHidden {userId :: UserId}
| CEChatNotStarted
| CEChatNotStopped
| CEChatStoreChanged
@@ -764,7 +789,9 @@ instance ToJSON SQLiteError where
throwDBError :: ChatMonad m => DatabaseError -> m ()
throwDBError = throwError . ChatErrorDatabase
type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m)
type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m)
type ChatMonad m = (ChatMonad' m, MonadError ChatError m)
chatCmdError :: Maybe User -> String -> ChatResponse
chatCmdError user = CRChatCmdError user . ChatError . CECommandError

View File

@@ -0,0 +1,14 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230317_hidden_profiles where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230317_hidden_profiles :: Query
m20230317_hidden_profiles =
[sql|
ALTER TABLE users ADD COLUMN view_pwd_hash BLOB;
ALTER TABLE users ADD COLUMN view_pwd_salt BLOB;
ALTER TABLE users ADD COLUMN show_ntfs INTEGER NOT NULL DEFAULT 1;
|]

View File

@@ -30,7 +30,10 @@ CREATE TABLE users(
active_user INTEGER NOT NULL DEFAULT 0,
created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL),
agent_user_id INTEGER CHECK(agent_user_id NOT NULL), -- 1 for active user
agent_user_id INTEGER CHECK(agent_user_id NOT NULL),
view_pwd_hash BLOB,
view_pwd_salt BLOB,
show_ntfs INTEGER NOT NULL DEFAULT 1, -- 1 for active user
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE

View File

@@ -12,12 +12,15 @@ import Control.Monad.Except
import Control.Monad.Reader
import Data.Aeson (ToJSON (..))
import qualified Data.Aeson as J
import qualified Data.ByteString.Base64.URL as U
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Functor (($>))
import Data.List (find)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word8)
import Database.SQLite.Simple (SQLError (..))
import qualified Database.SQLite.Simple as DB
@@ -65,6 +68,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString
foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
@@ -122,6 +127,12 @@ cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s
cChatParseServer :: CString -> IO CJSONString
cChatParseServer s = newCAString . chatParseServer =<< peekCAString s
cChatPasswordHash :: CString -> CString -> IO CString
cChatPasswordHash cPwd cSalt = do
pwd <- peekCAString cPwd
salt <- peekCAString cSalt
newCAString $ chatPasswordHash pwd salt
mobileChatOpts :: String -> String -> ChatOpts
mobileChatOpts dbFilePrefix dbKey =
ChatOpts
@@ -241,6 +252,12 @@ chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack
enc :: StrEncoding a => a -> String
enc = B.unpack . strEncode
chatPasswordHash :: String -> String -> String
chatPasswordHash pwd salt = either (const "") passwordHash salt'
where
salt' = U.decode $ B.pack salt
passwordHash = B.unpack . U.encode . C.sha512Hash . (encodeUtf8 (T.pack pwd) <>)
data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse}
deriving (Generic)

View File

@@ -39,6 +39,7 @@ module Simplex.Chat.Store
getUserByContactRequestId,
getUserFileInfo,
deleteUserRecord,
updateUserPrivacy,
createDirectConnection,
createConnReqConnection,
getProfileById,
@@ -277,6 +278,7 @@ import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (sortBy, sortOn)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
import Data.Ord (Down (..))
import Data.Text (Text)
@@ -345,6 +347,7 @@ import Simplex.Chat.Migrations.M20230118_recreate_smp_servers
import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx
import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id
import Simplex.Chat.Migrations.M20230303_group_link_role
import Simplex.Chat.Migrations.M20230317_hidden_profiles
-- import Simplex.Chat.Migrations.M20230304_file_description
import Simplex.Chat.Protocol
import Simplex.Chat.Types
@@ -412,7 +415,8 @@ schemaMigrations =
("20230118_recreate_smp_servers", m20230118_recreate_smp_servers),
("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx),
("20230206_item_deleted_by_group_member_id", m20230206_item_deleted_by_group_member_id),
("20230303_group_link_role", m20230303_group_link_role)
("20230303_group_link_role", m20230303_group_link_role),
("20230317_hidden_profiles", m20230317_hidden_profiles)
-- ("20230304_file_description", m20230304_file_description)
]
@@ -449,8 +453,8 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
DB.execute
db
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)"
(auId, displayName, activeUser, currentTs, currentTs)
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, created_at, updated_at) VALUES (?,?,?,0,?,?,?)"
(auId, displayName, activeUser, True, currentTs, currentTs)
userId <- insertedRowId db
DB.execute
db
@@ -467,7 +471,7 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr
(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, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences)
pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences, True) :. (Nothing, Nothing)
getUsersInfo :: DB.Connection -> IO [UserInfo]
getUsersInfo db = getUsers db >>= mapM getUserInfo
@@ -505,16 +509,19 @@ getUsers db =
userQuery :: Query
userQuery =
[sql|
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
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, u.show_ntfs, u.view_pwd_hash, u.view_pwd_salt
FROM users u
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
toUser (userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) =
let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences}
toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences, Bool) :. (Maybe B64UrlByteString, Maybe B64UrlByteString) -> User
toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences, showNtfs) :. (viewPwdHash_, viewPwdSalt_)) =
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, viewPwdHash}
where
profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""}
fullPreferences = mergePreferences Nothing userPreferences
viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_
setActiveUser :: DB.Connection -> UserId -> IO ()
setActiveUser db userId = do
@@ -581,6 +588,19 @@ deleteUserRecord :: DB.Connection -> User -> IO ()
deleteUserRecord db User {userId} =
DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId)
updateUserPrivacy :: DB.Connection -> User -> IO ()
updateUserPrivacy db User {userId, showNtfs, viewPwdHash} =
DB.execute
db
[sql|
UPDATE users
SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ?
WHERE user_id = ?
|]
(hashSalt viewPwdHash :. (showNtfs, userId))
where
hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt))
createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection
createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do
createdAt <- getCurrentTime

View File

@@ -110,11 +110,38 @@ data User = User
localDisplayName :: ContactName,
profile :: LocalProfile,
fullPreferences :: FullPreferences,
activeUser :: Bool
activeUser :: Bool,
viewPwdHash :: Maybe UserPwdHash,
showNtfs :: Bool
}
deriving (Show, Generic, FromJSON)
instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON User where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
newtype B64UrlByteString = B64UrlByteString ByteString
deriving (Eq, Show)
instance FromField B64UrlByteString where fromField f = B64UrlByteString <$> fromField f
instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField m
instance StrEncoding B64UrlByteString where
strEncode (B64UrlByteString m) = strEncode m
strP = B64UrlByteString <$> strP
instance FromJSON B64UrlByteString where
parseJSON = strParseJSON "B64UrlByteString"
instance ToJSON B64UrlByteString where
toJSON = strToJSON
toEncoding = strToJEncoding
data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions
data UserInfo = UserInfo
{ user :: User,

View File

@@ -116,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
CRUserProfile u p -> ttyUser u $ viewUserProfile p
CRUserProfileNoChange u -> ttyUser u ["user profile did not change"]
CRUserPrivacy u -> ttyUserPrefix u $ viewUserPrivacy u
CRVersionInfo info -> viewVersionInfo logLevel info
CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq
CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
@@ -229,12 +230,16 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
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
CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e
CRChatError u e -> ttyUser' u $ viewChatError logLevel e
where
ttyUser :: User -> [StyledString] -> [StyledString]
ttyUser _ [] = []
ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
ttyUser user@User {showNtfs, activeUser} ss
| showNtfs || activeUser = ttyUserPrefix user ss
| otherwise = []
ttyUserPrefix :: User -> [StyledString] -> [StyledString]
ttyUserPrefix _ [] = []
ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss
where
userPrefix = case user_ of
Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else ""
@@ -242,6 +247,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case
prefix = "[user: " <> highlight u <> "] "
ttyUser' :: Maybe User -> [StyledString] -> [StyledString]
ttyUser' = maybe id ttyUser
ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString]
ttyUserPrefix' = maybe id ttyUserPrefix
testViewChats :: [AChat] -> [StyledString]
testViewChats chats = [sShow $ map toChatView chats]
where
@@ -293,14 +300,19 @@ chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState
_ -> ""
viewUsersList :: [UserInfo] -> [StyledString]
viewUsersList = map userInfo . sortOn ldn
viewUsersList = mapMaybe userInfo . sortOn ldn
where
ldn (UserInfo User {localDisplayName = n} _) = T.toLower n
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser} count) =
ttyFullName n fullName <> active <> unread
userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count)
| activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName <> infoStr
| otherwise = Nothing
where
active = if activeUser then highlight' " (active)" else ""
unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else ""
infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")"
info =
[highlight' "active" | activeUser]
<> [highlight' "hidden" | isJust viewPwdHash]
<> ["muted" | not showNtfs]
<> [plain ("unread: " <> show count) | count /= 0]
muted :: ChatInfo c -> ChatItem c d -> Bool
muted chat ChatItem {chatDir} = case (chat, chatDir) of
@@ -722,6 +734,12 @@ viewUserProfile Profile {displayName, fullName} =
"(the updated profile will be sent to all your contacts)"
]
viewUserPrivacy :: User -> [StyledString]
viewUserPrivacy User {showNtfs, viewPwdHash} =
[ "user messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)",
"user profile is " <> if isJust viewPwdHash then "hidden" else "visible"
]
-- TODO make more generic messages or split
viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString]
viewSMPServers servers testView =
@@ -1210,9 +1228,15 @@ viewChatError logLevel = \case
CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError]
CEActiveUserExists -> ["error: active user already exists"]
CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"]
CEUserUnknown -> ["user does not exist or incorrect password"]
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"]
CECantHideLastUser _ -> ["cannot hide the only not hidden user"]
CECantUnmuteHiddenUser _ -> ["cannot unmute hidden user"]
CEEmptyUserPassword _ -> ["cannot set empty password"]
CEUserAlreadyHidden _ -> ["user is already hidden"]
CEUserNotHidden _ -> ["user is not hidden"]
CEChatNotStarted -> ["error: chat not started"]
CEChatNotStopped -> ["error: chat not stopped"]
CEChatStoreChanged -> ["error: chat store changed, please restart chat"]