Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.robots

import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelList.Channel
import io.getstream.chat.android.compose.uiautomator.isDisplayed
import io.getstream.chat.android.compose.uiautomator.waitForText
import io.getstream.chat.android.compose.uiautomator.waitToAppear
import io.getstream.chat.android.compose.uiautomator.waitToDisappear
import io.getstream.chat.android.e2e.test.robots.ParticipantRobot
Expand All @@ -36,7 +37,7 @@ fun UserRobot.assertMessageInChannelPreview(text: String, fromCurrentUser: Boole
false -> "${ParticipantRobot.name}: $text"
null -> text
}
assertEquals(expectedPreview, Channel.messagePreview.waitToAppear().text.trimEnd())
assertEquals(expectedPreview, Channel.messagePreview.waitToAppear().waitForText(expectedPreview).text.trimEnd())
return this
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1192,22 +1192,24 @@ public final class io/getstream/chat/android/compose/ui/components/channels/Chan
public final class io/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility {
public static final field $stable I
public fun <init> ()V
public fun <init> (ZZZZZZ)V
public synthetic fun <init> (ZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (ZZZZZZZ)V
public synthetic fun <init> (ZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun component2 ()Z
public final fun component3 ()Z
public final fun component4 ()Z
public final fun component5 ()Z
public final fun component6 ()Z
public final fun copy (ZZZZZZ)Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;
public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;ZZZZZZILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;
public final fun component7 ()Z
public final fun copy (ZZZZZZZ)Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;
public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;ZZZZZZZILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/components/channels/ChannelOptionItemVisibility;
public fun equals (Ljava/lang/Object;)Z
public fun hashCode ()I
public final fun isArchiveChannelVisible ()Z
public final fun isDeleteChannelVisible ()Z
public final fun isLeaveChannelVisible ()Z
public final fun isMuteChannelVisible ()Z
public final fun isMuteUserVisible ()Z
public final fun isPinChannelVisible ()Z
public final fun isViewInfoVisible ()Z
public fun toString ()Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,28 @@

package io.getstream.chat.android.compose.ui.channels.list

import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import io.getstream.chat.android.client.extensions.isArchive
import io.getstream.chat.android.client.extensions.isPinned
import io.getstream.chat.android.compose.R
import io.getstream.chat.android.compose.state.channels.list.ItemState
import io.getstream.chat.android.compose.ui.util.isDistinct
import io.getstream.chat.android.compose.ui.theme.ChatTheme
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.ChannelCapabilities
import io.getstream.chat.android.ui.common.state.channels.actions.ArchiveChannel
import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction
import io.getstream.chat.android.ui.common.state.channels.actions.MuteChannel
import io.getstream.chat.android.ui.common.state.channels.actions.PinChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnarchiveChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnmuteChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnpinChannel
import kotlinx.coroutines.launch

/**
* Default swipe actions for a channel list item.
*
* Shows two actions:
* - **More** (gray, left): Opens the channel options bottom sheet.
* - **Primary action** (blue, right): Archive for DMs, Mute for groups — with fallback priority.
*
* The primary action is resolved via a priority list:
* - DM: Archive → Mute → Pin
* - Group: Mute → Archive → Pin
* - **Primary action** (blue, right): Mute/Unmute channel.
*
* Each action is a self-executing [ChannelAction] that invokes its handler via
* [LocalSwipeActionHandler].
Expand All @@ -66,7 +55,7 @@ public fun DefaultChannelSwipeActions(channelItem: ItemState.ChannelItemState) {
if (moreHandler != null) {
SwipeActionItem(
icon = painterResource(R.drawable.stream_design_ic_more),
label = LocalContext.current.resources.getString(R.string.stream_compose_swipe_action_more),
label = LocalResources.current.getString(R.string.stream_compose_swipe_action_more),
onClick = {
scope.launch { coordinator?.closeAll() }
moreHandler(channel)
Expand All @@ -84,83 +73,38 @@ public fun DefaultChannelSwipeActions(channelItem: ItemState.ChannelItemState) {
SwipeActionItem(
icon = painterResource(primaryAction.icon),
label = primaryAction.label,
onClick = { primaryAction.onAction() },
onClick = primaryAction.onAction,
style = SwipeActionStyle.Primary,
)
}
}

/**
* Resolves and remembers the primary swipe action based on channel type and capabilities.
*
* DM priority: Archive → Mute → Pin.
* Group priority: Mute → Archive → Pin.
* Resolves and remembers the primary swipe action (mute/unmute channel).
*
* Archive and Pin are always available (membership operations, no capability gate).
* Mute requires [ChannelCapabilities.MUTE_CHANNEL].
* Requires [ChannelCapabilities.MUTE_CHANNEL] and `isMuteChannelVisible` in the theme.
*/
@Composable
private fun rememberPrimarySwipeAction(
channel: Channel,
isMuted: Boolean,
handler: (ChannelAction) -> Unit,
): ChannelAction? {
val resources = LocalContext.current.resources
val resources = LocalResources.current
val handlerState = rememberUpdatedState(handler)
val isPinned = channel.isPinned()
val isArchived = channel.isArchive()
val canMute = channel.ownCapabilities.contains(ChannelCapabilities.MUTE_CHANNEL)
val isDM = channel.isDistinct() && channel.members.size == 2
val canMute = ChatTheme.channelOptionsTheme.optionVisibility.isMuteChannelVisible &&
channel.ownCapabilities.contains(ChannelCapabilities.MUTE_CHANNEL)

return remember(channel.cid, isMuted, isPinned, isArchived, canMute, isDM) {
return remember(channel.cid, isMuted, canMute) {
if (!canMute) return@remember null
var resolved: ChannelAction? = null
val onAction: () -> Unit = { resolved?.let { handlerState.value(it) } }

val archiveAction = archiveAction(channel, isArchived, resources, onAction)
val muteAction = muteAction(channel, isMuted, canMute, resources, onAction)
val pinAction = pinAction(channel, isPinned, resources, onAction)

val candidates: List<ChannelAction?> = if (isDM) {
listOf(archiveAction, muteAction, pinAction)
resolved = if (isMuted) {
UnmuteChannel(channel, resources.getString(R.string.stream_compose_swipe_action_unmute), onAction)
} else {
listOf(muteAction, archiveAction, pinAction)
MuteChannel(channel, resources.getString(R.string.stream_compose_swipe_action_mute), onAction)
}

resolved = candidates.firstOrNull { it != null }
resolved
}
}

private fun archiveAction(
channel: Channel,
isArchived: Boolean,
resources: Resources,
onAction: () -> Unit,
): ChannelAction = if (isArchived) {
UnarchiveChannel(channel, resources.getString(R.string.stream_compose_swipe_action_unarchive), onAction)
} else {
ArchiveChannel(channel, resources.getString(R.string.stream_compose_swipe_action_archive), onAction)
}

private fun muteAction(
channel: Channel,
isMuted: Boolean,
canMute: Boolean,
resources: Resources,
onAction: () -> Unit,
): ChannelAction? = when {
!canMute -> null
isMuted -> UnmuteChannel(channel, resources.getString(R.string.stream_compose_swipe_action_unmute), onAction)
else -> MuteChannel(channel, resources.getString(R.string.stream_compose_swipe_action_mute), onAction)
}

private fun pinAction(
channel: Channel,
isPinned: Boolean,
resources: Resources,
onAction: () -> Unit,
): ChannelAction = if (isPinned) {
UnpinChannel(channel, resources.getString(R.string.stream_compose_swipe_action_unpin), onAction)
} else {
PinChannel(channel, resources.getString(R.string.stream_compose_swipe_action_pin), onAction)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package io.getstream.chat.android.compose.ui.components.channels
* @param isViewInfoVisible Visibility of the view channel info option.
* @param isLeaveChannelVisible Visibility of the leave channel option.
* @param isMuteChannelVisible Visibility of the mute channel option.
* @param isMuteUserVisible Visibility of the mute user option (DM channels only).
* @param isArchiveChannelVisible Visibility of the archive channel option.
* @param isPinChannelVisible Visibility of the pin channel option.
* @param isDeleteChannelVisible Visibility of the delete channel option.
Expand All @@ -32,7 +33,8 @@ public data class ChannelOptionItemVisibility(
val isViewInfoVisible: Boolean = true,
val isLeaveChannelVisible: Boolean = true,
val isMuteChannelVisible: Boolean = true,
val isArchiveChannelVisible: Boolean = true,
val isMuteUserVisible: Boolean = true,
val isArchiveChannelVisible: Boolean = false,
val isPinChannelVisible: Boolean = false,
val isDeleteChannelVisible: Boolean = true,
)
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ import io.getstream.chat.android.ui.common.state.channels.actions.MuteUser
import io.getstream.chat.android.ui.common.state.channels.actions.PinChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnarchiveChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnblockUser
import io.getstream.chat.android.ui.common.state.channels.actions.UnmuteChannel
import io.getstream.chat.android.ui.common.state.channels.actions.UnmuteUser
import io.getstream.chat.android.ui.common.state.channels.actions.UnpinChannel
import io.getstream.chat.android.ui.common.state.channels.actions.ViewInfo
Expand Down Expand Up @@ -96,8 +95,8 @@ public fun ChannelOptions(
*
* Actions vary by channel type:
* - **DM:** View Info, Mute/Unmute User, Block/Unblock User, Archive Chat, Delete Chat
* - **Group (owner):** View Info, Mute/Unmute Group, Archive Group, Delete Group
* - **Group (member):** View Info, Mute/Unmute Group, Archive Group, Leave Group
* - **Group (owner):** View Info, Archive Group, Delete Group
* - **Group (member):** View Info, Archive Group, Leave Group
*
* @param selectedChannel The currently selected channel.
* @param isMuted If the channel is muted or not.
Expand Down Expand Up @@ -136,7 +135,6 @@ public fun buildDefaultChannelActions(
} else {
buildGroupChannelActions(
selectedChannel = selectedChannel,
isMuted = isMuted,
ownCapabilities = ownCapabilities,
optionVisibility = optionVisibility,
channelName = channelName,
Expand Down Expand Up @@ -171,7 +169,7 @@ private fun buildDmChannelActions(
onViewInfoAction = onViewInfoAction,
),
buildDmMuteUserAction(
isVisible = optionVisibility.isMuteChannelVisible,
isVisible = optionVisibility.isMuteUserVisible,
otherUserId = otherUserId,
selectedChannel = selectedChannel,
viewModel = viewModel,
Expand Down Expand Up @@ -292,14 +290,13 @@ private fun buildDmDeleteAction(

/**
* Builds channel actions for group channels.
* - **Owner (has DELETE_CHANNEL):** View Info, Mute/Unmute Group, Archive Group, Delete Group
* - **Member (no DELETE_CHANNEL):** View Info, Mute/Unmute Group, Archive Group, Leave Group
* - **Owner (has DELETE_CHANNEL):** View Info, Archive Group, Delete Group
* - **Member (no DELETE_CHANNEL):** View Info, Archive Group, Leave Group
*/
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun buildGroupChannelActions(
selectedChannel: Channel,
isMuted: Boolean,
ownCapabilities: Set<String>,
optionVisibility: ChannelOptionItemVisibility,
channelName: String,
Expand All @@ -308,7 +305,6 @@ private fun buildGroupChannelActions(
): List<ChannelAction> {
val canLeaveChannel = ownCapabilities.contains(ChannelCapabilities.LEAVE_CHANNEL)
val canDeleteChannel = ownCapabilities.contains(ChannelCapabilities.DELETE_CHANNEL)
val canMuteChannel = ownCapabilities.contains(ChannelCapabilities.MUTE_CHANNEL)

return listOfNotNull(
if (optionVisibility.isViewInfoVisible) {
Expand All @@ -320,12 +316,6 @@ private fun buildGroupChannelActions(
} else {
null
},
buildGroupMuteAction(
canMuteChannel = optionVisibility.isMuteChannelVisible && canMuteChannel,
isMuted = isMuted,
selectedChannel = selectedChannel,
viewModel = viewModel,
),
buildGroupPinAction(
canPinChannel = optionVisibility.isPinChannelVisible,
selectedChannel = selectedChannel,
Expand Down Expand Up @@ -479,33 +469,6 @@ private fun buildGroupArchiveAction(
null -> null
}

/**
* Builds the mute action for group channels, using "Mute Group" / "Unmute Group" labels.
*/
@Composable
private fun buildGroupMuteAction(
canMuteChannel: Boolean,
isMuted: Boolean,
selectedChannel: Channel,
viewModel: ChannelListViewModel,
): ChannelAction? = if (canMuteChannel) {
when (isMuted) {
true -> UnmuteChannel(
channel = selectedChannel,
label = stringResource(id = R.string.stream_compose_selected_channel_menu_unmute_group),
onAction = { viewModel.unmuteChannel(selectedChannel) },
)

false -> MuteChannel(
channel = selectedChannel,
label = stringResource(id = R.string.stream_compose_selected_channel_menu_mute_group),
onAction = { viewModel.muteChannel(selectedChannel) },
)
}
} else {
null
}

/**
* Preview of [ChannelOptions].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ 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
Expand Down Expand Up @@ -434,8 +433,7 @@ public class ChannelListViewModel(
channelMutes,
typingChannels,
channelDraftMessages,
globalMuted,
) { state, channelMutes, typingChannels, channelDraftMessages, userMutes ->
) { state, channelMutes, typingChannels, channelDraftMessages ->
when (state) {
ChannelsStateData.NoQueryActive,
ChannelsStateData.Loading,
Expand All @@ -460,8 +458,6 @@ public class ChannelListViewModel(
channelItems = createChannelItems(
channels = state.channels,
channelMutes = channelMutes,
userMutes = userMutes,
currentUser = user.value,
typingEvents = typingChannels,
draftMessages = channelDraftMessages.takeIf { isDraftMessageEnabled } ?: emptyMap(),
),
Expand Down Expand Up @@ -805,41 +801,25 @@ 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.
* @param currentUser The currently logged in user.
*
*/
@Suppress("LongParameterList")
private fun createChannelItems(
channels: List<Channel>,
channelMutes: List<ChannelMute>,
userMutes: List<Mute>,
currentUser: User?,
typingEvents: Map<String, TypingEvent>,
draftMessages: Map<String, DraftMessage>,
): List<ItemState.ChannelItemState> {
val mutedChannelIds = channelMutes.map { channelMute -> channelMute.channel?.cid }.toSet()
val mutedUserIds = userMutes.mapNotNullTo(mutableSetOf()) { it.target?.id }
return channels.map {
ItemState.ChannelItemState(
channel = it,
isMuted = it.cid in mutedChannelIds || it.isOneToOneMutedByUser(currentUser, mutedUserIds),
isMuted = it.cid in mutedChannelIds,
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<String>) =
if (mutedUserIds.isEmpty() || currentUser == null || !isOneToOne(currentUser)) {
false
} else {
val otherUser = members.find { it.user.id != currentUser.id }?.user
otherUser != null && otherUser.id in mutedUserIds
}

internal companion object {
/**
* Default value of number of channels to return when querying channels.
Expand Down
Loading
Loading