Skip to content

Commit

Permalink
Feature: Unread posts indicator (#375)
Browse files Browse the repository at this point in the history
  • Loading branch information
FelberMartin authored Feb 13, 2025
1 parent 36c87d9 commit 2356353
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ private fun generateMessage(
time: Instant,
id: String,
authorId: Long
): ChatListItem.PostChatListItem {
return ChatListItem.PostChatListItem(
): ChatListItem.IndexedPost {
return ChatListItem.IndexedPost(
PostPojo(
clientPostId = id,
serverPostId = 0L,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand Down Expand Up @@ -50,6 +51,10 @@ fun ConversationScreen(
viewModel.updateOpenedThread(threadPostId)
}

DisposableEffect(viewModel) {
onDispose { viewModel.chatListUseCase.resetLastAlreadyReadPostId() }
}

val showThread by remember(threadPostId) {
mutableStateOf(threadPostId != null)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,31 @@ internal open class ConversationViewModel(
private val deleteJobs = mutableMapOf<IBasePost, Job>()
val isMarkedAsDeleteList = mutableStateListOf<IBasePost>()

val conversation: StateFlow<DataState<Conversation>> = flatMapLatest(
serverConfigurationService.serverUrl,
accountService.authToken,
onReloadRequestAndWebsocketReconnect.onStart { emit(Unit) }
) { serverUrl, authToken, _ ->
retryOnInternet(networkStatusProvider.currentNetworkStatus) {
conversationService.getConversation(
courseId = metisContext.courseId,
conversationId = metisContext.conversationId,
authToken = authToken,
serverUrl = serverUrl
)
}
}
.stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly)

val chatListUseCase = ConversationChatListUseCase(
viewModelScope = viewModelScope,
metisService = metisService,
metisStorageService = metisStorageService,
metisContext = metisContext,
onRequestSoftReload = onRequestSoftReload,
serverConfigurationService = serverConfigurationService,
accountService = accountService
accountService = accountService,
conversation = conversation,
)

val threadUseCase = ConversationThreadUseCase(
Expand Down Expand Up @@ -199,22 +216,9 @@ internal open class ConversationViewModel(
}
.stateIn(viewModelScope + coroutineContext, SharingStarted.Lazily)

private val hasModerationRights: StateFlow<Boolean> = flatMapLatest(
serverConfigurationService.serverUrl,
accountService.authToken,
onRequestReload.onStart { emit(Unit) }
) { serverUrl, authToken, _ ->
retryOnInternet(networkStatusProvider.currentNetworkStatus) {
conversationService
.getConversation(
courseId = metisContext.courseId,
conversationId = metisContext.conversationId,
authToken = authToken,
serverUrl = serverUrl
)
.bind { it.hasModerationRights }
}
.map { it.orElse(false) }
private val hasModerationRights: StateFlow<Boolean> = conversation.map {
it.bind { conversation -> conversation.hasModerationRights }
.orElse(false)
}
.stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, false)

Expand Down Expand Up @@ -249,21 +253,6 @@ internal open class ConversationViewModel(
}
.stateIn(viewModelScope, SharingStarted.Eagerly, DataStatus.Outdated)

val conversation: StateFlow<DataState<Conversation>> = flatMapLatest(
serverConfigurationService.serverUrl,
accountService.authToken,
onReloadRequestAndWebsocketReconnect.onStart { emit(Unit) }
) { serverUrl, authToken, _ ->
retryOnInternet(networkStatusProvider.currentNetworkStatus) {
conversationService.getConversation(
courseId = metisContext.courseId,
conversationId = metisContext.conversationId,
authToken = authToken,
serverUrl = serverUrl
)
}
}
.stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly)

val latestUpdatedConversation: StateFlow<DataState<Conversation>> = flatMapLatest(
conversation.holdLatestLoaded(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ sealed class ChatListItem {

fun getItemKey(): Any = when (this) {
is DateDivider -> localDate.toEpochDays()
is PostChatListItem -> post.key
is IndexedPost -> post.key
is UnreadIndicator -> "unread"
}

data class PostChatListItem(val post: IStandalonePost) : ChatListItem()
data class IndexedPost(val post: IStandalonePost, val index: Long = -1L) : ChatListItem()

data class DateDivider(val localDate: LocalDate) : ChatListItem()

data object UnreadIndicator : ChatListItem()
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.insertSeparators
import androidx.paging.map
import de.tum.informatics.www1.artemis.native_app.core.data.DataState
import de.tum.informatics.www1.artemis.native_app.core.data.NetworkResponse
import de.tum.informatics.www1.artemis.native_app.core.datastore.AccountService
import de.tum.informatics.www1.artemis.native_app.core.datastore.ServerConfigurationService
import de.tum.informatics.www1.artemis.native_app.core.datastore.authToken
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisFilter
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.network.MetisService.StandalonePostsContext
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.service.storage.MetisStorageService
import de.tum.informatics.www1.artemis.native_app.feature.metis.conversation.ui.DataStatus
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisContext
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.MetisFilter
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.IStandalonePost
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.StandalonePost
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.content.dto.conversation.Conversation
import de.tum.informatics.www1.artemis.native_app.feature.metis.shared.db.pojo.PostPojo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
Expand All @@ -44,7 +46,6 @@ import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.lang.RuntimeException
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.math.max
Expand All @@ -54,10 +55,11 @@ class ConversationChatListUseCase(
private val viewModelScope: CoroutineScope,
private val metisService: MetisService,
private val metisStorageService: MetisStorageService,
private val metisContext: MetisContext,
private val metisContext: MetisContext.Conversation,
onRequestSoftReload: Flow<Unit>,
private val serverConfigurationService: ServerConfigurationService,
private val accountService: AccountService,
conversation: StateFlow<DataState<Conversation>>,
private val coroutineContext: CoroutineContext = EmptyCoroutineContext
) {
companion object {
Expand Down Expand Up @@ -108,6 +110,20 @@ class ConversationChatListUseCase(

private val highestVisiblePostIndex = MutableStateFlow(0)

private val unreadMessagesCountFlow: Flow<Long> = conversation.map {
it.bind { conversation ->
conversation.unreadMessagesCount ?: 0L
}.orElse(0L)
}
.stateIn(viewModelScope + coroutineContext, SharingStarted.Eagerly, 0L)

// We store this in addition to the unreadPostCount, because as new posts come in, the unreadPostCount will become outdated.
private var lastAlreadyReadPostId: Long? = null

fun resetLastAlreadyReadPostId() {
lastAlreadyReadPostId = null
}

@OptIn(ExperimentalPagingApi::class)
val postPagingData: Flow<PagingData<ChatListItem>> =
pagingDataInput.flatMapLatest { pagingDataInput ->
Expand All @@ -116,7 +132,8 @@ class ConversationChatListUseCase(
enablePlaceholders = true
)

if (pagingDataInput.standalonePostsContext.query.isNullOrBlank()) {
val isSearchActive = !pagingDataInput.standalonePostsContext.query.isNullOrBlank()
val pagerFlow = if (!isSearchActive) {
Pager(
config = config,
remoteMediator = MetisRemoteMediator(
Expand Down Expand Up @@ -144,6 +161,7 @@ class ConversationChatListUseCase(
}
)
.flow
.mapIndexedPosts()
.cachedIn(viewModelScope + coroutineContext)
} else {
Pager(
Expand All @@ -160,21 +178,42 @@ class ConversationChatListUseCase(
}
)
.flow
.mapIndexedPosts()
.cachedIn(viewModelScope + coroutineContext)
}
.map { pagingList -> pagingList.map(ChatListItem::PostChatListItem) }

pagerFlow
.map(::insertDateSeparators)
.map {
if (!isSearchActive) {
insertUnreadSeparator(it)
} else {
it
}
}
}
.shareIn(viewModelScope + coroutineContext, SharingStarted.Lazily, replay = 1)


private fun Flow<PagingData<out IStandalonePost>>.mapIndexedPosts(): Flow<PagingData<ChatListItem.IndexedPost>> {
// TODO: this indexing seems to work, BUT if a chat has between 40 and 60 posts, the pager messes something up
// https://github.com/ls1intum/artemis-android/issues/392
return this.map { pagingData ->
var indexCounter = 0L
pagingData.map { post ->
ChatListItem.IndexedPost(post, indexCounter++)
}
}
}

/**
* This flow handles reloading the visible posts from the server.
* It is invoked when a soft reload is requested.
*
* It loads the necessary data from the server and updates the db.
*
* Instead of directly emitting a DataState, it instead emits a wrapper that also contains which
* highestvisiblepostindex was used. This is then used in [softReloadOnScrollDataStatus].
* highestVisiblePostIndex was used. This is then used in [softReloadOnScrollDataStatus].
*/
private val softReloadOnRequestDataStatus: Flow<SoftReloadOnRequestResult> = onRequestSoftReload
.transformLatest {
Expand Down Expand Up @@ -264,8 +303,8 @@ class ConversationChatListUseCase(
_query.value = new.ifEmpty { null }
}

private fun insertDateSeparators(pagingList: PagingData<ChatListItem.PostChatListItem>) =
pagingList.insertSeparators { before: ChatListItem.PostChatListItem?, after: ChatListItem.PostChatListItem? ->
private fun insertDateSeparators(pagingList: PagingData<ChatListItem.IndexedPost>) =
pagingList.insertSeparators { before: ChatListItem.IndexedPost?, after: ChatListItem.IndexedPost? ->
when {
before == null && after == null -> null
before != null && after == null -> {
Expand All @@ -285,6 +324,33 @@ class ConversationChatListUseCase(
}
}

private fun insertUnreadSeparator(pagingList: PagingData<ChatListItem>) =
pagingList.insertSeparators { _, after: ChatListItem? ->
// If we already know the id, great
if (lastAlreadyReadPostId != null) {
if (after != null && after is ChatListItem.IndexedPost && after.post.serverPostId == lastAlreadyReadPostId) {
return@insertSeparators ChatListItem.UnreadIndicator
} else {
return@insertSeparators null
}
}

// Otherwise we first need to figure out the id of the last already read post

val unreadMessagesCount = unreadMessagesCountFlow.first()

if (unreadMessagesCount == 0L) {
return@insertSeparators null
}

if (after != null && after is ChatListItem.IndexedPost && after.index == unreadMessagesCount) {
lastAlreadyReadPostId = after.post.serverPostId
return@insertSeparators ChatListItem.UnreadIndicator
}

return@insertSeparators null
}

/**
* Tries to load all posts that have been added in the meantime.
*/
Expand Down
Loading

0 comments on commit 2356353

Please sign in to comment.