diff --git a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt index 9385b95..3d461e7 100644 --- a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt +++ b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt @@ -20,13 +20,13 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) = suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict() suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString()) -suspend fun Preferences.append(key: String, value: String) { +suspend fun Preferences.append(key: String, value: String): Set { val current = this.readStrings(key) ?: emptySet() - this.store(key, current + value) + return (current + value).also { this.store(key, it) } } -suspend fun Preferences.removeFromSet(key: String, value: String) { +suspend fun Preferences.removeFromSet(key: String, value: String): Set { val current = this.readStrings(key) ?: emptySet() - this.store(key, current - value) + return (current - value).also { this.store(key, it) } } diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt index 2b4d071..a36db06 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/StoreModule.kt @@ -34,13 +34,17 @@ class StoreModule( private val coroutineDispatchers: CoroutineDispatchers, ) { + private val muteableStore by unsafeLazy { MutedStorePersistence(preferences) } + fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers) - fun roomStore(): RoomStore = RoomPersistence( - database = database, - overviewPersistence = OverviewPersistence(database, coroutineDispatchers), - coroutineDispatchers = coroutineDispatchers, - muteableStore = MutedStorePersistence(preferences), - ) + fun roomStore(): RoomStore { + return RoomPersistence( + database = database, + overviewPersistence = OverviewPersistence(database, coroutineDispatchers), + coroutineDispatchers = coroutineDispatchers, + muteableStore = muteableStore, + ) + } fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences) fun syncStore(): SyncStore = SyncTokenPreferences(preferences) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt index 0b8eb14..2169b02 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt @@ -5,6 +5,10 @@ import app.dapk.st.core.append import app.dapk.st.core.removeFromSet import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.sync.MuteableStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.onStart private const val KEY_MUTE = "mute" @@ -12,20 +16,23 @@ internal class MutedStorePersistence( private val preferences: Preferences ) : MuteableStore { + private val allMutedFlow = MutableSharedFlow>(replay = 1) + override suspend fun mute(roomId: RoomId) { - preferences.append(KEY_MUTE, roomId.value) + preferences.append(KEY_MUTE, roomId.value).notifyChange() } override suspend fun unmute(roomId: RoomId) { - preferences.removeFromSet(KEY_MUTE, roomId.value) + preferences.removeFromSet(KEY_MUTE, roomId.value).notifyChange() } - override suspend fun isMuted(roomId: RoomId): Boolean { - val allMuted = allMuted() - return allMuted.contains(roomId) - } + private suspend fun Set.notifyChange() = allMutedFlow.emit(this.map { RoomId(it) }.toSet()) - override suspend fun allMuted(): Set { + override suspend fun isMuted(roomId: RoomId) = allMutedFlow.firstOrNull()?.contains(roomId) ?: false + + override fun observeMuted(): Flow> = allMutedFlow.onStart { emit(readAll()) } + + private suspend fun readAll(): Set { return preferences.readStrings(KEY_MUTE)?.map { RoomId(it) }?.toSet() ?: emptySet() } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt index 2093b05..ed78563 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryListingScreen.kt @@ -2,6 +2,7 @@ package app.dapk.st.directory import android.content.Intent import android.net.Uri +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -10,6 +11,13 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Speaker +import androidx.compose.material.icons.filled.VolumeMute +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.SpeakerNotesOff +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -198,36 +206,24 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock: ) } - if (hasUnread) { + if (hasUnread || room.isMuted) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.weight(1f)) { body(overview, secondaryText, room.typing) } - Spacer(modifier = Modifier.width(6.dp)) - Box(Modifier.align(Alignment.CenterVertically)) { - Box( - Modifier - .align(Alignment.Center) - .background(color = MaterialTheme.colorScheme.primary, shape = CircleShape) - .size(22.dp), - contentAlignment = Alignment.Center - ) { - val unreadTextSize = when (room.unreadCount.value > 99) { - true -> 9.sp - false -> 10.sp - } - val unreadLabelContent = when { - room.unreadCount.value > 99 -> "99+" - else -> room.unreadCount.value.toString() - } - Text( - fontSize = unreadTextSize, - fontWeight = FontWeight.Medium, - text = unreadLabelContent, - color = MaterialTheme.colorScheme.onPrimary - ) + if (hasUnread) { + Spacer(modifier = Modifier.width(6.dp)) + Box(Modifier.align(Alignment.CenterVertically)) { + UnreadCircle(room) } } + if (room.isMuted) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Filled.VolumeOff, + contentDescription = "", + ) + } } } else { body(overview, secondaryText, room.typing) @@ -237,6 +233,32 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock: } } +@Composable +private fun BoxScope.UnreadCircle(room: DirectoryItem) { + Box( + Modifier.Companion + .align(Alignment.Center) + .background(color = MaterialTheme.colorScheme.primary, shape = CircleShape) + .size(22.dp), + contentAlignment = Alignment.Center + ) { + val unreadTextSize = when (room.unreadCount.value > 99) { + true -> 9.sp + false -> 10.sp + } + val unreadLabelContent = when { + room.unreadCount.value > 99 -> "99+" + else -> room.unreadCount.value.toString() + } + Text( + fontSize = unreadTextSize, + fontWeight = FontWeight.Medium, + text = unreadLabelContent, + color = MaterialTheme.colorScheme.onPrimary + ) + } +} + @Composable private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) { val bodySize = 14.sp diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt index 9cf1b85..b8033cd 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/DirectoryUseCase.kt @@ -22,15 +22,15 @@ internal class DirectoryUseCase( overviewDatasource(), messageService.localEchos(), roomStore.observeUnreadCountById(), - syncService.events() - ) { overviewState, localEchos, unread, events -> - val allMuted = roomStore.allMuted() + syncService.events(), + roomStore.observeMuted(), + ) { overviewState, localEchos, unread, events, muted -> overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview -> DirectoryItem( overview = roomOverview, unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0), typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId }?.engine(), - isMuted = allMuted.contains(roomOverview.roomId), + isMuted = muted.contains(roomOverview.roomId), ) } } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixFactory.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixFactory.kt index c7cd770..63b00cd 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixFactory.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixFactory.kt @@ -28,6 +28,9 @@ import app.dapk.st.matrix.sync.internal.room.MessageDecrypter import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmStore import app.dapk.st.olm.OlmWrapper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import java.time.Clock internal object MatrixFactory { @@ -146,7 +149,7 @@ internal object MatrixFactory { singleRoomStore = object : SingleRoomStore { override suspend fun mute(roomId: RoomId) = roomStore.mute(roomId) override suspend fun unmute(roomId: RoomId) = roomStore.unmute(roomId) - override suspend fun isMuted(roomId: RoomId) = roomStore.isMuted(roomId) + override fun isMuted(roomId: RoomId): Flow = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged() } ) diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt index f0e6e72..f8f7048 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt @@ -23,8 +23,9 @@ internal class TimelineUseCaseImpl( return combine( roomDatasource(roomId), messageService.localEchos(roomId), - syncService.events(roomId) - ) { roomState, localEchos, events -> + syncService.events(roomId), + roomService.observeIssMuted(roomId), + ) { roomState, localEchos, events, isMuted -> MessengerPageState( roomState = when { localEchos.isEmpty() -> roomState @@ -38,7 +39,7 @@ internal class TimelineUseCaseImpl( }, typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }?.engine(), self = userId, - isMuted = roomService.isMuted(roomId) + isMuted = isMuted, ) } } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt index f7b7e49..1109d6b 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -6,6 +6,7 @@ import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomMember import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.room.internal.* +import kotlinx.coroutines.flow.Flow private val SERVICE_KEY = RoomService::class @@ -26,7 +27,7 @@ interface RoomService : MatrixService { suspend fun muteRoom(roomId: RoomId) suspend fun unmuteRoom(roomId: RoomId) - suspend fun isMuted(roomId: RoomId): Boolean + fun observeIssMuted(roomId: RoomId): Flow data class JoinedMember( val userId: UserId, diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt index 7a824a3..e641b20 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -10,6 +10,7 @@ import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.RoomService import io.ktor.client.plugins.* import io.ktor.http.* +import kotlinx.coroutines.flow.Flow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -101,13 +102,13 @@ class DefaultRoomService( singleRoomStore.unmute(roomId) } - override suspend fun isMuted(roomId: RoomId) = singleRoomStore.isMuted(roomId) + override fun observeIssMuted(roomId: RoomId): Flow = singleRoomStore.isMuted(roomId) } interface SingleRoomStore { suspend fun mute(roomId: RoomId) suspend fun unmute(roomId: RoomId) - suspend fun isMuted(roomId: RoomId): Boolean + fun isMuted(roomId: RoomId): Flow } internal fun joinedMembersRequest(roomId: RoomId) = httpRequest( diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt index 90c7df0..db07b13 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/Store.kt @@ -25,10 +25,9 @@ interface MuteableStore { suspend fun mute(roomId: RoomId) suspend fun unmute(roomId: RoomId) suspend fun isMuted(roomId: RoomId): Boolean - suspend fun allMuted(): Set + fun observeMuted(): Flow> } - interface FilterStore { suspend fun store(key: String, filterId: String) diff --git a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt index 7178154..b6db3f8 100644 --- a/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt +++ b/matrix/services/sync/src/main/kotlin/app/dapk/st/matrix/sync/internal/sync/UnreadEventsProcessor.kt @@ -26,15 +26,18 @@ internal class UnreadEventsProcessor( isInitialSync -> { // let's assume everything is read } + previousState?.readMarker != overview.readMarker -> { // assume the user has viewed the room logger.matrixLog(MatrixLogTag.SYNC, "marking room read due to new read marker") roomStore.markRead(overview.roomId) } + areWeViewingRoom -> { logger.matrixLog(MatrixLogTag.SYNC, "marking room read") roomStore.markRead(overview.roomId) } + newEvents.isNotEmpty() -> { logger.matrixLog(MatrixLogTag.SYNC, "insert new unread events")