Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ internal class DomainMapping(
disabled = disabled,
createdAt = createdAt,
updatedAt = updatedAt,
lastMessageAt = lastMessageAt,
team = team.orEmpty(),
extraData = custom.withDeclared("disabled" to disabled)
.mapNotNull { (key, value) -> value?.let { key to it } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent
import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent
import io.getstream.chat.android.client.events.NotificationMessageNewEvent
import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate
import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt
import io.getstream.chat.android.models.Channel
Expand All @@ -54,6 +55,7 @@ internal class GroupAwareChatEventHandler(
private val resolver: ChannelGroupResolver,
channels: StateFlow<Map<String, Channel>?>,
clientState: ClientState,
private val blockedUserIds: () -> Collection<String>,
private val now: () -> Date = ::Date,
) : DefaultChatEventHandler(channels, clientState) {

Expand All @@ -69,9 +71,14 @@ internal class GroupAwareChatEventHandler(

is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter)

is NotificationAddedToChannelEvent,
is NotificationMessageNewEvent,
-> watchAndAddIfBelongsHere(event.cid, event.channel, filter)
is NotificationMessageNewEvent ->
if ((cachedChannel ?: event.channel).staysHiddenAfter(event.message)) {
EventHandlingResult.Skip
} else {
watchAndAddIfBelongsHere(event.cid, event.channel, filter)
}

is NotificationAddedToChannelEvent -> watchAndAddIfBelongsHere(event.cid, event.channel, filter)

// channel.visible replayed by /sync can carry a channel with only its type and id.
is ChannelVisibleEvent -> {
Expand All @@ -93,12 +100,17 @@ internal class GroupAwareChatEventHandler(
filter: FilterObject,
cachedChannel: Channel?,
): EventHandlingResult {
val defaultResult = super.handleCidEvent(event, filter, cachedChannel)
// The cached channel can predate the new message, which may bring it back within a cutoff.
// The cached channel can predate the new message, which may bring it back within a cutoff or un-hide it.
val newMessage = (event as? NewMessageEvent)?.message
if (newMessage != null && cachedChannel?.staysHiddenAfter(newMessage) == true) return EventHandlingResult.Skip
val defaultResult = super.handleCidEvent(event, filter, cachedChannel)
return filterResultByGroup(defaultResult, cachedChannel, filter, newMessage)
}

/** Whether this channel is hidden and the new [message] keeps it hidden on the server. */
private fun Channel.staysHiddenAfter(message: Message): Boolean =
hidden == true && !message.unhidesChannel(blocked, blockedUserIds())

/**
* The membership guard prevents a `ChannelUpdatedEvent` from (re-)adding a channel the user
* has already left — relies on [cachedChannel] (in-memory state after `member.removed` is
Expand Down Expand Up @@ -126,14 +138,14 @@ internal class GroupAwareChatEventHandler(

/**
* Same checks as [routeByGroup], against [cachedChannel] with the partial update applied. The event's channel
* lacks data such as members and the last message time, so a channel that is not in the list yet is watched to
* get its full data instead of being added from the event.
* lacks data such as members, so a channel that is not in the list yet is watched to get its full data instead of
* being added from the event.
*
* The cutoff is checked only for a channel in the list, whose last activity is up to date. For any other
* channel only the group is checked: without [cachedChannel] the last activity is unknown, and a stored copy can
* be stale, so the cutoff is left to the list, which checks the watched channel before showing it. The event is
* only sent to members, and a hidden channel is dropped after the watch. The query skips the watch when the
* channel sorts below its loaded pages.
* The cutoff is checked when the channel's last activity is known: the channel is in the list, or it has a local
* copy and the event carries its last message time. For any other channel only the group is checked, and the
* cutoff is left to the list, which checks the watched channel before showing it. The event is only sent to
* members, and a hidden channel is dropped after the watch. The query skips the watch when the channel sorts below
* its loaded pages, and does not watch it when the list was not queried with `watch`.
*/
private fun routePartialUpdate(
event: NotificationChannelPartialUpdatedEvent,
Expand All @@ -142,7 +154,9 @@ internal class GroupAwareChatEventHandler(
): EventHandlingResult {
val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel
val isInList = channels.value?.containsKey(event.cid) == true
val matches = if (isInList) channelBelongsHere(channel, filter) else groupMatches(channel, filter)
// A stored copy can miss recent messages, so its last activity is known only with the event's value.
val isLastActivityKnown = isInList || (cachedChannel != null && event.channel.lastMessageAt != null)
val matches = if (isLastActivityKnown) channelBelongsHere(channel, filter) else groupMatches(channel, filter)
val belongsHere = matches &&
(cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true))
return when {
Expand All @@ -152,14 +166,17 @@ internal class GroupAwareChatEventHandler(
}
}

/** Whether [channel] belongs in this group. A disabled channel never does, since the backend leaves it out. */
private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean =
resolver.resolve(channel).contains(groupKey) &&
!channel.disabled &&
resolver.resolve(channel).contains(groupKey) &&
filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now())

/** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown or may be stale. */
private fun groupMatches(channel: Channel, filter: FilterObject): Boolean {
val checkedAt = now()
return resolver.resolve(channel).contains(groupKey) &&
return !channel.disabled &&
resolver.resolve(channel).contains(groupKey) &&
filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.state.event.handler.grouped.in
import io.getstream.chat.android.client.ChatClient
import io.getstream.chat.android.client.api.event.ChatEventHandler
import io.getstream.chat.android.client.api.event.ChatEventHandlerFactory
import io.getstream.chat.android.client.api.state.globalState
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.core.internal.InternalStreamChatApi
import io.getstream.chat.android.models.Channel
Expand All @@ -34,6 +35,7 @@ internal class GroupAwareChatEventHandlerFactory(
private val groupKey: String,
private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(),
private val clientState: ClientState = ChatClient.instance().clientState,
private val blockedUserIds: () -> Collection<String> = { ChatClient.instance().globalState.blockedUserIds.value },
) : ChatEventHandlerFactory(clientState) {

override fun chatEventHandler(channels: StateFlow<Map<String, Channel>?>): ChatEventHandler =
Expand All @@ -42,6 +44,7 @@ internal class GroupAwareChatEventHandlerFactory(
resolver = resolver,
channels = channels,
clientState = clientState,
blockedUserIds = blockedUserIds,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import java.util.concurrent.TimeUnit

private const val QUERY_TIMESTAMP_FIELD_NAME = "query_timestamp"
private const val TOUCHED_AT_FIELD_NAME = "touched_at"
private const val DISABLED_FIELD_NAME = "disabled"
private const val RELATIVE_DATE_PREFIX = "now-"
private const val RELATIVE_DATE_DAYS_SUFFIX = "d"

Expand All @@ -58,11 +57,7 @@ private val Channel.touchedAt: Date?
* messages, as the backend counts it.
*/
internal fun Channel.isUnreadInGroupFor(userId: String): Boolean =
hidden != true && !isDisabled && currentUserUnreadCount(userId) > 0

// The model has no field for it, so the server's `disabled` flag arrives in the extra data.
private val Channel.isDisabled: Boolean
get() = extraData[DISABLED_FIELD_NAME] == true
hidden != true && !disabled && currentUserUnreadCount(userId) > 0

private val queryTimestampDescSort = QuerySortByField.descByName<Channel>(QUERY_TIMESTAMP_FIELD_NAME)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent
import io.getstream.chat.android.client.events.NotificationMarkReadEvent
import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent
import io.getstream.chat.android.client.events.NotificationMessageNewEvent
import io.getstream.chat.android.client.extensions.currentUserUnreadCount
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.GroupedChannels
import io.getstream.chat.android.models.UserId
Expand Down Expand Up @@ -171,7 +170,7 @@ internal class GroupedUnreadChannelsUpdater(
private fun hadUnreadFor(cid: String, oldChannel: Channel): Boolean = when {
cid in hadUnreadOverride -> hadUnreadOverride.getValue(cid)
markAllReadApplied -> false
else -> oldChannel.currentUserUnreadCount(currentUserId) > 0
else -> oldChannel.isUnreadInGroupFor(currentUserId)
}

private fun rotateBatchIfNeeded(batchId: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@
import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic
import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState
import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate
import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel
import io.getstream.chat.android.client.persistance.repository.RepositoryFacade
import io.getstream.chat.android.client.setup.state.ClientState
import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt
import io.getstream.chat.android.client.utils.internal.ChannelId
import io.getstream.chat.android.client.utils.mergePartially
import io.getstream.chat.android.client.utils.observable.Disposable
import io.getstream.chat.android.core.internal.lazy.parameterizedLazy
import io.getstream.chat.android.models.Channel
import io.getstream.chat.android.models.ChannelCapabilities
import io.getstream.chat.android.models.Member
import io.getstream.chat.android.models.Message
Expand Down Expand Up @@ -317,12 +319,9 @@
queryChannelsLogic.parseChatEventResults(batchEvent.sortedEvents).forEach { result ->
when (result) {
is EventHandlingResult.Add -> {
// Use trackChannel instead of addChannel to avoid overwriting the shared
// per-channel state with a potentially stale DB-cached channel.
// Channel events have already updated per-channel state (e.g., lastMessageAt)
// before this method runs, and refreshChannelsState below will reconcile
// the query map with the live per-channel data.
queryChannelsLogic.trackChannel(result.channel)
// Channel events have already updated the state of active channels before this method runs, and
// refreshChannelsState below reconciles the query map with it.
queryChannelsLogic.addChannelFromEvent(result.channel)
}
is EventHandlingResult.WatchAndAdd -> queryChannelsLogic.watchAndAddChannel(result.cid)
is EventHandlingResult.Remove -> queryChannelsLogic.removeChannel(result.cid)
Expand Down Expand Up @@ -661,7 +660,7 @@
skipLastMsgUpdateForSystemMsgs = channel.config.skipLastMsgUpdateForSystemMsgs,
)
val updatedChannel = channel.copy(
hidden = channel.hidden.takeIf { enrichedMessage.shadowed } ?: false,
hidden = channel.hiddenAfter(enrichedMessage),
messages = channel.messages + listOf(enrichedMessage),
messageCount = event.channelMessageCount ?: channel.messageCount,
lastMessageAt = newLastMessageAt,
Expand Down Expand Up @@ -701,8 +700,8 @@
is NotificationMessageNewEvent -> {
batch.addMessageData(event.createdAt, event.cid, event.message)
val channel = batch.getCurrentChannel(event.cid)
?.mergeChannelFromEvent(event.channel) ?: event.channel
batch.addChannel(channel.copy(hidden = false))
?.mergeSharedEventChannel(event.channel) ?: event.channel
batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message)))
}
is NotificationAddedToChannelEvent -> {
val channel = batch.getCurrentChannel(event.cid)
Expand Down Expand Up @@ -816,12 +815,12 @@
}
is ChannelUpdatedEvent -> {
val channel = batch.getCurrentChannel(event.cid)
?.mergeChannelFromEvent(event.channel) ?: event.channel
?.mergeSharedEventChannel(event.channel) ?: event.channel
batch.addChannel(channel)
}
is ChannelUpdatedByUserEvent -> {
val channel = batch.getCurrentChannel(event.cid)
?.mergeChannelFromEvent(event.channel) ?: event.channel
?.mergeSharedEventChannel(event.channel) ?: event.channel
batch.addChannel(channel)
}
is NotificationChannelPartialUpdatedEvent -> {
Expand All @@ -837,7 +836,7 @@
}
is ChannelTruncatedEvent -> {
val channel = batch.getCurrentChannel(event.cid)
?.mergeChannelFromEvent(event.channel) ?: event.channel
?.mergeSharedEventChannel(event.channel) ?: event.channel
batch.addChannel(channel)
}
is NotificationChannelDeletedEvent -> {
Expand All @@ -847,7 +846,7 @@
}
is NotificationChannelTruncatedEvent -> {
val channel = batch.getCurrentChannel(event.cid)
?.mergeChannelFromEvent(event.channel) ?: event.channel
?.mergeSharedEventChannel(event.channel) ?: event.channel
batch.addChannel(channel)
}

Expand Down Expand Up @@ -987,6 +986,17 @@
logger.v { "[updateOfflineStorage] completed batchId: ${batchEvent.id}" }
}

/**
* This stored channel with the channel of an event applied. The event's channel is shared by all members, so it
* carries no `hidden` value of the current user, and the stored one is kept.
*/
private fun Channel.mergeSharedEventChannel(eventChannel: Channel): Channel =
mergeChannelFromEvent(eventChannel).copy(hidden = eventChannel.hidden ?: hidden)

/** The hidden state of this channel after the new [message]. */
private fun Channel.hiddenAfter(message: Message): Boolean? =
if (message.unhidesChannel(blocked, mutableGlobalState.blockedUserIds.value)) false else hidden

Check warning on line 998 in stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unnecessary Boolean literal.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-chat-android&issues=AaFfBpmy_pdBSvaC7i9s&open=AaFfBpmy_pdBSvaC7i9s&pullRequest=6864

private fun List<ChatEvent>.extractMessageIds() = mapNotNull { event ->
(event as? HasMessage)?.message?.id
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import io.getstream.chat.android.client.internal.state.event.handler.internal.ut
import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl
import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState
import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate
import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel
import io.getstream.chat.android.client.utils.message.isPinned
import io.getstream.chat.android.models.Message
import io.getstream.chat.android.models.MessageReminder
Expand Down Expand Up @@ -151,10 +152,7 @@ internal class ChannelEventHandlerImpl(
}
// Update channel read state for the current user
state.updateCurrentUserRead(event.createdAt, enrichedMessage)
// Update hidden state if the message is not shadowed
if (!enrichedMessage.shadowed) {
state.setHidden(false)
}
unhideFor(enrichedMessage)
// Update message count
event.channelMessageCount?.let(state::setMessageCount)
// Update last message at
Expand All @@ -177,10 +175,7 @@ internal class ChannelEventHandlerImpl(
is NotificationMessageNewEvent -> {
// Update channel read state
state.updateCurrentUserRead(event.createdAt, event.message)
// Update hidden state if the message is not shadowed
if (!event.message.shadowed) {
state.setHidden(false)
}
unhideFor(event.message)
// Update last message at
state.updateLastMessageAt(event.message)
// Insert the message into the appropriate message list
Expand Down Expand Up @@ -238,10 +233,7 @@ internal class ChannelEventHandlerImpl(
is NotificationThreadMessageNewEvent -> {
// Handle only if thread reply was sent to channel as well
if (event.message.showInChannel) {
// Update hidden state if the message is not shadowed
if (!event.message.shadowed) {
state.setHidden(false)
}
unhideFor(event.message)
// Insert the message into the appropriate message list
if (state.insideSearch.value) {
state.upsertCachedMessage(event.message)
Expand Down Expand Up @@ -393,6 +385,13 @@ internal class ChannelEventHandlerImpl(
}
}

/** Shows the channel again when the new [message] makes the server show it. */
private fun unhideFor(message: Message) {
if (message.unhidesChannel(state.channelData.value.blocked, globalState.blockedUserIds.value)) {
state.setHidden(false)
}
}

private fun updateReminder(messageId: String, reminder: MessageReminder) {
state.updateMessageById(messageId) { message ->
message.copy(reminder = reminder.toMessageReminderInfo())
Expand Down
Loading
Loading