Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-02-05 18:01:11 +00:00
8 changed files with 164 additions and 85 deletions

View File

@@ -27,79 +27,76 @@ struct GroupMentionsView: View {
@State private var mentionName: String = ""
@State private var mentionRange: NSRange?
@State private var mentionMemberId: String?
@State private var sortedMembers: [GMember] = []
var body: some View {
ZStack {
if isVisible {
Color.white.opacity(0.01)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isVisible = false
}
}
VStack {
Spacer()
VStack {
Spacer()
let filtered = filteredMembers()
if filtered.count > 0 {
Color.white.opacity(0.01)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
isVisible = false
}
VStack {
Divider()
let list = List {
ForEach(filteredMembers, id: \.wrapped.groupMemberId) { member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
.disabled(disabled)
.opacity(disabled ? 0.6 : 1)
.onTapGesture {
memberSelected(member)
Spacer()
VStack {
Spacer()
VStack {
Divider()
let list = List {
ForEach(filtered, id: \.wrapped.groupMemberId) { member in
let mentioned = mentionMemberId == member.wrapped.memberId
let disabled = composeState.mentions.count >= MAX_NUMBER_OF_MENTIONS && !mentioned
memberRowView(member.wrapped, mentioned)
.contentShape(Rectangle())
.disabled(disabled)
.opacity(disabled ? 0.6 : 1)
.onTapGesture {
memberSelected(member)
}
}
}
.listStyle(PlainListStyle())
.frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count)))
if #available(iOS 16.0, *) {
list.scrollDismissesKeyboard(.never)
} else {
list
}
}
.background(Color(UIColor.systemBackground))
}
.listStyle(PlainListStyle())
.frame(height: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filteredMembers.count)))
if #available(iOS 16.0, *) {
list.scrollDismissesKeyboard(.never)
} else {
list
}
.frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS)
}
.background(Color(UIColor.systemBackground))
}
.frame(maxWidth: .infinity, maxHeight: MEMBER_ROW_SIZE * MAX_VISIBLE_MEMBER_ROWS)
}
.offset(y: isVisible ? 0 : 300)
.animation(.spring(), value: isVisible)
.onChange(of: composeState.parsedMessage) { parsedMsg in
currentMessage = composeState.message
messageChanged(currentMessage, parsedMsg, selectedRange)
}
.onChange(of: selectedRange) { r in
// This condition is needed to prevent messageChanged called twice,
// because composeState.formattedText triggers later when message changes.
// The condition is only true if position changed without text change
if currentMessage == composeState.message {
messageChanged(currentMessage, composeState.parsedMessage, r)
.animation(.spring(), value: isVisible)
}
}
.onAppear {
currentMessage = composeState.message
}
.onChange(of: composeState.parsedMessage) { parsedMsg in
currentMessage = composeState.message
messageChanged(currentMessage, parsedMsg, selectedRange)
}
.onChange(of: selectedRange) { r in
// This condition is needed to prevent messageChanged called twice,
// because composeState.formattedText triggers later when message changes.
// The condition is only true if position changed without text change
if currentMessage == composeState.message {
messageChanged(currentMessage, composeState.parsedMessage, r)
}
}
.onAppear {
currentMessage = composeState.message
}
}
private var filteredMembers: [GMember] {
let members = m.groupMembers
.filter { m in
let status = m.wrapped.memberStatus
return status != .memLeft && status != .memRemoved && status != .memInvited
}
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
private func filteredMembers() -> [GMember] {
let s = mentionName.lowercased()
return s.isEmpty
? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
? sortedMembers
: sortedMembers.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
}
private func messageChanged(_ msg: String, _ parsedMsg: [FormattedText], _ range: NSRange) {
@@ -112,7 +109,10 @@ struct GroupMentionsView: View {
mentionRange = r
mentionMemberId = composeState.mentions[name]?.memberId
if !m.membersLoaded {
Task { await m.loadGroupMembers(groupInfo) }
Task {
await m.loadGroupMembers(groupInfo)
sortMembers()
}
}
return
case .none: () //
@@ -124,7 +124,10 @@ struct GroupMentionsView: View {
mentionName = ""
mentionRange = atRange
mentionMemberId = nil
Task { await m.loadGroupMembers(groupInfo) }
Task {
await m.loadGroupMembers(groupInfo)
sortMembers()
}
return
}
}
@@ -134,6 +137,14 @@ struct GroupMentionsView: View {
closeMemberList()
}
private func sortMembers() {
sortedMembers = m.groupMembers.filter({ m in
let status = m.wrapped.memberStatus
return status != .memLeft && status != .memRemoved && status != .memInvited
})
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
}
private func removeUnusedMentions(_ parsedMsg: [FormattedText]) {
let usedMentions: Set<String> = Set(parsedMsg.compactMap { ft in
if case let .mention(name) = ft.format { name } else { nil }

View File

@@ -172,9 +172,9 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */; };
649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */; };
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -530,9 +530,9 @@
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a"; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a"; sourceTree = "<group>"; };
649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -689,9 +689,9 @@
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a in Frameworks */,
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -772,8 +772,8 @@
649B28D82CFE07CF00536B68 /* libffi.a */,
649B28DC2CFE07CF00536B68 /* libgmp.a */,
649B28DA2CFE07CF00536B68 /* libgmpxx.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-HduHbmAnH3q9HOCIBFU5AE.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.2-Dof9ekfAaNQ8X3suD73WcH.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1957,7 +1957,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2006,7 +2006,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2047,7 +2047,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2067,7 +2067,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2092,7 +2092,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2129,7 +2129,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2166,7 +2166,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2217,7 +2217,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2268,7 +2268,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2302,7 +2302,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 260;
CURRENT_PROJECT_VERSION = 261;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;

View File

@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.3-beta.1
android.version_code=270
android.version_name=6.3-beta.2
android.version_code=271
desktop.version_name=6.3-beta.1
desktop.version_code=88
desktop.version_name=6.3-beta.2
desktop.version_code=89
kotlin.version=1.9.23
gradle.plugin.version=8.2.0

View File

@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.3.0.2
version: 6.3.0.3
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat

View File

@@ -390,19 +390,34 @@ forwardedGroupMsg msg@ChatMessage {chatMsgEvent} = case encoding @e of
_ -> Nothing
-- applied after checking forwardedGroupMsg and building list of group members to forward to, see Chat;
--
-- this filters out members if any of forwarded events in batch is an XGrpMemRestrict event referring to them,
-- but practically XGrpMemRestrict is not batched with other events so it wouldn't prevent forwarding of other events
-- to these members
-- to these members;
--
-- same for reports (MCReport) - they are not batched with other events, so we can safely filter out
-- members with role less than moderator when forwarding
forwardedToGroupMembers :: forall e. MsgEncodingI e => [GroupMember] -> NonEmpty (ChatMessage e) -> [GroupMember]
forwardedToGroupMembers ms forwardedMsgs =
filter (\GroupMember {memberId} -> memberId `notElem` restrictMemberIds) ms
filter forwardToMember ms
where
forwardToMember GroupMember {memberId, memberRole} =
(memberId `notElem` restrictMemberIds)
&& (not hasReport || memberRole >= GRModerator)
restrictMemberIds = mapMaybe restrictMemberId $ L.toList forwardedMsgs
restrictMemberId ChatMessage {chatMsgEvent} = case encoding @e of
SJson -> case chatMsgEvent of
XGrpMemRestrict mId _ -> Just mId
_ -> Nothing
_ -> Nothing
hasReport = any isReport forwardedMsgs
isReport ChatMessage {chatMsgEvent} = case encoding @e of
SJson -> case chatMsgEvent of
XMsgNew mc -> case mcExtMsgContent mc of
ExtMsgContent {content = MCReport {}} -> True
_ -> False
_ -> False
_ -> False
data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object}
deriving (Eq, Show)

View File

@@ -891,7 +891,7 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} =
map (toContactMember vr user)
<$> DB.query
db
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)")
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)")
(userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner)
getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember]

View File

@@ -4577,7 +4577,7 @@ Query:
FROM connections cc
WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id
)
WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)
WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)
Plan:
SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)

View File

@@ -133,6 +133,7 @@ chatGroupTests = do
it "re-create member contact after deletion, many groups" testRecreateMemberContactManyGroups
describe "group message forwarding" $ do
it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward
it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport
it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate
it "forward message edit (x.msg.update)" testGroupMsgForwardEdit
it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction
@@ -3980,6 +3981,58 @@ testGroupMsgForward =
cath <# "#team bob> hi there [>>]"
cath <# "#team hey team"
testGroupMsgForwardReport :: HasCallStack => TestParams -> IO ()
testGroupMsgForwardReport =
testChat3 aliceProfile bobProfile cathProfile $
\alice bob cath -> do
setupGroupForwarding3 "team" alice bob cath
bob #> "#team hi there"
alice <# "#team bob> hi there"
cath <# "#team bob> hi there [>>]"
alice ##> "/mr team bob moderator"
concurrentlyN_
[ alice <## "#team: you changed the role of bob from admin to moderator",
bob <## "#team: alice changed your role from admin to moderator",
cath <## "#team: alice changed the role of bob from admin to moderator"
]
cath ##> "/report #team content hi there"
cath <# "#team > bob hi there"
cath <## " report content"
concurrentlyN_
[ do
alice <# "#team cath> > bob hi there"
alice <## " report content",
do
bob <# "#team cath!> > bob hi there [>>]"
bob <## " report content [>>]"
]
alice ##> "/mr team bob member"
concurrentlyN_
[ alice <## "#team: you changed the role of bob from moderator to member",
bob <## "#team: alice changed your role from moderator to member",
cath <## "#team: alice changed the role of bob from moderator to member"
]
cath ##> "/report #team content hi there"
cath <# "#team > bob hi there"
cath <## " report content"
concurrentlyN_
[ do
alice <# "#team cath> > bob hi there"
alice <## " report content",
(bob </)
]
-- regular messages are still forwarded
cath #> "#team hey team"
alice <# "#team cath> hey team"
bob <# "#team cath> hey team [>>]"
setupGroupForwarding3 :: String -> TestCC -> TestCC -> TestCC -> IO ()
setupGroupForwarding3 gName alice bob cath = do
createGroup3 gName alice bob cath