diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt index 5d75fbb..564560d 100644 --- a/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt @@ -36,6 +36,8 @@ interface ChatEngine : TaskRunner { fun pushHandler(): PushHandler + suspend fun muteRoom(roomId: RoomId) + suspend fun unmuteRoom(roomId: RoomId) } interface TaskRunner { diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt index 167591e..4efb5d6 100644 --- a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt @@ -13,7 +13,8 @@ typealias InviteState = List data class DirectoryItem( val overview: RoomOverview, val unreadCount: UnreadCount, - val typing: Typing? + val typing: Typing?, + val isMuted: Boolean, ) data class RoomOverview( @@ -87,7 +88,8 @@ sealed interface ImportResult { data class MessengerPageState( val self: UserId, val roomState: RoomState, - val typing: Typing? + val typing: Typing?, + val isMuted: Boolean, ) data class RoomState( diff --git a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt index 829902e..b36c6be 100644 --- a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt +++ b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt @@ -6,8 +6,9 @@ import app.dapk.st.matrix.common.* fun aMessengerState( self: UserId = aUserId(), roomState: RoomState, - typing: Typing? = null -) = MessengerPageState(self, roomState, typing) + typing: Typing? = null, + isMuted: Boolean = false, +) = MessengerPageState(self, roomState, typing, isMuted) fun aRoomOverview( roomId: RoomId = aRoomId(), diff --git a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt index 18066b4..3d91bfc 100644 --- a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt +++ b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt @@ -6,7 +6,8 @@ class JobBag { private val jobs = mutableMapOf() - fun add(key: String, job: Job) { + fun replace(key: String, job: Job) { + jobs[key]?.cancel() jobs[key] = job } 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 bac20a1..510290c 100644 --- a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt +++ b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt @@ -17,4 +17,5 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) = .toBooleanStrict() suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict() -suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString()) \ No newline at end of file +suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString()) + diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/StateViewModel.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/StateViewModel.kt index 2c01997..a584b8a 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/StateViewModel.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/StateViewModel.kt @@ -11,7 +11,6 @@ import app.dapk.state.Store import app.dapk.state.createStore import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch class StateViewModel( reducerFactory: ReducerFactory, @@ -32,7 +31,7 @@ class StateViewModel( } override fun dispatch(action: Action) { - viewModelScope.launch { store.dispatch(action) } + store.dispatch(action) } } diff --git a/domains/state/src/main/kotlin/app/dapk/state/State.kt b/domains/state/src/main/kotlin/app/dapk/state/State.kt index d65dfcf..b8819c8 100644 --- a/domains/state/src/main/kotlin/app/dapk/state/State.kt +++ b/domains/state/src/main/kotlin/app/dapk/state/State.kt @@ -11,8 +11,8 @@ fun createStore(reducerFactory: ReducerFactory, coroutineScope: Coroutine private val scope = createScope(coroutineScope, this) private val reducer = reducerFactory.create(scope) - override suspend fun dispatch(action: Action) { - scope.coroutineScope.launch { + override fun dispatch(action: Action) { + coroutineScope.launch { state = reducer.reduce(action).also { nextState -> if (nextState != state) { subscribers.forEach { it.invoke(nextState) } @@ -35,7 +35,7 @@ interface ReducerFactory { } fun interface Reducer { - suspend fun reduce(action: Action): S + fun reduce(action: Action): S } private fun createScope(coroutineScope: CoroutineScope, store: Store) = object : ReducerScope { @@ -45,7 +45,7 @@ private fun createScope(coroutineScope: CoroutineScope, store: Store) = o } interface Store { - suspend fun dispatch(action: Action) + fun dispatch(action: Action) fun getState(): S fun subscribe(subscriber: (S) -> Unit) } @@ -82,14 +82,18 @@ fun createReducer( actionHandlers.fold(acc) { acc, handler -> when (handler) { is ActionHandler.Async -> { - handler.handler.invoke(scope, action) + scope.coroutineScope.launch { + handler.handler.invoke(scope, action) + } acc } is ActionHandler.Sync -> handler.handler.invoke(action, acc) is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) { is ActionHandler.Async -> { - next.handler.invoke(scope, action) + scope.coroutineScope.launch { + next.handler.invoke(scope, action) + } acc } diff --git a/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt b/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt index 3fdf94a..950c7d1 100644 --- a/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt +++ b/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt @@ -53,7 +53,7 @@ class ReducerTestScope( } private val reducer: Reducer = reducerFactory.create(reducerScope) - override suspend fun reduce(action: Action) = reducer.reduce(action).also { + override fun reduce(action: Action) = reducer.reduce(action).also { capturedResult = 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 aa8b1ef..076693b 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 @@ -13,6 +13,7 @@ import app.dapk.st.domain.preference.CachingPreferences import app.dapk.st.domain.preference.PropertyCache import app.dapk.st.domain.profile.ProfilePersistence import app.dapk.st.domain.push.PushTokenRegistrarPreferences +import app.dapk.st.domain.room.MutedStorePersistence import app.dapk.st.domain.sync.OverviewPersistence import app.dapk.st.domain.sync.RoomPersistence import app.dapk.st.matrix.common.CredentialsStore @@ -33,8 +34,18 @@ class StoreModule( private val coroutineDispatchers: CoroutineDispatchers, ) { + private val muteableStore by unsafeLazy { MutedStorePersistence(database, coroutineDispatchers) } + fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers) - fun roomStore(): RoomStore = RoomPersistence(database, OverviewPersistence(database, coroutineDispatchers), coroutineDispatchers) + 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) fun filterStore(): FilterStore = FilterPreferences(preferences) diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt index 1d891b4..7b9ef84 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/preference/CachingPreferences.kt @@ -4,6 +4,7 @@ import app.dapk.st.core.CachedPreferences import app.dapk.st.core.Preferences class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences { + override suspend fun store(key: String, value: String) { cache.setValue(key, value) preferences.store(key, value) 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 new file mode 100644 index 0000000..4a45012 --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt @@ -0,0 +1,41 @@ +package app.dapk.st.domain.room + +import app.dapk.db.DapkDb +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.withIoContext +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.sync.MuteableStore +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map + +internal class MutedStorePersistence( + private val database: DapkDb, + private val coroutineDispatchers: CoroutineDispatchers, +) : MuteableStore { + + private val allMutedFlow = MutableSharedFlow>(replay = 1) + + override suspend fun mute(roomId: RoomId) { + coroutineDispatchers.withIoContext { + database.mutedRoomQueries.insertMuted(roomId.value) + } + } + + override suspend fun unmute(roomId: RoomId) { + coroutineDispatchers.withIoContext { + database.mutedRoomQueries.removeMuted(roomId.value) + } + } + + override suspend fun isMuted(roomId: RoomId) = allMutedFlow.firstOrNull()?.contains(roomId) ?: false + + override fun observeMuted(): Flow> = database.mutedRoomQueries.select() + .asFlow() + .mapToList() + .map { it.map { RoomId(it) }.toSet() } + +} \ No newline at end of file diff --git a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt index 7864bb7..0e97c7d 100644 --- a/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/sync/RoomPersistence.kt @@ -4,12 +4,11 @@ import app.dapk.db.DapkDb import app.dapk.db.model.RoomEventQueries import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.withIoContext +import app.dapk.st.domain.room.MutedStorePersistence import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.sync.RoomEvent -import app.dapk.st.matrix.sync.RoomOverview -import app.dapk.st.matrix.sync.RoomState -import app.dapk.st.matrix.sync.RoomStore +import app.dapk.st.matrix.sync.* +import com.squareup.sqldelight.Query import com.squareup.sqldelight.runtime.coroutines.asFlow import com.squareup.sqldelight.runtime.coroutines.mapToList import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull @@ -25,7 +24,8 @@ internal class RoomPersistence( private val database: DapkDb, private val overviewPersistence: OverviewPersistence, private val coroutineDispatchers: CoroutineDispatchers, -) : RoomStore { + private val muteableStore: MutedStorePersistence, +) : RoomStore, MuteableStore by muteableStore { override suspend fun persist(roomId: RoomId, events: List) { coroutineDispatchers.withIoContext { @@ -57,10 +57,8 @@ internal class RoomPersistence( }.distinctUntilChanged() return database.roomEventQueries.selectRoom(roomId.value) - .asFlow() - .mapToList() + .distinctFlowList() .map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } } - .distinctUntilChanged() .combine(overviewFlow) { events, overview -> RoomState(overview, events) } @@ -92,9 +90,7 @@ internal class RoomPersistence( override fun observeUnread(): Flow>> { return database.roomEventQueries.selectAllUnread() - .asFlow() - .mapToList() - .distinctUntilChanged() + .distinctFlowList() .map { it.groupBy { RoomId(it.room_id) } .mapKeys { overviewPersistence.retrieve(it.key)!! } @@ -116,6 +112,22 @@ internal class RoomPersistence( } } + override fun observeNotMutedUnread(): Flow>> { + return database.roomEventQueries.selectNotMutedUnread() + .distinctFlowList() + .map { + it.groupBy { RoomId(it.room_id) } + .mapKeys { overviewPersistence.retrieve(it.key)!! } + .mapValues { + it.value.map { + json.decodeFromString(RoomEvent.serializer(), it.blob) + } + } + } + } + + private fun Query.distinctFlowList() = this.asFlow().mapToList().distinctUntilChanged() + override suspend fun markRead(roomId: RoomId) { coroutineDispatchers.withIoContext { database.unreadEventQueries.removeRead(room_id = roomId.value) diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/MutedRoom.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/MutedRoom.sq new file mode 100644 index 0000000..2054a20 --- /dev/null +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/MutedRoom.sq @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS dbMutedRoom ( + room_id TEXT NOT NULL, + PRIMARY KEY (room_id) +); + +insertMuted: +INSERT OR REPLACE INTO dbMutedRoom(room_id) +VALUES (?); + +removeMuted: +DELETE FROM dbMutedRoom +WHERE room_id = ?; + +select: +SELECT room_id +FROM dbMutedRoom; diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq index d6067ed..9fb7553 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomEvent.sq @@ -34,6 +34,16 @@ INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id ORDER BY dbRoomEvent.timestamp_utc DESC LIMIT 100; +selectNotMutedUnread: +SELECT dbRoomEvent.blob, dbRoomEvent.room_id +FROM dbUnreadEvent +INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id +LEFT OUTER JOIN dbMutedRoom + ON dbUnreadEvent.room_id = dbMutedRoom.room_id + WHERE dbMutedRoom.room_id IS NULL +ORDER BY dbRoomEvent.timestamp_utc DESC +LIMIT 100; + remove: DELETE FROM dbRoomEvent WHERE room_id = ?; diff --git a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq index 081bccd..4e1de82 100644 --- a/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq +++ b/domains/store/src/main/sqldelight/app/dapk/db/model/RoomMember.sq @@ -16,7 +16,6 @@ FROM dbRoomMember WHERE room_id = ? LIMIT ?; - insert: INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob) VALUES (?, ?, ?); \ No newline at end of file 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/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt index bd997f8..41c8f8b 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt @@ -21,7 +21,7 @@ internal fun directoryReducer( multi(ComponentLifecycle::class) { action -> when (action) { ComponentLifecycle.OnVisible -> async { _ -> - jobBag.add(KEY_SYNCING_JOB, chatEngine.directory().onEach { + jobBag.replace(KEY_SYNCING_JOB, chatEngine.directory().onEach { shortcutHandler.onDirectoryUpdate(it.map { it.overview }) when (it.isEmpty()) { true -> dispatch(DirectoryStateChange.Empty) diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt index ce6fe82..ca04e8d 100644 --- a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt @@ -2,7 +2,6 @@ package app.dapk.st.directory import app.dapk.st.core.JobBag import app.dapk.st.directory.state.* -import app.dapk.st.engine.DirectoryItem import app.dapk.st.engine.UnreadCount import fake.FakeChatEngine import fixture.aRoomOverview @@ -13,7 +12,7 @@ import test.expect import test.testReducer private val AN_OVERVIEW = aRoomOverview() -private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null) +private val AN_OVERVIEW_STATE = app.dapk.st.engine.DirectoryItem(AN_OVERVIEW, UnreadCount(1), null, isMuted = false) class DirectoryReducerTest { @@ -38,7 +37,7 @@ class DirectoryReducerTest { @Test fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest { fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) } - fakeJobBag.instance.expect { it.add("sync", any()) } + fakeJobBag.instance.expect { it.replace("sync", any()) } fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE))) reduce(ComponentLifecycle.OnVisible) @@ -49,7 +48,7 @@ class DirectoryReducerTest { @Test fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest { fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) } - fakeJobBag.instance.expect { it.add("sync", any()) } + fakeJobBag.instance.expect { it.replace("sync", any()) } fakeChatEngine.givenDirectory().returns(flowOf(emptyList())) reduce(ComponentLifecycle.OnVisible) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index a661231..e1c4c32 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -87,9 +87,20 @@ internal fun MessengerScreen( Column { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { -// OverflowMenu { -// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) -// } + state.roomState.takeIfContent()?.let { + OverflowMenu { + when (it.isMuted) { + true -> DropdownMenuItem(text = { Text("Unmute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = { + viewModel.dispatch(ScreenAction.Notifications.Unmute) + }) + + false -> DropdownMenuItem(text = { Text("Mute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = { + viewModel.dispatch(ScreenAction.Notifications.Mute) + }) + } + } + + } }) when (state.composerState) { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt index 4dc63e1..07541ca 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt @@ -10,6 +10,11 @@ sealed interface ScreenAction : Action { data class CopyToClipboard(val model: BubbleModel) : ScreenAction object SendMessage : ScreenAction object OpenGalleryPicker : ScreenAction + + sealed interface Notifications : ScreenAction { + object Mute : Notifications + object Unmute : Notifications + } } sealed interface ComponentLifecycle : Action { @@ -18,7 +23,8 @@ sealed interface ComponentLifecycle : Action { } sealed interface MessagesStateChange : Action { - data class Content(val content: MessengerPageState) : ComposerStateChange + data class Content(val content: MessengerPageState) : MessagesStateChange + data class MuteContent(val isMuted: Boolean) : MessagesStateChange } sealed interface ComposerStateChange : Action { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt index ef65982..0d66d8b 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt @@ -42,9 +42,10 @@ internal fun messengerReducer( val state = getState() when (action) { is ComponentLifecycle.Visible -> { - jobBag.add("messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) - .onEach { dispatch(MessagesStateChange.Content(it)) } - .launchIn(coroutineScope) + jobBag.replace( + "messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) + .onEach { dispatch(MessagesStateChange.Content(it)) } + .launchIn(coroutineScope) ) } @@ -134,6 +135,30 @@ internal fun messengerReducer( } } }, + + change(MessagesStateChange.MuteContent::class) { action, state -> + when (val roomState = state.roomState) { + is Lce.Content -> state.copy(roomState = roomState.copy(value = roomState.value.copy(isMuted = action.isMuted))) + is Lce.Error -> state + is Lce.Loading -> state + } + }, + + async(ScreenAction.Notifications::class) { action -> + when (action) { + ScreenAction.Notifications.Mute -> chatEngine.muteRoom(roomId) + ScreenAction.Notifications.Unmute -> chatEngine.unmuteRoom(roomId) + } + + dispatch( + MessagesStateChange.MuteContent( + isMuted = when (action) { + ScreenAction.Notifications.Mute -> true + ScreenAction.Notifications.Unmute -> false + } + ) + ) + }, ) } @@ -158,7 +183,6 @@ private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply( timestampUtc = this.utcTimestamp, ) - private fun initialComposerState(initialAttachments: List?) = initialAttachments ?.takeIf { it.isNotEmpty() } ?.let { ComposerState.Attachments(it, null) } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt index caeea8d..0fa7a5c 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt @@ -14,7 +14,7 @@ data class MessengerScreenState( val roomId: RoomId, val roomState: Lce, val composerState: ComposerState, - val viewerState: ViewerState? + val viewerState: ViewerState?, ) data class ViewerState( diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt index d061d32..5e1e760 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt @@ -27,6 +27,7 @@ private const val READ_RECEIPTS_ARE_DISABLED = true private val A_ROOM_ID = aRoomId("messenger state room id") private const val A_MESSAGE_CONTENT = "message content" private val AN_EVENT_ID = anEventId("state event") +private const val ROOM_IS_MUTED = true private val A_SELF_ID = aUserId("self") private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image) @@ -101,7 +102,7 @@ class MessengerReducerTest { @Test fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest { - fakeJobBag.instance.expect { it.add("messages", any()) } + fakeJobBag.instance.expect { it.replace("messages", any()) } fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED) val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID) fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state)) @@ -359,4 +360,4 @@ class FakeDeviceMeta { val instance = mockk() fun givenApiVersion() = every { instance.apiVersion }.delegateReturn() -} \ No newline at end of file +} 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 fc876fd..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,13 +22,15 @@ internal class DirectoryUseCase( overviewDatasource(), messageService.localEchos(), roomStore.observeUnreadCountById(), - syncService.events() - ) { overviewState, localEchos, unread, events -> + 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() + typing = events.filterIsInstance().firstOrNull { it.roomId == roomOverview.roomId }?.engine(), + isMuted = muted.contains(roomOverview.roomId), ) } } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt index fce1c4c..6239ad3 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt @@ -3,30 +3,28 @@ package app.dapk.st.engine import app.dapk.st.core.Base64 import app.dapk.st.core.BuildMeta import app.dapk.st.core.CoroutineDispatchers -import app.dapk.st.core.SingletonFlows import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.matrix.MatrixClient import app.dapk.st.matrix.MatrixTaskRunner import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator import app.dapk.st.matrix.auth.authService -import app.dapk.st.matrix.auth.installAuthService -import app.dapk.st.matrix.common.* -import app.dapk.st.matrix.crypto.* +import app.dapk.st.matrix.common.CredentialsStore +import app.dapk.st.matrix.common.MatrixLogger +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.crypto.MatrixMediaDecrypter +import app.dapk.st.matrix.crypto.cryptoService import app.dapk.st.matrix.device.KnownDeviceStore -import app.dapk.st.matrix.device.deviceService -import app.dapk.st.matrix.device.installEncryptionService -import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory -import app.dapk.st.matrix.message.* +import app.dapk.st.matrix.message.BackgroundScheduler +import app.dapk.st.matrix.message.LocalEchoStore import app.dapk.st.matrix.message.internal.ImageContentReader -import app.dapk.st.matrix.push.installPushService +import app.dapk.st.matrix.message.messageService import app.dapk.st.matrix.push.pushService -import app.dapk.st.matrix.room.* +import app.dapk.st.matrix.room.MemberStore +import app.dapk.st.matrix.room.ProfileStore +import app.dapk.st.matrix.room.profileService +import app.dapk.st.matrix.room.roomService import app.dapk.st.matrix.sync.* -import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent -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.map import kotlinx.coroutines.flow.onEach @@ -114,6 +112,10 @@ class MatrixEngine internal constructor( override fun pushHandler() = matrixPushHandler.value + override suspend fun muteRoom(roomId: RoomId) = matrix.value.roomService().muteRoom(roomId) + + override suspend fun unmuteRoom(roomId: RoomId) = matrix.value.roomService().unmuteRoom(roomId) + override suspend fun runTask(task: ChatEngineTask): TaskRunner.TaskResult { return when (val result = matrix.value.run(MatrixTaskRunner.MatrixTask(task.type, task.jsonPayload))) { is MatrixTaskRunner.TaskResult.Failure -> TaskRunner.TaskResult.Failure(result.canRetry) @@ -209,222 +211,4 @@ class MatrixEngine internal constructor( } - -object MatrixFactory { - - fun createMatrix( - base64: Base64, - buildMeta: BuildMeta, - logger: MatrixLogger, - nameGenerator: DeviceDisplayNameGenerator, - coroutineDispatchers: CoroutineDispatchers, - errorTracker: ErrorTracker, - imageContentReader: ImageContentReader, - backgroundScheduler: BackgroundScheduler, - memberStore: MemberStore, - roomStore: RoomStore, - profileStore: ProfileStore, - syncStore: SyncStore, - overviewStore: OverviewStore, - filterStore: FilterStore, - localEchoStore: LocalEchoStore, - credentialsStore: CredentialsStore, - knownDeviceStore: KnownDeviceStore, - olmStore: OlmStore, - ) = MatrixClient( - KtorMatrixHttpClientFactory( - credentialsStore, - includeLogging = buildMeta.isDebug, - ), - logger - ).also { - it.install { - installAuthService(credentialsStore, nameGenerator) - installEncryptionService(knownDeviceStore) - - val singletonFlows = SingletonFlows(coroutineDispatchers) - val olm = OlmWrapper( - olmStore = olmStore, - singletonFlows = singletonFlows, - jsonCanonicalizer = JsonCanonicalizer(), - deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), - errorTracker = errorTracker, - logger = logger, - clock = Clock.systemUTC(), - coroutineDispatchers = coroutineDispatchers, - ) - installCryptoService( - credentialsStore, - olm, - roomMembersProvider = { services -> - RoomMembersProvider { - services.roomService().joinedMembers(it).map { it.userId } - } - }, - base64 = base64, - coroutineDispatchers = coroutineDispatchers, - ) - installMessageService( - localEchoStore, - backgroundScheduler, - imageContentReader, - messageEncrypter = { - val cryptoService = it.cryptoService() - MessageEncrypter { message -> - val result = cryptoService.encrypt( - roomId = message.roomId, - credentials = credentialsStore.credentials()!!, - messageJson = message.contents, - ) - - MessageEncrypter.EncryptedMessagePayload( - result.algorithmName, - result.senderKey, - result.cipherText, - result.sessionId, - result.deviceId, - ) - } - }, - mediaEncrypter = { - val cryptoService = it.cryptoService() - MediaEncrypter { input -> - val result = cryptoService.encrypt(input) - MediaEncrypter.Result( - uri = result.uri, - contentLength = result.contentLength, - algorithm = result.algorithm, - ext = result.ext, - keyOperations = result.keyOperations, - kty = result.kty, - k = result.k, - iv = result.iv, - hashes = result.hashes, - v = result.v, - ) - } - }, - ) - - installRoomService( - memberStore, - roomMessenger = { - val messageService = it.messageService() - object : RoomMessenger { - override suspend fun enableEncryption(roomId: RoomId) { - messageService.sendEventMessage( - roomId, MessageService.EventMessage.Encryption( - algorithm = AlgorithmName("m.megolm.v1.aes-sha2") - ) - ) - } - } - }, - roomInviteRemover = { - overviewStore.removeInvites(listOf(it)) - } - ) - - installProfileService(profileStore, singletonFlows, credentialsStore) - - installSyncService( - credentialsStore, - overviewStore, - roomStore, - syncStore, - filterStore, - deviceNotifier = { services -> - val encryption = services.deviceService() - val crypto = services.cryptoService() - DeviceNotifier { userIds, syncToken -> - encryption.updateStaleDevices(userIds) - crypto.updateOlmSession(userIds, syncToken) - } - }, - messageDecrypter = { serviceProvider -> - val cryptoService = serviceProvider.cryptoService() - MessageDecrypter { - cryptoService.decrypt(it) - } - }, - keySharer = { serviceProvider -> - val cryptoService = serviceProvider.cryptoService() - KeySharer { sharedRoomKeys -> - cryptoService.importRoomKeys(sharedRoomKeys) - } - }, - verificationHandler = { services -> - val cryptoService = services.cryptoService() - VerificationHandler { apiEvent -> - logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") - cryptoService.onVerificationEvent( - when (apiEvent) { - is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.transactionId, - apiEvent.content.methods, - apiEvent.content.timestampPosix, - ) - - is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.transactionId, - apiEvent.content.methods, - ) - - is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( - apiEvent.sender, - apiEvent.content.fromDevice, - apiEvent.content.method, - apiEvent.content.protocols, - apiEvent.content.hashes, - apiEvent.content.codes, - apiEvent.content.short, - apiEvent.content.transactionId, - ) - - is ApiToDeviceEvent.VerificationCancel -> TODO() - is ApiToDeviceEvent.VerificationAccept -> TODO() - is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( - apiEvent.sender, - apiEvent.content.transactionId, - apiEvent.content.key - ) - - is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( - apiEvent.sender, - apiEvent.content.transactionId, - apiEvent.content.keys, - apiEvent.content.mac, - ) - } - ) - } - }, - oneTimeKeyProducer = { services -> - val cryptoService = services.cryptoService() - MaybeCreateMoreKeys { - cryptoService.maybeCreateMoreKeys(it) - } - }, - roomMembersService = { services -> - val roomService = services.roomService() - object : RoomMembersService { - override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) - override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) - override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) - } - }, - errorTracker = errorTracker, - coroutineDispatchers = coroutineDispatchers, - ) - - installPushService(credentialsStore) - } - } - -} - -fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) +private fun unsafeLazy(initializer: () -> T): Lazy = lazy(mode = LazyThreadSafetyMode.NONE, initializer = initializer) 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 new file mode 100644 index 0000000..a2eb715 --- /dev/null +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixFactory.kt @@ -0,0 +1,258 @@ +package app.dapk.st.engine + +import app.dapk.st.core.Base64 +import app.dapk.st.core.BuildMeta +import app.dapk.st.core.CoroutineDispatchers +import app.dapk.st.core.SingletonFlows +import app.dapk.st.core.extensions.ErrorTracker +import app.dapk.st.matrix.MatrixClient +import app.dapk.st.matrix.auth.DeviceDisplayNameGenerator +import app.dapk.st.matrix.auth.installAuthService +import app.dapk.st.matrix.common.* +import app.dapk.st.matrix.crypto.RoomMembersProvider +import app.dapk.st.matrix.crypto.Verification +import app.dapk.st.matrix.crypto.cryptoService +import app.dapk.st.matrix.crypto.installCryptoService +import app.dapk.st.matrix.device.KnownDeviceStore +import app.dapk.st.matrix.device.deviceService +import app.dapk.st.matrix.device.installEncryptionService +import app.dapk.st.matrix.http.ktor.KtorMatrixHttpClientFactory +import app.dapk.st.matrix.message.* +import app.dapk.st.matrix.message.internal.ImageContentReader +import app.dapk.st.matrix.push.installPushService +import app.dapk.st.matrix.room.* +import app.dapk.st.matrix.room.internal.SingleRoomStore +import app.dapk.st.matrix.sync.* +import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent +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 { + + fun createMatrix( + base64: Base64, + buildMeta: BuildMeta, + logger: MatrixLogger, + nameGenerator: DeviceDisplayNameGenerator, + coroutineDispatchers: CoroutineDispatchers, + errorTracker: ErrorTracker, + imageContentReader: ImageContentReader, + backgroundScheduler: BackgroundScheduler, + memberStore: MemberStore, + roomStore: RoomStore, + profileStore: ProfileStore, + syncStore: SyncStore, + overviewStore: OverviewStore, + filterStore: FilterStore, + localEchoStore: LocalEchoStore, + credentialsStore: CredentialsStore, + knownDeviceStore: KnownDeviceStore, + olmStore: OlmStore, + ) = MatrixClient( + KtorMatrixHttpClientFactory( + credentialsStore, + includeLogging = buildMeta.isDebug, + ), + logger + ).also { + it.install { + installAuthService(credentialsStore, nameGenerator) + installEncryptionService(knownDeviceStore) + + val singletonFlows = SingletonFlows(coroutineDispatchers) + val olm = OlmWrapper( + olmStore = olmStore, + singletonFlows = singletonFlows, + jsonCanonicalizer = JsonCanonicalizer(), + deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()), + errorTracker = errorTracker, + logger = logger, + clock = Clock.systemUTC(), + coroutineDispatchers = coroutineDispatchers, + ) + installCryptoService( + credentialsStore, + olm, + roomMembersProvider = { services -> + RoomMembersProvider { + services.roomService().joinedMembers(it).map { it.userId } + } + }, + base64 = base64, + coroutineDispatchers = coroutineDispatchers, + ) + installMessageService( + localEchoStore, + backgroundScheduler, + imageContentReader, + messageEncrypter = { + val cryptoService = it.cryptoService() + MessageEncrypter { message -> + val result = cryptoService.encrypt( + roomId = message.roomId, + credentials = credentialsStore.credentials()!!, + messageJson = message.contents, + ) + + MessageEncrypter.EncryptedMessagePayload( + result.algorithmName, + result.senderKey, + result.cipherText, + result.sessionId, + result.deviceId, + ) + } + }, + mediaEncrypter = { + val cryptoService = it.cryptoService() + MediaEncrypter { input -> + val result = cryptoService.encrypt(input) + MediaEncrypter.Result( + uri = result.uri, + contentLength = result.contentLength, + algorithm = result.algorithm, + ext = result.ext, + keyOperations = result.keyOperations, + kty = result.kty, + k = result.k, + iv = result.iv, + hashes = result.hashes, + v = result.v, + ) + } + }, + ) + + installRoomService( + memberStore, + roomMessenger = { + val messageService = it.messageService() + object : RoomMessenger { + override suspend fun enableEncryption(roomId: RoomId) { + messageService.sendEventMessage( + roomId, MessageService.EventMessage.Encryption( + algorithm = AlgorithmName("m.megolm.v1.aes-sha2") + ) + ) + } + } + }, + roomInviteRemover = { + overviewStore.removeInvites(listOf(it)) + }, + singleRoomStore = singleRoomStoreAdapter(roomStore) + ) + + installProfileService(profileStore, singletonFlows, credentialsStore) + + installSyncService( + credentialsStore, + overviewStore, + roomStore, + syncStore, + filterStore, + deviceNotifier = { services -> + val encryption = services.deviceService() + val crypto = services.cryptoService() + DeviceNotifier { userIds, syncToken -> + encryption.updateStaleDevices(userIds) + crypto.updateOlmSession(userIds, syncToken) + } + }, + messageDecrypter = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + MessageDecrypter { + cryptoService.decrypt(it) + } + }, + keySharer = { serviceProvider -> + val cryptoService = serviceProvider.cryptoService() + KeySharer { sharedRoomKeys -> + cryptoService.importRoomKeys(sharedRoomKeys) + } + }, + verificationHandler = { services -> + val cryptoService = services.cryptoService() + VerificationHandler { apiEvent -> + logger.matrixLog(MatrixLogTag.VERIFICATION, "got a verification request $it") + cryptoService.onVerificationEvent( + when (apiEvent) { + is ApiToDeviceEvent.VerificationRequest -> Verification.Event.Requested( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + apiEvent.content.timestampPosix, + ) + + is ApiToDeviceEvent.VerificationReady -> Verification.Event.Ready( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.transactionId, + apiEvent.content.methods, + ) + + is ApiToDeviceEvent.VerificationStart -> Verification.Event.Started( + apiEvent.sender, + apiEvent.content.fromDevice, + apiEvent.content.method, + apiEvent.content.protocols, + apiEvent.content.hashes, + apiEvent.content.codes, + apiEvent.content.short, + apiEvent.content.transactionId, + ) + + is ApiToDeviceEvent.VerificationCancel -> TODO() + is ApiToDeviceEvent.VerificationAccept -> TODO() + is ApiToDeviceEvent.VerificationKey -> Verification.Event.Key( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.key + ) + + is ApiToDeviceEvent.VerificationMac -> Verification.Event.Mac( + apiEvent.sender, + apiEvent.content.transactionId, + apiEvent.content.keys, + apiEvent.content.mac, + ) + } + ) + } + }, + oneTimeKeyProducer = { services -> + val cryptoService = services.cryptoService() + MaybeCreateMoreKeys { + cryptoService.maybeCreateMoreKeys(it) + } + }, + roomMembersService = { services -> + val roomService = services.roomService() + object : RoomMembersService { + override suspend fun find(roomId: RoomId, userIds: List) = roomService.findMembers(roomId, userIds) + override suspend fun findSummary(roomId: RoomId) = roomService.findMembersSummary(roomId) + override suspend fun insert(roomId: RoomId, members: List) = roomService.insertMembers(roomId, members) + } + }, + errorTracker = errorTracker, + coroutineDispatchers = coroutineDispatchers, + ) + + installPushService(credentialsStore) + } + } + + private fun singleRoomStoreAdapter(roomStore: RoomStore) = object : SingleRoomStore { + override suspend fun mute(roomId: RoomId) = roomStore.mute(roomId) + override suspend fun unmute(roomId: RoomId) = roomStore.unmute(roomId) + override fun isMuted(roomId: RoomId): Flow = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged() + } + +} \ No newline at end of file diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt index 83f2981..d1008cc 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ObserveUnreadNotificationsUseCaseImpl.kt @@ -16,7 +16,7 @@ internal typealias ObserveUnreadNotificationsUseCase = () -> Flow { - return roomStore.observeUnread() + return roomStore.observeNotMutedUnread() .mapWithDiff() .avoidShowingPreviousNotificationsOnLaunch() .onlyRenderableChanges() 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 2b79e0d..1f7f930 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.observeIsMuted(roomId), + ) { roomState, localEchos, events, isMuted -> MessengerPageState( roomState = when { localEchos.isEmpty() -> roomState @@ -38,6 +39,7 @@ internal class TimelineUseCaseImpl( }, typing = events.filterIsInstance().firstOrNull { it.roomId == roomId }?.engine(), self = userId, + isMuted = isMuted, ) } } diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt index 075de8e..7cb1de1 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/ObserveUnreadRenderNotificationsUseCaseTest.kt @@ -61,7 +61,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { @Test fun `given initial unreads, when receiving new message, then emits all messages`() = runTest { - fakeRoomStore.givenUnreadEvents( + fakeRoomStore.givenNotMutedUnreadEvents( flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE, A_MESSAGE_2)) ) @@ -74,7 +74,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { @Test fun `given initial unreads, when reading a message, then emits nothing`() = runTest { - fakeRoomStore.givenUnreadEvents( + fakeRoomStore.givenNotMutedUnreadEvents( flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE) + A_ROOM_OVERVIEW_2.withUnreads(A_MESSAGE_2), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) ) @@ -85,7 +85,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { @Test fun `given new and then historical message, when reading a message, then only emits the latest`() = runTest { - fakeRoomStore.givenUnreadEvents( + fakeRoomStore.givenNotMutedUnreadEvents( flowOf( NO_UNREADS, A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), @@ -105,7 +105,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { @Test fun `given initial unreads, when reading a duplicate unread, then emits nothing`() = runTest { - fakeRoomStore.givenUnreadEvents( + fakeRoomStore.givenNotMutedUnreadEvents( flowOf(A_ROOM_OVERVIEW.withUnreads(A_MESSAGE), A_ROOM_OVERVIEW.withUnreads(A_MESSAGE)) ) @@ -115,7 +115,7 @@ class ObserveUnreadRenderNotificationsUseCaseTest { } private fun givenNoInitialUnreads(vararg unreads: Map>) = - fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) + fakeRoomStore.givenNotMutedUnreadEvents(flowOf(NO_UNREADS, *unreads)) } private fun Map>.engine() = this diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt index 9775a0d..2c553fe 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt @@ -22,6 +22,7 @@ import org.junit.Test import test.FlowTestObserver import test.delegateReturn +private const val IS_ROOM_MUTED = false private val A_ROOM_ID = aRoomId() private val AN_USER_ID = aUserId() private val A_ROOM_STATE = aMatrixRoomState() @@ -63,6 +64,7 @@ class TimelineUseCaseTest { fakeMergeWithLocalEchosUseCase.givenMerging(A_ROOM_STATE, A_ROOM_MEMBER, A_LOCAL_ECHOS_LIST).returns(A_MERGED_ROOM_STATE.engine()) + timelineUseCase.invoke(A_ROOM_ID, AN_USER_ID) .test(this) .assertValues( @@ -103,6 +105,7 @@ class TimelineUseCaseTest { fakeSyncService.givenRoom(A_ROOM_ID).returns(flowOf(roomState)) fakeMessageService.givenEchos(A_ROOM_ID).returns(flowOf(echos)) fakeSyncService.givenEvents(A_ROOM_ID).returns(flowOf(events)) + fakeRoomService.givenMuted(A_ROOM_ID).returns(flowOf(IS_ROOM_MUTED)) } } @@ -129,10 +132,12 @@ class FakeMessageService : MessageService by mockk() { class FakeRoomService : RoomService by mockk() { fun givenFindMember(roomId: RoomId, userId: UserId) = coEvery { findMember(roomId, userId) }.delegateReturn() + fun givenMuted(roomId: RoomId) = every { observeIsMuted(roomId) }.delegateReturn() } fun aMessengerState( self: UserId = aUserId(), roomState: app.dapk.st.engine.RoomState, - typing: Typing? = null -) = MessengerPageState(self, roomState, typing) \ No newline at end of file + typing: Typing? = null, + isMuted: Boolean = IS_ROOM_MUTED, +) = MessengerPageState(self, roomState, typing, isMuted) \ No newline at end of file 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 a51817b..1da2df3 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 @@ -5,10 +5,8 @@ import app.dapk.st.matrix.common.EventId 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.DefaultRoomService -import app.dapk.st.matrix.room.internal.RoomInviteRemover -import app.dapk.st.matrix.room.internal.RoomMembers -import app.dapk.st.matrix.room.internal.RoomMembersCache +import app.dapk.st.matrix.room.internal.* +import kotlinx.coroutines.flow.Flow private val SERVICE_KEY = RoomService::class @@ -27,6 +25,10 @@ interface RoomService : MatrixService { suspend fun joinRoom(roomId: RoomId) suspend fun rejectJoinRoom(roomId: RoomId) + suspend fun muteRoom(roomId: RoomId) + suspend fun unmuteRoom(roomId: RoomId) + fun observeIsMuted(roomId: RoomId): Flow + data class JoinedMember( val userId: UserId, val displayName: String?, @@ -39,6 +41,7 @@ fun MatrixServiceInstaller.installRoomService( memberStore: MemberStore, roomMessenger: ServiceDepFactory, roomInviteRemover: RoomInviteRemover, + singleRoomStore: SingleRoomStore, ): InstallExtender { return this.install { (httpClient, _, services, logger) -> SERVICE_KEY to DefaultRoomService( @@ -46,7 +49,8 @@ fun MatrixServiceInstaller.installRoomService( logger, RoomMembers(memberStore, RoomMembersCache()), roomMessenger.create(services), - roomInviteRemover + roomInviteRemover, + singleRoomStore, ) } } 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 22e1551..b5b4472 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 @@ -19,6 +20,7 @@ class DefaultRoomService( private val roomMembers: RoomMembers, private val roomMessenger: RoomMessenger, private val roomInviteRemover: RoomInviteRemover, + private val singleRoomStore: SingleRoomStore, ) : RoomService { override suspend fun joinedMembers(roomId: RoomId): List { @@ -82,6 +84,7 @@ class DefaultRoomService( } else { throw it } + } else -> throw it @@ -90,6 +93,22 @@ class DefaultRoomService( ) roomInviteRemover.remove(roomId) } + + override suspend fun muteRoom(roomId: RoomId) { + singleRoomStore.mute(roomId) + } + + override suspend fun unmuteRoom(roomId: RoomId) { + singleRoomStore.unmute(roomId) + } + + override fun observeIsMuted(roomId: RoomId): Flow = singleRoomStore.isMuted(roomId) +} + +interface SingleRoomStore { + suspend fun mute(roomId: RoomId) + suspend fun unmute(roomId: RoomId) + 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 2d43ad7..d5b644c 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 @@ -5,7 +5,7 @@ import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.SyncToken import kotlinx.coroutines.flow.Flow -interface RoomStore { +interface RoomStore : MuteableStore { suspend fun persist(roomId: RoomId, events: List) suspend fun remove(rooms: List) @@ -16,11 +16,19 @@ interface RoomStore { suspend fun markRead(roomId: RoomId) fun observeUnread(): Flow>> fun observeUnreadCountById(): Flow> + fun observeNotMutedUnread(): Flow>> fun observeEvent(eventId: EventId): Flow suspend fun findEvent(eventId: EventId): RoomEvent? } +interface MuteableStore { + suspend fun mute(roomId: RoomId) + suspend fun unmute(roomId: RoomId) + suspend fun isMuted(roomId: RoomId): Boolean + 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") diff --git a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt index 7719a53..3b1cbb2 100644 --- a/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt +++ b/matrix/services/sync/src/testFixtures/kotlin/fake/FakeRoomStore.kt @@ -34,4 +34,8 @@ class FakeRoomStore : RoomStore by mockk() { every { observeUnread() } returns unreadEvents } + fun givenNotMutedUnreadEvents(unreadEvents: Flow>>) { + every { observeNotMutedUnread() } returns unreadEvents + } + } \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index fce9c4f..088340d 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -23,6 +23,7 @@ import app.dapk.st.matrix.message.internal.ImageContentReader import app.dapk.st.matrix.push.installPushService import app.dapk.st.matrix.room.RoomMessenger import app.dapk.st.matrix.room.installRoomService +import app.dapk.st.matrix.room.internal.SingleRoomStore import app.dapk.st.matrix.room.roomService import app.dapk.st.matrix.sync.* import app.dapk.st.matrix.sync.internal.request.ApiToDeviceEvent @@ -31,6 +32,9 @@ import app.dapk.st.olm.DeviceKeyFactory import app.dapk.st.olm.OlmPersistenceWrapper import app.dapk.st.olm.OlmWrapper import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json import org.amshove.kluent.fail import test.impl.InMemoryDatabase @@ -178,7 +182,8 @@ class TestMatrix( } } }, - roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) } + roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }, + singleRoomStore = singleRoomStoreAdapter(storeModule.roomStore()) ) installSyncService( @@ -378,4 +383,10 @@ class ProxyDeviceService(private val deviceService: DeviceService) : DeviceServi } +private fun singleRoomStoreAdapter(roomStore: RoomStore) = object : SingleRoomStore { + override suspend fun mute(roomId: RoomId) = roomStore.mute(roomId) + override suspend fun unmute(roomId: RoomId) = roomStore.unmute(roomId) + override fun isMuted(roomId: RoomId): Flow = roomStore.observeMuted().map { it.contains(roomId) }.distinctUntilChanged() +} + fun MatrixClient.proxyDeviceService() = this.deviceService() as ProxyDeviceService \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt index 9818819..3390155 100644 --- a/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt +++ b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt @@ -1,7 +1,6 @@ package test.impl import app.dapk.st.core.Preferences -import test.unit class InMemoryPreferences : Preferences { @@ -12,7 +11,13 @@ class InMemoryPreferences : Preferences { } override suspend fun readString(key: String): String? = prefs[key] - override suspend fun remove(key: String) = prefs.remove(key).unit() - override suspend fun clear() = prefs.clear() + + override suspend fun remove(key: String) { + prefs.remove(key) + } + + override suspend fun clear() { + prefs.clear() + } } \ No newline at end of file