diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index be6ab571f78..b2edb136166 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -67,15 +67,16 @@ public abstract class io/getstream/chat/android/compose/state/channels/list/Item public final class io/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState : io/getstream/chat/android/compose/state/channels/list/ItemState { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;Z)V - public synthetic fun (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZZ)V + public synthetic fun (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Channel; public final fun component2 ()Z public final fun component3 ()Ljava/util/List; public final fun component4 ()Lio/getstream/chat/android/models/DraftMessage; public final fun component5 ()Z - public final fun copy (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;Z)Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState;Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZILjava/lang/Object;)Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState; + public final fun component6 ()Z + public final fun copy (Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZZ)Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState;Lio/getstream/chat/android/models/Channel;ZLjava/util/List;Lio/getstream/chat/android/models/DraftMessage;ZZILjava/lang/Object;)Lio/getstream/chat/android/compose/state/channels/list/ItemState$ChannelItemState; public fun equals (Ljava/lang/Object;)Z public final fun getChannel ()Lio/getstream/chat/android/models/Channel; public final fun getDraftMessage ()Lio/getstream/chat/android/models/DraftMessage; @@ -84,6 +85,7 @@ public final class io/getstream/chat/android/compose/state/channels/list/ItemSta public fun hashCode ()I public final fun isMuted ()Z public final fun isSelected ()Z + public final fun isUserMuted ()Z public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ItemState.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ItemState.kt index f3006cd7335..47e12058237 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ItemState.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/state/channels/list/ItemState.kt @@ -35,6 +35,7 @@ public sealed class ItemState { * @param typingUsers The list of users currently typing in the channel. * @param draftMessage The draft message for the current user in the channel. * @param isSelected Whether this channel is currently selected (e.g. via long-press context menu). + * @param isUserMuted If this is a 1:1 channel and the other member is muted by the current user. */ public data class ChannelItemState( val channel: Channel, @@ -42,6 +43,7 @@ public sealed class ItemState { val typingUsers: List = emptyList(), val draftMessage: DraftMessage? = null, val isSelected: Boolean = false, + val isUserMuted: Boolean = false, ) : ItemState() { override val key: String = channel.cid } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index e5d760b3203..e179650a888 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -254,7 +254,7 @@ private fun TitleRow( unreadCount: Int, ) { val channel = channelItemState.channel - val isMuted = channelItemState.isMuted + val isMuted = channelItemState.isMuted || channelItemState.isUserMuted val mutePosition = ChatTheme.config.channelList.muteIndicatorPosition Row( modifier = Modifier.fillMaxWidth(), @@ -344,7 +344,9 @@ private fun MessageRow( MessageContent(channelItemState, currentUser, lastMessage, isDirectMessaging) } - if (channelItemState.isMuted && mutePosition == MuteIndicatorPosition.TrailingBottom) { + if ((channelItemState.isMuted || channelItemState.isUserMuted) && + mutePosition == MuteIndicatorPosition.TrailingBottom + ) { Icon( modifier = Modifier .testTag("Stream_ChannelMutedIcon") diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 85145e37642..f372d218437 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter +import io.getstream.chat.android.ui.common.utils.extensions.isOneToOne import io.getstream.log.taggedLogger import io.getstream.result.call.toUnitCall import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -283,6 +284,7 @@ public class ChannelListViewModel( ), ) } + is SearchQuery.Messages -> { chListScope.coroutineContext.cancelChildren() handleSearchQuery(query.query) @@ -401,6 +403,7 @@ public class ChannelListViewModel( next = result.value.next, ) } + is io.getstream.result.Result.Failure -> { logger.e { "[searchMessages] #$src; failed: ${result.value}" } currentState.copy( @@ -433,7 +436,8 @@ public class ChannelListViewModel( channelMutes, typingChannels, channelDraftMessages, - ) { state, channelMutes, typingChannels, channelDraftMessages -> + globalMuted, + ) { state, channelMutes, typingChannels, channelDraftMessages, userMutes -> when (state) { ChannelsStateData.NoQueryActive, ChannelsStateData.Loading, @@ -458,6 +462,7 @@ public class ChannelListViewModel( channelItems = createChannelItems( channels = state.channels, channelMutes = channelMutes, + userMutes = userMutes, typingEvents = typingChannels, draftMessages = channelDraftMessages.takeIf { draftMessagesEnabled } ?: emptyMap(), ), @@ -575,6 +580,7 @@ public class ChannelListViewModel( is SearchQuery.Empty, is SearchQuery.Channels, -> chListScope.launch { loadMoreQueryChannels() } + is SearchQuery.Messages, -> searchScope.launch { loadMoreQueryMessages() } } @@ -801,25 +807,37 @@ public class ChannelListViewModel( * * @param channels The channels to show. * @param channelMutes The list of channels muted for the current user. - * + * @param userMutes The list of users muted by the current user. */ private fun createChannelItems( channels: List, channelMutes: List, + userMutes: List, typingEvents: Map, draftMessages: Map, ): List { val mutedChannelIds = channelMutes.map { channelMute -> channelMute.channel?.cid }.toSet() + val mutedUserIds = userMutes.mapNotNullTo(mutableSetOf()) { it.target?.id } + val currentUser = user.value return channels.map { ItemState.ChannelItemState( channel = it, isMuted = it.cid in mutedChannelIds, + isUserMuted = it.isOneToOneMutedByUser(currentUser, mutedUserIds), typingUsers = typingEvents[it.cid]?.users ?: emptyList(), draftMessage = draftMessages[it.cid], ) } } + /** Checks if a 1:1 channel is muted via user mute (i.e. the other member is muted). */ + private fun Channel.isOneToOneMutedByUser(currentUser: User?, mutedUserIds: Set): Boolean = + if (mutedUserIds.isEmpty() || !isOneToOne(currentUser)) { + false + } else { + members.any { it.user.id != currentUser?.id && it.user.id in mutedUserIds } + } + internal companion object { /** * Default value of number of channels to return when querying channels. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index b995617771a..f83b0579a2b 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -36,7 +36,9 @@ import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.OrFilterObject import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.TypingEvent @@ -191,6 +193,97 @@ internal class ChannelListViewModelTest { verify(chatClient).unmuteChannel("messaging", "channel1") } + @Test + fun `Given a DM with a muted user Should mark the channel item as user-muted`() = runTest { + val viewModel = Fixture() + .givenCurrentUser(currentUser) + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(directChannel)), + loading = false, + ) + .givenChannelMutes() + .givenUserMutes(listOf(otherUserMute)) + .givenTypingChannels() + .get(this) + + val channelItem = viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState + assertFalse(channelItem.isMuted) + assertTrue(channelItem.isUserMuted) + } + + @Test + fun `Given a DM without a muted user Should not mark the channel item as muted`() = runTest { + val viewModel = Fixture() + .givenCurrentUser(currentUser) + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(directChannel)), + loading = false, + ) + .givenChannelMutes() + .givenTypingChannels() + .get(this) + + val channelItem = viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState + assertFalse(channelItem.isMuted) + assertFalse(channelItem.isUserMuted) + } + + @Test + fun `Given a DM whose other member is not muted Should not mark the channel item as muted`() = runTest { + val unrelatedUserMute = Mute( + user = currentUser, + target = User(id = "unrelatedUser"), + createdAt = Date(), + updatedAt = Date(), + expires = null, + ) + val viewModel = Fixture() + .givenCurrentUser(currentUser) + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(directChannel)), + loading = false, + ) + .givenChannelMutes() + .givenUserMutes(listOf(unrelatedUserMute)) + .givenTypingChannels() + .get(this) + + val channelItem = viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState + assertFalse(channelItem.isMuted) + assertFalse(channelItem.isUserMuted) + } + + @Test + fun `Given a group channel with a muted user Should not mark the channel item as muted`() = runTest { + val groupChannel = Channel( + type = "messaging", + id = "groupChannel", + members = listOf( + Member(user = currentUser), + Member(user = otherUser), + Member(user = User(id = "thirdUser")), + ), + ) + val viewModel = Fixture() + .givenCurrentUser(currentUser) + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(groupChannel)), + loading = false, + ) + .givenChannelMutes() + .givenUserMutes(listOf(otherUserMute)) + .givenTypingChannels() + .get(this) + + val channelItem = viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState + assertFalse(channelItem.isMuted) + assertFalse(channelItem.isUserMuted) + } + @Test fun `Given channel list in content state When selecting a channel and dismissing the menu Should hide the menu`() = runTest { @@ -569,6 +662,10 @@ internal class ChannelListViewModelTest { whenever(globalState.channelMutes) doReturn MutableStateFlow(channelMutes) } + fun givenUserMutes(userMutes: List = emptyList()) = apply { + whenever(globalState.muted) doReturn MutableStateFlow(userMutes) + } + fun givenTypingChannels(typingChannels: Map = emptyMap()) = apply { whenever(globalState.typingChannels) doReturn MutableStateFlow(typingChannels) } @@ -649,6 +746,9 @@ internal class ChannelListViewModelTest { ) private val querySort = QuerySortByField.descByName("lastUpdated") + private val currentUser = User(id = "currentUser") + private val otherUser = User(id = "otherUser") + private val channel1: Channel = Channel( type = "messaging", id = "channel1", @@ -657,5 +757,20 @@ internal class ChannelListViewModelTest { type = "messaging", id = "channel2", ) + private val directChannel = Channel( + type = "messaging", + id = "!members-currentUser-otherUser", + members = listOf( + Member(user = currentUser), + Member(user = otherUser), + ), + ) + private val otherUserMute = Mute( + user = currentUser, + target = otherUser, + createdAt = Date(), + updatedAt = Date(), + expires = null, + ) } }