From e6e5faeb9cc6613792eb680212b878827e4273eb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 23 Nov 2022 11:04:08 +0000 Subject: [PATCH] core: chat items for group preferences (#1402) * core: chat items for group preferences * chat items for group preference changes and sent item for contact/user prerences changes * prohibited features, tests * enable all tests * fix --- .../Chat/ChatItem/CIChatFeatureView.swift | 11 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 12 +- apps/ios/SimpleXChat/ChatTypes.swift | 15 ++ src/Simplex/Chat.hs | 188 ++++++++++++------ src/Simplex/Chat/Messages.hs | 16 ++ src/Simplex/Chat/Types.hs | 3 + src/Simplex/Chat/View.hs | 8 +- tests/ChatTests.hs | 99 +++++++-- 8 files changed, 249 insertions(+), 103 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index c615497fc8..98ef18f2b0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -12,7 +12,7 @@ import SimpleXChat struct CIChatFeatureView: View { var chatItem: ChatItem var feature: Feature - var enabled: FeatureEnabled? + var iconColor: Color var body: some View { HStack(alignment: .bottom, spacing: 0) { @@ -24,18 +24,11 @@ struct CIChatFeatureView: View { .padding(.bottom, 6) .textSelection(.disabled) } - - private var iconColor: Color { - if let enabled = enabled { - return enabled.forUser ? .green : enabled.forContact ? .yellow : .secondary - } - return .red - } } struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: .fullDelete, enabled: enabled) + CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: .fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 4b19dba173..3875745459 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -31,9 +31,11 @@ struct ChatItemView: View { case .sndGroupEvent: eventItemView() case .rcvConnEvent: eventItemView() case .sndConnEvent: eventItemView() - case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled) - case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled) - case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, nil) + case let .rcvChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor) + case let .sndChatFeature(feature, enabled): chatFeatureView(feature, enabled.iconColor) + case let .rcvGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor) + case let .sndGroupFeature(feature, preference): chatFeatureView(feature, preference.enable.iconColor) + case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) } } @@ -61,8 +63,8 @@ struct ChatItemView: View { CIEventView(chatItem: chatItem) } - private func chatFeatureView(_ feature: Feature, _ enabled: FeatureEnabled?) -> some View { - CIChatFeatureView(chatItem: chatItem, feature: feature, enabled: enabled) + private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { + CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0ccbc81bc5..a98e9a7285 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -220,6 +220,10 @@ public struct FeatureEnabled: Decodable { : forContact ? NSLocalizedString("enabled for contact", comment: "enabled status") : NSLocalizedString("off", comment: "enabled status") } + + public var iconColor: Color { + forUser ? .green : forContact ? .yellow : .secondary + } } public enum ContactUserPref: Decodable { @@ -467,6 +471,13 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable { case .off: return NSLocalizedString("off", comment: "group pref value") } } + + public var iconColor: Color { + switch self { + case .on: return .green + case .off: return .secondary + } + } } public enum ChatInfo: Identifiable, Decodable, NamedChat { @@ -1485,6 +1496,8 @@ public enum CIContent: Decodable, ItemContent { case sndConnEvent(sndConnEvent: SndConnEvent) case rcvChatFeature(feature: Feature, enabled: FeatureEnabled) case sndChatFeature(feature: Feature, enabled: FeatureEnabled) + case rcvGroupFeature(feature: Feature, preference: GroupPreference) + case sndGroupFeature(feature: Feature, preference: GroupPreference) case rcvChatFeatureRejected(feature: Feature) public var text: String { @@ -1505,6 +1518,8 @@ public enum CIContent: Decodable, ItemContent { case let .sndConnEvent(sndConnEvent): return sndConnEvent.text case let .rcvChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)" case let .sndChatFeature(feature, enabled): return "\(feature.text): \(enabled.text)" + case let .rcvGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)" + case let .sndGroupFeature(feature, preference): return "\(feature.text): \(preference.enable.text)" case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) } } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index a860b9c77e..5d4fab5360 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -289,16 +289,19 @@ processChatCommand = \case CTDirect -> do ct@Contact {localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct - (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct - (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ - (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) - case ft_ of - Just ft@FileTransferMeta {fileInline = Just IFMSent} -> - sendDirectFileInline ct ft sharedMsgId - _ -> pure () - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ - setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + case featureProhibited forUser user ct mc of + Just f -> pure $ chatCmdError $ "feature not allowed " <> T.unpack (chatFeatureToText f) + _ -> do + (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ + (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + case ft_ of + Just ft@FileTransferMeta {fileInline = Just IFMSent} -> + sendDirectFileInline ct ft sharedMsgId + _ -> pure () + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveC c + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer ct = forM file_ $ \file -> do @@ -335,13 +338,16 @@ processChatCommand = \case CTGroup -> do Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length ms) - (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership - msg@SndMessage {sharedMsgId} <- sendGroupMessage gInfo ms (XMsgNew msgContainer) - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ - setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + case groupFeatureProhibited gInfo mc of + Just f -> pure $ chatCmdError $ "feature not allowed " <> T.unpack (chatFeatureToText f) + _ -> do + (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length ms) + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership + msg@SndMessage {sharedMsgId} <- sendGroupMessage gInfo ms (XMsgNew msgContainer) + mapM_ (sendGroupFileInline ms sharedMsgId) ft_ + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci where setupSndFileTransfer :: GroupInfo -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer gInfo n = forM file_ $ \file -> do @@ -988,7 +994,7 @@ processChatCommand = \case processChatCommand $ APIListMembers groupId ListGroups -> CRGroupsList <$> withUser (\user -> withStore' (`getUserGroupDetails` user)) APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - Group g ms <- withStore $ \db -> getGroup db user groupId + Group g@GroupInfo {groupProfile = p} ms <- withStore $ \db -> getGroup db user groupId let s = memberStatus $ membership g canUpdate = memberRole (membership g :: GroupMember) == GROwner @@ -996,8 +1002,11 @@ processChatCommand = \case unless canUpdate $ throwChatError CEGroupUserRole g' <- withStore $ \db -> updateGroupProfile db user g p' msg <- sendGroupMessage g' ms (XGrpInfo p') - ci <- saveSndChatItem user (CDGroupSnd g') msg (CISndGroupEvent $ SGEGroupUpdated p') Nothing Nothing - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat g') ci + let cd = CDGroupSnd g' + unless (sameGroupProfileInfo p p') $ do + ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') Nothing Nothing + toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat g') ci + createGroupFeatureChangedItems user cd CISndGroupFeature p p' pure $ CRGroupUpdated g g' Nothing UpdateGroupProfile gName profile -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -1181,6 +1190,7 @@ processChatCommand = \case forM_ contacts $ \ct -> do let mergedProfile = userProfileToSend user' Nothing $ Just ct void (sendDirectContactMessage ct $ XInfo mergedProfile) `catchError` (toView . CRChatError) + createFeatureChangedItems user user' ct ct CDDirectSnd CISndChatFeature pure $ CRUserProfileUpdated (fromLocalProfile p) p' updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' @@ -1191,6 +1201,7 @@ processChatCommand = \case let p' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') withChatLock "updateProfile" . procCmd $ do void (sendDirectContactMessage ct' $ XInfo p') `catchError` (toView . CRChatError) + createFeatureChangedItems user user ct ct' CDDirectSnd CISndChatFeature pure $ CRContactPrefsUpdated ct ct' isReady :: Contact -> Bool isReady ct = @@ -1782,8 +1793,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = SWITCH qd phase cStats -> do toView . CRContactSwitch ct $ SwitchProgress qd phase cStats when (phase /= SPConfirmed) $ case qd of - QDRcv -> createInternalChatItem (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing - QDSnd -> createInternalChatItem (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing + QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing + QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing OK -> -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> @@ -1829,7 +1840,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile groupLinkId (_msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv -- we could link chat item with sent group invitation message (_msg) - createInternalChatItem (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing _ -> throwChatError $ CECommandError "unexpected cmdFunction" CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" CONF confId _ connInfo -> do @@ -1874,8 +1885,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings case memberCategory m of GCHostMember -> do - memberConnectedChatItem gInfo m toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + createGroupFeatureItems gInfo m + memberConnectedChatItem gInfo m setActive $ ActiveG gName showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do @@ -1930,8 +1942,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = SWITCH qd phase cStats -> do toView . CRGroupMemberSwitch gInfo m $ SwitchProgress qd phase cStats when (phase /= SPConfirmed) $ case qd of - QDRcv -> createInternalChatItem (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing - QDSnd -> createInternalChatItem (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing + QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing + QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing OK -> -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> @@ -2149,7 +2161,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = memberConnectedChatItem :: GroupInfo -> GroupMember -> m () memberConnectedChatItem gInfo m = -- ts should be broker ts but we don't have it for CON - createInternalChatItem (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing + createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvGroupEvent RGEMemberConnected) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do @@ -2186,12 +2198,19 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc - ciFile_ <- processFileInvitation fileInvitation_ $ \db -> createRcvFileTransfer db userId ct - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - when (enableNtfs chatSettings) $ showMsgToast (c <> "> ") content formattedText + let ExtMsgContent content fileInvitation_ = mcExtMsgContent mc + case featureProhibited forContact user ct content of + Just f -> void $ newChatItem (CIRcvChatFeatureRejected f) Nothing + _ -> do + ciFile_ <- processFileInvitation fileInvitation_ $ \db -> createRcvFileTransfer db userId ct + ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ + when (enableNtfs chatSettings) $ showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c + where + newChatItem ciContent ciFile_ = do + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta ciContent ciFile_ + toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + pure ci processFileInvitation :: Maybe FileInvitation -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> IO RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) processFileInvitation fInv_ createRcvFT = forM fInv_ $ \fInv@FileInvitation {fileName, fileSize} -> do @@ -2245,12 +2264,19 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo@GroupInfo {chatSettings} m@GroupMember {localDisplayName = c} mc msg msgMeta = do let (ExtMsgContent content fInv_) = mcExtMsgContent mc - ciFile_ <- processFileInvitation fInv_ $ \db -> createRcvGroupFileTransfer db userId m - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ - groupMsgToView gInfo m ci msgMeta - let g = groupName' gInfo - when (enableNtfs chatSettings) $ showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText - setActive $ ActiveG g + case groupFeatureProhibited gInfo content of + Just f -> void $ newChatItem (CIRcvChatFeatureRejected f) Nothing + _ -> do + ciFile_ <- processFileInvitation fInv_ $ \db -> createRcvGroupFileTransfer db userId m + ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ + let g = groupName' gInfo + when (enableNtfs chatSettings) $ showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText + setActive $ ActiveG g + where + newChatItem ciContent ciFile_ = do + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta ciContent ciFile_ + groupMsgToView gInfo m ci msgMeta + pure ci groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = @@ -2469,38 +2495,28 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () MsgError e -> case e of - MsgSkipped {} -> createInternalChatItem cd (CIRcvIntegrityError e) (Just brokerTs) + MsgSkipped {} -> createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) _ -> toView $ CRMsgIntegrityError e - createInternalChatItem :: forall c d. (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> CIContent d -> Maybe UTCTime -> m () - createInternalChatItem cd content itemTs_ = do - createdAt <- liftIO getCurrentTime - let itemTs = fromMaybe createdAt itemTs_ - ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content itemTs createdAt - ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing itemTs createdAt - toView $ CRNewChatItem $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci - xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do c' <- withStore $ \db -> updateContactProfile db user c p' toView $ CRContactUpdated c c' - createFeatureChangedItems c' - where - createFeatureChangedItems c' = unless (preferences' c == preferences' c') $ do - let cups = contactUserPreferences' user c - cups' = contactUserPreferences' user c' - forM_ allChatFeatures $ \f -> do - let ContactUserPreference {enabled} = getContactUserPreference f cups - ContactUserPreference {enabled = enabled'} = getContactUserPreference f cups' - unless (enabled == enabled') $ - createInternalChatItem (CDDirectRcv c') (CIRcvChatFeature f enabled') Nothing + createFeatureChangedItems user user c c' CDDirectRcv CIRcvChatFeature createFeatureEnabledItems :: Contact -> m () createFeatureEnabledItems ct = do let cups = contactUserPreferences' user ct forM_ allChatFeatures $ \f -> do let ContactUserPreference {enabled} = getContactUserPreference f cups - createInternalChatItem (CDDirectRcv ct) (CIRcvChatFeature f enabled) Nothing + createInternalChatItem user (CDDirectRcv ct) (CIRcvChatFeature f enabled) Nothing + + createGroupFeatureItems :: GroupInfo -> GroupMember -> m () + createGroupFeatureItems g@GroupInfo {groupProfile} m = do + let prefs = mergeGroupPreferences $ groupPreferences groupProfile + forM_ allChatFeatures $ \f -> do + let p = getGroupPreference f prefs + createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature f p) Nothing xInfoProbe :: Contact -> Probe -> m () xInfoProbe c2 probe = @@ -2789,13 +2805,16 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = toView $ CRGroupDeleted gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> MsgMeta -> m () - xGrpInfo g m@GroupMember {memberRole} p' msg msgMeta + xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg msgMeta | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" - | otherwise = do + | otherwise = unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - ci <- saveRcvChatItem user (CDGroupRcv g' m) msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') Nothing - groupMsgToView g' m ci msgMeta toView . CRGroupUpdated g g' $ Just m + let cd = CDGroupRcv g' m + unless (sameGroupProfileInfo p p') $ do + ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') Nothing + groupMsgToView g' m ci msgMeta + createGroupFeatureChangedItems user cd CIRcvGroupFeature p p' sendDirectFileInline :: ChatMonad m => Contact -> FileTransferMeta -> SharedMsgId -> m () sendDirectFileInline ct ft sharedMsgId = do @@ -3068,6 +3087,51 @@ userProfileToSend user@User {profile = p} incognitoProfile ct = userPrefs = maybe (preferences' user) (const Nothing) incognitoProfile in (p' :: Profile) {preferences = Just . toChatPrefs $ mergePreferences (userPreferences <$> ct) userPrefs} +createFeatureChangedItems :: (MsgDirectionI d, ChatMonad m) => User -> User -> Contact -> Contact -> (Contact -> ChatDirection 'CTDirect d) -> (ChatFeature -> PrefEnabled -> CIContent d) -> m () +createFeatureChangedItems user user' ct ct' chatDir ciContent = + forM_ allChatFeatures $ \f -> do + let ContactUserPreference {enabled} = getContactUserPreference f cups + ContactUserPreference {enabled = enabled'} = getContactUserPreference f cups' + unless (enabled == enabled') $ + createInternalChatItem user (chatDir ct') (ciContent f enabled') Nothing + where + cups = contactUserPreferences' user ct + cups' = contactUserPreferences' user' ct' + +createGroupFeatureChangedItems :: (MsgDirectionI d, ChatMonad m) => User -> ChatDirection 'CTGroup d -> (ChatFeature -> GroupPreference -> CIContent d) -> GroupProfile -> GroupProfile -> m () +createGroupFeatureChangedItems user cd ciContent p p' = + forM_ allChatFeatures $ \f -> do + let pref = getGroupPreference f $ groupPreferences p + pref' = getGroupPreference f $ groupPreferences p' + unless (pref == pref') $ + createInternalChatItem user cd (ciContent f pref') Nothing + +sameGroupProfileInfo :: GroupProfile -> GroupProfile -> Bool +sameGroupProfileInfo p p' = p {groupPreferences = Nothing} == p' {groupPreferences = Nothing} + +featureProhibited :: (PrefEnabled -> Bool) -> User -> Contact -> MsgContent -> Maybe ChatFeature +featureProhibited forWhom user ct = \case + MCVoice {} -> + let ContactUserPreference {enabled} = + getContactUserPreference CFVoice $ contactUserPreferences' user ct + in if forWhom enabled then Nothing else Just CFVoice + _ -> Nothing + +groupFeatureProhibited :: GroupInfo -> MsgContent -> Maybe ChatFeature +groupFeatureProhibited GroupInfo {groupProfile} = \case + MCVoice {} -> + let GroupPreference {enable} = getGroupPreference CFVoice $ groupPreferences groupProfile + in case enable of FEOn -> Nothing; FEOff -> Just CFVoice + _ -> Nothing + +createInternalChatItem :: forall c d m. (ChatTypeI c, MsgDirectionI d, ChatMonad m) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> m () +createInternalChatItem user cd content itemTs_ = do + createdAt <- liftIO getCurrentTime + let itemTs = fromMaybe createdAt itemTs_ + ciId <- withStore' $ \db -> createNewChatItemNoMsg db user cd content itemTs createdAt + ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing itemTs createdAt + toView $ CRNewChatItem $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci + getCreateActiveUser :: SQLiteStore -> IO User getCreateActiveUser st = do user <- diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9aa28dfab5..99a24c64b3 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -560,6 +560,8 @@ data CIContent (d :: MsgDirection) where CISndConnEvent :: SndConnEvent -> CIContent 'MDSnd CIRcvChatFeature :: ChatFeature -> PrefEnabled -> CIContent 'MDRcv CISndChatFeature :: ChatFeature -> PrefEnabled -> CIContent 'MDSnd + CIRcvGroupFeature :: ChatFeature -> GroupPreference -> CIContent 'MDRcv + CISndGroupFeature :: ChatFeature -> GroupPreference -> CIContent 'MDSnd CIRcvChatFeatureRejected :: ChatFeature -> CIContent 'MDRcv -- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! ^ Nested sum types also have to use different encodings for database and API @@ -714,6 +716,8 @@ ciContentToText = \case CISndConnEvent event -> sndConnEventToText event CIRcvChatFeature feature enabled -> chatFeatureToText feature <> ": " <> prefEnabledToText enabled CISndChatFeature feature enabled -> chatFeatureToText feature <> ": " <> prefEnabledToText enabled + CIRcvGroupFeature feature pref -> chatFeatureToText feature <> ": " <> groupPrefToText pref + CISndGroupFeature feature pref -> chatFeatureToText feature <> ": " <> groupPrefToText pref CIRcvChatFeatureRejected feature -> chatFeatureToText feature <> ": received, prohibited" msgIntegrityError :: MsgErrorType -> Text @@ -767,6 +771,8 @@ data JSONCIContent | JCISndConnEvent {sndConnEvent :: SndConnEvent} | JCIRcvChatFeature {feature :: ChatFeature, enabled :: PrefEnabled} | JCISndChatFeature {feature :: ChatFeature, enabled :: PrefEnabled} + | JCIRcvGroupFeature {feature :: ChatFeature, preference :: GroupPreference} + | JCISndGroupFeature {feature :: ChatFeature, preference :: GroupPreference} | JCIRcvChatFeatureRejected {feature :: ChatFeature} deriving (Generic) @@ -794,6 +800,8 @@ jsonCIContent = \case CISndConnEvent sndConnEvent -> JCISndConnEvent {sndConnEvent} CIRcvChatFeature feature enabled -> JCIRcvChatFeature {feature, enabled} CISndChatFeature feature enabled -> JCISndChatFeature {feature, enabled} + CIRcvGroupFeature feature preference -> JCIRcvGroupFeature {feature, preference} + CISndGroupFeature feature preference -> JCISndGroupFeature {feature, preference} CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} aciContentJSON :: JSONCIContent -> ACIContent @@ -813,6 +821,8 @@ aciContentJSON = \case JCISndConnEvent {sndConnEvent} -> ACIContent SMDSnd $ CISndConnEvent sndConnEvent JCIRcvChatFeature {feature, enabled} -> ACIContent SMDRcv $ CIRcvChatFeature feature enabled JCISndChatFeature {feature, enabled} -> ACIContent SMDSnd $ CISndChatFeature feature enabled + JCIRcvGroupFeature {feature, preference} -> ACIContent SMDRcv $ CIRcvGroupFeature feature preference + JCISndGroupFeature {feature, preference} -> ACIContent SMDSnd $ CISndGroupFeature feature preference JCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature -- platform independent @@ -832,6 +842,8 @@ data DBJSONCIContent | DBJCISndConnEvent {sndConnEvent :: DBSndConnEvent} | DBJCIRcvChatFeature {feature :: ChatFeature, enabled :: PrefEnabled} | DBJCISndChatFeature {feature :: ChatFeature, enabled :: PrefEnabled} + | DBJCIRcvGroupFeature {feature :: ChatFeature, preference :: GroupPreference} + | DBJCISndGroupFeature {feature :: ChatFeature, preference :: GroupPreference} | DBJCIRcvChatFeatureRejected {feature :: ChatFeature} deriving (Generic) @@ -859,6 +871,8 @@ dbJsonCIContent = \case CISndConnEvent sce -> DBJCISndConnEvent $ SCE sce CIRcvChatFeature feature enabled -> DBJCIRcvChatFeature {feature, enabled} CISndChatFeature feature enabled -> DBJCISndChatFeature {feature, enabled} + CIRcvGroupFeature feature preference -> DBJCIRcvGroupFeature {feature, preference} + CISndGroupFeature feature preference -> DBJCISndGroupFeature {feature, preference} CIRcvChatFeatureRejected feature -> DBJCIRcvChatFeatureRejected {feature} aciContentDBJSON :: DBJSONCIContent -> ACIContent @@ -878,6 +892,8 @@ aciContentDBJSON = \case DBJCISndConnEvent (SCE sce) -> ACIContent SMDSnd $ CISndConnEvent sce DBJCIRcvChatFeature {feature, enabled} -> ACIContent SMDRcv $ CIRcvChatFeature feature enabled DBJCISndChatFeature {feature, enabled} -> ACIContent SMDSnd $ CISndChatFeature feature enabled + DBJCIRcvGroupFeature {feature, preference} -> ACIContent SMDRcv $ CIRcvGroupFeature feature preference + DBJCISndGroupFeature {feature, preference} -> ACIContent SMDSnd $ CISndGroupFeature feature preference DBJCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature data CICallStatus diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 21ca5996c6..b5e9de7c08 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -442,6 +442,9 @@ data GroupPreference = GroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show, Generic, FromJSON) +groupPrefToText :: GroupPreference -> Text +groupPrefToText GroupPreference {enable} = safeDecodeUtf8 $ strEncode enable + instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions data FeatureAllowed diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c5901d5e2f..6ae7c052cb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -799,16 +799,10 @@ viewGroupUpdated prefs = mapMaybe viewPref allChatFeatures viewPref pt | pref gps == pref gps' = Nothing - | otherwise = Just $ plain (chatPrefName pt) <> " enabled: " <> viewGroupPreference (pref gps') + | otherwise = Just $ plain (chatPrefName pt) <> " enabled: " <> plain (groupPrefToText $ pref gps') where pref pss = getGroupPreference pt $ mergeGroupPreferences pss -viewGroupPreference :: GroupPreference -> StyledString -viewGroupPreference = \case - GroupPreference {enable} -> case enable of - FEOn -> "on" - FEOff -> "off" - viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}} | localAlias == "" = ["contact " <> ttyContact n <> " alias removed"] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 9a48036f30..2c706d0f4e 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -561,9 +561,9 @@ testGroupShared alice bob cath checkMessages = do alice #$> ("/_get chat #1 before=" <> groupItemId 2 7 <> " count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] - bob #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] - cath #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "connected"), (0, "hello"), (0, "hi there"), (1, "hey team")]) + cath #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "connected"), (0, "hello"), (0, "hi there"), (1, "hey team")]) alice #$> ("/_read chat #1 from=1 to=100", id, "ok") bob #$> ("/_read chat #1 from=1 to=100", id, "ok") cath #$> ("/_read chat #1 from=1 to=100", id, "ok") @@ -1262,7 +1262,7 @@ testGroupMessageDelete = cath #$> ("/_get chat #1 count=2", chat', [((0, "hello!"), Nothing), ((0, "hi alic"), Just (0, "hello!"))]) -- alice: msg id 5 - bob #$> ("/_update item #1 " <> groupItemId 1 6 <> " text hi alice", id, "message updated") + bob #$> ("/_update item #1 " <> groupItemId 2 6 <> " text hi alice", id, "message updated") concurrently_ (alice <# "#team bob> [edited] hi alice") ( do @@ -1281,7 +1281,7 @@ testGroupMessageDelete = (alice <# "#team cath> how are you?") (bob <# "#team cath> how are you?") - cath #$> ("/_delete item #1 " <> groupItemId 1 6 <> " broadcast", id, "message deleted") + cath #$> ("/_delete item #1 " <> groupItemId 2 6 <> " broadcast", id, "message deleted") concurrently_ (alice <# "#team cath> [deleted] how are you?") (bob <# "#team cath> [deleted] how are you?") @@ -2840,73 +2840,126 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ testSetContactPrefs :: IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do + bob ##> "/_profile {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" + bob <## "profile image removed" + bob <## "updated preferences:" + bob <## "voice messages allowed: no" + (bob "/_set prefs @2 {}" alice <## "your preferences for bob did not change" (bob "/_set prefs @2 {\"fullDelete\": {\"allow\": \"always\"}}" + let startFeatures = [(0, "Full deletion: off"), (0, "Voice messages: off")] + alice #$> ("/_get chat @2 count=100", chat, startFeatures) + bob #$> ("/_get chat @2 count=100", chat, startFeatures) + let sendVoice = "/_send @2 json {\"filePath\": \"./tests/fixtures/test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" + voiceNotAllowed = "bad chat command: feature not allowed Voice messages" + alice ##> sendVoice + alice <## voiceNotAllowed + bob ##> sendVoice + bob <## voiceNotAllowed + alice ##> "/_set prefs @2 {\"voice\": {\"allow\": \"always\"}}" alice <## "you updated preferences for bob:" - alice <## "full message deletion: enabled for contact (you allow: always, contact allows: no)" + alice <## "voice messages: enabled for contact (you allow: always, contact allows: no)" + alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact")]) bob <## "alice updated preferences for you:" - bob <## "full message deletion: enabled for you (you allow: default (no), contact allows: always)" + bob <## "voice messages: enabled for you (you allow: default (no), contact allows: always)" + bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you")]) + alice ##> sendVoice + alice <## voiceNotAllowed + bob ##> sendVoice + bob <# "@alice voice message (00:10)" + bob <# "/f @alice ./tests/fixtures/test.txt" + bob <## "use /fc 1 to cancel sending" + alice <# "bob> voice message (00:10)" + alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" + alice <## "use /fr 1 [/ | ] to receive it" (bob "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"no\"}}}" + alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" alice <## "user full name removed (your contacts are notified)" + alice <## "updated preferences:" + alice <## "voice messages allowed: no" + (alice "/_set prefs @2 {\"fullDelete\": {\"allow\": \"yes\"}}" - alice <## "you updated preferences for bob:" - alice <## "full message deletion: off (you allow: yes, contact allows: no)" - bob <## "alice updated preferences for you:" - bob <## "full message deletion: off (you allow: default (no), contact allows: yes)" (bob "/_profile {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"yes\"}}}" + alice ##> "/_set prefs @2 {\"voice\": {\"allow\": \"yes\"}}" + alice <## "you updated preferences for bob:" + alice <## "voice messages: off (you allow: yes, contact allows: no)" + alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off")]) + bob <## "alice updated preferences for you:" + 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 <## "user full name removed (your contacts are notified)" bob <## "updated preferences:" - bob <## "full message deletion allowed: yes" + bob <## "voice messages allowed: yes" + bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled")]) + (bob ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "Voice messages: enabled")]) (alice "/_set prefs @2 {}" bob <## "your preferences for alice did not change" + -- no change + bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled")]) + (bob "/_set prefs @2 {\"fullDelete\": {\"allow\": \"no\"}}" + alice ##> "/_set prefs @2 {\"voice\": {\"allow\": \"no\"}}" alice <## "you updated preferences for bob:" - alice <## "full message deletion: off (you allow: no, contact allows: yes)" + alice <## "voice messages: off (you allow: no, contact allows: yes)" + alice #$> ("/_get chat @2 count=100", chat, startFeatures <> [(1, "Voice messages: enabled for contact"), (0, "voice message (00:10)"), (1, "Voice messages: off"), (0, "Voice messages: enabled"), (1, "Voice messages: off")]) bob <## "alice updated preferences for you:" - bob <## "full message deletion: off (you allow: default (yes), contact allows: no)" + bob <## "voice messages: off (you allow: default (yes), contact allows: no)" + bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off"), (1, "Voice messages: enabled"), (0, "Voice messages: off")]) testUpdateGroupPrefs :: IO () testUpdateGroupPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do createGroup2 "team" alice bob + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected")]) + threadDelay 1000000 alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "full message deletion enabled: on" + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "full message deletion enabled: on" + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"off\"}}}" alice <## "updated group preferences:" alice <## "full message deletion enabled: off" alice <## "voice messages enabled: off" + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "full message deletion enabled: off" bob <## "voice messages enabled: off" + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}" alice <## "updated group preferences:" alice <## "voice messages enabled: on" + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "voice messages enabled: on" + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on")]) alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"team\", \"groupPreferences\": {\"fullDelete\": {\"enable\": \"off\"}, \"voice\": {\"enable\": \"on\"}}}" -- no update + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")]) + threadDelay 1000000 alice #> "#team hey" bob <# "#team alice> hey" + threadDelay 1000000 bob #> "#team hi" alice <# "#team bob> hi" + alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on"), (1, "hey"), (0, "hi")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "Full deletion: on"), (0, "Full deletion: off"), (0, "Voice messages: off"), (0, "Voice messages: on"), (0, "hey"), (1, "hi")]) testGetSetSMPServers :: IO () testGetSetSMPServers = @@ -3851,7 +3904,7 @@ testSwitchGroupMember = bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" alice #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) - bob #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) + bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" bob <# "#team alice> hey" bob #> "#team hi" @@ -4039,6 +4092,12 @@ chatFeaturesF = map (\(a, _, c) -> (a, c)) chatFeatures'' chatFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] chatFeatures'' = [((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: enabled"), Nothing, Nothing)] +groupFeatures :: [(Int, String)] +groupFeatures = map (\(a, _, _) -> a) groupFeatures'' + +groupFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] +groupFeatures'' = [((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: on"), Nothing, Nothing)] + itemId :: Int -> String itemId i = show $ length chatFeatures + i