Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[i231] Old messages are read by newly added member #5212

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -758,9 +758,11 @@ public class MessageListController(
val groupedMessages = mutableListOf<MessageListItemState>()
val membersMap = members.associateBy { it.user.id }
val sortedReads = reads
.filter { it.user.id != currentUser?.id && !it.belongsToFreshlyAddedMember(membersMap) }
.filter {
it.user.id != currentUser?.id && membersMap.contains(it.user.id)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix N2

&& !it.belongsToFreshlyAddedMember(membersMap)
}
.sortedBy { it.lastRead }
val lastRead = sortedReads.lastOrNull()?.lastRead

val isThreadWithNoReplies = isInThread && messages.size == 1
val isThreadWithReplies = isInThread && messages.size > 1
Expand Down Expand Up @@ -805,14 +807,15 @@ public class MessageListController(
if (message.isSystem() || (message.isError() && !message.isModerationBounce())) {
groupedMessages.add(SystemMessageItemState(message = message))
} else {
val isMessageRead = message.createdAt
?.let { lastRead != null && it <= lastRead }
?: false

val messageReadBy = message.createdAt?.let { messageCreatedAt ->
sortedReads.filter { it.lastRead.after(messageCreatedAt) ?: false }
sortedReads.filter {
it.lastRead.after(messageCreatedAt)
&& membersMap[it.user.id]?.createdAt?.before(messageCreatedAt) == true
}
} ?: emptyList()

val isMessageRead = messageReadBy.isNotEmpty()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix N3

val isMessageFocused = message.id == focusedMessage?.id
if (isMessageFocused) removeMessageFocus(message.id)

Expand Down Expand Up @@ -875,6 +878,12 @@ public class MessageListController(
): Boolean {
val member = membersMap[user.id]
val membershipAndLastReadDiff = member?.createdAt?.diff(lastRead)?.millis ?: Long.MAX_VALUE
if (member?.createdAt?.after(lastRead) == true) {
// If the member was added after the last read, we consider it as a freshly added member.
return true
}
// If the difference between the member's creation and the last read is less than the threshold, we consider it
// as a freshly added member.
Comment on lines +945 to +950
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix N1

return membershipAndLastReadDiff < MEMBERSHIP_AND_LAST_READ_THRESHOLD_MS
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import io.getstream.chat.android.ui.common.state.messages.list.DeleteMessage
import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility
import io.getstream.chat.android.ui.common.state.messages.list.EditMessage
import io.getstream.chat.android.ui.common.state.messages.list.SendAnyway
import io.getstream.chat.android.ui.feature.messages.list.options.message.MessageOptionItemsFactory
import io.getstream.chat.android.ui.feature.messages.list.options.message.plus
import io.getstream.chat.android.ui.utils.extensions.getCreatedAtOrThrow
import io.getstream.chat.android.ui.viewmodel.messages.MessageComposerViewModel
import io.getstream.chat.android.ui.viewmodel.messages.MessageListHeaderViewModel
Expand All @@ -52,6 +54,8 @@ import io.getstream.chat.android.ui.viewmodel.messages.bindView
import io.getstream.chat.ui.sample.common.navigateSafely
import io.getstream.chat.ui.sample.databinding.FragmentChatBinding
import io.getstream.chat.ui.sample.feature.chat.composer.CustomMessageComposerLeadingContent
import io.getstream.chat.ui.sample.feature.chat.messagelist.options.CustomMessageOption
import io.getstream.chat.ui.sample.feature.chat.messagelist.options.CustomMessageOptionItemsFactory
import io.getstream.chat.ui.sample.feature.common.ConfirmationDialogFragment
import io.getstream.chat.ui.sample.util.extensions.useAdjustResize
import io.getstream.log.taggedLogger
Expand Down Expand Up @@ -303,6 +307,24 @@ class ChatFragment : Fragment() {
else -> Unit
}
}

setMessageOptionItemsFactory(
CustomMessageOptionItemsFactory(requireContext()) +
MessageOptionItemsFactory.defaultFactory(requireContext()),
)

setCustomActionHandler { message, extra ->
when (extra[CustomMessageOption.TYPE]) {
CustomMessageOption.TYPE_MESSAGE_DETAILS -> {
findNavController().navigateSafely(
ChatFragmentDirections.actionChatFragmentToMessageDetailsFragment(
args.cid,
message.id,
),
)
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.chat.ui.sample.feature.chat.messagelist.details

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.getstream.chat.android.models.User
import io.getstream.chat.ui.sample.common.appThemeContext
import io.getstream.chat.ui.sample.databinding.AdapterMessageDetailsReadByBinding
import java.util.Date

class MessageDetailsAdapter : ListAdapter<MessageDetailsItem, MessageDetailsViewHolder<*>>(MessageDetailsItemDiff) {

override fun getItemCount(): Int {
return super.getItemCount()
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageDetailsViewHolder<*> {
return AdapterMessageDetailsReadByBinding
.inflate(LayoutInflater.from(parent.context.appThemeContext), parent, false)
.let(::ReadByViewHolder)
}

override fun onBindViewHolder(holder: MessageDetailsViewHolder<*>, position: Int) {
when (holder) {
is ReadByViewHolder -> holder.bind(getItem(position) as ReadByItem)
}
}
}

sealed class MessageDetailsItem {
abstract val id: String
}

sealed class MessageDetailsViewHolder<T : MessageDetailsItem>(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
abstract fun bind(item: T)
}

class ReadByViewHolder(
private val binding: AdapterMessageDetailsReadByBinding,
) : MessageDetailsViewHolder<ReadByItem>(binding.root) {
override fun bind(item: ReadByItem) {
binding.userAvatarView.setUser(item.user)
binding.nameTextView.text = item.user.name
binding.readAtTextView.text = item.lastReadAt.toString()
}
}

data class ReadByItem(
val user: User,
val lastReadAt: Date,
val lastReadMessageId: String,
) : MessageDetailsItem() {
override val id: String get() = user.id
}

private object MessageDetailsItemDiff : DiffUtil.ItemCallback<MessageDetailsItem>() {
override fun areItemsTheSame(oldItem: MessageDetailsItem, newItem: MessageDetailsItem): Boolean {
return oldItem.id == newItem.id
}

override fun areContentsTheSame(oldItem: MessageDetailsItem, newItem: MessageDetailsItem): Boolean {
return oldItem == newItem
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* Copyright (c) 2014-2024 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.chat.ui.sample.feature.chat.messagelist.details

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.navArgs
import io.getstream.chat.android.client.extensions.getCreatedAtOrNull
import io.getstream.chat.android.models.ChannelUserRead
import io.getstream.chat.android.ui.feature.messages.list.adapter.MessageListItem
import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModel
import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelFactory
import io.getstream.chat.ui.sample.R
import io.getstream.chat.ui.sample.databinding.FragmentMessageDetailsBinding
import io.getstream.log.taggedLogger
import kotlinx.coroutines.launch

class MessageDetailsFragment : Fragment() {

private val logger by taggedLogger("ChatFragment")

private val args: MessageDetailsFragmentArgs by navArgs()

private val viewModel: MessageDetailsViewModel by viewModels {
MessageDetailsViewModelFactory(args.cid, args.messageId)
}

private val messageListViewModel: MessageListViewModel by viewModels {
MessageListViewModelFactory(
context = requireContext().applicationContext,
cid = args.cid,
messageId = args.messageId,
)
}

private var _binding: FragmentMessageDetailsBinding? = null
private val binding get() = _binding!!

private val adapter: MessageDetailsAdapter = MessageDetailsAdapter()

override fun onAttach(context: Context) {
super.onAttach(context)
logger.i { "[onAttach] context: $context, targetFragment: $targetFragment" }
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
logger.i { "[onCreate] args: $args" }
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentMessageDetailsBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.initToolbar()

binding.readByValue.itemAnimator = null
binding.readByValue.adapter = adapter

observeMessageList()
// observeMessage()
}

private fun observeMessageList() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
messageListViewModel.state.observe(viewLifecycleOwner) { state ->
logger.i { "[onViewCreated] state: $state" }
when (state) {
is MessageListViewModel.State.Loading -> {
binding.progressBar.isVisible = true
}
is MessageListViewModel.State.Result -> {
binding.progressBar.isVisible = false
val messageItem = state.messageListItem.items.filterIsInstance<MessageListItem.MessageItem>()
.firstOrNull { it.message.id == args.messageId }
if (messageItem == null) {
logger.w { "[observeMessageList] was unable to find messageId: ${args.messageId}" }
return@observe
}
binding.sentByValue.text = messageItem.message.user.name
binding.createAtValue.text = messageItem.message.getCreatedAtOrNull()?.toString() ?: "N/A"
binding.readByValueEmpty.isVisible = messageItem.messageReadBy.isEmpty()
binding.readByValue.isVisible = messageItem.messageReadBy.isNotEmpty()

val items = messageItem.messageReadBy.toItems()
adapter.submitList(items)
}
MessageListViewModel.State.NavigateUp -> {
// TODO: Navigate up
}
}
}
}
}
}

private fun observeMessage() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
logger.i { "[onViewCreated] state: $state" }
when (state) {
MessageDetailsViewState.Empty -> {
binding.progressBar.isVisible = false
}

MessageDetailsViewState.Loading -> {
binding.progressBar.isVisible = true
}

is MessageDetailsViewState.Failed -> {
binding.progressBar.isVisible = false
// TODO: Show error
}

is MessageDetailsViewState.Loaded -> {
binding.progressBar.isVisible = false
binding.sentByValue.text = state.sentBy
binding.createAtValue.text = state.createdAt
binding.readByValueEmpty.isVisible = state.readBy.isEmpty()
binding.readByValue.isVisible = state.readBy.isNotEmpty()
adapter.submitList(state.readBy.toItems())
}
}
}
}
}
}

private fun FragmentMessageDetailsBinding.initToolbar() {
(requireActivity() as AppCompatActivity).run {
setSupportActionBar(toolbar)
supportActionBar?.run {
setDisplayShowTitleEnabled(false)
setDisplayShowHomeEnabled(true)
setDisplayHomeAsUpEnabled(true)

ContextCompat.getDrawable(requireContext(), R.drawable.ic_icon_left)?.apply {
setTint(ContextCompat.getColor(requireContext(), R.color.stream_ui_black))
}?.let(toolbar::setNavigationIcon)

toolbar.setNavigationOnClickListener {
onBackPressed()
}
}
}
toolbar.setTitle(R.string.message_details)
}
}

private fun List<ChannelUserRead>.toItems(): List<MessageDetailsItem> {
return map(ChannelUserRead::toItem)
}

private fun ChannelUserRead.toItem(): MessageDetailsItem {
return ReadByItem(
user = user,
lastReadAt = lastRead,
lastReadMessageId = lastReadMessageId.orEmpty(),
)
}
Loading
Loading