From bfe5d51df7bc4c3fe0a551315957983fc6ea4b5d Mon Sep 17 00:00:00 2001
From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Date: Thu, 11 Jan 2024 17:55:13 +0400
Subject: [PATCH] core, ui: create dummy member record when admin forwards a
message from an unknown member (#3651)
* core: create dummy member record when admin forwards a message from an unknown member
* comments
* update unknown member if announced
* change removed
* change unknown name, revert diff
* revert diff
* ios
* update ios library
* android
* remove changes in iOS project file
* rename event
* remove unknown category
* android
---------
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
---
apps/ios/SimpleXChat/ChatTypes.swift | 18 ++++-
.../chat/simplex/common/model/ChatModel.kt | 13 +++-
.../commonMain/resources/MR/base/strings.xml | 4 ++
src/Simplex/Chat.hs | 28 ++++++--
src/Simplex/Chat/Controller.hs | 2 +
src/Simplex/Chat/Store/Groups.hs | 65 ++++++++++++++---
src/Simplex/Chat/Types.hs | 6 ++
src/Simplex/Chat/View.hs | 3 +
tests/ChatTests/Groups.hs | 69 +++++++++++++++++++
9 files changed, 189 insertions(+), 19 deletions(-)
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index cd58b2000..a40827003 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -1822,9 +1822,16 @@ public struct GroupMember: Identifiable, Decodable {
public var chatViewName: String {
get {
let p = memberProfile
- return p.localAlias == ""
- ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)")
- : p.localAlias
+ let name = (
+ p.localAlias == ""
+ ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)")
+ : p.localAlias
+ )
+ return (
+ memberStatus == .memUnknown
+ ? String.localizedStringWithFormat(NSLocalizedString("_Previous member_ %@", comment: "previous/unknown group member"), name)
+ : name
+ )
}
}
@@ -1833,6 +1840,7 @@ public struct GroupMember: Identifiable, Decodable {
case .memRemoved: return false
case .memLeft: return false
case .memGroupDeleted: return false
+ case .memUnknown: return false
case .memInvited: return false
case .memIntroduced: return false
case .memIntroInvited: return false
@@ -1849,6 +1857,7 @@ public struct GroupMember: Identifiable, Decodable {
case .memRemoved: return false
case .memLeft: return false
case .memGroupDeleted: return false
+ case .memUnknown: return false
case .memInvited: return false
case .memIntroduced: return true
case .memIntroInvited: return true
@@ -1953,6 +1962,7 @@ public enum GroupMemberStatus: String, Decodable {
case memRemoved = "removed"
case memLeft = "left"
case memGroupDeleted = "deleted"
+ case memUnknown = "unknown"
case memInvited = "invited"
case memIntroduced = "introduced"
case memIntroInvited = "intro-inv"
@@ -1967,6 +1977,7 @@ public enum GroupMemberStatus: String, Decodable {
case .memRemoved: return "removed"
case .memLeft: return "left"
case .memGroupDeleted: return "group deleted"
+ case .memUnknown: return "unknown status"
case .memInvited: return "invited"
case .memIntroduced: return "connecting (introduced)"
case .memIntroInvited: return "connecting (introduction invitation)"
@@ -1983,6 +1994,7 @@ public enum GroupMemberStatus: String, Decodable {
case .memRemoved: return "removed"
case .memLeft: return "left"
case .memGroupDeleted: return "group deleted"
+ case .memUnknown: return "unknown"
case .memInvited: return "invited"
case .memIntroduced: return "connecting"
case .memIntroInvited: return "connecting"
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index b59d57d8a..b76bc05a0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -1268,12 +1268,19 @@ data class GroupMember (
val verified get() = activeConn?.connectionCode != null
val chatViewName: String
- get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
+ get() {
+ val name = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") }
+ return if (memberStatus == GroupMemberStatus.MemUnknown)
+ String.format(generalGetString(MR.strings.previous_member_vName), name)
+ else
+ name
+ }
val memberActive: Boolean get() = when (this.memberStatus) {
GroupMemberStatus.MemRemoved -> false
GroupMemberStatus.MemLeft -> false
GroupMemberStatus.MemGroupDeleted -> false
+ GroupMemberStatus.MemUnknown -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemIntroduced -> false
GroupMemberStatus.MemIntroInvited -> false
@@ -1288,6 +1295,7 @@ data class GroupMember (
GroupMemberStatus.MemRemoved -> false
GroupMemberStatus.MemLeft -> false
GroupMemberStatus.MemGroupDeleted -> false
+ GroupMemberStatus.MemUnknown -> false
GroupMemberStatus.MemInvited -> false
GroupMemberStatus.MemIntroduced -> true
GroupMemberStatus.MemIntroInvited -> true
@@ -1377,6 +1385,7 @@ enum class GroupMemberStatus {
@SerialName("removed") MemRemoved,
@SerialName("left") MemLeft,
@SerialName("deleted") MemGroupDeleted,
+ @SerialName("unknown") MemUnknown,
@SerialName("invited") MemInvited,
@SerialName("introduced") MemIntroduced,
@SerialName("intro-inv") MemIntroInvited,
@@ -1390,6 +1399,7 @@ enum class GroupMemberStatus {
MemRemoved -> generalGetString(MR.strings.group_member_status_removed)
MemLeft -> generalGetString(MR.strings.group_member_status_left)
MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted)
+ MemUnknown -> generalGetString(MR.strings.group_member_status_unknown)
MemInvited -> generalGetString(MR.strings.group_member_status_invited)
MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced)
MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation)
@@ -1404,6 +1414,7 @@ enum class GroupMemberStatus {
MemRemoved -> generalGetString(MR.strings.group_member_status_removed)
MemLeft -> generalGetString(MR.strings.group_member_status_left)
MemGroupDeleted -> generalGetString(MR.strings.group_member_status_group_deleted)
+ MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short)
MemInvited -> generalGetString(MR.strings.group_member_status_invited)
MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting)
MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting)
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 0d076c94c..8f1b25f2e 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -1217,6 +1217,7 @@
removed
left
group deleted
+ unknown status
invited
connecting (introduced)
connecting (introduction invitation)
@@ -1227,6 +1228,9 @@
creator
connecting
+ unknown
+
+ Previous member %1$s]]>
No contacts to add
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index dbf7c6079..f455ca63a 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -5100,16 +5100,24 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
_ -> pure conn'
xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> RcvMessage -> UTCTime -> m ()
- xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ memberProfile) msg brokerTs = do
+ xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msg brokerTs = do
checkHostRole m memRole
unless (sameMemberId memId $ membership gInfo) $
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memId) >>= \case
+ Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do
+ updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db user m unknownMember memInfo
+ toView $ CRUnknownMemberAnnounced user gInfo m unknownMember updatedMember
+ memberAnnouncedToView updatedMember
Right _ -> messageError "x.grp.mem.new error: member already exists"
Left _ -> do
- newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced
- ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile)
- groupMsgToView gInfo ci
- toView $ CRJoinedGroupMemberConnecting user gInfo m newMember
+ newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced
+ memberAnnouncedToView newMember
+ where
+ memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do
+ let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile)
+ ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg brokerTs (CIRcvGroupEvent event)
+ groupMsgToView gInfo ci
+ toView $ CRJoinedGroupMemberConnecting user gInfo m announcedMember
xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m ()
xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memChatVRange _) = do
@@ -5355,8 +5363,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> ChatMessage 'Json -> UTCTime -> m ()
xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId msg msgTs = do
when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName)
- author <- withStore $ \db -> getGroupMemberByMemberId db user gInfo memberId
- processForwardedMsg author msg
+ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db user gInfo memberId) >>= \case
+ Right author -> processForwardedMsg author msg
+ Left (SEGroupMemberNotFoundByMemberId _) -> do
+ let name = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId $ memberId
+ unknownAuthor <- withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name
+ toView $ CRUnknownMemberCreated user gInfo m unknownAuthor
+ processForwardedMsg unknownAuthor msg
+ Left e -> throwError $ ChatErrorStore e
where
-- Note: forwarded group events (see forwardedGroupMsg) should include msgId to be deduplicated
processForwardedMsg :: GroupMember -> ChatMessage 'Json -> m ()
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index a5282c2b3..f9004db4c 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -630,6 +630,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}
+ | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember}
+ | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember}
| CRGroupEmpty {user :: User, groupInfo :: GroupInfo}
| CRGroupRemoved {user :: User, groupInfo :: GroupInfo}
| CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs
index 7fedc10fa..f174d348f 100644
--- a/src/Simplex/Chat/Store/Groups.hs
+++ b/src/Simplex/Chat/Store/Groups.hs
@@ -110,6 +110,8 @@ module Simplex.Chat.Store.Groups
updateMemberProfile,
getXGrpLinkMemReceived,
setXGrpLinkMemReceived,
+ createNewUnknownGroupMember,
+ updateUnknownMemberAnnounced,
)
where
@@ -594,11 +596,9 @@ getGroupSummary db User {userId} groupId = do
JOIN group_members m USING (group_id)
WHERE g.user_id = ?
AND g.group_id = ?
- AND m.member_status != ?
- AND m.member_status != ?
- AND m.member_status != ?
+ AND m.member_status NOT IN (?,?,?,?)
|]
- (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited)
+ (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited)
pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_}
getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences]
@@ -682,13 +682,13 @@ getGroupMembersForExpiration db user@User {userId, userContactId} GroupInfo {gro
( groupMemberQuery
<> [sql|
WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)
- AND m.member_status IN (?, ?, ?)
+ AND m.member_status IN (?, ?, ?, ?)
AND m.group_member_id NOT IN (
SELECT DISTINCT group_member_id FROM chat_items
)
|]
)
- (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
+ (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown)
toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember
toContactMember User {userContactId} (memberRow :. connRow) =
@@ -1339,10 +1339,10 @@ getGroupInfoByGroupLinkHash db vr user@User {userId, userContactId} (groupLinkHa
FROM groups g
JOIN group_members mu ON mu.group_id = g.group_id
WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?)
- AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?)
+ AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?,?)
LIMIT 1
|]
- (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)
+ (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown)
maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db vr user) groupId_
getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId
@@ -1965,3 +1965,52 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do
db
"UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?"
(xGrpLinkMemReceived, currentTs, mId)
+
+createNewUnknownGroupMember :: DB.Connection -> VersionRange -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember
+createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do
+ currentTs <- liftIO getCurrentTime
+ let memberProfile = profileFromName memberName
+ (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs
+ groupMemberId <- liftIO $ do
+ DB.execute
+ db
+ [sql|
+ INSERT INTO group_members
+ ( group_id, member_id, member_role, member_category, member_status, invited_by,
+ user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at,
+ peer_chat_min_version, peer_chat_max_version)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ |]
+ ( (groupId, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown)
+ :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
+ :. (minV, maxV)
+ )
+ insertedRowId db
+ getGroupMemberById db user groupMemberId
+ where
+ VersionRange minV maxV = vr
+
+updateUnknownMemberAnnounced :: DB.Connection -> User -> GroupMember -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember
+updateUnknownMemberAnnounced db user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do
+ _ <- updateMemberProfile db user unknownMember profile
+ currentTs <- liftIO getCurrentTime
+ liftIO $
+ DB.execute
+ db
+ [sql|
+ UPDATE group_members
+ SET member_role = ?,
+ member_category = ?,
+ member_status = ?,
+ invited_by_group_member_id = ?,
+ peer_chat_min_version = ?,
+ peer_chat_max_version = ?,
+ updated_at = ?
+ WHERE user_id = ? AND group_member_id = ?
+ |]
+ ( (memberRole, GCPostMember, GSMemAnnounced, groupMemberId' invitingMember)
+ :. (minV, maxV, currentTs, userId, groupMemberId)
+ )
+ getGroupMemberById db user groupMemberId
+ where
+ VersionRange minV maxV = maybe (fromJVersionRange memberChatVRange) fromChatVRange v
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index dd832d6fa..889c6fe5e 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -825,6 +825,7 @@ data GroupMemberStatus
= GSMemRemoved -- member who was removed from the group
| GSMemLeft -- member who left the group
| GSMemGroupDeleted -- user member of the deleted group
+ | GSMemUnknown -- unknown member, whose message was forwarded by an admin (likely member wasn't introduced due to not being a current member, but message was included in history)
| GSMemInvited -- member is sent to or received invitation to join the group
| GSMemIntroduced -- user received x.grp.mem.intro for this member (only with GCPreMember)
| GSMemIntroInvited -- member is sent to or received from intro invitation
@@ -851,6 +852,7 @@ memberActive m = case memberStatus m of
GSMemRemoved -> False
GSMemLeft -> False
GSMemGroupDeleted -> False
+ GSMemUnknown -> False
GSMemInvited -> False
GSMemIntroduced -> False
GSMemIntroInvited -> False
@@ -869,6 +871,7 @@ memberCurrent' = \case
GSMemRemoved -> False
GSMemLeft -> False
GSMemGroupDeleted -> False
+ GSMemUnknown -> False
GSMemInvited -> False
GSMemIntroduced -> True
GSMemIntroInvited -> True
@@ -883,6 +886,7 @@ memberRemoved m = case memberStatus m of
GSMemRemoved -> True
GSMemLeft -> True
GSMemGroupDeleted -> True
+ GSMemUnknown -> False
GSMemInvited -> False
GSMemIntroduced -> False
GSMemIntroInvited -> False
@@ -897,6 +901,7 @@ instance TextEncoding GroupMemberStatus where
"removed" -> Just GSMemRemoved
"left" -> Just GSMemLeft
"deleted" -> Just GSMemGroupDeleted
+ "unknown" -> Just GSMemUnknown
"invited" -> Just GSMemInvited
"introduced" -> Just GSMemIntroduced
"intro-inv" -> Just GSMemIntroInvited
@@ -910,6 +915,7 @@ instance TextEncoding GroupMemberStatus where
GSMemRemoved -> "removed"
GSMemLeft -> "left"
GSMemGroupDeleted -> "deleted"
+ GSMemUnknown -> "unknown"
GSMemInvited -> "invited"
GSMemIntroduced -> "introduced"
GSMemIntroInvited -> "intro-inv"
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index bc1743276..c68f54eca 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -178,6 +178,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRGroupLinkConnecting 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
+ CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um]
+ CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m]
CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"]
CRRcvFileDescrReady _ _ -> []
CRRcvFileDescrNotReady _ _ -> []
@@ -963,6 +965,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt
status m = case memberStatus m of
GSMemRemoved -> ["removed"]
GSMemLeft -> ["left"]
+ GSMemUnknown -> ["status unknown"]
GSMemInvited -> ["not yet joined"]
GSMemConnected -> ["connected"]
GSMemComplete -> ["connected"]
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index 28ca35cba..c388ccfb9 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -132,6 +132,7 @@ chatGroupTests = do
it "deleted message is not included" testGroupHistoryDeletedMessage
it "disappearing message is sent as disappearing" testGroupHistoryDisappearingMessage
it "welcome message (group description) is sent after history" testGroupHistoryWelcomeMessage
+ it "unknown member messages are processed" testGroupHistoryUnknownMember
where
_0 = supportedChatVRange -- don't create direct connections
_1 = groupCreateDirectVRange
@@ -5179,3 +5180,71 @@ testGroupHistoryWelcomeMessage =
[alice, cath] *<# "#team bob> 2"
cath #> "#team 3"
[alice, bob] *<# "#team cath> 3"
+
+testGroupHistoryUnknownMember :: HasCallStack => FilePath -> IO ()
+testGroupHistoryUnknownMember =
+ testChat4 aliceProfile bobProfile cathProfile danProfile $
+ \alice bob cath dan -> do
+ createGroup3 "team" alice bob cath
+
+ threadDelay 1000000
+
+ alice #> "#team hi from alice"
+ [bob, cath] *<# "#team alice> hi from alice"
+
+ threadDelay 1000000
+
+ bob #> "#team hi from bob"
+ [alice, cath] *<# "#team bob> hi from bob"
+
+ threadDelay 1000000
+
+ cath #> "#team hi from cath"
+ [alice, bob] *<# "#team cath> hi from cath"
+
+ bob ##> "/l team"
+ concurrentlyN_
+ [ do
+ bob <## "#team: you left the group"
+ bob <## "use /d #team to delete the group",
+ alice <## "#team: bob left the group",
+ cath <## "#team: bob left the group"
+ ]
+
+ connectUsers alice dan
+ addMember "team" alice dan GRAdmin
+ dan ##> "/j team"
+ concurrentlyN_
+ [ alice <## "#team: dan joined the group",
+ dan
+ <### [ "#team: you joined the group",
+ WithTime "#team alice> hi from alice [>>]",
+ StartsWith "#team: alice forwarded a message from an unknown member, creating unknown member record",
+ EndsWith "hi from bob [>>]",
+ WithTime "#team cath> hi from cath [>>]",
+ "#team: member cath (Catherine) is connected"
+ ],
+ do
+ cath <## "#team: alice added dan (Daniel) to the group (connecting...)"
+ cath <## "#team: new member dan is connected"
+ ]
+
+ dan ##> "/_get chat #1 count=100"
+ r <- chat <$> getTermLine dan
+ r `shouldContain` [(0, "hi from alice"), (0, "hi from bob"), (0, "hi from cath")]
+
+ dan ##> "/ms team"
+ dan
+ <### [ "dan (Daniel): admin, you, connected",
+ "alice (Alice): owner, host, connected",
+ "cath (Catherine): admin, connected",
+ EndsWith "author, status unknown"
+ ]
+
+ -- message delivery works after sending history
+ alice #> "#team 1"
+ [cath, dan] *<# "#team alice> 1"
+ cath #> "#team 2"
+ [alice, dan] *<# "#team cath> 2"
+ dan #> "#team 3"
+ [alice, cath] *<# "#team dan> 3"