core, ui: correct member attention stat for support chat on opening it; mark support chat read (#6240)

This commit is contained in:
spaced4ndy
2025-09-05 14:00:32 +00:00
committed by GitHub
parent 9705ae15ea
commit ca9b0d4e43
18 changed files with 413 additions and 62 deletions
+3
View File
@@ -912,6 +912,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
case groupMembers(user: UserRef, group: SimpleXChat.Group)
case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
@@ -961,6 +962,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .leftMemberUser: "leftMemberUser"
case .groupMembers: "groupMembers"
case .memberAccepted: "memberAccepted"
case .memberSupportChatRead: "memberSupportChatRead"
case .memberSupportChatDeleted: "memberSupportChatDeleted"
case .membersRoleUser: "membersRoleUser"
case .membersBlockedForAllUser: "membersBlockedForAllUser"
@@ -1006,6 +1008,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupMembers(u, group): return withUser(u, String(describing: group))
case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
+23 -3
View File
@@ -1391,8 +1391,14 @@ func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? {
throw r.unexpected
}
func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope))
func apiChatRead(type: ChatType, id: Int64) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil))
}
func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) {
let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope))
if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) }
throw r.unexpected
}
func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo {
@@ -1729,7 +1735,7 @@ func markChatRead(_ im: ItemsModel, _ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope())
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) }
}
@@ -1754,6 +1760,20 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
}
}
func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async {
do {
if member.supportChatNotRead {
let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId))
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
ChatModel.shared.updateGroup(updatedGroupInfo)
}
}
} catch {
logger.error("markSupportChatRead apiChatRead error: \(responseError(error))")
}
}
func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
@@ -92,6 +92,16 @@ struct MemberSupportView: View {
.frame(width: 1, height: 1)
.hidden()
}
.if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in
v.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) }
} label: {
Label("Read", systemImage: "checkmark")
}
.tint(theme.colors.primary)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if memberWithChat.wrapped.memberPending {
Button {
+9
View File
@@ -2661,6 +2661,15 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION
}
public var supportChatNotRead: Bool {
if let supportChat = supportChat,
supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 {
true
} else {
false
}
}
public var versionRange: VersionRange {
if let activeConn {
activeConn.peerChatVRange
@@ -2355,6 +2355,12 @@ data class GroupMember (
&& !memberPending
}
val supportChatNotRead: Boolean get() =
if (supportChat != null)
supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0
else
false
val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange
val memberIncognito = memberProfile.profileId != memberContactProfileId
@@ -1849,13 +1849,20 @@ object ChatController {
return null
}
suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean {
val r = sendCmd(rh, CC.ApiChatRead(type, id, scope))
suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean {
val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null))
if (r.result is CR.CmdOk) return true
Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiSupportChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope): Pair<GroupInfo, GroupMember>? {
val r = sendCmd(rh, CC.ApiChatRead(type, id, scope))
if (r is API.Result && r.res is CR.MemberSupportChatRead) return r.res.groupInfo to r.res.member
apiErrorAlert("apiSupportChatRead", generalGetString(MR.strings.error_marking_member_support_chat_read), r)
return null
}
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List<Long>): ChatInfo? {
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds))
if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo
@@ -6211,6 +6218,7 @@ sealed class CR {
@Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("memberSupportChatRead") class MemberSupportChatRead(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR()
@Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR()
@@ -6395,6 +6403,7 @@ sealed class CR {
is GroupDeletedUser -> "groupDeletedUser"
is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting"
is MemberAccepted -> "memberAccepted"
is MemberSupportChatRead -> "memberSupportChatRead"
is MemberSupportChatDeleted -> "memberSupportChatDeleted"
is MemberAcceptedByOther -> "memberAcceptedByOther"
is MemberRole -> "memberRole"
@@ -6572,6 +6581,7 @@ sealed class CR {
is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo))
is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member")
is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is MemberSupportChatRead -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member")
is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole")
@@ -707,8 +707,7 @@ fun ChatView(
chatModel.controller.apiChatRead(
chatRh,
chatInfo.chatType,
chatInfo.apiId,
chatInfo.groupChatScope()
chatInfo.apiId
)
}
withContext(Dispatchers.Main) {
@@ -270,6 +270,12 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn
showMenu.value = false
})
} else {
if (member.supportChatNotRead) {
ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = {
markSupportChatRead(rhId, groupInfo, member)
showMenu.value = false
})
}
ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
deleteMemberSupportChatDialog(rhId, groupInfo, member)
showMenu.value = false
@@ -300,3 +306,22 @@ private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: G
}
}
}
private fun markSupportChatRead(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) {
withBGApi {
if (member.supportChatNotRead) {
val r = chatModel.controller.apiSupportChatRead(
rh = rhId,
type = ChatType.Group,
id = groupInfo.apiId,
scope = GroupChatScope.MemberSupport(member.groupMemberId)
)
if (r != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second)
chatModel.chatsContext.updateGroup(rhId, r.first)
}
}
}
}
}
@@ -648,8 +648,7 @@ fun markChatRead(c: Chat) {
chatModel.controller.apiChatRead(
chat.remoteHostId,
chat.chatInfo.chatType,
chat.chatInfo.apiId,
chat.chatInfo.groupChatScope()
chat.chatInfo.apiId
)
chat = chatModel.getChat(chat.id) ?: return@withApi
}
@@ -167,6 +167,7 @@
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
<string name="error_accepting_member">Error accepting member</string>
<string name="error_marking_member_support_chat_read">Error marking chat with member as read</string>
<string name="error_deleting_member_support_chat">Error deleting chat with member</string>
<string name="cannot_receive_file">Cannot receive file</string>
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
+1
View File
@@ -162,6 +162,7 @@ undocumentedResponses =
"CRGroupUserChanged",
"CRItemsReadForChat",
"CRJoinedGroupMember",
"CRMemberSupportChatRead",
"CRMemberSupportChatDeleted",
"CRMemberSupportChats",
"CRNetworkConfig",
+1
View File
@@ -726,6 +726,7 @@ data ChatResponse
| CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]}
| CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember}
| CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole}
| CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool}
+42 -8
View File
@@ -517,13 +517,36 @@ processChatCommand vr nm = \case
pure $ CRApiChat user (AChat SCTDirect directChat) navInfo
CTGroup -> do
(groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search)
pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo
groupChat' <- checkSupportChatAttention user groupChat
pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo
CTLocal -> do
when (isJust contentFilter) $ throwCmdError "content filter not supported"
(localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search)
pure $ CRApiChat user (AChat SCTLocal localChat) navInfo
CTContactRequest -> throwCmdError "not implemented"
CTContactConnection -> throwCmdError "not supported"
where
checkSupportChatAttention :: User -> Chat 'CTGroup -> CM (Chat 'CTGroup)
checkSupportChatAttention user groupChat@Chat {chatInfo, chatItems} =
case chatInfo of
GroupChat gInfo (Just GCSIMemberSupport {groupMember_ = Just scopeMem@GroupMember {supportChat = Just suppChat}}) -> do
case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of
Just newMemAttention -> do
(gInfo', scopeMem') <-
withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention
pure $ groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))}
Nothing -> pure groupChat
_ -> pure groupChat
where
correctedMemAttention :: GroupMemberId -> GroupSupportChat -> [CChatItem 'CTGroup] -> Maybe Int64
correctedMemAttention scopeGMId GroupSupportChat {memberAttention} items =
let numNewFromMember = fromIntegral . length . takeWhile newFromMember $ reverse items
in if numNewFromMember == memberAttention then Nothing else Just numNewFromMember
where
newFromMember :: CChatItem 'CTGroup -> Bool
newFromMember (CChatItem _ ChatItem {chatDir = CIGroupRcv m, meta = CIMeta {itemStatus = CISRcvNew}}) =
groupMemberId' m == scopeGMId
newFromMember _ = False
APIGetChatItems pagination search -> withUser $ \user -> do
chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search
pure $ CRChatItems user Nothing chatItems
@@ -998,7 +1021,7 @@ processChatCommand vr nm = \case
pure $ prefix <> formattedDate <> ext
APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user
UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId
APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of
APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of
CTDirect -> do
user <- withFastStore $ \db -> getUserByContactId db chatId
ts <- liftIO getCurrentTime
@@ -1014,12 +1037,23 @@ processChatCommand vr nm = \case
gInfo <- getGroupInfo db vr user chatId
pure (user, gInfo)
ts <- liftIO getCurrentTime
timedItems <- withFastStore' $ \db -> do
timedItems <- getGroupUnreadTimedItems db user chatId
updateGroupChatItemsRead db user gInfo scope
setGroupChatItemsDeleteAt db user chatId timedItems ts
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
case scope_ of
Nothing -> do
timedItems <- withFastStore' $ \db -> do
timedItems <- getGroupUnreadTimedItems db user chatId Nothing
updateGroupChatItemsRead db user gInfo
setGroupChatItemsDeleteAt db user chatId timedItems ts
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
Just scope -> do
scopeInfo <- getChatScopeInfo vr user scope
(gInfo', m', timedItems) <- withFastStore' $ \db -> do
timedItems <- getGroupUnreadTimedItems db user chatId (Just scope)
(gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo
timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts
pure (gInfo', m', timedItems')
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
pure $ CRMemberSupportChatRead user gInfo' m'
CTLocal -> do
user <- withFastStore $ \db -> getUserByNoteFolderId db chatId
withFastStore' $ \db -> updateLocalChatItemsRead db user chatId
+10 -2
View File
@@ -1495,13 +1495,21 @@ decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO
decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do
DB.execute
db
#if defined(dbPostgres)
[sql|
UPDATE groups
SET members_require_attention = members_require_attention - 1
SET members_require_attention = GREATEST(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
|]
#else
[sql|
UPDATE groups
SET members_require_attention = MAX(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
|]
#endif
(userId, groupId)
pure g {membersRequireAttention = membersRequireAttention - 1}
pure g {membersRequireAttention = max 0 (membersRequireAttention - 1)}
increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo
increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do
+102 -23
View File
@@ -38,6 +38,7 @@ module Simplex.Chat.Store.Messages
MemberAttention (..),
updateChatTsStats,
setSupportChatTs,
setSupportChatMemberAttention,
createNewSndChatItem,
createNewRcvChatItem,
createNewChatItemNoMsg,
@@ -79,6 +80,7 @@ module Simplex.Chat.Store.Messages
setDirectChatItemRead,
setDirectChatItemsDeleteAt,
updateGroupChatItemsRead,
updateSupportChatItemsRead,
getGroupUnreadTimedItems,
updateGroupChatItemsReadList,
updateGroupScopeUnreadStats,
@@ -423,14 +425,23 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas
| not nowRequires && didRequire -> do
DB.execute
db
#if defined(dbPostgres)
[sql|
UPDATE groups
SET chat_ts = ?,
members_require_attention = members_require_attention - 1
members_require_attention = GREATEST(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
|]
#else
[sql|
UPDATE groups
SET chat_ts = ?,
members_require_attention = MAX(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
|]
#endif
(chatTs, userId, groupId)
pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member'))
pure $ GroupChat g {membersRequireAttention = max 0 (membersRequireAttention - 1), chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member'))
| otherwise -> do
DB.execute
db
@@ -496,6 +507,21 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO ()
setSupportChatTs db groupMemberId chatTs =
DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId)
setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember)
setSupportChatMemberAttention db vr user g m memberAttention = do
m' <- updateGMAttention m
g' <- updateGroupMembersRequireAttention db user g m m'
pure (g', m')
where
updateGMAttention m@GroupMember {groupMemberId} = do
currentTs <- getCurrentTime
DB.execute
db
"UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?"
(memberAttention, currentTs, groupMemberId' m)
m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId
pure $ either (const m) id m_ -- Left shouldn't happen, but types require it
createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId
createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt =
createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt
@@ -2010,20 +2036,46 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i
(deleteAt, userId, contactId, chatItemId)
pure (chatItemId, deleteAt)
updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO ()
updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do
updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> IO ()
updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} = do
currentTs <- getCurrentTime
DB.execute
db
[sql|
UPDATE chat_items SET item_status = ?, updated_at = ?
WHERE user_id = ? AND group_id = ? AND item_status = ?
WHERE user_id = ? AND group_id = ?
AND item_status = ?
|]
(CISRcvRead, currentTs, userId, groupId, CISRcvNew)
case scope of
Nothing -> pure ()
Just GCSMemberSupport {groupMemberId_} -> do
let gmId = fromMaybe (groupMemberId' membership) groupMemberId_
updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember)
updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do
currentTs <- getCurrentTime
case scopeInfo of
GCSIMemberSupport {groupMember_} -> do
DB.execute
db
[sql|
UPDATE chat_items SET item_status = ?, updated_at = ?
WHERE user_id = ? AND group_id = ?
AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ?
AND item_status = ?
|]
(CISRcvRead, currentTs, userId, groupId, GCSTMemberSupport_, groupMemberId' <$> groupMember_, CISRcvNew)
case groupMember_ of
Nothing -> do
membership' <- updateGMStats membership
pure (g {membership = membership'}, membership')
Just member -> do
member' <- updateGMStats member
let didRequire = gmRequiresAttention member
nowRequires = gmRequiresAttention member'
if (not nowRequires && didRequire)
then (,member') <$> decreaseGroupMembersRequireAttention db user g
else pure (g, member')
where
updateGMStats m@GroupMember {groupMemberId} = do
currentTs <- getCurrentTime
DB.execute
db
[sql|
@@ -2033,18 +2085,34 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope
support_chat_items_mentions = 0
WHERE group_member_id = ?
|]
(Only gmId)
(Only groupMemberId)
m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId
pure $ either (const m) id m_ -- Left shouldn't happen, but types require it
getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)]
getGroupUnreadTimedItems db User {userId} groupId =
DB.query
db
[sql|
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
|]
(userId, groupId, CISRcvNew)
getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)]
getGroupUnreadTimedItems db User {userId} groupId scope =
case scope of
Nothing ->
DB.query
db
[sql|
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
|]
(userId, groupId, CISRcvNew)
Just GCSMemberSupport {groupMemberId_} ->
DB.query
db
[sql|
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ?
AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
|]
(userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew)
updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo)
updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do
@@ -2110,14 +2178,25 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea
currentTs <- getCurrentTime
DB.execute
db
#if defined(dbPostgres)
[sql|
UPDATE group_members
SET support_chat_items_unread = support_chat_items_unread - ?,
support_chat_items_member_attention = support_chat_items_member_attention - ?,
support_chat_items_mentions = support_chat_items_mentions - ?,
SET support_chat_items_unread = GREATEST(0, support_chat_items_unread - ?),
support_chat_items_member_attention = GREATEST(0, support_chat_items_member_attention - ?),
support_chat_items_mentions = GREATEST(0, support_chat_items_mentions - ?),
updated_at = ?
WHERE group_member_id = ?
|]
#else
[sql|
UPDATE group_members
SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?),
support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?),
support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?),
updated_at = ?
WHERE group_member_id = ?
|]
#endif
(unread, unanswered, mentions, currentTs, groupMemberId)
m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId
pure $ either (const m) id m_ -- Left shouldn't happen, but types require it
@@ -25,7 +25,7 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE groups
SET chat_ts = ?,
members_require_attention = members_require_attention + 1
members_require_attention = MAX(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
Plan:
@@ -34,7 +34,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE groups
SET chat_ts = ?,
members_require_attention = members_require_attention - 1
members_require_attention = members_require_attention + 1
WHERE user_id = ? AND group_id = ?
Plan:
@@ -1189,6 +1189,25 @@ Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?)
USE TEMP B-TREE FOR ORDER BY
Query:
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ?
AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?)
Query:
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ?
AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
Plan:
SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?)
Query:
SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at
FROM chat_item_moderations
@@ -1431,6 +1450,15 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?)
SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
Query:
UPDATE chat_items SET item_status = ?, updated_at = ?
WHERE user_id = ? AND group_id = ?
AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ?
AND item_status = ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?)
Query:
UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL
WHERE user_id = ? AND via_group_link = 1 AND contact_id IN (
@@ -1482,9 +1510,9 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE group_members
SET support_chat_items_unread = support_chat_items_unread - ?,
support_chat_items_member_attention = support_chat_items_member_attention - ?,
support_chat_items_mentions = support_chat_items_mentions - ?,
SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?),
support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?),
support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?),
updated_at = ?
WHERE group_member_id = ?
@@ -4267,14 +4295,6 @@ Query:
Plan:
SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?)
Query:
SELECT chat_item_id, timed_ttl
FROM chat_items
WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL
Plan:
SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?)
Query:
SELECT group_snd_item_status, COUNT(1)
FROM group_snd_item_statuses
@@ -4361,7 +4381,8 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE chat_items SET item_status = ?, updated_at = ?
WHERE user_id = ? AND group_id = ? AND item_status = ?
WHERE user_id = ? AND group_id = ?
AND item_status = ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?)
@@ -4623,7 +4644,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE groups
SET members_require_attention = members_require_attention + 1
SET members_require_attention = MAX(0, members_require_attention - 1)
WHERE user_id = ? AND group_id = ?
Plan:
@@ -4631,7 +4652,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE groups
SET members_require_attention = members_require_attention - 1
SET members_require_attention = members_require_attention + 1
WHERE user_id = ? AND group_id = ?
Plan:
@@ -6277,6 +6298,14 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -6317,6 +6346,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id =
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE groups SET members_require_attention=1 WHERE group_id=?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+11 -1
View File
@@ -231,6 +231,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else []
CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m
CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m
CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m
CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"]
CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r'
CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked
@@ -1229,6 +1230,11 @@ viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of
GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"]
_ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"]
viewSupportChatRead :: GroupInfo -> GroupMember -> [StyledString]
viewSupportChatRead g@GroupInfo {membership = GroupMember {groupMemberId = membershipId}} m
| groupMemberId' m == membershipId = [ttyGroup' g <> ": support chat read"]
| otherwise = [ttyGroup' g <> ": " <> ttyMember m <> " support chat read"]
viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString]
viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of
GCUserMember -> case memberStatus of
@@ -1324,8 +1330,12 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt
| otherwise = []
viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString]
viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms
viewMemberSupportChats GroupInfo {membership = membership@GroupMember {memberRole = membershipRole}, membersRequireAttention} ms =
memsAttention <> support <> map groupMember ms
where
memsAttention
| membershipRole >= GRModerator = ["members require attention: " <> sShow membersRequireAttention]
| otherwise = []
support = case supportChat membership of
Just sc -> ["support: " <> chatStats sc]
Nothing -> []
+106 -3
View File
@@ -18,6 +18,7 @@ import Control.Concurrent.Async (concurrently_)
import Control.Monad (forM_, void, when)
import Data.Bifunctor (second)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.List (intercalate, isInfixOf)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
@@ -219,6 +220,7 @@ chatGroupTests = do
it "should send messages to admins and members" testSupportCLISendCommand
it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead
it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete
it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen
testGroupCheckMessages :: HasCallStack => TestParams -> IO ()
testGroupCheckMessages =
@@ -7432,8 +7434,10 @@ testScopedSupportManyModerators =
cath #$> ("/_get chat #1(_support:3) count=100", chat, [])
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
dan ##> "/member support chats #team"
dan <## "members require attention: 0"
dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 0, require attention: 0, mentions: 0"
@@ -7888,8 +7892,10 @@ testScopedSupportUnreadStatsOnRead =
dan <# "#team (support: bob) alice> 3"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
dan ##> "/member support chats #team"
dan <## "members require attention: 0"
dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 1, require attention: 0, mentions: 0"
@@ -7899,8 +7905,10 @@ testScopedSupportUnreadStatsOnRead =
[alice, dan] *<# "#team (support: bob) bob> 4"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0"
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 1, require attention: 0, mentions: 0"
@@ -7913,9 +7921,11 @@ testScopedSupportUnreadStatsOnRead =
bob <# "#team (support) dan> 5"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0"
-- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 2, require attention: 0, mentions: 0"
@@ -7928,8 +7938,10 @@ testScopedSupportUnreadStatsOnRead =
bob <# "#team (support) dan> @alice 6"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1"
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 3, require attention: 0, mentions: 0"
@@ -7944,8 +7956,10 @@ testScopedSupportUnreadStatsOnRead =
dan <# "#team (support: bob) bob> @alice 7"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2"
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0"
bob ##> "/member support chats #team"
bob <## "support: unread: 3, require attention: 0, mentions: 0"
@@ -7958,8 +7972,10 @@ testScopedSupportUnreadStatsOnRead =
dan <# "#team (support: bob) bob!> @dan 8"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2"
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1"
bob ##> "/member support chats #team"
bob <## "support: unread: 3, require attention: 0, mentions: 0"
@@ -7967,11 +7983,13 @@ testScopedSupportUnreadStatsOnRead =
alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat")
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1"
alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat")
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0"
threadDelay 1000000
@@ -7982,23 +8000,30 @@ testScopedSupportUnreadStatsOnRead =
bob <# "#team (support) dan!> @bob 9"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0"
dan ##> "/member support chats #team"
dan <## "members require attention: 1"
dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1"
bob ##> "/member support chats #team"
bob <## "support: unread: 4, require attention: 0, mentions: 1"
alice #$> ("/_read chat #1(_support:2)", id, "ok")
alice ##> "/_read chat #1(_support:2)"
alice <## "#team: bob support chat read"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
dan #$> ("/_read chat #1(_support:3)", id, "ok")
dan ##> "/_read chat #1(_support:3)"
dan <## "#team: bob support chat read"
dan ##> "/member support chats #team"
dan <## "members require attention: 0"
dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0"
bob #$> ("/_read chat #1(_support)", id, "ok")
bob ##> "/_read chat #1(_support)"
bob <## "#team: support chat read"
bob ##> "/member support chats #team"
bob <## "support: unread: 0, require attention: 0, mentions: 0"
@@ -8029,12 +8054,90 @@ testScopedSupportUnreadStatsOnDelete =
msgIdBob <- lastItemId bob
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0"
bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted")
alice <# "#team (support: bob) bob> [deleted] 1"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
where
opts =
testOpts
{ markRead = False
}
testScopedSupportUnreadStatsCorrectOnOpen :: HasCallStack => TestParams -> IO ()
testScopedSupportUnreadStatsCorrectOnOpen =
testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
createGroup2 "team" alice bob
bob #> "#team (support) 1"
alice <# "#team (support: bob) bob> 1"
bob #> "#team (support) 2"
alice <# "#team (support: bob) bob> 2"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0"
alice ##> "/_read chat #1(_support:2)"
alice <## "#team: bob support chat read"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
bob #> "#team (support) 3"
alice <# "#team (support: bob) bob> 3"
bob #> "#team (support) 4"
alice <# "#team (support: bob) bob> 4"
bob #> "#team (support) 5"
alice <# "#team (support: bob) bob> 5"
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0"
-- opening chat should correct group_members.support_chat_items_member_attention value if it got out of sync
void $ withCCTransaction alice $ \db ->
DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64))
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 3, require attention: 100, mentions: 0"
alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0"
alice ##> "/_read chat #1(_support:2)"
alice <## "#team: bob support chat read"
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
-- opening chat should also correct groups.members_require_attention value if corrected member no longer requires attention
void $ withCCTransaction alice $ \db -> do
DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64))
DB.execute db "UPDATE groups SET members_require_attention=1 WHERE group_id=?" (Only (1 :: Int64))
alice ##> "/member support chats #team"
alice <## "members require attention: 1"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 100, mentions: 0"
alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")])
alice ##> "/member support chats #team"
alice <## "members require attention: 0"
alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
where
opts =