add room UI to mute room + persistence layer

This commit is contained in:
Adam Brown 2022-11-02 11:15:23 +00:00
parent e753cd30ad
commit 7e184cf200
15 changed files with 172 additions and 21 deletions

View File

@ -19,12 +19,24 @@ internal class SharedPreferencesDelegate(
}
}
override suspend fun store(key: String, value: Set<String>) {
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<String>? {
return coroutineDispatchers.withIoContext {
preferences.getStringSet(key, null)
}
}
override suspend fun remove(key: String) {
coroutineDispatchers.withIoContext {
preferences.edit().remove(key).apply()

View File

@ -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 {

View File

@ -6,7 +6,8 @@ class JobBag {
private val jobs = mutableMapOf<String, Job>()
fun add(key: String, job: Job) {
fun replace(key: String, job: Job) {
jobs[key]?.cancel()
jobs[key] = job
}

View File

@ -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<String>)
suspend fun readString(key: String): String?
suspend fun readStrings(key: String): Set<String>?
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())
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)
}

View File

@ -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<String>) {
cache.setValue(key, value)
preferences.store(key, value)
}
override suspend fun readStrings(key: String): Set<String>? {
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)

View File

@ -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<RoomId>
}
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<RoomId> {
return preferences.readStrings(KEY_MUTE)?.map { RoomId(it) }?.toSet() ?: emptySet()
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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<MessageAttachment>?,
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<MessageAttachment>?) = initialAttachments
?.takeIf { it.isNotEmpty() }
?.let { ComposerState.Attachments(it, null) }

View File

@ -14,7 +14,8 @@ data class MessengerScreenState(
val roomId: RoomId,
val roomState: Lce<MessengerPageState>,
val composerState: ComposerState,
val viewerState: ViewerState?
val viewerState: ViewerState?,
val isMuted: Boolean,
)
data class ViewerState(

View File

@ -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<DeviceMeta>()
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
}
}
class FakeMutedRoomsStore : MutedRoomsStore by mockk() {
fun givenIsMuted(roomId: RoomId) = coEvery { isMuted(roomId) }.delegateReturn()
}

View File

@ -1,18 +1,31 @@
package test.impl
import app.dapk.st.core.Preferences
import test.unit
class InMemoryPreferences : Preferences {
private val prefs = mutableMapOf<String, String>()
private val setPrefs = mutableMapOf<String, Set<String>>()
override suspend fun store(key: String, value: String) {
prefs[key] = value
}
override suspend fun store(key: String, value: Set<String>) {
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<String>? = setPrefs[key]
override suspend fun remove(key: String) {
prefs.remove(key)
setPrefs.remove(key)
}
override suspend fun clear() {
prefs.clear()
setPrefs.clear()
}
}