diff --git a/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt index 2fb5fe4..f9fb86e 100644 --- a/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt +++ b/app/src/main/kotlin/app/dapk/st/SharedPreferencesDelegate.kt @@ -19,12 +19,24 @@ internal class SharedPreferencesDelegate( } } + override suspend fun store(key: String, value: Set) { + coroutineDispatchers.withIoContext { + preferences.edit().putStringSet(key, value).apply() + } + } + override suspend fun readString(key: String): String? { return coroutineDispatchers.withIoContext { preferences.getString(key, null) } } + override suspend fun readStrings(key: String): Set? { + return coroutineDispatchers.withIoContext { + preferences.getStringSet(key, null) + } + } + override suspend fun remove(key: String) { coroutineDispatchers.withIoContext { preferences.edit().remove(key).apply() diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index 940c2f1..c26b826 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -18,6 +18,7 @@ import app.dapk.st.core.extensions.ErrorTracker import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.directory.DirectoryModule import app.dapk.st.domain.StoreModule +import app.dapk.st.domain.room.MutedRoomsStorePersistence import app.dapk.st.engine.MatrixEngine import app.dapk.st.firebase.messaging.MessagingModule import app.dapk.st.home.BetaVersionUpgradeUseCase @@ -163,6 +164,7 @@ internal class FeatureModules internal constructor( context, storeModule.value.messageStore(), deviceMeta, + MutedRoomsStorePersistence(storeModule.value.cachingPreferences) ) } val homeModule by unsafeLazy { 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..9385b95 100644 --- a/core/src/main/kotlin/app/dapk/st/core/Preferences.kt +++ b/core/src/main/kotlin/app/dapk/st/core/Preferences.kt @@ -3,7 +3,9 @@ package app.dapk.st.core interface Preferences { suspend fun store(key: String, value: String) + suspend fun store(key: String, value: Set) suspend fun readString(key: String): String? + suspend fun readStrings(key: String): Set? suspend fun clear() suspend fun remove(key: String) } @@ -17,4 +19,14 @@ 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()) +suspend fun Preferences.append(key: String, value: String) { + val current = this.readStrings(key) ?: emptySet() + this.store(key, current + value) +} + +suspend fun Preferences.removeFromSet(key: String, value: String) { + val current = this.readStrings(key) ?: emptySet() + this.store(key, current - value) +} + 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..f873df1 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,11 +4,23 @@ 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) } + override suspend fun store(key: String, value: Set) { + cache.setValue(key, value) + preferences.store(key, value) + } + + override suspend fun readStrings(key: String): Set? { + return cache.getValue(key) ?: preferences.readStrings(key)?.also { + cache.setValue(key, it) + } + } + override suspend fun readString(key: String): String? { return cache.getValue(key) ?: preferences.readString(key)?.also { cache.setValue(key, it) 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..81cabcd --- /dev/null +++ b/domains/store/src/main/kotlin/app/dapk/st/domain/room/MutedRoomsStore.kt @@ -0,0 +1,40 @@ +package app.dapk.st.domain.room + +import app.dapk.st.core.Preferences +import app.dapk.st.core.append +import app.dapk.st.core.removeFromSet +import app.dapk.st.matrix.common.RoomId + +private const val KEY_MUTE = "mute" + +interface MutedRoomsStore { + suspend fun mute(roomId: RoomId) + suspend fun unmute(roomId: RoomId) + suspend fun isMuted(roomId: RoomId): Boolean + suspend fun allMuted(): Set +} + +class MutedRoomsStorePersistence( + private val preferences: Preferences +) : MutedRoomsStore { + + override suspend fun mute(roomId: RoomId) { + preferences.append(KEY_MUTE, roomId.value) + } + + override suspend fun unmute(roomId: RoomId) { + preferences.removeFromSet(KEY_MUTE, roomId.value) + } + + override suspend fun isMuted(roomId: RoomId): Boolean { + val allMuted = allMuted() + println("??? isMuted - $roomId") + println("??? all - $allMuted") + return allMuted.contains(roomId) + } + + override suspend fun allMuted(): Set { + return preferences.readStrings(KEY_MUTE)?.map { RoomId(it) }?.toSet() ?: emptySet() + } + +} \ No newline at end of file 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..78767a2 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 @@ -38,7 +38,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 +49,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/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index 50f12ab..aaa0b04 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -10,6 +10,7 @@ import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.engine.ChatEngine import app.dapk.st.matrix.common.RoomId import app.dapk.st.messenger.state.MessengerState +import app.dapk.st.domain.room.MutedRoomsStore import app.dapk.st.messenger.state.messengerReducer class MessengerModule( @@ -17,6 +18,7 @@ class MessengerModule( private val context: Context, private val messageOptionsStore: MessageOptionsStore, private val deviceMeta: DeviceMeta, + private val mutedRoomsStore: MutedRoomsStore, ) : ProvidableModule { internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState { @@ -27,6 +29,7 @@ class MessengerModule( CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager), deviceMeta, messageOptionsStore, + mutedRoomsStore, RoomId(launchPayload.roomId), launchPayload.attachments, it 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..8258de0 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,17 @@ internal fun MessengerScreen( Column { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { -// OverflowMenu { -// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {}) -// } + OverflowMenu { + when (state.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..0555a4d 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 @@ -8,6 +8,7 @@ import app.dapk.st.core.asString import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.design.components.BubbleModel import app.dapk.st.domain.application.message.MessageOptionsStore +import app.dapk.st.domain.room.MutedRoomsStore import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.MessengerPageState import app.dapk.st.engine.RoomEvent @@ -26,6 +27,7 @@ internal fun messengerReducer( copyToClipboard: CopyToClipboard, deviceMeta: DeviceMeta, messageOptionsStore: MessageOptionsStore, + mutedRoomsStore: MutedRoomsStore, roomId: RoomId, initialAttachments: List?, eventEmitter: suspend (MessengerEvent) -> Unit, @@ -36,16 +38,19 @@ internal fun messengerReducer( roomState = Lce.Loading(), composerState = initialComposerState(initialAttachments), viewerState = null, + isMuted = false, ), async(ComponentLifecycle::class) { action -> 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) ) + dispatch(MessagesStateChange.MuteContent(mutedRoomsStore.isMuted(roomId))) } ComponentLifecycle.Gone -> jobBag.cancel("messages") @@ -134,6 +139,28 @@ internal fun messengerReducer( } } }, + + change(MessagesStateChange.MuteContent::class) { action, state -> + state.copy(isMuted = action.isMuted).also { + println("??? action - $action previous state: ${state.isMuted} next: ${it.isMuted}") + } + }, + + async(ScreenAction.Notifications::class) { action -> + when (action) { + ScreenAction.Notifications.Mute -> mutedRoomsStore.mute(roomId) + ScreenAction.Notifications.Unmute -> mutedRoomsStore.unmute(roomId) + } + + dispatch( + MessagesStateChange.MuteContent( + isMuted = when (action) { + ScreenAction.Notifications.Mute -> true + ScreenAction.Notifications.Unmute -> false + } + ) + ) + }, ) } @@ -158,7 +185,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..b94441d 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,8 @@ data class MessengerScreenState( val roomId: RoomId, val roomState: Lce, val composerState: ComposerState, - val viewerState: ViewerState? + val viewerState: ViewerState?, + val isMuted: Boolean, ) 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..efcd8f3 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 @@ -3,10 +3,12 @@ package app.dapk.st.messenger import android.os.Build import app.dapk.st.core.* import app.dapk.st.design.components.BubbleModel +import app.dapk.st.domain.room.MutedRoomsStore import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomState import app.dapk.st.engine.SendMessage import app.dapk.st.matrix.common.EventId +import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId import app.dapk.st.matrix.common.asString import app.dapk.st.messenger.state.* @@ -14,6 +16,7 @@ import app.dapk.st.navigator.MessageAttachment import fake.FakeChatEngine import fake.FakeMessageOptionsStore import fixture.* +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf @@ -27,6 +30,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) @@ -48,6 +52,7 @@ class MessengerReducerTest { private val fakeChatEngine = FakeChatEngine() private val fakeCopyToClipboard = FakeCopyToClipboard() private val fakeDeviceMeta = FakeDeviceMeta() + private val fakeMutedRoomsStore = FakeMutedRoomsStore() private val fakeJobBag = FakeJobBag() private val runReducerTest = testReducer { fakeEventSource -> @@ -57,6 +62,7 @@ class MessengerReducerTest { fakeCopyToClipboard.instance, fakeDeviceMeta.instance, fakeMessageOptionsStore.instance, + fakeMutedRoomsStore, A_ROOM_ID, emptyList(), fakeEventSource, @@ -71,6 +77,7 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Text(value = "", reply = null), viewerState = null, + isMuted = false, ) ) } @@ -83,6 +90,7 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Text(value = "", reply = null), viewerState = null, + isMuted = false, ) ) } @@ -95,20 +103,22 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null), viewerState = null, + isMuted = false, ) ) } @Test - fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest { - fakeJobBag.instance.expect { it.add("messages", any()) } + fun `given room is muted and messages emits state, when Visible, then dispatches content and mute changes`() = runReducerTest { + fakeMutedRoomsStore.givenIsMuted(A_ROOM_ID).returns(ROOM_IS_MUTED) + 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)) reduce(ComponentLifecycle.Visible) - assertOnlyDispatches(listOf(MessagesStateChange.Content(state))) + assertOnlyDispatches(listOf(MessagesStateChange.MuteContent(isMuted = ROOM_IS_MUTED), MessagesStateChange.Content(state))) } @Test @@ -330,6 +340,7 @@ class MessengerReducerTest { fakeCopyToClipboard.instance, fakeDeviceMeta.instance, fakeMessageOptionsStore.instance, + FakeMutedRoomsStore(), A_ROOM_ID, initialAttachments = initialAttachments, fakeEventSource, @@ -359,4 +370,8 @@ class FakeDeviceMeta { val instance = mockk() fun givenApiVersion() = every { instance.apiVersion }.delegateReturn() -} \ No newline at end of file +} + +class FakeMutedRoomsStore : MutedRoomsStore by mockk() { + fun givenIsMuted(roomId: RoomId) = coEvery { isMuted(roomId) }.delegateReturn() +} diff --git a/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt index 9818819..b6ec8ca 100644 --- a/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt +++ b/test-harness/src/test/kotlin/test/impl/InMemoryPreferences.kt @@ -1,18 +1,31 @@ package test.impl import app.dapk.st.core.Preferences -import test.unit class InMemoryPreferences : Preferences { private val prefs = mutableMapOf() + private val setPrefs = mutableMapOf>() override suspend fun store(key: String, value: String) { prefs[key] = value } + override suspend fun store(key: String, value: Set) { + setPrefs[key] = value + } + 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 readStrings(key: String): Set? = setPrefs[key] + + override suspend fun remove(key: String) { + prefs.remove(key) + setPrefs.remove(key) + } + + override suspend fun clear() { + prefs.clear() + setPrefs.clear() + } } \ No newline at end of file